322 lines
9.1 KiB
TypeScript
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);
|
|
}
|