1import fs from 'node:fs';2import path from 'node:path';3import { current, loadForge } from './lib/forge.ts';4import { codeVersion, record } from './lib/metrics.ts';5import { assetUrls, shipAssets } from './lib/assets.ts';6import { nunjucksOptions, setup, type Env } from './lib/render-page.ts';7import type { ForgeEvent } from './lib/events-server.ts';89// The parts of 11ty's config API the forge uses (11ty ships no types).10interface EleventyConfig {11 setNunjucksEnvironmentOptions(opts: Record<string, unknown>): void;12 amendLibrary(engine: 'njk', fn: (env: Env) => void): void;13 addGlobalData(name: string, fn: () => unknown): void;14 addBundle(name: string, opts: { toFileDirectory: string }): void;15 htmlTransformer: { remove(ext: string, match: (entry: { name?: string }) => boolean): void };16 setQuietMode(quiet: boolean): void;17 on(event: 'eleventy.after', fn: (args: { directories: { output: string }; results?: unknown[] }) => void): void;18}1920export default function (cfg: EleventyConfig) {21 // Options and filters are shared with lib/render-page.ts, which renders the22 // same templates without 11ty.23 cfg.setNunjucksEnvironmentOptions(nunjucksOptions());24 cfg.amendLibrary('njk', setup);25 cfg.addGlobalData('forge', () => loadForge());26 cfg.addGlobalData('assets', assetUrls);27 // Templates and includes carry their own CSS in {% css %} blocks; each page28 // links one file of the blocks it rendered, /assets/<hash>.css.29 cfg.addBundle('css', { toFileDirectory: 'assets' });30 // The bundle plugin would parse and reprint every page to drop empty bundle31 // links; ours never are, and big diff pages would pay for it.32 cfg.htmlTransformer.remove('html', (e) => e.name === '11ty/eleventy-bundle/prune-empty');33 cfg.setQuietMode(true);3435 cfg.on('eleventy.after', ({ directories, results }) => {36 const out = directories.output;37 const forge = current();38 // A private build's pages go to the private root, its assets to www's39 // (FORGE_PUBLIC_WWW), where every page finds them.40 shipAssets(out, process.env.FORGE_PUBLIC_WWW || (forge?.live ?? null), forge?.code ?? codeVersion());41 if (!forge) return;42 // Raw files (images, per-branch file lists) are bytes, not templates.43 // Code indexes are hard links to the cache's (same filesystem), so an44 // unchanged one stays the same file.45 for (const f of forge.raw) {46 const dst = path.join(out, f.path);47 fs.mkdirSync(path.dirname(dst), { recursive: true });48 if (f.data) fs.writeFileSync(dst, f.data);49 else if (f.file) {50 fs.rmSync(dst, { force: true });51 try {52 fs.linkSync(f.file, dst);53 } catch {54 fs.copyFileSync(f.file, dst);55 }56 }57 }58 // Unchanged pages: hard links to the live site's files (same filesystem).59 let linked = 0;60 for (const rel of forge.skipped) {61 const src = path.join(forge.live ?? '', rel);62 const dst = path.join(out, rel);63 fs.mkdirSync(path.dirname(dst), { recursive: true });64 try {65 fs.linkSync(src, dst);66 } catch {67 try {68 fs.copyFileSync(src, dst);69 } catch {70 continue;71 }72 }73 linked++;74 }75 for (const { cache, fps } of forge.pageCaches) {76 cache.pages = { code: forge.code, fps };77 cache.save();78 }79 const line = process.env.FORGE_METRICS === '0' ? null : record(path.join(forge.config.state, 'metrics.jsonl'), { written: (results?.length ?? 0) + forge.raw.length, linked });80 // Live pages: tell anyone watching this repo that its pages changed.81 const only = process.env.FORGE_ONLY;82 if (only && only !== '-') {83 const ev: ForgeEvent = { t: Date.now(), repo: only, type: 'built', ms: line?.ms.total ?? null, rendered: results?.length ?? 0 };84 // A private repo's events go only to its readers (lib/events-server.ts).85 if (process.env.FORGE_PRIVATE === '1' || !forge.repos.some((r) => r.slug === only)) ev.private = true;86 fs.appendFileSync(path.join(forge.config.state, 'events.jsonl'), JSON.stringify(ev) + '\n');87 }88 });8990 return {91 dir: { input: 'site', includes: '_includes', output: process.env.FORGE_WWW || '_site' },92 templateFormats: ['njk'],93 htmlTemplateEngine: 'njk',94 };95}