clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

main
forge / lib / render-page.ts
render-page.ts 8.4K
1// Page templates without 11ty. A Nunjucks environment set up like the one2// eleventy.config.ts gets (the same options, filters and assets, and the CSS3// bundle tags), so a server can render any page on demand from site/:4//5// const render = pageRenderer();6// const html = render('repo/commit.njk', { forge, p });7//8// Pass what the template uses: forge, and its pagination alias (p for repo9// pages, o for owner pages). Front matter is ignored. A page's CSS bundle10// gets the same /assets/ name 11ty gives it; its contents are in11// render.bundles, by URL, for whoever serves them.12//13// node lib/render-page.ts check <www> [<list>...]14// renders the forge's pages this way (all of them, or the index, owners and15// forge.pages.<list> named) and compares each with the file 11ty wrote.16import fs from 'node:fs';17import path from 'node:path';18import { createRequire } from 'node:module';19import { fileURLToPath } from 'node:url';20import { filters, markupFilters, loadForge } from './forge.ts';21import { assetUrls, hash } from './assets.ts';22import { forgeTokens } from './check-css.ts';2324const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');25const siteDir = path.join(root, 'site');2627// The parts of Nunjucks used here (11ty depends on it; it ships no types).28type Fn = (...args: never[]) => unknown;29interface Parser {30 nextToken(): { value: string };31 parseSignature(tolerant: boolean, noParens: boolean): unknown;32 advanceAfterBlockEnd(name?: string): void;33 parseUntilBlocks(...names: string[]): unknown;34}35interface Nodes {36 CallExtension: new (ext: object, method: string, args: unknown, body?: unknown[]) => unknown;37}38interface Context {39 ctx: Record<string, unknown>;40}41export interface Env {42 addFilter(name: string, fn: Fn): void;43 getFilter(name: string): (s: string) => unknown;44 addGlobal(name: string, value: unknown): void;45 addExtension(name: string, ext: object): void;46 render(name: string, ctx: object): string;47}48interface Nunjucks {49 Environment: new (loaders: object, opts: object) => Env;50 runtime: { SafeString: new (s: string) => object };51}5253// Shared with eleventy.config.ts. FORGE_STRICT=1 (CI) makes printing an54// undefined or null value an error, so a typo can't render as nothing.55export const nunjucksOptions = (env: NodeJS.ProcessEnv = process.env) => ({56 autoescape: true, trimBlocks: true, lstripBlocks: true, throwOnUndefined: env.FORGE_STRICT === '1',57});5859// The forge's filters. Those that return markup (dates as <time>) are marked60// safe, so templates need no `| safe` for them.61export function setup(env: Env): void {62 const safe = env.getFilter('safe');63 for (const [name, fn] of Object.entries(filters)) {64 const f = fn as (...args: unknown[]) => unknown;65 env.addFilter(name, markupFilters.has(name) ? (...args: unknown[]) => safe(String(f(...args))) : f);66 }67 // The style guide (design.njk) lists the tokens style.css has.68 env.addGlobal('designTokens', forgeTokens());69}7071// Templates by name from site/_includes, then site/, as 11ty finds them. Front72// matter becomes an empty comment as many lines high, so errors name the73// right line.74const frontMatter = (fm: string): string => `{#${'\n'.repeat(fm.split('\n').length - 1)}-#}`;75const loader = {76 getSource(name: string) {77 for (const dir of [path.join(siteDir, '_includes'), siteDir]) {78 const file = path.join(dir, name);79 if (!fs.existsSync(file)) continue;80 return { src: fs.readFileSync(file, 'utf8').replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, frontMatter), path: file, noCache: false };81 }82 return null;83 },84};8586const BUNDLE = '__bundle';87const MARK = '\u0000bundle:css\u0000';8889// {% css %}...{% endcss %} and {% getBundleFileUrl "css" %} as 11ty's bundle90// plugin has them: each page collects its blocks, trimmed and once each, and91// the link names a file by the hash of their contents. Like 11ty, a block92// rendered without the page's context (a macro imported without context)93// adds nothing.94function bundleTags(nunjucks: Nunjucks): object {95 const safe = (s: string): object => new nunjucks.runtime.SafeString(s);96 return {97 tags: ['css', 'getBundleFileUrl'],98 parse(parser: Parser, nodes: Nodes) {99 const tok = parser.nextToken();100 const args = parser.parseSignature(true, true);101 parser.advanceAfterBlockEnd(tok.value);102 if (tok.value === 'getBundleFileUrl') return new nodes.CallExtension(this, 'url', args);103 const body = parser.parseUntilBlocks('endcss');104 parser.advanceAfterBlockEnd();105 return new nodes.CallExtension(this, 'css', args, [body]);106 },107 css(context: Context, body: () => string) {108 const code = String(body()).trim();109 const set = context.ctx[BUNDLE] as Set<string> | undefined;110 if (set && code) set.add(code);111 return safe('');112 },113 url(_context: Context, type: string) {114 if (type !== 'css') throw new Error(`no ${type} bundle`);115 return safe(MARK);116 },117 };118}119120export interface Render {121 (template: string, data: Record<string, unknown>): string;122 bundles: Map<string, string>;123}124125export function pageRenderer(): Render {126 const nunjucks = createRequire(import.meta.url)('nunjucks') as Nunjucks;127 const env = new nunjucks.Environment(loader, nunjucksOptions());128 setup(env);129 env.addExtension('bundles', bundleTags(nunjucks));130 env.addGlobal('assets', assetUrls());131 const bundles = new Map<string, string>();132 const render = (template: string, data: Record<string, unknown>): string => {133 const set = new Set<string>();134 const html = env.render(template, { ...data, [BUNDLE]: set });135 if (!html.includes(MARK)) return html;136 const css = [...set].join('\n');137 const url = css ? `/assets/${hash(css)}.css` : '';138 if (url) bundles.set(url, css);139 return html.replaceAll(MARK, url);140 };141 return Object.assign(render, { bundles });142}143144// Which template paginates each page list, from the templates' front matter.145function listTemplates(): Map<string, string> {146 const map = new Map<string, string>();147 for (const f of fs.readdirSync(path.join(siteDir, 'repo')).filter((f) => f.endsWith('.njk'))) {148 const list = /^\s+data: forge\.pages\.(\w+)$/m.exec(fs.readFileSync(path.join(siteDir, 'repo', f), 'utf8'))?.[1];149 if (list) map.set(list, `repo/${f}`);150 }151 return map;152}153154// Run directly (realpath: /tmp is a symlink on macOS, and import.meta.url is resolved).155if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {156 const [cmd, www, ...lists] = process.argv.slice(2);157 if (cmd !== 'check' || !www) {158 console.error('usage: node lib/render-page.ts check <www> [<list>...]');159 process.exit(2);160 }161 const forge = loadForge();162 const render = pageRenderer();163 const templates = listTemplates();164 // Every page, unless lists are named: the index, owners, the forge's insights, the style guide, topics, and forge.pages.<list>.165 const jobs: { file: string; template: string; data: Record<string, unknown> }[] = [];166 for (const list of lists.length ? lists : ['index', 'owners', 'forgeInsights', 'design', 'topics', ...templates.keys()]) {167 if (list === 'index') jobs.push({ file: '/index.html', template: 'index.njk', data: { forge } });168 else if (list === 'forgeInsights') jobs.push({ file: '/insights/index.html', template: 'insights.njk', data: { forge } });169 else if (list === 'design') jobs.push({ file: '/design/index.html', template: 'design.njk', data: { forge } });170 else if (list === 'topics') jobs.push({ file: '/topics/index.html', template: 'topics.njk', data: { forge } });171 else if (list === 'owners') for (const o of forge.owners) jobs.push({ file: `/${o.owner}/index.html`, template: 'owner.njk', data: { forge, o } });172 else {173 const template = templates.get(list);174 if (!template) throw new Error(`no template paginates forge.pages.${list}`);175 for (const p of forge.pages[list] ?? []) jobs.push({ file: p.permalink, template, data: { forge, p } });176 }177 }178 let bad = 0;179 for (const { file, template, data } of jobs) {180 const want = fs.readFileSync(path.join(www, file), 'utf8');181 const got = render(template, data);182 if (got === want) continue;183 bad++;184 const a = got.split('\n');185 const b = want.split('\n');186 let i = a.findIndex((l, n) => l !== b[n]);187 if (i < 0) i = a.length;188 console.error(`${file}: differs from 11ty's at line ${i + 1}\n plain: ${a[i]?.slice(0, 200)}\n 11ty: ${b[i]?.slice(0, 200)}`);189 }190 console.log(`${jobs.length - bad} of ${jobs.length} pages render the same without 11ty`);191 process.exit(bad || !jobs.length ? 1 : 0);192}