* fix(screenshots): sharp-Typen fuer 0.34 und 0.35 gleichzeitig (#410) sharp 0.35 ist von `export =` auf ESM umgestellt: der Modul-Namespace ist nicht mehr aufrufbar, die Factory sitzt unter .default. Der Type-Check brach damit in scripts/screenshots.ts weg, sobald der Next-Bump (#402) sharp von 0.34.5 auf 0.35.3 mitzieht -- sharp ist hier keine deklarierte Dependency, sondern kommt ueber Next mit. Die Form wird jetzt aus dem installierten Paket abgeleitet statt festgeschrieben, zur Laufzeit wie im Typ. Geprueft mit beiden Versionen: tsc laeuft mit 0.34.5 und mit 0.35.3 fehlerfrei durch. Co-authored-by: ralksta <ralksta@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(deps): bump the nextjs group across 1 directory with 2 updates (#402) Bumps the nextjs group with 2 updates in the / directory: [next](https://github.com/vercel/next.js) and [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next). Updates `next` from 16.2.12 to 16.3.0 - [Release notes](https://github.com/vercel/next.js/releases) - [Commits](https://github.com/vercel/next.js/compare/v16.2.12...v16.3.0) Updates `eslint-config-next` from 16.2.12 to 16.3.0 - [Release notes](https://github.com/vercel/next.js/releases) - [Commits](https://github.com/vercel/next.js/commits/v16.3.0/packages/eslint-config-next) --- updated-dependencies: - dependency-name: eslint-config-next dependency-version: 16.3.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: nextjs - dependency-name: next dependency-version: 16.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nextjs ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump prettier from 3.8.3 to 3.9.6 (#405) Bumps [prettier](https://github.com/prettier/prettier) from 3.8.3 to 3.9.6. - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.8.3...3.9.6) --- updated-dependencies: - dependency-name: prettier dependency-version: 3.9.6 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * docs(contributing): PRs gehen gegen dev Nirgends stand, welcher Branch das Ziel ist -- die offene PR #398 zeigt folgerichtig auf main. main bewegt sich aber nur ueber Releases: es wird von dev fast-forwarded, getaggt, und der Tag stoesst den Image-Publish an. Eine PR dagegen legt unveroeffentlichten Code in genau den Branch, der dem publizierten Image entsprechen soll. Steht jetzt als eigener Abschnitt in CONTRIBUTING.md, im Setup-Snippet und in beiden Checklisten. Mit Hinweis, dass ein falsches Ziel ueber "Edit" an der PR umgestellt werden kann, statt die Arbeit neu zu machen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: ralksta <ralksta@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
840 lines
32 KiB
TypeScript
840 lines
32 KiB
TypeScript
/**
|
||
* 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.
|
||
*
|
||
* 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:
|
||
* - 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/*.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 OUTPUT_DIR = path.join(process.cwd(), 'docs', 'screenshots');
|
||
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() {
|
||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||
|
||
// 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();
|
||
|
||
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 {
|
||
await ensureServerHealthy();
|
||
|
||
const { chromium } = await import('@playwright/test');
|
||
browser = await chromium.launch();
|
||
|
||
// ── 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)'}`);
|
||
|
||
// ── 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`);
|
||
});
|
||
}
|
||
}
|
||
|
||
// ── 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'),
|
||
);
|
||
}
|
||
|
||
// ── 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`));
|
||
}
|
||
}
|
||
|
||
// ── 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);
|
||
});
|
||
}
|
||
|
||
// ── 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 {
|
||
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}/`);
|
||
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> {
|
||
// sharp 0.35 switched from `export =` to ESM: the module namespace stopped
|
||
// being callable and the factory moved to `.default`. Resolve whichever
|
||
// shape the installed version has, so this type-checks and runs on both.
|
||
type SharpFactory = typeof import('sharp') extends { default: infer Factory }
|
||
? Factory
|
||
: typeof import('sharp');
|
||
let sharp: SharpFactory;
|
||
try {
|
||
const mod = (await import('sharp')) as unknown as { default?: SharpFactory };
|
||
sharp = mod.default ?? (mod as unknown as SharpFactory);
|
||
} 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',
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
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;
|
||
}
|
||
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 });
|
||
}
|
||
console.log('\n✅ Restored original content files');
|
||
}
|
||
|
||
/**
|
||
* 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 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;
|
||
}
|
||
}
|
||
|
||
function startDevServer(): ChildProcess {
|
||
const server = spawn('npm', ['run', 'dev'], {
|
||
cwd: process.cwd(),
|
||
stdio: 'pipe',
|
||
env: { ...process.env, PORT },
|
||
detached: true,
|
||
});
|
||
|
||
// Suppress server output unless DEBUG is set
|
||
if (process.env.DEBUG) {
|
||
server.stdout?.pipe(process.stdout);
|
||
server.stderr?.pipe(process.stderr);
|
||
}
|
||
|
||
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 {
|
||
// /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
|
||
}
|
||
await sleep(500);
|
||
}
|
||
throw new Error(`Server did not start within ${timeoutMs}ms`);
|
||
}
|
||
|
||
/**
|
||
* 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: Page): Promise<void> {
|
||
await page.waitForTimeout(1000); // Initial settle
|
||
|
||
await page.evaluate((timeout) => {
|
||
return new Promise<void>((resolve) => {
|
||
const images = Array.from(document.querySelectorAll('img'));
|
||
let pending = images.filter((img) => !img.complete).length;
|
||
|
||
if (pending === 0) {
|
||
// All images already loaded, wait a bit for any CSS transitions
|
||
setTimeout(resolve, 500);
|
||
return;
|
||
}
|
||
|
||
// 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', () => {
|
||
if (--pending <= 0) setTimeout(resolve, 500);
|
||
});
|
||
img.addEventListener('error', () => {
|
||
if (--pending <= 0) setTimeout(resolve, 500);
|
||
});
|
||
}
|
||
});
|
||
|
||
// Safety timeout
|
||
setTimeout(resolve, timeout);
|
||
});
|
||
}, IMAGE_LOAD_TIMEOUT);
|
||
}
|
||
|
||
function sleep(ms: number): Promise<void> {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
// ── Run ──────────────────────────────────────────────────────────
|
||
main().catch((err) => {
|
||
console.error('❌ Screenshot generation failed:', err);
|
||
// 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);
|
||
});
|