restore clover ssr engine from the dead. comment the code a bit too.
This commit is contained in:
parent
89b97744e9
commit
f841f766d2
4 changed files with 376 additions and 38 deletions
|
@ -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<string, 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 [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<string, unknown>,
|
||||
// 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";
|
||||
|
|
|
@ -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<string, unknown>, 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<string>();
|
||||
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<unknown>,
|
||||
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";
|
||||
|
|
|
@ -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<string | symbol, unknown>;
|
||||
export function ssrSync(node: Node): Result;
|
||||
export function ssrSync<A extends AddonData>(
|
||||
node: Node,
|
||||
addon: AddonData,
|
||||
): Result<A>;
|
||||
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<Result>;
|
||||
export function ssrAsync<A extends AddonData>(
|
||||
node: Node,
|
||||
addon: AddonData,
|
||||
): Promise<Result<A>>;
|
||||
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<Result>();
|
||||
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<A extends AddonData = AddonData> {
|
||||
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<Node> // Await
|
||||
// Ignore
|
||||
| undefined
|
||||
| null
|
||||
| boolean;
|
||||
export type Element = [
|
||||
tag: typeof kElement,
|
||||
type: string | Component,
|
||||
props: Record<string, unknown>,
|
||||
];
|
||||
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<string, unknown>,
|
||||
) => Exclude<Node, undefined>;
|
||||
|
||||
/**
|
||||
* 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<string, unknown>,
|
||||
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 += `</${tag}>`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
export function renderStyleAttribute(style: Record<string, string>) {
|
||||
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<T>(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<string, boolean | null> | 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, "'").replace(/`/g, "`");
|
12
package.json
12
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue