muddle/views/api.ts
2026-10-23 09:12:49 -04:00

322 lines
9.1 KiB
TypeScript

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<string, string>;
/** Additional locals handed to the template. */
locals?: Record<string, unknown>;
}
/** Result of a render. */
export interface RenderResult {
/** Html of the shell. */
html: string;
/** Chunks rendered after the shell. */
deferred: DeferredChunk[];
}
export class ViewBuilder<P extends ViewPath = ViewPath> {
private readonly state: {
props: Record<string, unknown>;
params: Record<string, string>;
locals: Record<string, unknown>;
};
constructor(
private readonly views: Views,
private readonly path: P,
props: Record<string, unknown> = {},
params: Record<string, string> = {},
locals: Record<string, unknown> = {},
) {
this.state = {
props: { ...props },
params: { ...params },
locals: { ...locals },
};
}
/** Sets the props of the template. */
with(props: ViewPropsOf<P>): this {
Object.assign(this.state.props, props);
return this;
}
/** Sets the route parameters handed to the template. */
params(params: Record<string, string>): this {
Object.assign(this.state.params, params);
return this;
}
/** Sets additional locals handed to the template. */
locals(locals: Record<string, unknown>): this {
Object.assign(this.state.locals, locals);
return this;
}
/** Renders the template and awaits the deferred components. */
async render(): Promise<string> {
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) =>
`<div data-muddle-deferred="${result.deferred[index]?.id ?? index}">${html}</div>`,
)
.join("");
return `${result.html}${appended}`;
}
/** Renders the template and returns the deferred chunks separately. */
async renderResult(): Promise<RenderResult> {
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<ReadableStream<Uint8Array>> {
const { html, deferred } = await this.renderResult();
const encoder = new TextEncoder();
return new ReadableStream<Uint8Array>({
async start(controller) {
controller.enqueue(encoder.encode(html));
for (const chunk of deferred) {
const content = await chunk.content;
controller.enqueue(
encoder.encode(
`<div data-muddle-deferred="${chunk.id}">${content}</div>`,
),
);
}
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<P extends ViewPath>(path: P): ViewBuilder<P> {
return new ViewBuilder<P>(this, path);
}
/** Renders a template with the given props. */
async render<P extends ViewPath>(
path: P,
props: ViewPropsOf<P>,
options: RenderOptions = {},
): Promise<string> {
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<string, unknown> = {},
options: RenderOptions = {},
): Promise<string> {
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) =>
`<div data-muddle-deferred="${context.deferred[index]?.id ?? index}">${html}</div>`,
)
.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<string | RenderedView> {
const props = (descriptor.props ?? {}) as Record<string, unknown>;
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<Views["view"]>[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);
}