authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-06-07 16:17:52-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-14 02:40:44-07:00
log7c84a70b79546fa0cfc34ca1e765413d288e55d8
tree6ac5d0c89fb02b7dee1213696d7304fecbcc06af
parent67635da6472e2ade6fde8646a21c3fb12af87acd
signature Commit is signed but in an unrecognized format.

restore clover ssr engine from the dead. comment the code a bit too.


4 files changed, 376 insertions(+), 38 deletions(-)

framework/engine/jsx-runtime.ts+20-10
...@@ -1,22 +1,32 @@...@@ -1,22 +1,32 @@
1export const Fragment = ({ children }: { children }) => children; 1export const Fragment = ({ children }: { children: engine.Node[] }) => children;
2 2
3// jsx 3export function jsx(
4export function jsx(type, props, key) { 4 type: string | engine.Component,
5 props: Record<string, unknown>,
6): engine.Element {
5 if (typeof type !== "function" && typeof type !== "string") { 7 if (typeof type !== "function" && typeof type !== "string") {
6 throw new Error("Invalid JSX component type: " + ssr.inspect(type)); 8 throw new Error("Invalid component type: " + engine.inspect(type));
7 } 9 }
8 return [import_ssr.kElement, type, props]; 10 return [engine.kElement, type, props];
9} 11}
10 12
11// jsxDEV 13export function jsxDEV(
12function jsxDEV(type, props, _key, _isStaticChildren, source) { 14 type: string | engine.Component,
15 props: Record<string, unknown>,
16 // Unused with the clover engine
17 _key: string,
18 // Unused with the clover engine
19 _isStaticChildren: boolean,
20 // Unused with the clover engine
21 _source: unknown,
22): engine.Element {
13 if (typeof type !== "function" && typeof type !== "string") { 23 if (typeof type !== "function" && typeof type !== "string") {
14 throw new Error("Invalid JSX component type: " + ssr.inspect(type)); 24 throw new Error("Invalid component type: " + engine.inspect(type));
15 } 25 }
16 return [ssr.kElement, type, props, source]; 26 return [engine.kElement, type, props];
17} 27}
18 28
19// jsxs 29// jsxs
20export { jsx as jsxs }; 30export { jsx as jsxs };
21 31
22import * as ssr from "./ssr.ts"; 32import * as engine from "./ssr.ts";
framework/engine/marko-runtime.ts+45-27
...@@ -1,17 +1,23 @@...@@ -1,17 +1,23 @@
1import * as ssr from "./ssr.ts"; 1// This file is used to integrate Marko into the Clover Engine and Sitegen
2// @ts-ignore no types :( 2// To use, replace the "marko/html" import with this file.
3import * as marko from "marko/debug/html"; 3export * from "#marko/html";
4// @ts-ignore no types :(
5export * from "marko/debug/html";
6 4
7export const createTemplate = (templateId: string, renderer) => { 5interface BodyContentObject {
6 [x: PropertyKey]: unknown;
7 content: ServerRenderer;
8}
9
10export const createTemplate = (
11 templateId: string,
12 renderer: ServerRenderer,
13) => {
8 const { render } = marko.createTemplate(templateId, renderer); 14 const { render } = marko.createTemplate(templateId, renderer);
9 function wrap(props: Record<string, unknown>, n: number) { 15 function wrap(props: Record<string, unknown>, n: number) {
10 // Marko components 16 // Marko components
11 const cloverAsyncMarker = { isAsync: false }; 17 const cloverAsyncMarker = { isAsync: false };
12 let r: ssr.Render | undefined = undefined; 18 let r: engine.Render | undefined = undefined;
13 try { 19 try {
14 r = ssr.getCurrentRender(); 20 r = engine.getCurrentRender();
15 } catch {} 21 } catch {}
16 // Support using Marko outside of Clover SSR 22 // Support using Marko outside of Clover SSR
17 if (r) { 23 if (r) {
...@@ -20,10 +26,10 @@ export const createTemplate = (templateId: string, renderer) => {...@@ -20,10 +26,10 @@ export const createTemplate = (templateId: string, renderer) => {
20 $global: { clover: r, cloverAsyncMarker }, 26 $global: { clover: r, cloverAsyncMarker },
21 }); 27 });
22 if (cloverAsyncMarker.isAsync) { 28 if (cloverAsyncMarker.isAsync) {
23 return markoResult.then(ssr.html); 29 return markoResult.then(engine.html);
24 } 30 }
25 const rr = markoResult.toString(); 31 const rr = markoResult.toString();
26 return ssr.html(rr); 32 return engine.html(rr);
27 } else { 33 } else {
28 return renderer(props, n); 34 return renderer(props, n);
29 } 35 }
...@@ -34,32 +40,33 @@ export const createTemplate = (templateId: string, renderer) => {...@@ -34,32 +40,33 @@ export const createTemplate = (templateId: string, renderer) => {
34}; 40};
35 41
36export const dynamicTag = ( 42export const dynamicTag = (
37 scopeId, 43 scopeId: number,
38 accessor, 44 accessor: Accessor,
39 tag, 45 tag: unknown | string | ServerRenderer | BodyContentObject,
40 inputOrArgs, 46 inputOrArgs: unknown,
41 content, 47 content?: (() => void) | 0,
42 inputIsArgs, 48 inputIsArgs?: 1,
43 serializeReason, 49 serializeReason?: 1 | 0,
44) => { 50) => {
51 marko.dynamicTag;
45 if (typeof tag === "function") { 52 if (typeof tag === "function") {
46 clover: { 53 clover: {
47 const unwrapped = tag.unwrapped; 54 const unwrapped = (tag as any).unwrapped;
48 if (unwrapped) { 55 if (unwrapped) {
49 tag = unwrapped; 56 tag = unwrapped;
50 break clover; 57 break clover;
51 } 58 }
52 let r: ssr.Render; 59 let r: engine.Render;
53 try { 60 try {
54 r = ssr.getCurrentRender(); 61 r = engine.getCurrentRender();
55 if (!r) throw 0; 62 if (!r) throw 0;
56 } catch { 63 } catch {
57 r = marko.$global().clover as ssr.Render; 64 r = marko.$global().clover as engine.Render;
58 } 65 }
59 if (!r) throw new Error("No Clover Render Active"); 66 if (!r) throw new Error("No Clover Render Active");
60 const subRender = ssr.initRender(r.async !== -1, r.user); 67 const subRender = engine.initRender(r.async !== -1, r.addon);
61 const resolved = ssr.resolveNode(subRender, [ 68 const resolved = engine.resolveNode(subRender, [
62 ssr.kElement, 69 engine.kElement,
63 tag, 70 tag,
64 inputOrArgs, 71 inputOrArgs,
65 ]); 72 ]);
...@@ -72,7 +79,7 @@ export const dynamicTag = (...@@ -72,7 +79,7 @@ export const dynamicTag = (
72 const { resolve, reject, promise } = Promise.withResolvers<string>(); 79 const { resolve, reject, promise } = Promise.withResolvers<string>();
73 subRender.asyncDone = () => { 80 subRender.asyncDone = () => {
74 const rejections = subRender.rejections; 81 const rejections = subRender.rejections;
75 if (!rejections) return resolve(ssr.renderNodeOrUndefined(resolved)); 82 if (!rejections) return resolve(engine.renderNode(resolved));
76 (r.rejections ??= []).push(...rejections); 83 (r.rejections ??= []).push(...rejections);
77 return reject(new Error("Render had errors")); 84 return reject(new Error("Render had errors"));
78 }; 85 };
...@@ -84,7 +91,7 @@ export const dynamicTag = (...@@ -84,7 +91,7 @@ export const dynamicTag = (
84 0, 91 0,
85 ); 92 );
86 } else { 93 } else {
87 marko.write(ssr.renderNodeOrUndefined(resolved)); 94 marko.write(engine.renderNode(resolved));
88 } 95 }
89 return; 96 return;
90 } 97 }
...@@ -100,8 +107,19 @@ export const dynamicTag = (...@@ -100,8 +107,19 @@ export const dynamicTag = (
100 ); 107 );
101}; 108};
102 109
103export function fork(scopeId, accessor, promise, callback, serializeMarker) { 110export function fork(
111 scopeId: string,
112 accessor: Accessor,
113 promise: Promise<unknown>,
114 callback: (data: unknown) => void,
115 serializeMarker?: 0 | 1,
116) {
104 const marker = marko.$global().cloverAsyncMarker; 117 const marker = marko.$global().cloverAsyncMarker;
105 marker.isAsync = true; 118 marker.isAsync = true;
106 marko.fork(scopeId, accessor, promise, callback, serializeMarker); 119 marko.fork(scopeId, accessor, promise, callback, serializeMarker);
107} 120}
121
122import * as engine from "./ssr.ts";
123import type { ServerRenderer } from "marko/html/template";
124import { type Accessor } from "marko/common/types";
125import * as marko from "#marko/html";
framework/engine/ssr.ts+300
...@@ -0,0 +1,300 @@...@@ -0,0 +1,300 @@
1// Clover's Rendering Engine is the backbone of her website generator. It
2// converts objects and components (functions returning 'Node') into HTML. The
3// engine is simple and self-contained, with integrations for JSX and Marko
4// (which can interop with each-other) are provided next to this file.
5//
6// Add-ons to the rendering engine can provide opaque data, And retrieve it
7// within component calls with 'getAddonData'. For example, 'sitegen' uses this
8// to track needed client scripts without introducing patches to the engine.
9
10type AddonData = Record<string | symbol, unknown>;
11export function ssrSync(node: Node): Result;
12export function ssrSync<A extends AddonData>(
13 node: Node,
14 addon: AddonData,
15): Result<A>;
16export function ssrSync(node: Node, addon: AddonData = {}) {
17 const r = initRender(false, addon);
18 const resolved = resolveNode(r, node);
19 return { text: renderNode(resolved), addon };
20}
21export function ssrAsync(node: Node): Promise<Result>;
22export function ssrAsync<A extends AddonData>(
23 node: Node,
24 addon: AddonData,
25): Promise<Result<A>>;
26export function ssrAsync(node: Node, addon: AddonData = {}) {
27 const r = initRender(true, addon);
28 const resolved = resolveNode(r, node);
29 if (r.async === 0) {
30 return Promise.resolve({ text: renderNode(resolved), addon });
31 }
32 const { resolve, reject, promise } = Promise.withResolvers<Result>();
33 r.asyncDone = () => {
34 const rejections = r.rejections;
35 if (!rejections) return resolve({ text: renderNode(resolved), addon });
36 if (rejections.length === 1) return reject(rejections[0]);
37 return reject(new AggregateError(rejections));
38 };
39 return promise;
40}
41
42/** Inline HTML into a render without escaping it */
43export function html(rawText: string) {
44 return [kDirectHtml, rawText];
45}
46
47interface Result<A extends AddonData = AddonData> {
48 text: string;
49 addon: A;
50}
51
52export interface Render {
53 /**
54 * Set to '-1' if rendering synchronously
55 * Number of async promises the render is waiting on.
56 */
57 async: number | -1;
58 asyncDone: null | (() => void);
59 /** When components reject, those are logged here */
60 rejections: unknown[] | null;
61 /** Add-ons to the rendering engine store state here */
62 addon: AddonData;
63}
64
65export const kElement = Symbol("Element");
66export const kDirectHtml = Symbol("DirectHtml");
67
68/** Node represents a webpage that can be 'rendered' into HTML. */
69export type Node =
70 | number
71 | string // Escape HTML
72 | Node[] // Concat
73 | Element // Render
74 | DirectHtml // Insert
75 | Promise<Node> // Await
76 // Ignore
77 | undefined
78 | null
79 | boolean;
80export type Element = [
81 tag: typeof kElement,
82 type: string | Component,
83 props: Record<string, unknown>,
84];
85export type DirectHtml = [tag: typeof kDirectHtml, html: string];
86/**
87 * Components must return a value; 'undefined' is prohibited here
88 * to avoid functions that are missing a return statement.
89 */
90export type Component = (
91 props: Record<string, unknown>,
92) => Exclude<Node, undefined>;
93
94/**
95 * Resolution narrows the type 'Node' into 'ResolvedNode'. Async trees are
96 * marked in the 'Render'. This operation performs everything besides the final
97 * string concatenation. This function is agnostic across async/sync modes.
98 */
99export function resolveNode(r: Render, node: unknown): ResolvedNode {
100 if (!node && node !== 0) return ""; // falsy, non numeric
101 if (typeof node !== "object") {
102 if (node === true) return ""; // booleans are ignored
103 if (typeof node === "string") return escapeHTML(node);
104 if (typeof node === "number") return String(node); // no escaping ever
105 throw new Error(`Cannot render ${inspect(node)} to HTML`);
106 }
107 if (node instanceof Promise) {
108 if (r.async === -1) {
109 throw new Error(`Asynchronous rendering is not supported here.`);
110 }
111 const placeholder: InsertionPoint = [null];
112 r.async += 1;
113 node
114 .then((result) => void (placeholder[0] = resolveNode(r, result)))
115 // Intentionally catching errors in `resolveNode`
116 .catch((e) => (r.rejections ??= []).push(e))
117 .finally(() => {
118 if (--r.async == 0) {
119 if (r.asyncDone == null) throw new Error("r.asyncDone == null");
120 r.asyncDone();
121 r.asyncDone = null;
122 }
123 });
124 // This lie is checked with an assertion in `renderNode`
125 return placeholder as [ResolvedNode];
126 }
127 if (!Array.isArray(node)) {
128 throw new Error(`Invalid node type: ${inspect(node)}`);
129 }
130 const type = node[0];
131 if (type === kElement) {
132 const { 1: tag, 2: props } = node;
133 if (typeof tag === "function") {
134 currentRender = r;
135 const result = tag(props);
136 currentRender = null;
137 return resolveNode(r, result);
138 }
139 if (typeof tag !== "string") throw new Error("Unexpected " + typeof type);
140 const children = props?.children;
141 if (children) return [kElement, tag, props, resolveNode(r, children)];
142 return node;
143 }
144 if (type === kDirectHtml) return node[1];
145 return node.map((elem) => resolveNode(r, elem));
146}
147
148export type ResolvedNode =
149 | ResolvedNode[] // Concat
150 | ResolvedElement // Render
151 | string; // Direct HTML
152export type ResolvedElement = [
153 tag: typeof kElement,
154 type: string,
155 props: Record<string, unknown>,
156 children: ResolvedNode,
157];
158/**
159 * Async rendering is done by creating an array of one item,
160 * which is already a valid 'Node', but the element is written
161 * once the data is available. The 'Render' contains a count
162 * of how many async jobs are left.
163 */
164export type InsertionPoint = [null | ResolvedNode];
165
166/**
167 * Convert 'ResolvedNode' into HTML text. This operation happens after all
168 * async work is settled. The HTML is emitted as concisely as possible.
169 */
170export function renderNode(node: ResolvedNode): string {
171 if (typeof node === "string") return node;
172 ASSERT(node, "Unresolved Render Node");
173 const type = node[0];
174 if (type === kElement) {
175 return renderElement(node as ResolvedElement);
176 }
177 node = node as ResolvedNode[]; // TS cannot infer.
178 let out = type ? renderNode(type) : "";
179 let len = node.length;
180 for (let i = 1; i < len; i++) {
181 const elem = node[i];
182 if (elem) out += renderNode(elem);
183 }
184 return out;
185}
186function renderElement(element: ResolvedElement) {
187 const { 1: tag, 2: props, 3: children } = element;
188 let out = "<" + tag;
189 let needSpace = true;
190 for (const prop in props) {
191 const value = props[prop];
192 if (!value || typeof value === "function") continue;
193 let attr;
194 switch (prop) {
195 default:
196 attr = `${prop}=${quoteIfNeeded(escapeHTML(String(value)))}`;
197 break;
198 case "className":
199 // Legacy React Compat
200 case "class":
201 attr = `class=${quoteIfNeeded(escapeHTML(clsx(value)))}`;
202 break;
203 case "htmlFor":
204 throw new Error("Do not use the `htmlFor` attribute. Use `for`");
205 // Do not process these
206 case "children":
207 case "ref":
208 case "dangerouslySetInnerHTML":
209 case "key":
210 continue;
211 }
212 if (needSpace) out += " ", needSpace = !attr.endsWith('"');
213 out += attr;
214 }
215 out += ">";
216 if (children) out += renderNode(children);
217 if (
218 tag !== "br" && tag !== "img" && tag !== "input" && tag !== "meta" &&
219 tag !== "link" && tag !== "hr"
220 ) {
221 out += `</${tag}>`;
222 }
223 return out;
224}
225export function renderStyleAttribute(style: Record<string, string>) {
226 let out = ``;
227 for (const styleName in style) {
228 if (out) out += ";";
229 out += `${styleName.replace(/[A-Z]/g, "-$&").toLowerCase()}:${
230 escapeHTML(String(style[styleName]))
231 }`;
232 }
233 return "style=" + quoteIfNeeded(out);
234}
235export function quoteIfNeeded(text) {
236 if (text.includes(" ")) return '"' + text + '"';
237 return text;
238}
239
240// -- utility functions --
241
242export function initRender(allowAsync: boolean, addon: AddonData): Render {
243 return {
244 async: allowAsync ? 0 : -1,
245 rejections: null,
246 asyncDone: null,
247 addon,
248 };
249}
250
251let currentRender: Render | null = null;
252export function getCurrentRender() {
253 if (!currentRender) throw new Error("No Render Active");
254 return currentRender;
255}
256export function setCurrentRender(r?: Render | null) {
257 currentRender = r ?? null;
258}
259export function getUserData<T>(namespace: PropertyKey, def: () => T): T {
260 return (getCurrentRender().addon[namespace] ??= def()) as T;
261}
262
263export function inspect(object: unknown) {
264 try {
265 return require("node:util").inspect(object);
266 } catch {
267 return typeof object;
268 }
269}
270
271export type ClsxInput = string | Record<string, boolean | null> | ClsxInput[];
272export function clsx(mix: ClsxInput) {
273 var k, y, str;
274 if (typeof mix === "string") {
275 return mix;
276 } else if (typeof mix === "object") {
277 str = "";
278 if (Array.isArray(mix)) {
279 for (k = 0; k < mix.length; k++) {
280 if (mix[k] && (y = clsx(mix[k]))) {
281 str && (str += " ");
282 str += y;
283 }
284 }
285 } else {
286 for (k in mix) {
287 if (mix[k]) {
288 str && (str += " ");
289 str += k;
290 }
291 }
292 }
293 }
294 return str;
295}
296
297export const escapeHTML = (unsafeText: string) =>
298 String(unsafeText)
299 .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
300 .replace(/"/g, "&quot;").replace(/'/g, "&#x27;").replace(/`/g, "&#x60;");
package.json+11-1
...@@ -20,6 +20,16 @@...@@ -20,6 +20,16 @@
20 "#ssr": "./framework/engine/ssr.ts",20 "#ssr": "./framework/engine/ssr.ts",
21 "#ssr/jsx-dev-runtime": "./framework/engine/jsx-runtime.ts",21 "#ssr/jsx-dev-runtime": "./framework/engine/jsx-runtime.ts",
22 "#ssr/jsx-runtime": "./framework/engine/jsx-runtime.ts",22 "#ssr/jsx-runtime": "./framework/engine/jsx-runtime.ts",
23 "#ssr/marko": "./framework/engine/marko-runtime.ts"23 "#ssr/marko": "./framework/engine/marko-runtime.ts",
24 "#marko/html": {
25 "development": "marko/debug/html",
26 "production": "marko/html"
27 },
28 "#hono/platform": {
29 "bun": "hono/bun",
30 "deno": "hono/deno",
31 "node": "@hono/node-server",
32 "worker": "hono/cloudflare-workers"
33 }
24 }34 }
25}35}