import { dirname, isAbsolute, join, resolve } from "node:path"; import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import type { ViewDescriptor } from "../core/router.ts"; import type { RequestContext } from "../contracts/typegen/http/request.ts"; import type { RenderedView } from "../contracts/typegen/http/response.ts"; import type { ViewPath, ViewPropsOf } from "../contracts/typegen/views.ts"; import type { DeferredChunk } from "../contracts/typegen/http/response.ts"; import { TemplateEngine, type RenderContext } from "./engine/runtime.ts"; import { compileCss, styleTag } from "./css/index.ts"; import type { TemplateContext } from "../core/context.ts"; import type { ComponentDefinition, ComponentRegistry, ServerComponent, } from "./components.ts"; import type { MuddleHooks } from "./hooks.ts"; /** * Fluent, type generated view api. * * The props of a template are declared inside the template itself, the * generator turns them into a contract and the builder only accepts the props * of the selected template. */ export interface ViewsOptions { context?: TemplateContext; /** 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 registered on creation. */ components?: ComponentRegistry; globalLayouts?: string[]; /** Contextual hooks shared by Spug rendering and STCSS component styles. */ hooks?: MuddleHooks; } export interface SpugComponentOptions { template?: string; style?: string | string[]; deferred?: boolean; } /** Options of a single render. */ export interface RenderOptions { /** Route parameters handed to the template. */ params?: Record; /** Additional locals handed to the template. */ locals?: Record; } /** Result of a render. */ export interface RenderResult { /** Html of the shell. */ html: string; /** Chunks rendered after the shell. */ deferred: DeferredChunk[]; } export class ViewBuilder

{ private readonly state: { props: Record; params: Record; locals: Record; }; constructor( private readonly views: Views, private readonly path: P, props: Record = {}, params: Record = {}, locals: Record = {}, ) { this.state = { props: { ...props }, params: { ...params }, locals: { ...locals }, }; } /** Sets the props of the template. */ with(props: ViewPropsOf

): this { Object.assign(this.state.props, props); return this; } /** Sets the route parameters handed to the template. */ params(params: Record): this { Object.assign(this.state.params, params); return this; } /** Sets additional locals handed to the template. */ locals(locals: Record): this { Object.assign(this.state.locals, locals); return this; } /** Renders the template and awaits the deferred components. */ async render(): Promise { const result = await this.renderResult(); const deferred = await Promise.all( result.deferred.map((chunk) => chunk.content), ); if (deferred.length === 0) return result.html; const appended = deferred .map( (html, index) => `

${html}
`, ) .join(""); return `${result.html}${appended}`; } /** Renders the template and returns the deferred chunks separately. */ async renderResult(): Promise { const context: RenderContext = { locals: { ...this.state.locals, ...this.state.props }, params: this.state.params, deferred: [], }; const html = await this.views.engine.render( this.path, context.locals, context, ); return { html, deferred: context.deferred }; } /** * Renders the template as a stream. * * The shell is sent first, the deferred components follow as soon as they * are rendered. No javascript is required, the browser renders the chunks * while they arrive. */ async renderStream(): Promise> { const { html, deferred } = await this.renderResult(); const encoder = new TextEncoder(); return new ReadableStream({ async start(controller) { controller.enqueue(encoder.encode(html)); for (const chunk of deferred) { const content = await chunk.content; controller.enqueue( encoder.encode( `
${content}
`, ), ); } controller.close(); }, }); } } export class Views { readonly engine: TemplateEngine; readonly components: ComponentRegistry; private readonly hooks?: MuddleHooks; constructor(options: ViewsOptions) { this.hooks = options.hooks; this.components = options.components ?? new Map(); for (const [name, component] of options.context?.imports ?? []) this.components.set(name, { name, component: component as ServerComponent, deferred: false }); this.engine = new TemplateEngine({ viewsDir: options.viewsDir, componentsDir: options.componentsDir, root: options.root, components: this.components, globalLayouts: options.globalLayouts, hooks: options.hooks, }); } /** Registers a server component. */ component( name: string, component: ServerComponent, options: { deferred?: boolean } = {}, ): this { const definition: ComponentDefinition = { name, component, deferred: options.deferred === true, }; this.components.set(name, definition); return this; } /** Registers a component rendered by a SPUG template and optional STCSS files. */ spugComponent(name: string, options: SpugComponentOptions = {}): this { const template = options.template ?? name; const styles = Array.isArray(options.style) ? options.style : options.style ? [options.style] : []; return this.component(name, { render: async (props) => { const html = await this.engine.renderComponent(template, props); const css = await Promise.all(styles.map(async (style) => { const path = resolve(this.componentsDir, style); return existsSync(path) ? compileCss(await readFile(path, "utf8"), { hooks: this.hooks }) : ""; })); return `${css.filter(Boolean).map(styleTag).join("")}${html}`; }, }, { deferred: options.deferred }); } /** Starts a fluent render of a template. */ view

(path: P): ViewBuilder

{ return new ViewBuilder

(this, path); } /** Renders a template with the given props. */ async render

( path: P, props: ViewPropsOf

, options: RenderOptions = {}, ): Promise { const builder = this.view(path).with(props); if (options.params) builder.params(options.params); if (options.locals) builder.locals(options.locals); return builder.render(); } /** * Renders a template by its path on disk. * * Used by the `@View()` decorator, therefore the path is resolved relative * to the project. */ async renderFile( path: string, props: Record = {}, options: RenderOptions = {}, ): Promise { const context: RenderContext = { locals: { ...options.locals, ...props }, params: options.params ?? {}, deferred: [], }; const html = await this.engine.render(path, context.locals, context); const deferred = await Promise.all( context.deferred.map((chunk) => chunk.content), ); if (deferred.length === 0) return html; const appended = deferred .map( (html, index) => `

${html}
`, ) .join(""); return `${html}${appended}`; } /** * Renders a view descriptor of a route. * * The descriptor of a `@View()` decorator carries an absolute path, the * descriptor of a controller carries the key of a view. */ async renderDescriptor( descriptor: ViewDescriptor, context: RequestContext, ): Promise { const props = (descriptor.props ?? {}) as Record; if (isAbsolute(descriptor.template)) { const html = await this.renderFile(descriptor.template, props, { params: context.params, locals: { ...context.locals }, }); return html; } const builder = this.view( descriptor.template as Parameters[0], ).with(props as never); builder.params(context.params); builder.locals(context.locals); return builder.renderResult(); } /** Directory of the templates. */ get viewsDir(): string { return this.engine.viewsDir; } /** Directory of the components. */ get componentsDir(): string { return this.engine.componentsDir; } } /** Creates the views of a project. */ export function createViews(options: ViewsOptions): Views { return new Views(options); } /** Resolves a template path relative to the views directory. */ export function resolveTemplatePath(viewsDir: string, path: string): string { return join(resolve(viewsDir), dirname(path), path); }