import { defineConfig } from 'astro/config'; import sitemap from '@astrojs/sitemap'; import AstroPWA from '@vite-pwa/astro'; import tailwindcss from '@tailwindcss/vite'; import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = path.dirname(fileURLToPath(import.meta.url)); // ── lastmod til sitemap ──────────────────────────────────────────────────────── // Google bruger kun lastmod hvis den er troværdig, så vi gætter aldrig: statiske // sider får datoen for deres SIDSTE COMMIT, blogindlæg får updated/date fra // frontmatter, og alt vi ikke kan datere præcist får slet ingen lastmod. // NB: kræver fuld git-historik — deploy-workflowen bruger fetch-depth: 0. // Er historikken shallow (eller mangler .git), falder vi stille tilbage til // "ingen lastmod" i stedet for at udgive en forkert dato. const gitLastModified = (file) => { try { const out = execFileSync('git', ['log', '-1', '--format=%cI', '--', file], { cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], }).trim(); return out || null; } catch { return null; } }; // Shallow clone => hver fil ser ud til at være ændret i den ene commit vi har. // Så er datoerne værdiløse, og vi dropper lastmod helt. const gitHistoryIsUsable = (() => { try { const n = execFileSync('git', ['rev-list', '--count', 'HEAD'], { cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], }).trim(); return Number(n) > 1; } catch { return false; } })(); const walk = (dir) => fs.existsSync(dir) ? fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => { const p = path.join(dir, e.name); return e.isDirectory() ? walk(p) : [p]; }) : []; // src/pages/foo.astro -> /foo/ ; src/pages/index.astro -> / ; [dynamiske] springes over const routeForPage = (file) => { const rel = path.relative(path.join(ROOT, 'src/pages'), file).replace(/\\/g, '/'); if (rel.includes('[') || rel.startsWith('404.')) return null; if (!rel.endsWith('.astro')) return null; const noExt = rel.slice(0, -'.astro'.length); if (noExt === 'index') return '/'; return `/${noExt.replace(/\/index$/, '')}/`; }; // Minimal frontmatter-læsning — vi skal kun bruge date/updated/tags/draft. const readFrontmatter = (file) => { const src = fs.readFileSync(file, 'utf8'); const m = src.match(/^---\r?\n([\s\S]*?)\r?\n---/); if (!m) return {}; const fm = {}; for (const line of m[1].split(/\r?\n/)) { const kv = line.match(/^(\w+):\s*(.*)$/); if (!kv) continue; const [, k, rawv] = kv; const v = rawv.trim().replace(/^['"]|['"]$/g, ''); if (k === 'tags') { const inline = v.match(/^\[(.*)\]$/); fm.tags = inline ? inline[1].split(',').map((t) => t.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean) : []; } else { fm[k] = v; } } return fm; }; const iso = (v) => { const d = new Date(v); return Number.isNaN(d.getTime()) ? null : d.toISOString(); }; const newest = (dates) => dates.filter(Boolean).sort().at(-1) ?? null; const buildLastmodMap = () => { const map = new Map(); if (!gitHistoryIsUsable) return map; for (const file of walk(path.join(ROOT, 'src/pages'))) { const route = routeForPage(file); if (!route) continue; const d = gitLastModified(file); if (d) map.set(route, d); } // Blogindlæg: frontmatter er sandheden om hvornår indholdet sidst ændrede sig. const postDates = []; const tagDates = new Map(); for (const file of walk(path.join(ROOT, 'src/content/blog'))) { if (!file.endsWith('.md')) continue; const fm = readFrontmatter(file); if (fm.draft === 'true') continue; const d = iso(fm.updated || fm.date); if (!d) continue; const slug = path.basename(file, '.md'); map.set(`/blog/${slug}/`, d); postDates.push(d); for (const t of fm.tags ?? []) { const key = t.toLowerCase(); tagDates.set(key, newest([tagDates.get(key), d])); } } // Oversigtssider er lige så friske som det nyeste de viser. const latest = newest(postDates); if (latest) { map.set('/blog/', latest); map.set('/tags/', latest); for (const [tag, d] of tagDates) map.set(`/tags/${tag}/`, d); } return map; }; const lastmodByRoute = buildLastmodMap(); // Preview-build af udkast (PW_PREVIEW=1): hele sitet under /udkast, uden sitemap // og service worker. Det serveres kun bag login og må aldrig ende i et index. const PREVIEW = process.env.PW_PREVIEW === '1'; export default defineConfig({ site: 'https://asger.xn--weirse-fya.dk', ...(PREVIEW ? { base: '/udkast' } : {}), trailingSlash: 'ignore', integrations: PREVIEW ? [] : [ sitemap({ changefreq: 'weekly', priority: 0.7, // /tarballz/ er ikke en Astro-side (genereres på webhosten), men oversigten må // gerne indekseres; selve pakkefilerne er noindex der. customPages: ['https://asger.xn--weirse-fya.dk/tarballz/'], // Tag-siderne er noindex (tynde lister), så de hører heller ikke i sitemap'et. // /peppol er kun en omdirigering til /nemhandel og skal ikke indekseres. // /cookies, /status og /chat er hjælpesider uden søgeværdi; de er noindex // og står derfor heller ikke i sitemap'et (ellers melder Search Console // "indsendt URL markeret som noindex"). filter: (page) => { const p = new URL(page).pathname.replace(/\/$/, ''); return !p.startsWith('/tags') && !p.startsWith('/peppol') && !['/cookies', '/status', '/chat'].includes(p); }, serialize(item) { const pathname = new URL(item.url).pathname; if (pathname === '/') item.priority = 1.0; const lastmod = lastmodByRoute.get(pathname); if (lastmod) item.lastmod = lastmod; // Samme kanoniske form som og de interne links: // uden afsluttende skråstreg. Slås lastmod op FØR strippet, fordi // lastmodByRoute er nøglet på Astros egne ruter (som har skråstregen). // Undtagen /tarballz/: den er ikke en Astro-side, og webhosten serverer // den kun med skråstreg (uden skråstreg giver den en 301). if (pathname !== '/' && pathname !== '/tarballz/') item.url = item.url.replace(/\/$/, ''); return item; }, }), // Service worker → offline support + installable PWA. autoUpdate + outdated-cache // cleanup so a redeploy never strands a visitor on a stale version. Reuses the // existing public/site.webmanifest (manifest: false = don't generate another). AstroPWA({ registerType: 'autoUpdate', manifest: false, includeManifestIcons: false, workbox: { globPatterns: ['**/*.{html,css,js,svg,png,woff2,xml,txt,webmanifest}'], cleanupOutdatedCaches: true, clientsClaim: true, skipWaiting: true, // No SPA fallback. The default answers EVERY navigation it has not // precached with the cached front page, so /tarballz/ (served by nginx // outside the Astro build) and unknown URLs showed the home page instead // of the real page or the 404. Every Astro page is precached anyway. navigateFallback: null, }, devOptions: { enabled: false }, }), ], prefetch: { prefetchAll: true, defaultStrategy: 'viewport' }, vite: { plugins: [tailwindcss()] }, });