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 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' }, // SVG sources in src/content/**/_assets are author-controlled. dangerouslyProcessSVG: true, }, 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
 or  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}`;
              }
            }
          });
        };
      },
    ],
  },
});