/** * scripts/generate-docs/lib/handlebars-setup.ts * * Registers every partial under `templates/partials/` and every helper the * page templates need. Single entry point: `getHandlebars()`. */ import Handlebars from 'handlebars'; import { readFileSync, readdirSync } from 'node:fs'; import { join, basename } from 'node:path'; import type { Skill } from './skill-parser'; import type { LocalisedString } from './markdown-parser'; const TEMPLATES_DIR = join(__dirname, '..', 'templates'); const PARTIALS_DIR = join(TEMPLATES_DIR, 'partials'); let prepared = false; function isLocalised(value: unknown): value is { fr: string; en: string } { return ( typeof value === 'object' && value !== null && 'fr' in value && 'en' in value ); } function localisedValue(value: LocalisedString | undefined, lang: 'fr' | 'en'): string { if (value == null) return ''; if (typeof value === 'string') return value; return value[lang] ?? value.fr ?? ''; } function registerHelpers(): void { Handlebars.registerHelper('eq', (a: unknown, b: unknown) => a === b); Handlebars.registerHelper('i18n', (value: LocalisedString | undefined, lang: 'fr' | 'en') => localisedValue(value, lang ?? 'fr') ); // Emit either a plain text node (if value is a string) or two // `` nodes (if value is a localised object). An optional // second argument is a CSS class added to every emitted . // // Generated DOM examples: // - value = "Hello", extraClass omitted → `Hello` // - value = "Hello", extraClass = "x" → `Hello` // - value = {fr, en}, extraClass omitted → `` // - value = {fr, en}, extraClass = "x" → `` // // The `app.js` runtime hides the inactive language by toggling // `[data-lang=""] { display: none }`. Both spans must therefore // remain valid HTML attributes — earlier implementations used `replace()` // on the class string and produced broken `` // markup that the browser rendered as two visible items side by side. Handlebars.registerHelper( 'i18nSpans', function (value: LocalisedString | undefined, extraClass?: unknown) { const klass = typeof extraClass === 'string' ? extraClass.trim() : ''; const classAttr = klass ? ` class="${klass}"` : ''; if (value == null) return new Handlebars.SafeString(''); if (typeof value === 'string') { const text = Handlebars.escapeExpression(value); return new Handlebars.SafeString( klass ? `${text}` : text ); } if (isLocalised(value)) { const fr = Handlebars.escapeExpression(value.fr); const en = Handlebars.escapeExpression(value.en); return new Handlebars.SafeString( `${fr}${en}` ); } return new Handlebars.SafeString(Handlebars.escapeExpression(String(value))); } ); Handlebars.registerHelper('firstSentence', (text: string) => { if (!text) return ''; const m = text.match(/^(.*?[.!?])\s/); return m ? m[1] : text.length > 160 ? text.slice(0, 157) + '…' : text; }); Handlebars.registerHelper('slugify', (text: string) => String(text ?? '') .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/(^-|-$)/g, '') ); Handlebars.registerHelper('count', (arr: unknown) => Array.isArray(arr) ? arr.length : 0 ); // Group an array of skills by their `phase` field and yield each group as // an object the template can iterate over predictably. Handlebars.registerHelper('groupSkillsByPhase', function (skills: Skill[], options: Handlebars.HelperOptions) { if (!Array.isArray(skills)) return ''; const groups = new Map(); for (const s of skills) { const arr = groups.get(s.phase) ?? []; arr.push(s); groups.set(s.phase, arr); } const sorted = Array.from(groups.entries()).sort(([a], [b]) => a.localeCompare(b)); let out = ''; for (const [phase, list] of sorted) { out += options.fn({ phase, skills: list }); } return out; }); // Stringify for debugging — `{{json someValue}}` Handlebars.registerHelper('json', (value: unknown) => new Handlebars.SafeString(JSON.stringify(value, null, 2)) ); } function registerPartials(): void { const entries = readdirSync(PARTIALS_DIR); for (const file of entries) { if (!file.endsWith('.hbs')) continue; const name = basename(file, '.hbs'); const src = readFileSync(join(PARTIALS_DIR, file), 'utf-8'); Handlebars.registerPartial(name, src); } } export function getHandlebars(): typeof Handlebars { if (!prepared) { registerHelpers(); registerPartials(); prepared = true; } return Handlebars; } /** Compile and cache a single page template by name (no `.hbs` extension). */ const pageCache = new Map(); export function loadPageTemplate(name: string): HandlebarsTemplateDelegate { let cached = pageCache.get(name); if (cached) return cached; const file = join(TEMPLATES_DIR, 'pages', `${name}.hbs`); const src = readFileSync(file, 'utf-8'); cached = getHandlebars().compile(src, { noEscape: false }); pageCache.set(name, cached); return cached; } /** Compile the outer layout once and cache it. */ let layoutCache: HandlebarsTemplateDelegate | null = null; export function loadLayoutTemplate(): HandlebarsTemplateDelegate { if (layoutCache) return layoutCache; const file = join(TEMPLATES_DIR, 'layout.hbs'); const src = readFileSync(file, 'utf-8'); layoutCache = getHandlebars().compile(src, { noEscape: false }); return layoutCache; }