Switch the site to Astro
This commit is contained in:
parent
a5f64a3ff8
commit
2e02e52661
14 changed files with 8633 additions and 17018 deletions
136
astro.config.mjs
Normal file
136
astro.config.mjs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
import { defineConfig } from 'astro/config';
|
||||
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
|
||||
import rehypeSlug from 'rehype-slug';
|
||||
import { visit } from 'unist-util-visit';
|
||||
|
||||
// Build a lookup of post slugs to their last modification dates so the sitemap
|
||||
// can advertise accurate <lastmod> values to crawlers. astro:content isn't
|
||||
// available inside the config, so we read post frontmatter directly. Our posts
|
||||
// always use single-line scalar `date:` / `updated:` keys, so a small regex
|
||||
// extraction is sufficient and intentional.
|
||||
const postsDir = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'src/content/posts'
|
||||
);
|
||||
|
||||
function extractScalar(frontmatter, key) {
|
||||
const match = frontmatter.match(new RegExp(`^${key}:\\s*(.+?)\\s*$`, 'm'));
|
||||
return match?.[1]?.replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
|
||||
const postLastmodLookup = new Map(
|
||||
readdirSync(postsDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
|
||||
.map((entry) => {
|
||||
const raw = readFileSync(path.join(postsDir, entry.name), 'utf8');
|
||||
const frontmatter = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? '';
|
||||
const rawDate =
|
||||
extractScalar(frontmatter, 'updated') ?? extractScalar(frontmatter, 'date');
|
||||
const parsed = rawDate ? new Date(rawDate) : null;
|
||||
const valid = parsed && !Number.isNaN(parsed.valueOf()) ? parsed : null;
|
||||
return [entry.name.replace(/\.md$/, ''), valid];
|
||||
})
|
||||
.filter(([, date]) => date !== null)
|
||||
);
|
||||
|
||||
export default defineConfig({
|
||||
site: 'https://schmelczer.dev',
|
||||
trailingSlash: 'ignore',
|
||||
integrations: [
|
||||
sitemap({
|
||||
filter: (page) => {
|
||||
const path = new URL(page).pathname;
|
||||
return !/^\/tags\/[^/]+\/?$/.test(path) && path !== '/404/';
|
||||
},
|
||||
serialize(item) {
|
||||
const url = new URL(item.url);
|
||||
const match = url.pathname.match(/^\/articles\/([^/]+)\/?$/);
|
||||
let lastmod = item.lastmod;
|
||||
if (match) {
|
||||
const date = postLastmodLookup.get(match[1]);
|
||||
if (date instanceof Date && !Number.isNaN(date.valueOf())) {
|
||||
lastmod = date.toISOString();
|
||||
}
|
||||
}
|
||||
return { ...item, changefreq: 'monthly', ...(lastmod ? { lastmod } : {}) };
|
||||
},
|
||||
}),
|
||||
],
|
||||
image: {
|
||||
service: { entrypoint: 'astro/assets/services/sharp' },
|
||||
},
|
||||
vite: {
|
||||
server: {
|
||||
watch: {
|
||||
// Avoid inotify instance limits in dev containers and mounted volumes.
|
||||
usePolling: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
markdown: {
|
||||
shikiConfig: {
|
||||
themes: {
|
||||
light: 'github-light',
|
||||
dark: 'github-dark',
|
||||
},
|
||||
defaultColor: false,
|
||||
wrap: false,
|
||||
},
|
||||
rehypePlugins: [
|
||||
rehypeSlug,
|
||||
[
|
||||
rehypeAutolinkHeadings,
|
||||
{
|
||||
behavior: 'append',
|
||||
properties: {
|
||||
className: ['heading-anchor'],
|
||||
},
|
||||
// Glyph rendered via CSS ::before so it doesn't leak into the TOC
|
||||
// when astro:content extracts heading.text from the rendered HTML.
|
||||
content: [],
|
||||
},
|
||||
],
|
||||
// Make scrollable code blocks and tables reachable via keyboard (WCAG
|
||||
// 2.1.1): without tabindex, a keyboard user cannot focus a horizontally
|
||||
// overflowing <pre> or <table> to scroll it. tabindex=0 is sufficient
|
||||
// on its own; role=region would require a meaningful per-block label,
|
||||
// which we don't have at markdown level.
|
||||
function rehypeFocusableScrollables() {
|
||||
const SCROLLABLE = new Set(['pre', 'table']);
|
||||
return (tree) => {
|
||||
visit(tree, 'element', (node) => {
|
||||
if (!SCROLLABLE.has(node.tagName)) return;
|
||||
node.properties.tabindex = '0';
|
||||
});
|
||||
};
|
||||
},
|
||||
function rehypeLabelHeadingPermalinks() {
|
||||
function textOf(node) {
|
||||
if (!node) return '';
|
||||
if (node.type === 'text') return node.value ?? '';
|
||||
return (node.children ?? []).map(textOf).join('');
|
||||
}
|
||||
|
||||
return (tree) => {
|
||||
visit(tree, 'element', (node) => {
|
||||
if (!/^h[2-6]$/.test(node.tagName)) return;
|
||||
const headingText = textOf(node).trim();
|
||||
if (!headingText) return;
|
||||
|
||||
for (const child of node.children ?? []) {
|
||||
const className = child.properties?.className;
|
||||
const classes = Array.isArray(className) ? className : [className];
|
||||
if (child.tagName === 'a' && classes.includes('heading-anchor')) {
|
||||
child.properties.ariaLabel = `Permalink to ${headingText}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue