Ryd op i kommentarer
Build & Deploy / deploy (push) Successful in 5m55s

This commit is contained in:
Asger Geel Weirsøe
2026-10-31 14:43:20 +01:00
parent 490835e6ae
commit 288205630e
12 changed files with 21 additions and 116 deletions
+3 -16
View File
@@ -12,9 +12,7 @@ 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).
# Fuld historik til sitemap-lastmod og IndexNow
fetch-depth: 0
- name: Setup Node
@@ -22,10 +20,7 @@ jobs:
with:
node-version: 22
# The runner forces omit of optional deps, dropping @img/sharp-linux-x64
# (sharp's platform binary) -> astro:assets MissingSharp. Clear the omit
# env and force-install the linux/glibc/x64 sharp binary per sharp's
# official cross-platform install, then verify it loads.
# Runneren udelader optional deps, så sharp-binæren installeres eksplicit
- name: Install dependencies
env:
npm_config_omit: ""
@@ -35,7 +30,6 @@ jobs:
npm install --include=optional --os=linux --libc=glibc --cpu=x64 sharp
node -e "console.log('sharp', require('sharp').versions.sharp)"
# Intet indlæg bygges uden godkendelse; se scripts/verify-approvals.mjs.
- name: Verify blog approvals
env:
PUBLISH_KEY: ${{ secrets.PUBLISH_KEY }}
@@ -44,11 +38,7 @@ jobs:
- name: Build site
run: npm run build
# tar over SSH (the runner image has no rsync). Build is unpacked into
# a .new dir on the web host and atomically swapped in, so the live web
# root is never left half-written. Host and path are repository secrets,
# not variables: run logs of a public repo are public, and Gitea prints
# variables in them but masks secrets.
# tar over SSH med atomisk swap (runner-imaget har ikke rsync)
- name: Deploy dist/ over SSH
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
@@ -72,9 +62,6 @@ jobs:
&& 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:
+5 -28
View File
@@ -9,13 +9,8 @@ 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.
// lastmod til sitemap: sidste commit for statiske sider, frontmatter for blogindlæg.
// Kræver fuld git-historik (fetch-depth: 0); ellers ingen lastmod.
const gitLastModified = (file) => {
try {
@@ -30,8 +25,6 @@ const gitLastModified = (file) => {
}
};
// 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'], {
@@ -145,14 +138,8 @@ export default defineConfig({
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").
// noindex-sider og omdirigeringer
filter: (page) => {
const p = new URL(page).pathname.replace(/\/$/, '');
return !p.startsWith('/tags') && !p.startsWith('/peppol')
@@ -163,18 +150,11 @@ export default defineConfig({
if (pathname === '/') item.priority = 1.0;
const lastmod = lastmodByRoute.get(pathname);
if (lastmod) item.lastmod = lastmod;
// Samme kanoniske form som <link rel="canonical"> 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).
// Kanonisk form uden afsluttende skråstreg (undtagen /tarballz/)
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,
@@ -184,10 +164,7 @@ export default defineConfig({
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.
// Ingen fallback til forsiden, så /tarballz/ og 404 virker
navigateFallback: null,
},
devOptions: { enabled: false },
+2 -11
View File
@@ -1,11 +1,5 @@
// 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.
// Pinger IndexNow med de sider der er ændret i dette push.
// Kør efter `astro build` (bruger dist/sitemap-0.xml).
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
@@ -62,8 +56,6 @@ const urls = [...new Set(files.map(routeForFile).filter(Boolean).map((r) => ORIG
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);
@@ -91,7 +83,6 @@ const res = await fetch('https://api.indexnow.org/indexnow', {
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(() => ''));
+3 -13
View File
@@ -1,14 +1,5 @@
// Spærre før build: intet blogindlæg går live uden at være godkendt.
//
// Hvert indlæg i src/content/blog skal bære et `godkendt:`-stempel i frontmatter.
// Stemplet er en HMAC-SHA256 over filens sti + indhold (uden selve stempel-linjen)
// + sha256 af hvert billede i indlæggets mappe (src/content/blog/<slug>/),
// lavet med en nøgle som kun godkendelsestjenesten og denne workflow kender. Et
// indlæg der er lagt direkte i repoet, eller ændret efter godkendelse, har derfor
// intet eller et forkert stempel, og så stopper buildet før noget bliver deployet.
//
// Udkast hører ikke hjemme her: repoet er offentligt, så `draft: true` ville stadig
// udgive teksten via git. Udkast ligger i et privat repo indtil de er godkendt.
// Stopper buildet hvis et blogindlæg mangler et gyldigt `godkendt:`-stempel
// (HMAC-SHA256 over sti, indhold og billeder).
//
// PUBLISH_KEY=<hex> node scripts/verify-approvals.mjs
import crypto from 'node:crypto';
@@ -21,8 +12,7 @@ if (!key) {
process.exit(1);
}
// Skal matche godkendelsestjenesten byte for byte. Et indlæg uden billeder har
// ingen @asset-linjer, så dets stempel er det samme som før billeder fandtes.
// Skal matche godkendelsestjenesten byte for byte.
const canonical = (relPath, text, assets = []) => {
const lines = text.replace(/\r\n/g, '\n').split('\n');
if (lines[0] === '---') {
-3
View File
@@ -32,7 +32,6 @@ const t = lang === 'en' ? {
function esc(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// Escape FØRST, linkificér så: rene URL'er og e-mails bliver klikbare, intet HTML slipper igennem.
function linkify(text) {
var html = esc(text);
html = html.replace(/\b(https?:\/\/[^\s<]+[^\s<.,!?:;)])/g,
@@ -41,8 +40,6 @@ const t = lang === 'en' ? {
'<a href="mailto:$1" class="text-accent underline">$1</a>');
return html;
}
// Track spørgsmålet i Matomo FRA browseren, så det knyttes til den besøgendes
// egen visitor-profil (cookie/visitor-ID, IP, browser) i stedet for en server-IP.
function track(action, q) {
try { (window._paq = window._paq || []).push(['trackEvent', 'Chat', action, q]); } catch (_) {}
}
+3 -11
View File
@@ -11,8 +11,7 @@ interface Props {
modifiedTime?: string;
tags?: string[];
jsonLd?: unknown[];
// Tynde oversigtssider (fx tags med 1-2 indlæg) skal ikke i Googles indeks,
// men links fra dem skal stadig følges.
// noindex, follow
noindex?: boolean;
// Sidens sprog (standard: dansk) og dens oversættelser, som hreflang-links.
lang?: 'da' | 'en';
@@ -28,9 +27,7 @@ const xDefault = hreflang.find((a) => a.lang === 'da');
const pageTitle = title ? `${title} · ${SITE.name}` : `${SITE.title} · Datalog & Technical Solution Architect`;
const desc = description ?? SITE.description;
// Kanonisk form er UDEN afsluttende skråstreg. Origin serverer begge former med
// 200, og de interne links peger alle på no-slash, så canonical og sitemap skal
// gøre det samme, ellers opdager Google to adskilte sæt af de samme sider.
// Kanonisk form er uden afsluttende skråstreg, som i sitemap og interne links.
const canonicalPath = Astro.url.pathname.replace(/\/$/, '') || '/';
const canonical = new URL(canonicalPath, SITE.url).href;
const ogImage = new URL(image ?? '/og.png', SITE.url).href;
@@ -76,20 +73,15 @@ const graphs = [baseGraph, ...jsonLd];
<link rel="manifest" href="/site.webmanifest" />
<link rel="sitemap" href="/sitemap-index.xml" />
<link rel="alternate" type="application/rss+xml" title={SITE.name} href="/rss.xml" />
<!-- Mastodon-verifikation: usynligt rel=me i head (kræver ingen JS) -->
<link rel="me" href="https://mastodon.social/@asgerweirsoee" />
{graphs.map((g) => <script type="application/ld+json" set:html={JSON.stringify(g)} />)}
<!-- Matomo (site 2) via first-party proxy /_a/: neutrale navne, så content-blockers ikke matcher "matomo".
Lagt direkte i kilden fordi HTML-minify dropper det valgfrie </head> som NPMplus' sub_filter ellers injicerede på. -->
<script is:inline>
var _paq = (window._paq = window._paq || []);
_paq.push(['setDomains', ['*.weirsoe.dk', '*.xn--weirse-fya.dk']]);
_paq.push(['setVisitorCookieTimeout', 31536000]); // visitor-cookie lever 1 år (sekunder)
_paq.push(['setVisitorCookieTimeout', 31536000]);
_paq.push(['enableLinkTracking']);
// Enhedsmodel (Custom Dimension 2): grov enheds-kohorte ud fra skaerm (logiske pt) + DPR.
// Apple maskerer GPU-strengen, saa 6,1-tommer-generationen kan ikke splittes finere end gruppe.
(function () {
try {
var w = screen.width, h = screen.height, dpr = Math.round(window.devicePixelRatio || 1);
-1
View File
@@ -1,7 +1,6 @@
---
import { NAV, NAV_EN, SITE } from '../consts';
const { lang, alternates = [] } = Astro.props as { lang?: 'da' | 'en'; alternates?: { lang: 'da' | 'en'; path: string }[] };
// Sprogskiftet bor KUN her: findes siden på det andet sprog (sidens alternates), linkes der til den.
const andet = alternates.find((a) => a.lang !== (lang ?? 'da'));
const nav = lang === 'en' ? NAV_EN : NAV;
const home = lang === 'en' ? '/en' : '/';
+2 -5
View File
@@ -25,8 +25,7 @@ const { title, description, image, type, publishedTime, modifiedTime, tags, json
<!doctype html>
<html lang={lang ?? SITE.lang}>
<head>
{/* Hilsenen skal stå INDE i <head>. Stod den før <html>, smed Astros compiler
både <html> og <head> væk, og sitet mistede lang-attributten (juni-okt. 2026). */}
{/* Skal stå i <head>, ellers dropper Astro <html> og <head>. */}
<Fragment set:html={`<!--
_ ____ ____ _____ ____
/ \\ / ___| / ___| ____| _ \\
@@ -58,8 +57,6 @@ const { title, description, image, type, publishedTime, modifiedTime, tags, json
</script>
<Head {title} {description} {image} {type} {publishedTime} {modifiedTime} {tags} {jsonLd} noindex={noindex || PREVIEW} {lang} {alternates} />
<ClientRouter />
<!-- Registrér service worker'en (PWA/offline). Genereret af @vite-pwa i build;
is:inline + public-sti så Astro ikke bundler den. 404 i dev = harmløst. -->
{!PREVIEW && <script is:inline src="/registerSW.js"></script>}
</head>
<body class="min-h-screen flex flex-col antialiased">
@@ -76,7 +73,7 @@ const { title, description, image, type, publishedTime, modifiedTime, tags, json
<Palette />
<CookieBanner {lang} />
<script is:inline>
// Konami-koden: ↑↑↓↓←→←→BA → terminal-tema + en lille rulle. En klassiker skal æres.
// Konami-kode → terminal-tema
(() => {
if (window.__konami) return; window.__konami = true;
const seq = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown','ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','b','a'];
-2
View File
@@ -16,8 +16,6 @@ const highlights = [
{ title: 'Selvhosting, AI & fuld-stack', body: 'Et Proxmox-homelab med ~50 LXC/VM’er bag SSO (Authentik), og projekter fra Java/Go/Python til TypeScript og LLM-drevne workflows.' },
];
// Spørgsmålene står i tredje person med navnet (det er det folk og LLM'er søger på,
// og det fodrer FAQ-schema); svarene er i første person, så det er mig der skriver.
const faqs = [
{
q: 'Hvem er Asger Weirsøe?',
+2 -4
View File
@@ -2,15 +2,13 @@
import Base from '../layouts/Base.astro';
import { SITE } from '../consts';
// Hvad jeg roder med lige nu, opdateres manuelt, det er hele pointen med en /now-side.
const focus = [
'Elektronisk fakturering hos Digisense: Peppol, NemHandel og løsningsarkitektur',
'HD 1. del i Erhvervsøkonomi på AAU (eksamener til sommer)',
'Homelab: crawlkit, denne side, og alt for mange LXC-containere',
];
// Build-time: aktivitets-heatmap fra min Gitea (offentligt API). Fejler stille,
// siden bygger fint uden graf hvis Gitea er nede under build.
// Aktivitets-heatmap fra Gitea ved build; ingen graf hvis Gitea er nede.
type Beat = { timestamp: number; contributions: number };
let perDay = new Map<string, number>();
try {
@@ -19,7 +17,7 @@ try {
const d = new Date(b.timestamp * 1000).toISOString().slice(0, 10);
perDay.set(d, (perDay.get(d) || 0) + b.contributions);
}
} catch { /* ingen graf, ingen drama */ }
} catch {}
// 26 uger bagud, uger som kolonner (man-søn), niveau 0-4
const WEEKS = 26;
-13
View File
@@ -3,7 +3,6 @@ import { SITE } from './consts';
export const personId = `${SITE.url}/#person`;
export const websiteId = `${SITE.url}/#website`;
// Canonical Person entity — referenced by @id from every other entity.
export const person = {
'@type': 'Person',
'@id': personId,
@@ -38,8 +37,6 @@ export const person = {
dateCreated: '2020',
},
],
// schema.org/callSign: kaldesignalet er en entydig identifikator på linje med
// sameAs-profilerne og knytter OZ0AGW til personen.
callSign: 'OZ0AGW',
worksFor: { '@type': 'Organization', name: 'Digisense A/S' },
alumniOf: { '@type': 'CollegeOrUniversity', name: 'Aalborg Universitet' },
@@ -79,11 +76,8 @@ export const website = {
publisher: { '@id': personId },
};
// Default graph present on every page (Google de-dupes by @id).
export const baseGraph = { '@context': 'https://schema.org', '@graph': [website, person] };
// Marks the front page as a ProfilePage about the Person — a strong "this site is
// about this human" signal that helps Google build a knowledge-panel entity.
export const profilePage = {
'@context': 'https://schema.org',
'@type': 'ProfilePage',
@@ -95,7 +89,6 @@ export const profilePage = {
inLanguage: 'da-DK',
};
// FAQ pairs — natural-language Q&A that search engines and LLMs can lift verbatim.
export function faqPage(items: { q: string; a: string }[]) {
return {
'@context': 'https://schema.org',
@@ -134,8 +127,6 @@ export function blogPosting(opts: {
};
}
// BreadcrumbList — the highest-ROI rich result for content sites: a human-readable
// path (Forside › Blog › …) in the SERP instead of the raw URL. Needs ≥2 items.
export function breadcrumb(items: { name: string; url: string }[]) {
return {
'@context': 'https://schema.org',
@@ -146,8 +137,6 @@ export function breadcrumb(items: { name: string; url: string }[]) {
};
}
// CollectionPage — tells search/AI that a listing page (the blog index, a tag) is a
// curated set authored by the Person, not a thin/duplicate page.
export function collectionPage(opts: { url: string; name: string; description: string }) {
return {
'@context': 'https://schema.org',
@@ -162,8 +151,6 @@ export function collectionPage(opts: { url: string; name: string; description: s
};
}
// SoftwareSourceCode — a project page for a tool the Person wrote and maintains.
// codeRepository points at Gitea; sameAs lists the package pages (AUR) for the same tool.
export function softwareSourceCode(opts: {
url: string; name: string; description: string; codeRepository: string;
language: string; license: string; platforms: string[]; sameAs: string[];
+1 -9
View File
@@ -1,23 +1,15 @@
@import "tailwindcss";
@import "@fontsource-variable/inter";
/* Shared design system (SSOT: agw/Weircon-wind, vendored as a submodule).
adapters/tailwind.css feeds the colour tokens into Tailwind's @theme so the
bg-surface / text-muted / border-border / text-accent utilities are generated
from the shared palette; weirsoe-theme.css adds the base glow, the sun⇄moon
toggle, .prose and the .wt-* components, in Tailwind's components layer so a
utility on the same element (pl-9, font-mono) still wins. Replaces the tokens/toggle/prose/glow
that used to live here by hand. */
/* Fælles tema (Weircon-wind) */
@import "../../vendor/weirsoe-theme/adapters/tailwind.css";
@import "../../vendor/weirsoe-theme/weirsoe-theme.css" layer(components);
/* asger keeps Inter as its UI font (the theme defaults to a system stack). */
@theme {
--font-sans: "Inter Variable", ui-sans-serif, system-ui, -apple-system, sans-serif;
}
html[data-theme="terminal"] {
--font-sans: ui-monospace, "Cascadia Code", "JetBrains Mono", Menlo, Consolas, monospace;
}
/* make the theme's body font follow asger's --font-sans (Inter / mono) */
:root { --wt-font-sans: var(--font-sans); }
@layer base { a { color: inherit; } }