chore(release): v0.10.0 (#400)

Photo Essay mode, client proofing, studio-modern preset, admin panel overhaul, watermark, cookieless analytics, subpage toggle, SEO title templates. Analytics API now admin-only, Trivy scan on publish. Album order mirrors the Immich timeline; map theme, admin status panel, lightbox counter and Docker health check fixed.

See CHANGELOG.md for the full list and the health-check upgrade note.
This commit is contained in:
Ralksta
2026-08-10 09:56:20 +02:00
committed by GitHub
parent c9036fdc94
commit 2318409e75
78 changed files with 3848 additions and 553 deletions

View File

@@ -1,201 +1,728 @@
/**
* Theme Screenshot Generator
* Documentation Screenshot Generator
*
* Captures every screenshot referenced by README.md and docs/theming.md:
* theme presets, hero styles, grid layouts, light mode, the standard pages
* (subpage overview, about, map), the lightbox with its EXIF panel, a mobile
* viewport and — if ADMIN_PASSWORD is set — the admin panel.
*
* Captures homepage and grid view screenshots for each theme preset.
* Uses Playwright to wait for images to fully load (not just blurhash placeholders).
*
* Usage:
* npx tsx scripts/screenshots.ts
* SCREENSHOT_GRID_PATH=/japan/koyasan-2023 npx tsx scripts/screenshots.ts
* SCREENSHOT_PORT=3100 npx tsx scripts/screenshots.ts
* SCREENSHOT_ONLY=admin,lightbox npx tsx scripts/screenshots.ts
*
* The album used for the grid shots is discovered by walking the running site,
* since album slugs come from Immich album names. SCREENSHOT_GRID_PATH picks a
* specific one instead, SCREENSHOT_SUBPAGE_PATH the collection overview.
*
* Prerequisites:
* - Dev server NOT running (script manages its own server lifecycle)
* - Nothing else listening on SCREENSHOT_PORT (the script manages its own
* server; a foreign server on that port would be screenshot instead)
* - Playwright browsers installed: npx playwright install chromium
*
* Note: this rewrites content/settings.yaml, content/gallery.yaml and
* content/about.md while it runs and restores all three afterwards — including
* on Ctrl+C. On a host serving live traffic from the same files, the public site
* changes with every capture. Run it against a copy of the repo instead.
*
* Because the screenshots end up in a public repository, the run replaces the
* site identity (title, footer, watermark, legal block, about page) with neutral
* demo values and blanks out every stored password — see DEMO_SETTINGS.
*
* Output:
* docs/screenshots/theme-{preset}-{page}.png
* docs/screenshots/*.png
*/
import { spawn, type ChildProcess } from 'child_process';
import fs from 'fs';
import path from 'path';
import yaml from 'js-yaml';
import type { Browser, Page } from '@playwright/test';
// Next reads .env.local itself; this script runs outside Next, so ADMIN_PASSWORD
// and friends have to be pulled in explicitly. Must run before the constants below.
loadEnvLocal();
const THEMES = ['studio', 'studio-modern', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
const HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic'];
const GRID_LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow'];
/** Preset used for everything that is not itself a theme comparison. */
const SHOWCASE_THEME = 'studio-modern';
const PORT = process.env.SCREENSHOT_PORT ?? '3000';
const BASE_URL = `http://localhost:${PORT}`;
/**
* Album detail page used for the grid screenshots. Discovered by walking the
* running site, because album slugs come from Immich album names and differ per
* installation. Set SCREENSHOT_GRID_PATH to pick a specific album instead.
*/
const GRID_PATH_OVERRIDE = process.env.SCREENSHOT_GRID_PATH;
/** Collection overview page. Derived from the grid path when not set. */
const SUBPAGE_PATH_OVERRIDE = process.env.SCREENSHOT_SUBPAGE_PATH;
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
/**
* Comma-separated section keys to capture, e.g. `SCREENSHOT_ONLY=admin,lightbox`.
* A full pass takes the better part of an hour, so re-shooting one section after
* a layout change should not mean redoing all of it. Empty means everything.
*/
const ONLY_SECTIONS = (process.env.SCREENSHOT_ONLY ?? '')
.split(',')
.map((key) => key.trim())
.filter(Boolean);
const wanted = (key: string) => ONLY_SECTIONS.length === 0 || ONLY_SECTIONS.includes(key);
const THEMES = ['studio', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
const BASE_URL = 'http://localhost:3000';
const GRID_PATH = '/deutschland/kloster-chorin'; // public subpage with photos
const OUTPUT_DIR = path.join(process.cwd(), 'docs', 'screenshots');
const SETTINGS_YAML = path.join(process.cwd(), 'content', 'settings.yaml');
const CONTENT_DIR = path.join(process.cwd(), 'content');
const SETTINGS_YAML = path.join(CONTENT_DIR, 'settings.yaml');
const GALLERY_YAML = path.join(CONTENT_DIR, 'gallery.yaml');
const ABOUT_MD = path.join(CONTENT_DIR, 'about.md');
/** Every file the run rewrites, and therefore has to restore. */
const MANAGED_FILES = [SETTINGS_YAML, GALLERY_YAML, ABOUT_MD];
const backupPath = (file: string) => `${file}.screenshot-bak`;
/** Pages that never carry a photo grid — skipped while looking for an album. */
const EXCLUDED_PATHS = new Set(['/', '/about', '/map', '/impressum', '/admin']);
const VIEWPORT = { width: 1440, height: 900 };
const MOBILE_VIEWPORT = { width: 375, height: 812 };
const IMAGE_LOAD_TIMEOUT = 8000; // ms to wait for images after networkidle
/** Settled once the dev server has re-read the changed YAML (mtime-based cache). */
const CONFIG_RELOAD_DELAY = 400;
/**
* Neutral stand-ins for everything identifying in settings.yaml. The screenshots
* are published, so the site must not show the operator's name, handle, postal
* address or watermark. Dotted keys are merged into the parsed YAML.
*/
const DEMO_SETTINGS: Record<string, unknown> = {
// A run loads every page dozens of times; without this it would write a few
// hundred fake page views into content/analytics.json.
analytics: false,
title: 'My Portfolio Gallery',
'seo.title': 'My Portfolio Gallery',
'footer.name': 'My Portfolio Gallery',
'footer.instagram': 'myportfolio',
'watermark.text': 'Folio ',
'legal.name': 'Jane Doe',
'legal.address': '1 Example Street',
'legal.zipCity': '12345 Example City',
'legal.country': 'Germany',
'legal.extraInfo': '',
};
async function main() {
// Ensure output directory exists
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
// Read original settings.yaml to restore later
const originalYaml = fs.readFileSync(SETTINGS_YAML, 'utf8');
// Read the originals to restore later. The on-disk backups are what the signal
// handlers and the top-level catch restore from — an in-memory copy is gone the
// moment the process is killed, and this script rewrites live content files.
const originals = new Map(MANAGED_FILES.map((f) => [f, fs.readFileSync(f, 'utf8')]));
for (const [file, content] of originals) fs.writeFileSync(backupPath(file), content);
installRestoreHandlers();
// Dynamic import for Playwright (may not be installed globally)
const { chromium } = await import('@playwright/test');
const browser = await chromium.launch();
const originalSettings = originals.get(SETTINGS_YAML)!;
const originalGallery = originals.get(GALLERY_YAML)!;
/** Writes settings.yaml with the demo identity plus the given overrides. */
const applySettings = async (overrides: Record<string, unknown> = {}) => {
fs.writeFileSync(SETTINGS_YAML, settingsWith(originalSettings, overrides));
await sleep(CONFIG_RELOAD_DELAY);
};
// Passwords live in gallery.yaml in plain text and are rendered by the admin
// page builder — blank them for the whole run rather than per screenshot.
fs.writeFileSync(GALLERY_YAML, withRedactedPasswords(originalGallery));
// The about page carries a real name, location and portrait. Swap in demo text
// and use a gallery photo as the portrait so no face ends up in the docs.
fs.writeFileSync(ABOUT_MD, demoAboutMarkdown(originalGallery));
await applySettings();
console.log(`\n⏳ Starting dev server on ${BASE_URL} ...`);
let browser: Browser | undefined;
try {
for (const theme of THEMES) {
console.log(`\n🎨 Theme: ${theme}`);
console.log('─'.repeat(50));
await ensureServerHealthy();
// Update settings.yaml with the theme
const yamlWithTheme = setThemeInSettingsYaml(originalYaml, theme);
fs.writeFileSync(SETTINGS_YAML, yamlWithTheme);
const { chromium } = await import('@playwright/test');
browser = await chromium.launch();
// Start dev server
console.log(' ⏳ Starting dev server...');
const server = startDevServer();
await waitForServer(BASE_URL, 15000);
console.log(' ✅ Server ready');
// ── Resolve the pages used throughout the run ──
const scout = await browser.newContext({ viewport: VIEWPORT });
const scoutPage = await scout.newPage();
const gridPath = GRID_PATH_OVERRIDE ?? (await discoverGridPath(scoutPage));
const subpagePath = SUBPAGE_PATH_OVERRIDE ?? (await resolveSubpagePath(scoutPage, gridPath));
await scout.close();
console.log(`🔎 Album page: ${gridPath}`);
console.log(`🔎 Collection page: ${subpagePath ?? '(none found — skipping)'}`);
const context = await browser.newContext({ viewport: VIEWPORT });
const page = await context.newPage();
// ── Homepage screenshot ──
console.log(' 📸 Capturing homepage...');
await page.goto(BASE_URL, { waitUntil: 'networkidle' });
await waitForImages(page);
await page.screenshot({
path: path.join(OUTPUT_DIR, `theme-${theme}-home.png`),
fullPage: false,
});
console.log(` ✅ theme-${theme}-home.png`);
// ── Grid view screenshot ──
console.log(' 📸 Capturing grid view...');
await page.goto(`${BASE_URL}${GRID_PATH}`, { waitUntil: 'networkidle' });
// Force all fade-in elements visible (don't scroll — keep album title in view)
await page.evaluate(() => {
document.querySelectorAll('.fade-in').forEach((el) => el.classList.add('fade-in--visible'));
// Trigger lazy images by switching to eager loading
document.querySelectorAll('img[loading="lazy"]').forEach((img) => {
(img as HTMLImageElement).loading = 'eager';
// ── Theme presets: homepage + grid ──
if (wanted('themes')) {
for (const theme of THEMES) {
section(`Theme: ${theme}`);
await applySettings({ 'theme.preset': theme });
await withPage(browser, VIEWPORT, async (page) => {
await captureHome(page, `theme-${theme}-home`);
await captureGrid(page, gridPath, `theme-${theme}-grid`);
});
});
await waitForImages(page);
// Extra settle time for late-loading images in lower columns
await page.waitForTimeout(3000);
await page.screenshot({
path: path.join(OUTPUT_DIR, `theme-${theme}-grid.png`),
fullPage: false,
});
console.log(` ✅ theme-${theme}-grid.png`);
await context.close();
// Stop dev server
if (server.pid) {
process.kill(-server.pid, 'SIGTERM');
}
await new Promise((resolve) => {
const timeout = setTimeout(resolve, 3000);
server.on('close', () => {
clearTimeout(timeout);
resolve(null);
});
});
console.log(' 🛑 Server stopped');
}
const HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic'];
for (const hero of HERO_STYLES) {
console.log(`\n🖼 Hero Style: ${hero} (Theme: studio)`);
console.log('─'.repeat(50));
// ── Light mode ──
if (wanted('light')) {
section('Light mode');
await applySettings({ 'theme.preset': SHOWCASE_THEME });
await withPage(
browser,
VIEWPORT,
async (page) => {
await captureHome(page, `theme-${SHOWCASE_THEME}-home-light`);
await captureGrid(page, gridPath, `theme-${SHOWCASE_THEME}-grid-light`);
},
// ThemeToggle reads this on module load and sets data-theme on <html>.
() => window.localStorage.setItem('theme', 'light'),
);
}
const yamlWithTheme = setThemeInSettingsYaml(originalYaml, 'studio');
const yamlWithHero = setHeroStyleInSettingsYaml(yamlWithTheme, hero);
fs.writeFileSync(SETTINGS_YAML, yamlWithHero);
console.log(' ⏳ Starting dev server...');
const server = startDevServer();
await waitForServer(BASE_URL, 15000);
console.log(' ✅ Server ready');
const context = await browser.newContext({ viewport: VIEWPORT });
const page = await context.newPage();
console.log(' 📸 Capturing homepage hero...');
await page.goto(BASE_URL, { waitUntil: 'networkidle' });
await waitForImages(page);
await page.screenshot({
path: path.join(OUTPUT_DIR, `hero-${hero}-home.png`),
fullPage: false,
});
console.log(` ✅ hero-${hero}-home.png`);
await context.close();
if (server.pid) {
process.kill(-server.pid, 'SIGTERM');
// ── Hero styles ──
if (wanted('hero')) {
for (const hero of HERO_STYLES) {
section(`Hero style: ${hero} (theme: studio)`);
await applySettings({ 'theme.preset': 'studio', 'theme.heroStyle': hero });
await withPage(browser, VIEWPORT, (page) => captureHome(page, `hero-${hero}-home`));
}
await new Promise((resolve) => {
const timeout = setTimeout(resolve, 3000);
server.on('close', () => {
clearTimeout(timeout);
resolve(null);
});
}
// ── Grid layouts ──
if (wanted('grid')) {
for (const layout of GRID_LAYOUTS) {
section(`Grid layout: ${layout}`);
await applySettings({ 'theme.preset': SHOWCASE_THEME, 'grid.layout': layout });
await withPage(browser, VIEWPORT, (page) => captureGrid(page, gridPath, `grid-${layout}`));
}
}
// ── Standard pages ──
if (wanted('pages')) {
section('Pages: collection, about, map');
await applySettings({ 'theme.preset': SHOWCASE_THEME });
await withPage(browser, VIEWPORT, async (page) => {
if (subpagePath) await captureGrid(page, subpagePath, 'page-collection');
await captureHome(page, 'page-about', '/about');
await capturePage(page, '/map', 'page-map', 6000);
});
console.log(' 🛑 Server stopped');
}
// ── Lightbox with EXIF panel ──
if (wanted('lightbox')) {
section('Lightbox');
await applySettings({ 'theme.preset': SHOWCASE_THEME });
await withPage(browser, VIEWPORT, (page) => captureLightbox(page, gridPath));
}
// ── Mobile viewport ──
if (wanted('mobile')) {
section('Mobile (375x812)');
await applySettings({ 'theme.preset': SHOWCASE_THEME });
await withPage(browser, MOBILE_VIEWPORT, async (page) => {
await captureHome(page, 'mobile-home');
await captureGrid(page, gridPath, 'mobile-grid');
});
}
// ── Admin panel ──
if (wanted('admin')) {
await applySettings({ 'theme.preset': SHOWCASE_THEME });
await captureAdmin(browser);
}
} finally {
// Always restore original settings.yaml
fs.writeFileSync(SETTINGS_YAML, originalYaml);
console.log('\n✅ Restored original gallery.yaml');
await browser.close();
await browser?.close();
await stopDevServer();
restoreManagedFiles(originals);
}
await optimizePngs();
if (failedSections.length > 0) {
console.log(`\n⚠️ Incomplete sections: ${failedSections.join(', ')}`);
}
console.log(`\n🎉 Done! Screenshots saved to ${OUTPUT_DIR}/`);
console.log(
` ${fs
.readdirSync(OUTPUT_DIR)
.filter((f) => f.endsWith('.png'))
.join(', ')}`,
for (const file of fs
.readdirSync(OUTPUT_DIR)
.filter((f) => f.endsWith('.png'))
.sort()) {
const kb = Math.round(fs.statSync(path.join(OUTPUT_DIR, file)).size / 1024);
console.log(` ${file} (${kb} KB)`);
}
}
/**
* Re-encode the PNGs losslessly. Playwright optimises for capture speed, not
* file size — a plain re-encode takes a full-page photo grid from ~2 MB to
* ~700 KB with identical pixels. sharp ships with Next.js but is not a declared
* dependency here, so a missing install just skips this step.
*/
async function optimizePngs(): Promise<void> {
let sharp: typeof import('sharp');
try {
sharp = (await import('sharp')).default;
} catch {
console.log('\n⏭ sharp not available — screenshots left as captured.');
return;
}
console.log('\n🗜 Optimising PNGs...');
let before = 0;
let after = 0;
for (const file of fs.readdirSync(OUTPUT_DIR).filter((f) => f.endsWith('.png'))) {
const filePath = path.join(OUTPUT_DIR, file);
before += fs.statSync(filePath).size;
const optimised = await sharp(filePath).png({ compressionLevel: 9, effort: 10 }).toBuffer();
// Never grow a file — some captures are already minimal.
if (optimised.length < fs.statSync(filePath).size) fs.writeFileSync(filePath, optimised);
after += fs.statSync(filePath).size;
}
console.log(` ${Math.round(before / 1024)} KB → ${Math.round(after / 1024)} KB`);
}
// ── Capture helpers ──────────────────────────────────────────────
let currentSection = '';
/** Sections that threw, reported again at the end of the run. */
const failedSections: string[] = [];
function section(title: string): void {
currentSection = title;
console.log(`\n📷 ${title}`);
console.log('─'.repeat(50));
}
/**
* Run a capture block in a fresh browser context, so localStorage (light mode)
* and cookies (admin session) never leak into the next block.
*
* A block is retried once — a wedged dev server is replaced in between — and
* then reported and skipped rather than aborting the run: a full pass takes many
* minutes, and losing the remaining thirty screenshots because one page
* misbehaved is worse than a gap in the output. Re-running a block simply
* overwrites the screenshots it had already taken.
*/
async function withPage(
browser: Browser,
viewport: { width: number; height: number },
fn: (page: Page) => Promise<void>,
initScript?: () => void,
): Promise<void> {
for (let attempt = 1; attempt <= 2; attempt++) {
await ensureServerHealthy();
const context = await browser.newContext({ viewport });
// The dev server compiles routes on first hit, which can exceed Playwright's
// 30s default right after a settings change.
context.setDefaultNavigationTimeout(90000);
context.setDefaultTimeout(30000);
if (initScript) await context.addInitScript(initScript);
const page = await context.newPage();
try {
await fn(page);
return;
} catch (err) {
const reason = String(err).split('\n')[0];
if (attempt === 1) {
console.error(` ↻ ${currentSection} failed (${reason}) — retrying`);
} else {
failedSections.push(currentSection);
console.error(` ⚠️ ${currentSection} failed: ${reason}`);
}
} finally {
await context.close();
}
}
}
async function captureHome(page: Page, name: string, urlPath = '/'): Promise<void> {
await page.goto(`${BASE_URL}${urlPath}`, { waitUntil: 'networkidle' });
await revealDeferredContent(page);
await waitForImages(page);
await capture(page, name);
}
async function captureGrid(page: Page, urlPath: string, name: string): Promise<void> {
await capturePage(page, urlPath, name, 3000);
}
/** Load a page, reveal everything that fades or lazy-loads in, then shoot it. */
async function capturePage(
page: Page,
urlPath: string,
name: string,
settleMs: number,
): Promise<void> {
await page.goto(`${BASE_URL}${urlPath}`, { waitUntil: 'networkidle' });
await revealDeferredContent(page);
await waitForImages(page);
// Extra settle time for late-loading images in lower columns
await page.waitForTimeout(settleMs);
await capture(page, name);
}
/**
* Open the first still image of an album in the lightbox and expand the EXIF
* panel. Videos are skipped — their tile opens a player, not a photo.
*/
async function captureLightbox(page: Page, gridPath: string): Promise<void> {
await page.goto(`${BASE_URL}${gridPath}`, { waitUntil: 'networkidle' });
await revealDeferredContent(page);
await waitForImages(page);
const tiles = page.locator('.photo-grid__item');
const count = await tiles.count();
for (let i = 0; i < count; i++) {
const tile = tiles.nth(i);
if ((await tile.locator('.photo-grid__item-play').count()) > 0) continue;
await tile.click();
break;
}
await page.keyboard.press('i');
await page.locator('#exif-panel').waitFor({ state: 'visible', timeout: 5000 });
await waitForImages(page);
await page.waitForTimeout(1500);
await capture(page, 'lightbox-exif');
}
/**
* Admin panel: login screen, page builder, album picker and settings editor.
* Skipped with a warning when ADMIN_PASSWORD is unset — the panel is optional
* and a missing password should not fail the whole run.
*/
async function captureAdmin(browser: Browser): Promise<void> {
section('Admin panel');
if (!ADMIN_PASSWORD) {
console.log(' ⏭ ADMIN_PASSWORD not set — skipping the admin screenshots.');
return;
}
await withPage(browser, VIEWPORT, async (page) => {
await page.goto(`${BASE_URL}/admin`, { waitUntil: 'networkidle' });
await page.locator('.admin-login-card').waitFor({ timeout: 15000 });
await capture(page, 'admin-login');
await page.locator('.admin-login-card input[type="password"]').fill(ADMIN_PASSWORD);
await page.locator('.admin-login-card button[type="submit"]').click();
await page.locator('.admin-tabs').waitFor({ timeout: 20000 });
await page.waitForTimeout(2500);
await waitForImages(page);
await capture(page, 'admin-page-builder');
await captureAlbumPicker(page);
// Reload rather than trusting Escape to have closed the picker — a modal
// still on screen swallows the click on the settings tab.
await page.reload({ waitUntil: 'networkidle' });
await page.locator('.admin-tabs').waitFor({ timeout: 20000 });
await page.locator('.admin-tab', { hasText: 'Settings' }).first().click();
await page.waitForTimeout(1500);
await capture(page, 'admin-settings');
});
}
/** Best effort: the picker is a modal behind a button whose label may change. */
async function captureAlbumPicker(page: Page): Promise<void> {
const trigger = page.getByRole('button', { name: /add album/i }).first();
if ((await trigger.count()) === 0) {
console.log(' ⏭ No "Add album" button found — skipping the album picker shot.');
return;
}
try {
await trigger.click();
await page.locator('input[placeholder*="Search" i]').first().waitFor({ timeout: 10000 });
await page.waitForTimeout(2000);
await waitForImages(page);
await capture(page, 'admin-album-picker');
await page.keyboard.press('Escape');
await page.waitForTimeout(500);
} catch {
console.log(' ⏭ Album picker did not open in time — skipping that shot.');
}
}
async function capture(page: Page, name: string): Promise<void> {
await hideDevOverlays(page);
await page.screenshot({ path: path.join(OUTPUT_DIR, `${name}.png`), fullPage: false });
console.log(` ✅ ${name}.png`);
}
/**
* Reveal content that only appears on scroll or interaction: fade-in wrappers
* stay transparent until their observer fires, and lazy images never load
* below the fold. Both would otherwise be captured empty.
*/
async function revealDeferredContent(page: Page): Promise<void> {
await page.evaluate(() => {
document.querySelectorAll('.fade-in').forEach((el) => el.classList.add('fade-in--visible'));
document.querySelectorAll('img[loading="lazy"]').forEach((img) => {
(img as HTMLImageElement).loading = 'eager';
});
});
}
/** Next.js' dev indicator and the in-app dev toolbar are not part of the product. */
async function hideDevOverlays(page: Page): Promise<void> {
await page.evaluate(() => {
document.querySelectorAll('nextjs-portal').forEach((el) => el.remove());
document.querySelectorAll('[aria-label="Dev Toolbar"]').forEach((el) => el.remove());
});
}
// ── Environment ──────────────────────────────────────────────────
/**
* Minimal .env.local reader. Next loads the file for the dev server it spawns,
* but this process needs ADMIN_PASSWORD too, and real environment variables
* must keep winning over the file.
*/
function loadEnvLocal(): void {
const envFile = path.join(process.cwd(), '.env.local');
if (!fs.existsSync(envFile)) return;
for (const line of fs.readFileSync(envFile, 'utf8').split('\n')) {
const match = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
if (!match || line.trimStart().startsWith('#')) continue;
const [, key, rawValue] = match;
if (process.env[key] !== undefined) continue;
process.env[key] = rawValue.trim().replace(/^(["'])(.*)\1$/, '$2');
}
}
// ── Content rewriting ────────────────────────────────────────────
/**
* Parse settings.yaml, merge the demo identity plus `overrides` (dotted keys)
* and dump it back. Comments are lost in the temporary file, which is fine —
* the original is restored verbatim from its backup, never re-serialised.
*/
function settingsWith(original: string, overrides: Record<string, unknown>): string {
const doc = (yaml.load(original) ?? {}) as Record<string, unknown>;
for (const [key, value] of Object.entries({ ...DEMO_SETTINGS, ...overrides })) {
setPath(doc, key, value);
}
return yaml.dump(doc, { lineWidth: 0 });
}
function setPath(target: Record<string, unknown>, dottedKey: string, value: unknown): void {
const keys = dottedKey.split('.');
let node = target;
for (const key of keys.slice(0, -1)) {
if (typeof node[key] !== 'object' || node[key] === null) node[key] = {};
node = node[key] as Record<string, unknown>;
}
node[keys[keys.length - 1]] = value;
}
/** Replace every `password:` value in gallery.yaml with a throwaway one. */
function withRedactedPasswords(original: string): string {
const doc = yaml.load(original);
const walk = (node: unknown): void => {
if (Array.isArray(node)) return node.forEach(walk);
if (typeof node !== 'object' || node === null) return;
const record = node as Record<string, unknown>;
for (const [key, value] of Object.entries(record)) {
if (key === 'password' && typeof value === 'string') record[key] = 'demo';
else walk(value);
}
};
walk(doc);
return yaml.dump(doc, { lineWidth: 0 });
}
/**
* A demo about page. The portrait is taken from the gallery's hero images so the
* layout is shown with a real photo without publishing the operator's face, and
* without hardcoding an asset UUID into the repository.
*/
function demoAboutMarkdown(galleryYaml: string): string {
const gallery = (yaml.load(galleryYaml) ?? {}) as { hero?: unknown };
const hero = Array.isArray(gallery.hero) ? gallery.hero : [];
const portrait = typeof hero[0] === 'string' ? hero[0] : '';
const frontmatter = [
portrait ? `portrait: ${portrait}` : null,
'name: Alex Rivera',
'location: Kyoto, Japan',
'gear:',
' - Leica Q3',
' - Summilux 35mm f/1.4',
' - Summicron 50mm f/2',
].filter((line): line is string => line !== null);
return [
'---',
...frontmatter,
'---',
'',
'Photographer and traveller.',
'Obsessed with light, geometry, and the quiet in-between.',
'',
].join('\n');
}
// ── Page discovery ───────────────────────────────────────────────
/**
* Find an album detail page by walking the running site.
*
* Album slugs are derived from Immich album names, so no path can be hardcoded
* without tying the script to one installation. Start at the homepage, follow
* internal links, and take the first page that renders a photo grid — either a
* standalone album, or an album reached through a subpage's cover grid.
* Password-gated pages are skipped.
*/
async function discoverGridPath(page: Page): Promise<string> {
const seen = new Set<string>();
const internalLinks = async (selector: string): Promise<string[]> =>
(
await page.$$eval(selector, (els) =>
els.map((el) => el.getAttribute('href')).filter((h): h is string => !!h),
)
).filter((href) => href.startsWith('/') && !EXCLUDED_PATHS.has(href) && !seen.has(href));
/** A photo grid means we arrived at an album; a password field means we cannot. */
const pageKind = async (): Promise<'grid' | 'gated' | 'other'> => {
if ((await page.locator('input[type="password"]').count()) > 0) return 'gated';
if ((await page.locator('.photo-grid, .essay').count()) > 0) return 'grid';
return 'other';
};
await page.goto(BASE_URL, { waitUntil: 'networkidle' });
const entryPoints = await internalLinks('a[href^="/"]');
for (const entry of entryPoints) {
seen.add(entry);
await page.goto(`${BASE_URL}${entry}`, { waitUntil: 'networkidle' });
const kind = await pageKind();
if (kind === 'gated') continue;
if (kind === 'grid') return entry;
// A subpage listing — descend into its album covers.
for (const cover of await internalLinks('.subpage-grid__item, .album-card')) {
seen.add(cover);
await page.goto(`${BASE_URL}${cover}`, { waitUntil: 'networkidle' });
if ((await pageKind()) === 'grid') return cover;
}
}
throw new Error(
'Could not find a public album page to screenshot. Every reachable page was ' +
'password-protected or empty. Point the script at a specific album with ' +
'SCREENSHOT_GRID_PATH=/my-subpage/my-album npx tsx scripts/screenshots.ts',
);
}
// ── Helpers ──────────────────────────────────────────────────────
/**
* Replace the theme preset in settings.yaml.
* Handles both `theme: preset` shorthand and `theme:\n preset: "x"` block.
* The collection overview belonging to the album, i.e. `/japan` for
* `/japan/koyasan-2023`. Standalone albums have no parent, so fall back to the
* first navigation entry that renders a subpage grid; null if there is none.
*/
function setThemeInSettingsYaml(yaml: string, theme: string): string {
// Handle ` preset: "studio"` or ` preset: studio` (block format)
if (/^ preset:\s*/m.test(yaml)) {
return yaml.replace(/^ preset:\s*.+$/m, ` preset: "${theme}"`);
async function resolveSubpagePath(page: Page, gridPath: string): Promise<string | null> {
const segments = gridPath.split('/').filter(Boolean);
if (segments.length > 1) return `/${segments[0]}`;
const links = await page.$$eval('nav a[href^="/"]', (els) =>
els.map((el) => el.getAttribute('href')).filter((h): h is string => !!h),
);
for (const href of links) {
if (EXCLUDED_PATHS.has(href) || href === gridPath) continue;
await page.goto(`${BASE_URL}${href}`, { waitUntil: 'networkidle' });
if ((await page.locator('.subpage-grid').count()) > 0) return href;
}
// Handle `theme: studio` shorthand
if (/^theme:\s*\w+\s*$/m.test(yaml)) {
return yaml.replace(/^theme:\s*\w+\s*$/m, `theme: ${theme}`);
return null;
}
// ── Content restore ──────────────────────────────────────────────
function restoreManagedFiles(originals: Map<string, string>): void {
for (const [file, content] of originals) {
fs.writeFileSync(file, content);
fs.rmSync(backupPath(file), { force: true });
}
// Fallback: insert block after the theme comment
const marker = '# Presets: studio, minimal, editorial, classic, noir, monograph';
if (yaml.includes(marker)) {
return yaml.replace(marker, `${marker}\ntheme: ${theme}`);
}
return yaml + `\ntheme: ${theme}\n`;
console.log('\n✅ Restored original content files');
}
/**
* Replace the heroStyle in settings.yaml.
* Restore the content files when the process is killed. Without this, Ctrl+C in
* the middle of a run leaves the site on whatever theme was being captured —
* which matters because these files are the live configuration.
*/
function setHeroStyleInSettingsYaml(yaml: string, style: string): string {
if (/^ heroStyle:\s*/m.test(yaml)) {
return yaml.replace(/^ heroStyle:\s*.+$/m, ` heroStyle: "${style}"`);
function installRestoreHandlers(): void {
let restored = false;
const restore = () => {
if (restored) return;
restored = true;
for (const file of MANAGED_FILES) {
try {
if (!fs.existsSync(backupPath(file))) continue;
fs.writeFileSync(file, fs.readFileSync(backupPath(file), 'utf8'));
fs.rmSync(backupPath(file), { force: true });
} catch {
console.error(
`\n⚠️ Could not restore ${file} automatically. Copy it back from ${backupPath(file)}`,
);
}
}
console.log('\n✅ Restored original content files');
};
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) {
process.on(signal, () => {
restore();
process.exit(130);
});
}
}
// ── Server lifecycle ─────────────────────────────────────────────
/**
* One dev server for the whole run. The YAML loader caches by mtime and the
* pages are force-dynamic, so a rewritten settings.yaml is picked up by the next
* request — restarting the server per theme would only cost startup time.
*
* It does not always survive a full pass though: after a few dozen recompiles
* the dev server occasionally stops answering, which used to turn into a run of
* navigation timeouts. ensureServerHealthy() replaces it when that happens.
*/
let devServer: ChildProcess | undefined;
async function ensureServerHealthy(): Promise<void> {
if (devServer && (await serverResponds())) return;
if (devServer) {
console.log(' ♻️ Dev server stopped responding — restarting it');
await stopDevServer();
}
devServer = startDevServer();
await waitForServer(BASE_URL, 90000);
console.log(' ✅ Server ready');
}
async function serverResponds(): Promise<boolean> {
try {
const res = await fetch(`${BASE_URL}/api/health`, { signal: AbortSignal.timeout(20000) });
return res.ok;
} catch {
return false;
}
return yaml;
}
function startDevServer(): ChildProcess {
const server = spawn('npm', ['run', 'dev'], {
cwd: process.cwd(),
stdio: 'pipe',
env: { ...process.env },
env: { ...process.env, PORT },
detached: true,
});
@@ -208,11 +735,34 @@ function startDevServer(): ChildProcess {
return server;
}
async function stopDevServer(): Promise<void> {
const server = devServer;
devServer = undefined;
if (!server) return;
if (server.pid) {
try {
process.kill(-server.pid, 'SIGTERM');
} catch {
// Already gone
}
}
await new Promise((resolve) => {
const timeout = setTimeout(resolve, 3000);
server.on('close', () => {
clearTimeout(timeout);
resolve(null);
});
});
console.log('🛑 Server stopped');
}
async function waitForServer(url: string, timeoutMs: number): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const res = await fetch(url);
// /api/health does not touch Immich, so it answers as soon as the server
// is up rather than waiting on the first album fetch.
const res = await fetch(`${url}/api/health`, { signal: AbortSignal.timeout(10000) });
if (res.ok) return;
} catch {
// Server not ready yet
@@ -226,7 +776,7 @@ async function waitForServer(url: string, timeoutMs: number): Promise<void> {
* Wait for all <img> elements to finish loading their full-resolution images.
* This prevents capturing blurhash placeholders instead of real photos.
*/
async function waitForImages(page: import('@playwright/test').Page): Promise<void> {
async function waitForImages(page: Page): Promise<void> {
await page.waitForTimeout(1000); // Initial settle
await page.evaluate((timeout) => {
@@ -240,15 +790,17 @@ async function waitForImages(page: import('@playwright/test').Page): Promise<voi
return;
}
const done = () => {
pending--;
if (pending <= 0) setTimeout(resolve, 500);
};
// Every callback below is inline and anonymous on purpose: tsx compiles
// this file with esbuild's keepNames, which rewrites *named* functions
// into `__name(fn, '…')` — and that helper does not exist in the page.
images.forEach((img) => {
if (!img.complete) {
img.addEventListener('load', done);
img.addEventListener('error', done);
img.addEventListener('load', () => {
if (--pending <= 0) setTimeout(resolve, 500);
});
img.addEventListener('error', () => {
if (--pending <= 0) setTimeout(resolve, 500);
});
}
});
@@ -265,12 +817,16 @@ function sleep(ms: number): Promise<void> {
// ── Run ──────────────────────────────────────────────────────────
main().catch((err) => {
console.error('❌ Screenshot generation failed:', err);
// Try to restore gallery.yaml on error
try {
const original = fs.readFileSync(SETTINGS_YAML + '.bak', 'utf8');
fs.writeFileSync(SETTINGS_YAML, original);
} catch {
// Backup may not exist
// The finally block normally restores; this covers a throw before it is reached.
for (const file of MANAGED_FILES) {
try {
if (!fs.existsSync(backupPath(file))) continue;
fs.writeFileSync(file, fs.readFileSync(backupPath(file), 'utf8'));
fs.rmSync(backupPath(file), { force: true });
console.log(`✅ Restored ${path.basename(file)}`);
} catch {
console.error(`⚠️ Restore failed — copy ${file} back from ${backupPath(file)}`);
}
}
process.exit(1);
});