Add new posts for Image Voice Memos, Initial VPS Setup on Debian, Local Webmention Avatars, Security Headers for Astro with Caddy, and Setting up Forgejo Actions Runner

- Created a new post on Image Voice Memos detailing a macOS app for browsing photos and recording voice memos with automatic transcription.
- Added a guide for Initial VPS Setup on Debian covering system updates, user creation, and SSH hardening.
- Introduced a post on caching webmention avatars locally at build time to enhance privacy and comply with CSP.
- Documented the implementation of security headers for an Astro site behind Caddy, focusing on GDPR compliance and CSP.
- Set up a Forgejo Actions runner for self-hosted CI/CD, detailing the installation and configuration process for automated deployments.
This commit is contained in:
Adrian Altner 2026-04-22 23:00:10 +02:00
parent 9d22d93361
commit 4bf4eb03b1
69 changed files with 4904 additions and 344 deletions

221
scripts/migrate-posts.mjs Normal file
View file

@ -0,0 +1,221 @@
#!/usr/bin/env node
// One-shot migration of /Users/adrian/Developer/Websites/Content → src/content/posts/en/.
// Rewrites frontmatter to this project's schema and preserves year/month/day layout.
import { mkdirSync, readFileSync, writeFileSync, copyFileSync, existsSync } from 'node:fs';
import path from 'node:path';
import { globSync } from 'node:fs';
const SRC_ROOT = '/Users/adrian/Developer/Websites/Content';
const DEST_ROOT = '/Users/adrian/Developer/Websites/adrian-altner.de/src/content/posts/en';
// Skip list: starter duplicates and explicitly German .mdx posts.
const SKIP = new Set([
'2022/07/08/first-post.mdx',
'2022/07/15/second-post.mdx',
'2022/07/22/third-post.mdx',
'2024/07/01/using-mdx.mdx',
'2024/07/19/markdown-style-guide.mdx',
'2026/04/06/image-voice-memos.mdx', // superseded by .md sibling
'2026/04/18/justified-layout.mdx', // German
'2026/04/18/maple-mono.mdx', // German
'2026/04/19/diigo-dsgvo-loeschung.mdx', // German
]);
// Category slug normalisation. Keys map from source `[[wiki-link]]` text.
const CATEGORY_MAP = {
'on-premises-private-cloud': 'on-premises-private-cloud',
development: 'development',
Development: 'development',
'in-eigener-sache': 'personal',
projekte: 'projects',
};
// Walk recursively for .md and .mdx files.
function findContentFiles(dir, out = []) {
const fs = require('node:fs');
for (const name of fs.readdirSync(dir)) {
if (name.startsWith('.')) continue;
const full = path.join(dir, name);
const stat = fs.statSync(full);
if (stat.isDirectory()) findContentFiles(full, out);
else if (/\.mdx?$/.test(name)) out.push(full);
}
return out;
}
// Very small inline YAML frontmatter parser. Handles the exact shapes we see
// in the source files: scalar lines, inline arrays [a, b], and block arrays
// introduced with a colon followed by `- item` lines.
function parseFrontmatter(raw) {
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if (!match) return { data: {}, body: raw };
const [, block, body] = match;
const data = {};
const lines = block.split('\n');
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Strip trailing "# comment" (only if preceded by whitespace; avoids
// eating # inside quoted strings). Simple heuristic that matches our data.
const stripped = line.replace(/\s+#\s.*$/, '');
const kv = stripped.match(/^(\w+):\s*(.*)$/);
if (!kv) { i++; continue; }
const key = kv[1];
let value = kv[2].trim();
if (value === '' && i + 1 < lines.length && /^\s+-\s/.test(lines[i + 1])) {
const arr = [];
i++;
while (i < lines.length && /^\s+-\s/.test(lines[i])) {
arr.push(lines[i].replace(/^\s+-\s+/, '').replace(/^["']|["']$/g, ''));
i++;
}
data[key] = arr;
continue;
}
if (value.startsWith('[') && value.endsWith(']')) {
data[key] = value.slice(1, -1).split(',').map((s) => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
} else if (/^(true|false)$/.test(value)) {
data[key] = value === 'true';
} else if (/^-?\d+$/.test(value)) {
data[key] = Number(value);
} else {
data[key] = value.replace(/^["']|["']$/g, '');
}
i++;
}
return { data, body };
}
function wikiLinkInner(value) {
if (typeof value !== 'string') return undefined;
const m = value.match(/^\[\[([^\]]+)\]\]$/);
return m ? m[1] : value;
}
function quoteIfNeeded(s) {
if (/[:#\[\]&*!|>'"%@`]/.test(s) || /^\s|\s$/.test(s)) {
return `'${s.replace(/'/g, "''")}'`;
}
return s;
}
function formatFrontmatter(data) {
const lines = ['---'];
const order = [
'title', 'description', 'pubDate', 'updatedDate',
'heroImage', 'heroAlt', 'hideHero',
'category',
'tags',
'seriesParent', 'seriesOrder',
'url', 'repo', 'toc', 'draft',
'translationKey',
];
for (const key of order) {
if (!(key in data)) continue;
const v = data[key];
if (v === undefined || v === null) continue;
if (Array.isArray(v)) {
if (v.length === 0) continue;
lines.push(`${key}:`);
for (const item of v) lines.push(` - ${quoteIfNeeded(String(item))}`);
} else if (typeof v === 'boolean' || typeof v === 'number') {
lines.push(`${key}: ${v}`);
} else {
lines.push(`${key}: ${quoteIfNeeded(String(v))}`);
}
}
lines.push('---');
return lines.join('\n') + '\n';
}
function transform(srcFile) {
const rel = path.relative(SRC_ROOT, srcFile).replace(/\\/g, '/');
if (SKIP.has(rel)) return null;
const raw = readFileSync(srcFile, 'utf8');
const { data, body } = parseFrontmatter(raw);
const out = {};
out.title = data.title;
out.description = data.description;
// Date: prefer pubDate, fall back to publishDate.
const rawDate = data.pubDate ?? data.publishDate;
if (rawDate !== undefined) {
// Strings like `2026-03-01T18:57:00+01:00` or `2026-03-22` or `Jul 08 2022`
// pass through unchanged; zod's z.coerce.date() parses them.
out.pubDate = String(rawDate);
}
if (data.updatedDate) out.updatedDate = String(data.updatedDate);
// Hero image: `heroImage` in existing format, `cover` in new format.
if (data.heroImage) out.heroImage = data.heroImage;
else if (data.cover) out.heroImage = data.cover;
if (data.coverAlt) out.heroAlt = data.coverAlt;
if (typeof data.hideHero === 'boolean') out.hideHero = data.hideHero;
// Category: `[[wiki-link]]` → en/<mapped-slug>. Plain string (already used
// in some existing posts) stays as en/<slug>.
if (data.category) {
const inner = wikiLinkInner(data.category);
const mapped = CATEGORY_MAP[inner] ?? inner.toLowerCase();
out.category = `en/${mapped}`;
}
// Series: `[[parent-slug]]` → bare parent-slug.
if (data.seriesParent) out.seriesParent = wikiLinkInner(data.seriesParent);
if (typeof data.seriesOrder === 'number') out.seriesOrder = data.seriesOrder;
if (Array.isArray(data.tags) && data.tags.length > 0) out.tags = data.tags;
if (data.url) out.url = data.url;
if (data.repo) out.repo = data.repo;
if (typeof data.toc === 'boolean') out.toc = data.toc;
if (typeof data.draft === 'boolean') out.draft = data.draft;
if (data.translationKey) out.translationKey = data.translationKey;
const destRel = rel; // preserve year/month/day
const destFile = path.join(DEST_ROOT, destRel);
const frontmatter = formatFrontmatter(out);
const content = frontmatter + '\n' + body.replace(/^\n+/, '');
return { destFile, content, srcDir: path.dirname(srcFile), destDir: path.dirname(destFile) };
}
function main() {
const fs = require('node:fs');
const files = findContentFiles(SRC_ROOT);
let written = 0, skipped = 0, assetsCopied = 0;
const destDirs = new Set();
for (const srcFile of files) {
const rel = path.relative(SRC_ROOT, srcFile).replace(/\\/g, '/');
if (SKIP.has(rel)) { skipped++; continue; }
const t = transform(srcFile);
if (!t) { skipped++; continue; }
mkdirSync(t.destDir, { recursive: true });
writeFileSync(t.destFile, t.content);
written++;
destDirs.add(t.srcDir + '|' + t.destDir);
}
// Copy accompanying asset files (images) from each source dir.
for (const pair of destDirs) {
const [srcDir, destDir] = pair.split('|');
for (const name of fs.readdirSync(srcDir)) {
if (name.startsWith('.')) continue;
if (/\.(mdx?|DS_Store)$/i.test(name)) continue;
const srcAsset = path.join(srcDir, name);
const destAsset = path.join(destDir, name);
if (!existsSync(destAsset)) {
copyFileSync(srcAsset, destAsset);
assetsCopied++;
}
}
}
console.log(`wrote ${written} post files, skipped ${skipped}, copied ${assetsCopied} assets`);
}
// Node ESM doesn't provide require by default — fall back to createRequire.
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
main();