import { existsSync, statSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { compileTemplate } from "./compiler.ts"; import { lex } from "./lexer.ts"; import { collectProps, parse } from "./parser.ts"; import type { Node, PropDeclaration } from "./types.ts"; import { isSpugComponentRender, type ComponentContext, type ComponentRegistry, type SpugComponentRender } from "../components.ts"; import { compileCss, styleTag } from "../css/index.ts"; import { isRaw, raw, styleValue } from "../raw.ts"; import type { DeferredChunk } from "../../contracts/typegen/http/response.ts"; import type { MuddleHooks } from "../hooks.ts"; /** * Template runtime. * * Compiles templates, resolves includes and extends and provides the helpers * used by the generated code. */ export interface TemplateHelpers { escapeHtml(value: string): string; stringify(value: unknown): string; /** Renders a value, raw values are never escaped. */ render(value: unknown, escaped: boolean): string; /** Transforms an object into the value of a style attribute. */ style(value: unknown): string; attr(name: string, value: unknown): string; attrBoolean(name: string): string; /** Merges the classes of a tag. */ classes(values: unknown[]): string; } export type CompiledFunction = ( runtime: TemplateRuntime, locals: Record, ) => Promise; export interface CompiledBlock { mode: "replace" | "append" | "prepend"; fn: CompiledFunction; } export interface CompiledTemplate { /** Absolute path of the template. */ path: string; template: CompiledFunction; blocks: Map; mixins: Record; /** Props declared by the template. */ props: PropDeclaration[]; /** Frontmatter of the template. */ frontmatter: string; /** Frontmatter of the template, evaluated before the body. */ frontmatterFn?: CompiledFunction; /** Identifiers used by the template. */ uses: string[]; /** The assertions of the template, written in a `$$$` block. */ tests: string[]; /** Template this template extends, if any. */ extends?: string; } /** Context of a single render. */ export interface RenderContext { /** Locals of the render. */ locals: Record; /** Route parameters of the render. */ params: Record; /** Chunks rendered after the shell. */ deferred: DeferredChunk[]; } export interface TemplateRuntime { helpers: TemplateHelpers; /** File of the template, available in the frontmatter as `__muddle`. */ file: { path: string; dir: string }; getBlock(name: string, fallback: CompiledFunction): Promise; renderComponent( name: string, props: Record, ): Promise; renderIsland(name: string, props: Record): Promise; /** Server components registered in the application. */ components: ComponentRegistry; /** Context of the current render. */ context: RenderContext; } export const helpers: TemplateHelpers = { escapeHtml(value: string): string { return String(value) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); }, stringify(value: unknown): string { if (value === null || value === undefined) return ""; if (typeof value === "string") return value; if (typeof value === "number" || typeof value === "boolean") { return String(value); } if (value instanceof Date) return value.toISOString(); if (typeof value === "object") { try { return JSON.stringify(value); } catch { return String(value); } } return String(value); }, attr(name: string, value: unknown): string { if (value === false || value === null || value === undefined) return ""; if (value === true) return ` ${name}="${name}"`; return ` ${name}="${helpers.escapeHtml(helpers.stringify(value))}"`; }, attrBoolean(name: string): string { return ` ${name}="${name}"`; }, render(value: unknown, escaped: boolean): string { if (isRaw(value)) return value.html; const text = helpers.stringify(value); return escaped ? helpers.escapeHtml(text) : text; }, style(value: unknown): string { return styleValue(value); }, classes(values: unknown[]): string { return values .filter( (value) => value !== undefined && value !== null && value !== false && value !== "", ) .map((value) => String(value)) .join(" "); }, }; interface CompiledArtifact { template: CompiledFunction; frontmatter?: CompiledFunction; blocks: Record; mixins: Record; /** Identifiers used by the template. */ uses: string[]; /** The assertions of the template, written in a `$$$` block. */ tests: string[]; } export interface TemplateEngineOptions { /** Directory holding the templates. */ viewsDir: string; /** Root directory of the project, template paths are resolved against it. */ root?: string; /** Directory holding the components, defaults to `viewsDir/components`. */ componentsDir?: string; /** Server components available to the templates. */ components?: ComponentRegistry; /** Layout templates wrapped around every page render. */ globalLayouts?: string[]; /** Contextual render expansions applied after Spug composition. */ hooks?: MuddleHooks; } /** Merges the blocks of a child template into the blocks of its parent. */ export function mergeBlocks( parent: Map, child: Map, ): Map { const merged = new Map(parent); for (const [name, block] of child) { const existing = merged.get(name); if (!existing) { merged.set(name, block); continue; } if (block.mode === "append") { const parentFn = existing.fn; const childFn = block.fn; merged.set(name, { mode: "replace", fn: async (runtime, locals) => `${await parentFn(runtime, locals)}${await childFn(runtime, locals)}`, }); continue; } if (block.mode === "prepend") { const parentFn = existing.fn; const childFn = block.fn; merged.set(name, { mode: "replace", fn: async (runtime, locals) => `${await childFn(runtime, locals)}${await parentFn(runtime, locals)}`, }); continue; } merged.set(name, block); } return merged; } export class TemplateEngine { private readonly cache = new Map< string, { mtime: number; compiled: CompiledTemplate } >(); constructor(private readonly options: TemplateEngineOptions) {} /** Directory holding the templates. */ get viewsDir(): string { return this.options.viewsDir; } /** Directory holding the components. */ get componentsDir(): string { return ( this.options.componentsDir ?? join(this.options.viewsDir, "components") ); } /** Server components available to the templates. */ get components(): ComponentRegistry { return this.options.components ?? new Map(); } /** Resolves a template path relative to the views directory. */ resolvePath(templatePath: string, from?: string): string { const candidates: string[] = []; if (isAbsolute(templatePath)) { candidates.push(templatePath); } else { if (from) candidates.push(resolve(dirname(from), templatePath)); candidates.push(resolve(this.options.viewsDir, templatePath)); if (this.options.root) { candidates.push(resolve(this.options.root, templatePath)); } } for (const candidate of candidates) { if (existsSync(candidate)) return candidate; if (existsSync(`${candidate}.spug`)) return `${candidate}.spug`; } throw new Error(`Template not found: ${templatePath}`); } /** Loads and compiles a template, using the cache when possible. */ async load(templatePath: string, from?: string): Promise { const path = this.resolvePath(templatePath, from); const mtime = statSync(path).mtimeMs; const cached = this.cache.get(path); if (cached && cached.mtime === mtime) return cached.compiled; const compiled = await this.compile(path); this.cache.set(path, { mtime, compiled }); return compiled; } /** Compiles a template without touching the cache. */ async compile(path: string): Promise { const source = await readFile(path, "utf8"); const tokens = lex(source); const parsed = parse(tokens); const frontmatter = await this.linkIncludes( parsed.nodes, parsed.frontmatter, path, ); const extendsPath = parsed.nodes.find((node) => node.kind === "extends"); const { source: compiledSource } = compileTemplate(parsed.nodes, { path, dir: dirname(path), frontmatter, }); const factory = new Function(compiledSource) as () => CompiledArtifact; const artifact = factory(); const blocks = new Map( Object.entries(artifact.blocks), ); const props = collectProps(parsed.nodes); return { path, template: artifact.template, frontmatterFn: artifact.frontmatter, uses: artifact.uses ?? [], tests: artifact.tests ?? [], blocks, mixins: artifact.mixins, props, frontmatter, extends: extendsPath?.kind === "extends" ? extendsPath.path : undefined, }; } /** Renders a template with the given locals. */ async render( templatePath: string, initialLocals: Record = {}, context?: Partial, ): Promise { const loaded = await this.load(templatePath); const locals: Record = { ...initialLocals }; const compiled = loaded; this.fillLocals(compiled, locals); const renderContext: RenderContext = { locals, params: context?.params ?? {}, deferred: context?.deferred ?? [], }; let html: string; if (compiled.extends) { const parent = await this.load(compiled.extends, compiled.path); const runtime = this.createRuntime( compiled, locals, compiled.blocks, renderContext, ); await this.runFrontmatter(compiled, locals, runtime); this.fillLocals(parent, locals); const blocks = mergeBlocks(parent.blocks, compiled.blocks); const parentRuntime = this.createRuntime( parent, locals, blocks, renderContext, ); await this.runFrontmatter(parent, locals, parentRuntime); html = await parent.template(parentRuntime, locals); } else { const blocks = mergeBlocks(compiled.blocks, new Map()); const runtime = this.createRuntime(compiled, locals, blocks, renderContext); await this.runFrontmatter(compiled, locals, runtime); html = await compiled.template(runtime, locals); } for (const layout of this.options.globalLayouts ?? []) { html = await this.renderLayout(layout, { ...locals, content: raw(html) }, renderContext); } return this.options.hooks ? this.options.hooks.renderSpug({ html, path: loaded.path, locals }) : html; } private async renderLayout(path: string, locals: Record, context: RenderContext): Promise { const compiled = await this.load(path); const runtime = this.createRuntime(compiled, locals, compiled.blocks, context); await this.runFrontmatter(compiled, locals, runtime); return compiled.template(runtime, locals); } /** * Fills the missing identifiers of a template with `undefined`. */ private fillLocals( compiled: CompiledTemplate, locals: Record, ): void { for (const name of compiled.uses ?? []) { if (!(name in locals)) locals[name] = undefined; } } /** * Evaluates the frontmatter of a template. */ private async runFrontmatter( compiled: CompiledTemplate, locals: Record, runtime: TemplateRuntime, ): Promise { if (!compiled.frontmatterFn) return; const values = await compiled.frontmatterFn(runtime, locals); Object.assign(locals, values); } /** Renders a component by name. */ async renderComponent( name: string, props: Record, context?: Partial, ): Promise { const path = this.resolveComponent(name); const compiled = await this.load(path); props = { ...props }; this.fillLocals(compiled, props); const runtime = this.createRuntime( compiled, props, compiled.blocks, context, ); await this.runFrontmatter(compiled, props, runtime); return compiled.template(runtime, props); } /** Renders a component and marks it for client side hydration. */ async renderIsland( name: string, props: Record, context?: Partial, ): Promise { const html = await this.renderComponent(name, props, context); const payload = JSON.stringify(props).replace(/${payload}`; } private resolveComponent(name: string): string { if (isAbsolute(name) && existsSync(name)) return name; const candidates = [ join(this.componentsDir, `${name}.spug`), join(this.componentsDir, name), join(this.viewsDir, `${name}.spug`), join(this.viewsDir, name), ]; for (const candidate of candidates) { if (existsSync(candidate) && statSync(candidate).isFile()) { return candidate; } } throw new Error(`Component not found: ${name}`); } private createRuntime( compiled: CompiledTemplate, locals: Record, blocks: Map, context?: Partial, ): TemplateRuntime { const renderContext: RenderContext = { locals, params: context?.params ?? {}, deferred: context?.deferred ?? [], }; const componentContext: ComponentContext = { defer: (id, content) => { renderContext.deferred.push({ id, content: typeof content === "string" ? Promise.resolve(content) : content, }); }, locals, params: renderContext.params, }; let deferredCount = 0; const runtime: TemplateRuntime = { helpers, file: { path: compiled.path, dir: dirname(compiled.path) }, getBlock: async (name, fallback) => { const block = blocks.get(name); if (!block) return fallback(runtime, locals); return block.fn(runtime, locals); }, renderComponent: async (name, props) => { const definition = this.components.get(name); if (!definition) { return this.renderComponent(name, props, renderContext); } if (definition.deferred) { deferredCount += 1; const id = `${name}-${deferredCount}`; componentContext.defer( id, Promise.resolve(definition.component.render(props, componentContext)) .then((output) => this.renderComponentOutput(output, props, renderContext, definition.requiresSpug)), ); return `
`; } return this.renderComponentOutput(await definition.component.render(props, componentContext), props, renderContext, definition.requiresSpug); }, renderIsland: (name, props) => this.renderIsland(name, props, renderContext), components: this.components, context: renderContext, }; return runtime; } private async renderComponentOutput( output: string | SpugComponentRender, props: Record, context: Partial, requiresSpug = false, ): Promise { if (!isSpugComponentRender(output)) { if (requiresSpug) throw new Error("@Component renderers must return { spug, stcss } rather than raw HTML"); return output; } const html = await this.renderComponent(output.spug, { ...props, ...(output.props ?? {}) }, context); const styles = Array.isArray(output.stcss) ? output.stcss : output.stcss ? [output.stcss] : []; const css = await Promise.all(styles.map(async (style) => { const path = resolve(this.componentsDir, style); return existsSync(path) ? compileCss(await readFile(path, "utf8")) : compileCss(style); })); return `${css.filter(Boolean).map(styleTag).join("")}${html}`; } /** Inlines the included files and collects their frontmatter. */ private async linkIncludes( nodes: Node[], frontmatter: string, from: string, ): Promise { let collected = frontmatter; for (const node of nodes) { const children = childrenOf(node); if (!children) continue; if (node.kind === "include") { const path = this.resolvePath(node.path, from); const source = await readFile(path, "utf8"); const parsed = parse(lex(source)); node.children = parsed.nodes; const nested = await this.linkIncludes( parsed.nodes, parsed.frontmatter, path, ); collected = `${collected}\n${nested}`; continue; } collected = await this.linkIncludes(children, collected, from); } return collected; } } function childrenOf(node: Node): Node[] | undefined { switch (node.kind) { case "element": case "each": case "while": case "mixin": case "mixin-call": case "include": case "block": case "slot": case "comment": return node.children; case "if": return node.branches.flatMap((branch) => branch.children); default: return undefined; } }