Initial commit: Astro 6 static blog site
All checks were successful
Deploy / deploy (push) Successful in 49s

- German (default) and English i18n support
- Categories and tags
- Blog posts with hero images
- Dark/light theme switcher
- View Transitions removed to fix reload ghost images
- Webmentions integration
- RSS feeds per locale

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Altner 2026-04-22 10:55:29 +02:00
commit 5bb63bacf5
95 changed files with 12199 additions and 0 deletions

424
scripts/metadata.ts Normal file
View file

@ -0,0 +1,424 @@
#!/usr/bin/env -S node --experimental-strip-types
import { writeFile } from "node:fs/promises";
import { basename, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { consola } from "consola";
import sharp from "sharp";
import {
getImagesMissingMetadata,
getMetadataPathForImage,
getPhotoAbsolutePath,
getPhotoDirectories,
PHOTOS_DIRECTORY,
} from "../src/lib/photo-albums.ts";
const PHOTOS_DIR = PHOTOS_DIRECTORY;
// ─── IPTC parser ────────────────────────────────────────────────────────────
interface IptcFields {
title?: string;
caption?: string;
keywords?: string[];
dateCreated?: string;
timeCreated?: string;
}
function parseIptc(buf: Buffer): IptcFields {
const fields: IptcFields = {};
let i = 0;
while (i < buf.length - 4) {
if (buf[i] !== 0x1c) {
i++;
continue;
}
const record = buf[i + 1];
const dataset = buf[i + 2];
const len = buf.readUInt16BE(i + 3);
const value = buf.subarray(i + 5, i + 5 + len).toString("utf8");
i += 5 + len;
if (record !== 2) continue;
switch (dataset) {
case 5:
fields.title = value;
break;
case 25:
fields.keywords ??= [];
fields.keywords.push(value);
break;
case 55:
fields.dateCreated = value;
break;
case 60:
fields.timeCreated = value;
break;
case 120:
fields.caption = value;
break;
}
}
return fields;
}
// ─── XMP parser ─────────────────────────────────────────────────────────────
interface XmpFields {
title?: string;
description?: string;
keywords?: string[];
lens?: string | undefined;
createDate?: string | undefined;
}
function extractRdfLiValues(xml: string, tagName: string): string[] {
const re = new RegExp(`<${tagName}[^>]*>[\\s\\S]*?<\\/${tagName}>`, "i");
const match = xml.match(re);
if (!match) return [];
const liRe = /<rdf:li[^>]*>([^<]*)<\/rdf:li>/gi;
const values: string[] = [];
for (let m = liRe.exec(match[0]); m !== null; m = liRe.exec(match[0])) {
if (m[1]?.trim()) values.push(m[1].trim());
}
return values;
}
function extractXmpAttr(xml: string, attr: string): string | undefined {
const re = new RegExp(`${attr}="([^"]*)"`, "i");
return xml.match(re)?.[1] ?? undefined;
}
function parseXmp(buf: Buffer): XmpFields {
const xml = buf.toString("utf8");
const fields: XmpFields = {};
const titles = extractRdfLiValues(xml, "dc:title");
if (titles[0]) fields.title = titles[0];
const descriptions = extractRdfLiValues(xml, "dc:description");
if (descriptions[0]) fields.description = descriptions[0];
const subjects = extractRdfLiValues(xml, "dc:subject");
if (subjects.length > 0) fields.keywords = subjects;
fields.lens = extractXmpAttr(xml, "aux:Lens");
fields.createDate = extractXmpAttr(xml, "xmp:CreateDate");
return fields;
}
// ─── EXIF parser (minimal TIFF IFD0 + SubIFD) ──────────────────────────────
interface ExifFields {
model?: string;
lensModel?: string;
fNumber?: number;
focalLength?: string;
exposureTime?: string;
iso?: number;
dateTimeOriginal?: string;
gpsLatitude?: string;
gpsLongitude?: string;
gpsLatitudeRef?: string;
gpsLongitudeRef?: string;
}
function parseExifBuffer(buf: Buffer): ExifFields {
// Skip "Exif\0\0" header if present
let offset = 0;
if (
buf[0] === 0x45 &&
buf[1] === 0x78 &&
buf[2] === 0x69 &&
buf[3] === 0x66
) {
offset = 6;
}
const isLE = buf[offset] === 0x49; // "II" = little endian
const read16 = isLE
? (o: number) => buf.readUInt16LE(offset + o)
: (o: number) => buf.readUInt16BE(offset + o);
const read32 = isLE
? (o: number) => buf.readUInt32LE(offset + o)
: (o: number) => buf.readUInt32BE(offset + o);
const readRational = (o: number): number => {
const num = read32(o);
const den = read32(o + 4);
return den === 0 ? 0 : num / den;
};
const readString = (o: number, len: number): string => {
return buf
.subarray(offset + o, offset + o + len)
.toString("ascii")
.replace(/\0+$/, "");
};
const fields: ExifFields = {};
const parseIfd = (ifdOffset: number, parseGps = false) => {
if (ifdOffset + 2 > buf.length - offset) return;
const count = read16(ifdOffset);
for (let i = 0; i < count; i++) {
const entryOffset = ifdOffset + 2 + i * 12;
if (entryOffset + 12 > buf.length - offset) break;
const tag = read16(entryOffset);
const type = read16(entryOffset + 2);
const numValues = read32(entryOffset + 4);
const valueOffset = read32(entryOffset + 8);
// For values that fit in 4 bytes, data is inline at entryOffset+8
const dataOffset =
type === 2 && numValues <= 4
? entryOffset + 8
: type === 5 || numValues > 4
? valueOffset
: entryOffset + 8;
if (parseGps) {
switch (tag) {
case 1: // GPSLatitudeRef
fields.gpsLatitudeRef = readString(entryOffset + 8, 2);
break;
case 2: // GPSLatitude
if (dataOffset + 24 <= buf.length - offset) {
const d = readRational(dataOffset);
const m = readRational(dataOffset + 8);
const s = readRational(dataOffset + 16);
fields.gpsLatitude = `${d} deg ${Math.floor(m)}' ${s.toFixed(2)}"`;
}
break;
case 3: // GPSLongitudeRef
fields.gpsLongitudeRef = readString(entryOffset + 8, 2);
break;
case 4: // GPSLongitude
if (dataOffset + 24 <= buf.length - offset) {
const d = readRational(dataOffset);
const m = readRational(dataOffset + 8);
const s = readRational(dataOffset + 16);
fields.gpsLongitude = `${d} deg ${Math.floor(m)}' ${s.toFixed(2)}"`;
}
break;
}
continue;
}
switch (tag) {
case 0x0110: // Model
if (dataOffset + numValues <= buf.length - offset) {
fields.model = readString(dataOffset, numValues);
}
break;
case 0x8769: // ExifIFD pointer
parseIfd(valueOffset);
break;
case 0x8825: // GPS IFD pointer
parseIfd(valueOffset, true);
break;
case 0x829a: // ExposureTime
if (dataOffset + 8 <= buf.length - offset) {
const num = read32(dataOffset);
const den = read32(dataOffset + 4);
fields.exposureTime =
den > num ? `1/${Math.round(den / num)}` : `${num / den}`;
}
break;
case 0x829d: // FNumber
if (dataOffset + 8 <= buf.length - offset) {
fields.fNumber = readRational(dataOffset);
}
break;
case 0x8827: // ISO
fields.iso = type === 3 ? read16(entryOffset + 8) : valueOffset;
break;
case 0x9003: // DateTimeOriginal
if (dataOffset + numValues <= buf.length - offset) {
fields.dateTimeOriginal = readString(dataOffset, numValues);
}
break;
case 0x920a: // FocalLength
if (dataOffset + 8 <= buf.length - offset) {
const fl = readRational(dataOffset);
fields.focalLength = fl.toFixed(1).replace(/\.0$/, "");
}
break;
case 0xa434: // LensModel
if (dataOffset + numValues <= buf.length - offset) {
fields.lensModel = readString(dataOffset, numValues);
}
break;
}
}
};
const ifdOffset = read32(4);
parseIfd(ifdOffset);
return fields;
}
// ─── Merged metadata ────────────────────────────────────────────────────────
interface ImageMetadata {
id: string;
title: string[];
image: string;
alt: string;
location: string;
date: string;
tags: string[];
exif: {
camera: string;
lens: string;
aperture: string;
iso: string;
focal_length: string;
shutter_speed: string;
};
}
function formatGpsLocation(exif: ExifFields): string {
if (!exif.gpsLatitude || !exif.gpsLongitude) return "";
const latRef = exif.gpsLatitudeRef ?? "N";
const lonRef = exif.gpsLongitudeRef ?? "E";
return `${exif.gpsLatitude} ${latRef}, ${exif.gpsLongitude} ${lonRef}`;
}
function formatDate(raw: string | undefined): string {
if (!raw) return "";
// Handle "YYYY:MM:DD HH:MM:SS" or "YYYYMMDD" or "YYYY-MM-DDTHH:MM:SS"
if (/^\d{8}$/.test(raw)) {
return `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}`;
}
const [datePart] = raw.split(/[T ]/);
if (!datePart) return "";
return datePart.replaceAll(":", "-");
}
async function extractMetadata(imagePath: string): Promise<ImageMetadata> {
const meta = await sharp(imagePath).metadata();
const fileName = basename(imagePath);
const iptc = meta.iptc ? parseIptc(meta.iptc) : ({} as IptcFields);
const xmp = meta.xmp ? parseXmp(meta.xmp) : ({} as XmpFields);
const exif = meta.exif ? parseExifBuffer(meta.exif) : ({} as ExifFields);
const title = iptc.title || xmp.title || "";
const caption = iptc.caption || xmp.description || "";
const keywords = iptc.keywords ?? xmp.keywords ?? [];
const date = formatDate(
exif.dateTimeOriginal ?? xmp.createDate ?? iptc.dateCreated,
);
if (!title && !caption) {
consola.warn(`No title or caption found in ${fileName}`);
}
return {
id: fileName.replace(/\.jpg$/i, ""),
title: title ? [title] : [],
image: `./${fileName}`,
alt: caption,
location: formatGpsLocation(exif),
date,
tags: keywords,
exif: {
camera: exif.model ?? "",
lens: exif.lensModel || xmp.lens || "",
aperture: exif.fNumber?.toString() ?? "",
iso: exif.iso?.toString() ?? "",
focal_length: exif.focalLength?.replace(/ mm$/, "") ?? "",
shutter_speed: exif.exposureTime ?? "",
},
};
}
// ─── CLI ────────────────────────────────────────────────────────────────────
interface CliOptions {
refresh: boolean;
photosDirectory: string;
}
function parseCliOptions(argv: string[]): CliOptions {
const nonFlagArgs = argv.filter((arg) => !arg.startsWith("--"));
return {
refresh: argv.includes("--refresh"),
photosDirectory: resolve(nonFlagArgs[0] ?? PHOTOS_DIR),
};
}
async function getImagesToProcess(
photosDirectory: string,
options: Pick<CliOptions, "refresh">,
): Promise<string[]> {
const relativeImagePaths = options.refresh
? (await getPhotoDirectories(photosDirectory)).flatMap((d) => d.imagePaths)
: await getImagesMissingMetadata(photosDirectory);
consola.info(
options.refresh
? `Refreshing ${relativeImagePaths.length} image(s)`
: `Found ${relativeImagePaths.length} image(s) without metadata`,
);
return relativeImagePaths.map((p) =>
getPhotoAbsolutePath(p, photosDirectory),
);
}
async function main() {
consola.start("Checking for images to process...");
const opts = parseCliOptions(process.argv.slice(2));
const images = await getImagesToProcess(opts.photosDirectory, opts);
if (images.length === 0) {
consola.success(
opts.refresh
? "No images found to refresh."
: "No images require metadata.",
);
return;
}
for (let i = 0; i < images.length; i++) {
const imagePath = images[i] as string;
const rel = relative(process.cwd(), imagePath);
consola.info(`Processing ${i + 1}/${images.length}: ${rel}`);
const metadata = await extractMetadata(imagePath);
const relativeImagePath = relative(opts.photosDirectory, imagePath);
const jsonPath = getMetadataPathForImage(
relativeImagePath,
opts.photosDirectory,
);
await writeFile(jsonPath, JSON.stringify(metadata, null, 2));
consola.info(`Wrote ${relative(process.cwd(), jsonPath)}`);
}
consola.success(`Processed ${images.length} image(s).`);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
try {
await main();
} catch (error) {
consola.error(error);
process.exit(1);
}
}