diff --git a/framework/engine/jsx-runtime.ts b/framework/engine/jsx-runtime.ts index eca26a1..fe84691 100644 --- a/framework/engine/jsx-runtime.ts +++ b/framework/engine/jsx-runtime.ts @@ -1,22 +1,32 @@ -export const Fragment = ({ children }: { children }) => children; +export const Fragment = ({ children }: { children: engine.Node[] }) => children; -// jsx -export function jsx(type, props, key) { +export function jsx( + type: string | engine.Component, + props: Record, +): engine.Element { if (typeof type !== "function" && typeof type !== "string") { - throw new Error("Invalid JSX component type: " + ssr.inspect(type)); + throw new Error("Invalid component type: " + engine.inspect(type)); } - return [import_ssr.kElement, type, props]; + return [engine.kElement, type, props]; } -// jsxDEV -function jsxDEV(type, props, _key, _isStaticChildren, source) { +export function jsxDEV( + type: string | engine.Component, + props: Record, + // Unused with the clover engine + _key: string, + // Unused with the clover engine + _isStaticChildren: boolean, + // Unused with the clover engine + _source: unknown, +): engine.Element { if (typeof type !== "function" && typeof type !== "string") { - throw new Error("Invalid JSX component type: " + ssr.inspect(type)); + throw new Error("Invalid component type: " + engine.inspect(type)); } - return [ssr.kElement, type, props, source]; + return [engine.kElement, type, props]; } // jsxs export { jsx as jsxs }; -import * as ssr from "./ssr.ts"; +import * as engine from "./ssr.ts"; diff --git a/framework/engine/marko-runtime.ts b/framework/engine/marko-runtime.ts index 7635012..cdc9c09 100644 --- a/framework/engine/marko-runtime.ts +++ b/framework/engine/marko-runtime.ts @@ -1,17 +1,23 @@ -import * as ssr from "./ssr.ts"; -// @ts-ignore no types :( -import * as marko from "marko/debug/html"; -// @ts-ignore no types :( -export * from "marko/debug/html"; +// This file is used to integrate Marko into the Clover Engine and Sitegen +// To use, replace the "marko/html" import with this file. +export * from "#marko/html"; -export const createTemplate = (templateId: string, renderer) => { +interface BodyContentObject { + [x: PropertyKey]: unknown; + content: ServerRenderer; +} + +export const createTemplate = ( + templateId: string, + renderer: ServerRenderer, +) => { const { render } = marko.createTemplate(templateId, renderer); function wrap(props: Record, n: number) { // Marko components const cloverAsyncMarker = { isAsync: false }; - let r: ssr.Render | undefined = undefined; + let r: engine.Render | undefined = undefined; try { - r = ssr.getCurrentRender(); + r = engine.getCurrentRender(); } catch {} // Support using Marko outside of Clover SSR if (r) { @@ -20,10 +26,10 @@ export const createTemplate = (templateId: string, renderer) => { $global: { clover: r, cloverAsyncMarker }, }); if (cloverAsyncMarker.isAsync) { - return markoResult.then(ssr.html); + return markoResult.then(engine.html); } const rr = markoResult.toString(); - return ssr.html(rr); + return engine.html(rr); } else { return renderer(props, n); } @@ -34,32 +40,33 @@ export const createTemplate = (templateId: string, renderer) => { }; export const dynamicTag = ( - scopeId, - accessor, - tag, - inputOrArgs, - content, - inputIsArgs, - serializeReason, + scopeId: number, + accessor: Accessor, + tag: unknown | string | ServerRenderer | BodyContentObject, + inputOrArgs: unknown, + content?: (() => void) | 0, + inputIsArgs?: 1, + serializeReason?: 1 | 0, ) => { + marko.dynamicTag; if (typeof tag === "function") { clover: { - const unwrapped = tag.unwrapped; + const unwrapped = (tag as any).unwrapped; if (unwrapped) { tag = unwrapped; break clover; } - let r: ssr.Render; + let r: engine.Render; try { - r = ssr.getCurrentRender(); + r = engine.getCurrentRender(); if (!r) throw 0; } catch { - r = marko.$global().clover as ssr.Render; + r = marko.$global().clover as engine.Render; } if (!r) throw new Error("No Clover Render Active"); - const subRender = ssr.initRender(r.async !== -1, r.user); - const resolved = ssr.resolveNode(subRender, [ - ssr.kElement, + const subRender = engine.initRender(r.async !== -1, r.addon); + const resolved = engine.resolveNode(subRender, [ + engine.kElement, tag, inputOrArgs, ]); @@ -72,7 +79,7 @@ export const dynamicTag = ( const { resolve, reject, promise } = Promise.withResolvers(); subRender.asyncDone = () => { const rejections = subRender.rejections; - if (!rejections) return resolve(ssr.renderNodeOrUndefined(resolved)); + if (!rejections) return resolve(engine.renderNode(resolved)); (r.rejections ??= []).push(...rejections); return reject(new Error("Render had errors")); }; @@ -84,7 +91,7 @@ export const dynamicTag = ( 0, ); } else { - marko.write(ssr.renderNodeOrUndefined(resolved)); + marko.write(engine.renderNode(resolved)); } return; } @@ -100,8 +107,19 @@ export const dynamicTag = ( ); }; -export function fork(scopeId, accessor, promise, callback, serializeMarker) { +export function fork( + scopeId: string, + accessor: Accessor, + promise: Promise, + callback: (data: unknown) => void, + serializeMarker?: 0 | 1, +) { const marker = marko.$global().cloverAsyncMarker; marker.isAsync = true; marko.fork(scopeId, accessor, promise, callback, serializeMarker); } + +import * as engine from "./ssr.ts"; +import type { ServerRenderer } from "marko/html/template"; +import { type Accessor } from "marko/common/types"; +import * as marko from "#marko/html"; diff --git a/framework/engine/ssr.ts b/framework/engine/ssr.ts index e69de29..242250d 100644 --- a/framework/engine/ssr.ts +++ b/framework/engine/ssr.ts @@ -0,0 +1,300 @@ +// Clover's Rendering Engine is the backbone of her website generator. It +// converts objects and components (functions returning 'Node') into HTML. The +// engine is simple and self-contained, with integrations for JSX and Marko +// (which can interop with each-other) are provided next to this file. +// +// Add-ons to the rendering engine can provide opaque data, And retrieve it +// within component calls with 'getAddonData'. For example, 'sitegen' uses this +// to track needed client scripts without introducing patches to the engine. + +type AddonData = Record; +export function ssrSync(node: Node): Result; +export function ssrSync( + node: Node, + addon: AddonData, +): Result; +export function ssrSync(node: Node, addon: AddonData = {}) { + const r = initRender(false, addon); + const resolved = resolveNode(r, node); + return { text: renderNode(resolved), addon }; +} +export function ssrAsync(node: Node): Promise; +export function ssrAsync( + node: Node, + addon: AddonData, +): Promise>; +export function ssrAsync(node: Node, addon: AddonData = {}) { + const r = initRender(true, addon); + const resolved = resolveNode(r, node); + if (r.async === 0) { + return Promise.resolve({ text: renderNode(resolved), addon }); + } + const { resolve, reject, promise } = Promise.withResolvers(); + r.asyncDone = () => { + const rejections = r.rejections; + if (!rejections) return resolve({ text: renderNode(resolved), addon }); + if (rejections.length === 1) return reject(rejections[0]); + return reject(new AggregateError(rejections)); + }; + return promise; +} + +/** Inline HTML into a render without escaping it */ +export function html(rawText: string) { + return [kDirectHtml, rawText]; +} + +interface Result { + text: string; + addon: A; +} + +export interface Render { + /** + * Set to '-1' if rendering synchronously + * Number of async promises the render is waiting on. + */ + async: number | -1; + asyncDone: null | (() => void); + /** When components reject, those are logged here */ + rejections: unknown[] | null; + /** Add-ons to the rendering engine store state here */ + addon: AddonData; +} + +export const kElement = Symbol("Element"); +export const kDirectHtml = Symbol("DirectHtml"); + +/** Node represents a webpage that can be 'rendered' into HTML. */ +export type Node = + | number + | string // Escape HTML + | Node[] // Concat + | Element // Render + | DirectHtml // Insert + | Promise // Await + // Ignore + | undefined + | null + | boolean; +export type Element = [ + tag: typeof kElement, + type: string | Component, + props: Record, +]; +export type DirectHtml = [tag: typeof kDirectHtml, html: string]; +/** + * Components must return a value; 'undefined' is prohibited here + * to avoid functions that are missing a return statement. + */ +export type Component = ( + props: Record, +) => Exclude; + +/** + * Resolution narrows the type 'Node' into 'ResolvedNode'. Async trees are + * marked in the 'Render'. This operation performs everything besides the final + * string concatenation. This function is agnostic across async/sync modes. + */ +export function resolveNode(r: Render, node: unknown): ResolvedNode { + if (!node && node !== 0) return ""; // falsy, non numeric + if (typeof node !== "object") { + if (node === true) return ""; // booleans are ignored + if (typeof node === "string") return escapeHTML(node); + if (typeof node === "number") return String(node); // no escaping ever + throw new Error(`Cannot render ${inspect(node)} to HTML`); + } + if (node instanceof Promise) { + if (r.async === -1) { + throw new Error(`Asynchronous rendering is not supported here.`); + } + const placeholder: InsertionPoint = [null]; + r.async += 1; + node + .then((result) => void (placeholder[0] = resolveNode(r, result))) + // Intentionally catching errors in `resolveNode` + .catch((e) => (r.rejections ??= []).push(e)) + .finally(() => { + if (--r.async == 0) { + if (r.asyncDone == null) throw new Error("r.asyncDone == null"); + r.asyncDone(); + r.asyncDone = null; + } + }); + // This lie is checked with an assertion in `renderNode` + return placeholder as [ResolvedNode]; + } + if (!Array.isArray(node)) { + throw new Error(`Invalid node type: ${inspect(node)}`); + } + const type = node[0]; + if (type === kElement) { + const { 1: tag, 2: props } = node; + if (typeof tag === "function") { + currentRender = r; + const result = tag(props); + currentRender = null; + return resolveNode(r, result); + } + if (typeof tag !== "string") throw new Error("Unexpected " + typeof type); + const children = props?.children; + if (children) return [kElement, tag, props, resolveNode(r, children)]; + return node; + } + if (type === kDirectHtml) return node[1]; + return node.map((elem) => resolveNode(r, elem)); +} + +export type ResolvedNode = + | ResolvedNode[] // Concat + | ResolvedElement // Render + | string; // Direct HTML +export type ResolvedElement = [ + tag: typeof kElement, + type: string, + props: Record, + children: ResolvedNode, +]; +/** + * Async rendering is done by creating an array of one item, + * which is already a valid 'Node', but the element is written + * once the data is available. The 'Render' contains a count + * of how many async jobs are left. + */ +export type InsertionPoint = [null | ResolvedNode]; + +/** + * Convert 'ResolvedNode' into HTML text. This operation happens after all + * async work is settled. The HTML is emitted as concisely as possible. + */ +export function renderNode(node: ResolvedNode): string { + if (typeof node === "string") return node; + ASSERT(node, "Unresolved Render Node"); + const type = node[0]; + if (type === kElement) { + return renderElement(node as ResolvedElement); + } + node = node as ResolvedNode[]; // TS cannot infer. + let out = type ? renderNode(type) : ""; + let len = node.length; + for (let i = 1; i < len; i++) { + const elem = node[i]; + if (elem) out += renderNode(elem); + } + return out; +} +function renderElement(element: ResolvedElement) { + const { 1: tag, 2: props, 3: children } = element; + let out = "<" + tag; + let needSpace = true; + for (const prop in props) { + const value = props[prop]; + if (!value || typeof value === "function") continue; + let attr; + switch (prop) { + default: + attr = `${prop}=${quoteIfNeeded(escapeHTML(String(value)))}`; + break; + case "className": + // Legacy React Compat + case "class": + attr = `class=${quoteIfNeeded(escapeHTML(clsx(value)))}`; + break; + case "htmlFor": + throw new Error("Do not use the `htmlFor` attribute. Use `for`"); + // Do not process these + case "children": + case "ref": + case "dangerouslySetInnerHTML": + case "key": + continue; + } + if (needSpace) out += " ", needSpace = !attr.endsWith('"'); + out += attr; + } + out += ">"; + if (children) out += renderNode(children); + if ( + tag !== "br" && tag !== "img" && tag !== "input" && tag !== "meta" && + tag !== "link" && tag !== "hr" + ) { + out += ``; + } + return out; +} +export function renderStyleAttribute(style: Record) { + let out = ``; + for (const styleName in style) { + if (out) out += ";"; + out += `${styleName.replace(/[A-Z]/g, "-$&").toLowerCase()}:${ + escapeHTML(String(style[styleName])) + }`; + } + return "style=" + quoteIfNeeded(out); +} +export function quoteIfNeeded(text) { + if (text.includes(" ")) return '"' + text + '"'; + return text; +} + +// -- utility functions -- + +export function initRender(allowAsync: boolean, addon: AddonData): Render { + return { + async: allowAsync ? 0 : -1, + rejections: null, + asyncDone: null, + addon, + }; +} + +let currentRender: Render | null = null; +export function getCurrentRender() { + if (!currentRender) throw new Error("No Render Active"); + return currentRender; +} +export function setCurrentRender(r?: Render | null) { + currentRender = r ?? null; +} +export function getUserData(namespace: PropertyKey, def: () => T): T { + return (getCurrentRender().addon[namespace] ??= def()) as T; +} + +export function inspect(object: unknown) { + try { + return require("node:util").inspect(object); + } catch { + return typeof object; + } +} + +export type ClsxInput = string | Record | ClsxInput[]; +export function clsx(mix: ClsxInput) { + var k, y, str; + if (typeof mix === "string") { + return mix; + } else if (typeof mix === "object") { + str = ""; + if (Array.isArray(mix)) { + for (k = 0; k < mix.length; k++) { + if (mix[k] && (y = clsx(mix[k]))) { + str && (str += " "); + str += y; + } + } + } else { + for (k in mix) { + if (mix[k]) { + str && (str += " "); + str += k; + } + } + } + } + return str; +} + +export const escapeHTML = (unsafeText: string) => + String(unsafeText) + .replace(/&/g, "&").replace(//g, ">") + .replace(/"/g, """).replace(/'/g, "'").replace(/`/g, "`"); diff --git a/package.json b/package.json index 960c812..0dfc9bf 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,16 @@ "#ssr": "./framework/engine/ssr.ts", "#ssr/jsx-dev-runtime": "./framework/engine/jsx-runtime.ts", "#ssr/jsx-runtime": "./framework/engine/jsx-runtime.ts", - "#ssr/marko": "./framework/engine/marko-runtime.ts" + "#ssr/marko": "./framework/engine/marko-runtime.ts", + "#marko/html": { + "development": "marko/debug/html", + "production": "marko/html" + }, + "#hono/platform": { + "bun": "hono/bun", + "deno": "hono/deno", + "node": "@hono/node-server", + "worker": "hono/cloudflare-workers" + } } }