diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index f671ec4..f58db9c 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -12,6 +12,10 @@ jobs: - uses: actions/checkout@v4 with: submodules: recursive + # Fuld historik: astro.config.mjs udleder sitemap-lastmod fra hver sides + # sidste commit, og IndexNow-steppet differ pushet. Begge er værdiløse + # med den default shallow clone (fetch-depth: 1). + fetch-depth: 0 - name: Setup Node uses: actions/setup-node@v4 @@ -56,3 +60,12 @@ jobs: && { mv $DEPLOY_PATH $DEPLOY_PATH.old 2>/dev/null || true; } \ && mv $DEPLOY_PATH.new $DEPLOY_PATH \ && rm -rf $DEPLOY_PATH.old" + + # Fortæl Bing/Yandex m.fl. med det samme hvilke sider der ændrede sig. + # Kører EFTER deploy, så nøglefilen og siderne er live når de kommer og tjekker. + # Google understøtter ikke IndexNow. Må aldrig vælte et ellers vellykket deploy. + - name: Ping IndexNow + continue-on-error: true + env: + GITHUB_EVENT_BEFORE: ${{ github.event.before }} + run: node scripts/indexnow.mjs diff --git a/astro.config.mjs b/astro.config.mjs index dbdcf58..59f004e 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -2,6 +2,136 @@ 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(); export default defineConfig({ site: 'https://asger.xn--weirse-fya.dk', @@ -11,7 +141,10 @@ export default defineConfig({ changefreq: 'weekly', priority: 0.7, serialize(item) { - if (item.url === 'https://asger.xn--weirse-fya.dk/') item.priority = 1.0; + const pathname = new URL(item.url).pathname; + if (pathname === '/') item.priority = 1.0; + const lastmod = lastmodByRoute.get(pathname); + if (lastmod) item.lastmod = lastmod; return item; }, }), diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..6b719f0 Binary files /dev/null and b/public/favicon.ico differ diff --git a/scripts/indexnow.mjs b/scripts/indexnow.mjs new file mode 100644 index 0000000..01e8753 --- /dev/null +++ b/scripts/indexnow.mjs @@ -0,0 +1,99 @@ +// Pinger IndexNow (Bing/Yandex/Seznam m.fl.) med de sider dette push faktisk ændrede. +// +// Vi sender bevidst IKKE hele sitemappet hver gang: IndexNow er til "det her ændrede sig +// nu", og gentagne fuld-lister ser ud som støj. Kan vi ikke regne diffen ud, springer vi +// over frem for at gætte. +// +// Kør efter `astro build` — dist/sitemap-0.xml bruges som facitliste over gyldige URL'er. +// Google understøtter ikke IndexNow; det her hjælper kun de øvrige motorer. + +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; + +const HOST = 'asger.xn--weirse-fya.dk'; +const KEY = 'b68b31655cb9c1a662d53905dd0b1b06'; +const ORIGIN = `https://${HOST}`; + +const git = (args) => { + try { + return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); + } catch { + return null; + } +}; + +const sitemapUrls = () => { + const xml = fs.readFileSync('dist/sitemap-0.xml', 'utf8'); + return new Set([...xml.matchAll(/([^<]+)<\/loc>/g)].map((m) => m[1])); +}; + +// src/pages/foo.astro -> /foo/ ; src/content/blog/bar.md -> /blog/bar/ +const routeForFile = (f) => { + let m = f.match(/^src\/pages\/(.+)\.astro$/); + if (m) { + if (m[1].includes('[')) return null; // dynamiske ruter kan vi ikke slå op + if (m[1] === 'index') return '/'; + return `/${m[1].replace(/\/index$/, '')}/`; + } + m = f.match(/^src\/content\/blog\/(.+)\.md$/); + if (m) return `/blog/${m[1]}/`; + return null; +}; + +const changedFiles = () => { + const { GITHUB_EVENT_BEFORE, GITHUB_SHA } = process.env; + const zero = /^0*$/; + if (GITHUB_EVENT_BEFORE && !zero.test(GITHUB_EVENT_BEFORE)) { + const d = git(['diff', '--name-only', GITHUB_EVENT_BEFORE, GITHUB_SHA || 'HEAD']); + if (d !== null) return d ? d.split('\n') : []; + } + const d = git(['diff', '--name-only', 'HEAD~1', 'HEAD']); + return d ? d.split('\n') : null; +}; + +const files = changedFiles(); +if (files === null) { + console.log('IndexNow: kan ikke bestemme hvad der ændrede sig (shallow clone?) — springer over.'); + process.exit(0); +} + +const valid = sitemapUrls(); +const urls = [...new Set(files.map(routeForFile).filter(Boolean).map((r) => ORIGIN + r))].filter((u) => + valid.has(u), +); + +// Layout/CSS/config-ændringer rammer hver side, men det er ikke en indholdsændring +// værd at pinge om. Kun ruter med eget ændret kildefil ryger afsted. +if (!urls.length) { + console.log('IndexNow: ingen sider med ændret indhold i dette push — springer over.'); + process.exit(0); +} + +const body = { + host: HOST, + key: KEY, + keyLocation: `${ORIGIN}/${KEY}.txt`, + urlList: urls, +}; + +console.log(`IndexNow: indsender ${urls.length} URL(er):`); +for (const u of urls) console.log(' ', u); + +// `node scripts/indexnow.mjs --dry-run` viser hvad der ville blive sendt. +if (process.argv.includes('--dry-run')) { + console.log('IndexNow: --dry-run, sender ikke.'); + process.exit(0); +} + +const res = await fetch('https://api.indexnow.org/indexnow', { + method: 'POST', + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + body: JSON.stringify(body), +}); + +// 200 = modtaget, 202 = modtaget men nøglen valideres stadig. Begge er fine. +console.log(`IndexNow: HTTP ${res.status} ${res.statusText}`); +if (![200, 202].includes(res.status)) { + console.log(await res.text().catch(() => '')); + console.log('IndexNow: afvist — men deployet er allerede ude, så vi fejler ikke buildet.'); +}