authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-08-02 22:22:07-04:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-14 02:40:46-07:00
log521edec76fecc043e04bedcf0c00c0368fa9e96e
tree696d510a0b649bb832b98862e784c8b5b56f74e9
parent630def77dd3df902c021bd8cc05db17578d21eda
signature Commit is signed but in an unrecognized format.

chore: rework Clover Engine API, remove "SSR" term

"server side rendering" is a misleading term since it implies there is a server. that isn't neccecarily the case here, since it supports running in the browser. I think "clover engine" is cute, short for "clover html rendering engine". Instead of "server side rendering", it's just rendering. This commit makes things a lot more concise, such as `ssr.ssrAsync` being renamed to `render.async` to play nicely with namespaced imports. `getCurrentRender` and `setCurrentRender` are just `current` and `setCurrent`, and the addon interface has been redesigned to force symbols with a wrapping helper.

24 files changed, 861 insertions(+), 879 deletions(-)

framework/bundle.ts+199-165
......@@ -1,27 +1,33 @@
11async function trackEsbuild(io: Io, metafile: esbuild.Metafile) {
2 await Promise.all(Object.keys(metafile.inputs)
3 .filter(file => !isIgnoredSource(file))
4 .map(file => io.trackFile(file)));
2 await Promise.all(
3 Object.keys(metafile.inputs)
4 .filter((file) => !isIgnoredSource(file))
5 .map((file) => io.trackFile(file)),
6 );
57}
68
79// This file implements client-side bundling, mostly wrapping esbuild.
810export async function bundleClientJavaScript(
911 io: Io,
10 { clientRefs, extraPublicScripts, dev = false }: {
12 {
13 clientRefs,
14 extraPublicScripts,
15 dev = false,
16 }: {
1117 clientRefs: string[];
1218 extraPublicScripts: string[];
1319 dev: boolean;
1420 },
1521) {
1622 const entryPoints = [
17 ...new Set([
18 ...clientRefs.map((x) => `src/${x}`),
19 ...extraPublicScripts,
20 ].map(toAbs)),
23 ...new Set(
24 [...clientRefs.map((x) => `src/${x}`), ...extraPublicScripts].map(toAbs),
25 ),
2126 ];
2227 if (entryPoints.length === 0) return {};
23 const invalidFiles = entryPoints
24 .filter((file) => !file.match(/\.client\.[tj]sx?/));
28 const invalidFiles = entryPoints.filter(
29 (file) => !file.match(/\.client\.[tj]sx?/),
30 );
2531 if (invalidFiles.length > 0) {
2632 const cwd = process.cwd();
2733 throw new Error(
......@@ -35,45 +41,48 @@ export async function bundleClientJavaScript(
3541 markoViaBuildCache(),
3642 ];
3743
38 const bundle = await esbuild.build({
39 assetNames: "/asset/[hash]",
40 bundle: true,
41 chunkNames: "/js/c.[hash]",
42 entryNames: "/js/[name]",
43 entryPoints,
44 format: "esm",
45 jsx: "automatic",
46 jsxDev: dev,
47 jsxImportSource: "#ssr",
48 logLevel: "silent",
49 metafile: true,
50 minify: !dev,
51 outdir: "out!",
52 plugins: clientPlugins,
53 write: false,
54 define: {
55 "ASSERT": "console.assert",
56 MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText),
57 },
58 }).catch((err: any) => {
59 err.message = `Client ${err.message}`;
60 throw err;
61 });
44 const bundle = await esbuild
45 .build({
46 assetNames: "/asset/[hash]",
47 bundle: true,
48 chunkNames: "/js/c.[hash]",
49 entryNames: "/js/[name]",
50 entryPoints,
51 format: "esm",
52 jsx: "automatic",
53 jsxDev: dev,
54 jsxImportSource: "#engine",
55 logLevel: "silent",
56 metafile: true,
57 minify: !dev,
58 outdir: "out!",
59 plugins: clientPlugins,
60 write: false,
61 define: {
62 ASSERT: "console.assert",
63 MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText),
64 },
65 })
66 .catch((err: any) => {
67 err.message = `Client ${err.message}`;
68 throw err;
69 });
6270 if (bundle.errors.length || bundle.warnings.length) {
6371 throw new AggregateError(
6472 bundle.errors.concat(bundle.warnings),
6573 "JS bundle failed",
6674 );
6775 }
68 const publicScriptRoutes = extraPublicScripts.map((file) =>
69 "/js/" +
70 path.relative(hot.projectSrc, file).replaceAll("\\", "/").replace(
71 /\.client\.[tj]sx?/,
72 ".js",
73 )
76 const publicScriptRoutes = extraPublicScripts.map(
77 (file) =>
78 "/js/" +
79 path
80 .relative(hot.projectSrc, file)
81 .replaceAll("\\", "/")
82 .replace(/\.client\.[tj]sx?/, ".js"),
7483 );
7584 const { metafile, outputFiles } = bundle;
76 const p = []
85 const p = [];
7786 p.push(trackEsbuild(io, metafile));
7887 const scripts: Record<string, string> = {};
7988 for (const file of outputFiles) {
......@@ -100,156 +109,184 @@ export async function bundleClientJavaScript(
100109
101110export type ServerPlatform = "node" | "passthru";
102111export interface ServerSideOptions {
103 entries: string[],
104 viewItems: sg.FileItem[]
105 viewRefs: incr.Ref<PreparedView>[],
112 entries: string[];
113 viewItems: sg.FileItem[];
114 viewRefs: incr.Ref<PreparedView>[];
106115 styleMap: Map<string, incr.Ref<string>>;
107116 scriptMap: incr.Ref<Record<string, string>>;
108 platform: ServerPlatform,
117 platform: ServerPlatform;
109118}
110export async function bundleServerJavaScript(
111 { viewItems, viewRefs, styleMap, scriptMap: wScriptMap, entries, platform }: ServerSideOptions
112) {
119export async function bundleServerJavaScript({
120 viewItems,
121 viewRefs,
122 styleMap,
123 scriptMap: wScriptMap,
124 entries,
125 platform,
126}: ServerSideOptions) {
113127 const wViewSource = incr.work(async (_, viewItems: sg.FileItem[]) => {
114128 const magicWord = "C_" + crypto.randomUUID().replaceAll("-", "_");
115129 return {
116130 magicWord,
117131 file: [
118 ...viewItems.map((view, i) => `import * as view${i} from ${JSON.stringify(view.file)}`),
132 ...viewItems.map(
133 (view, i) => `import * as view${i} from ${JSON.stringify(view.file)}`,
134 ),
119135 `const styles = ${magicWord}[-2]`,
120136 `export const scripts = ${magicWord}[-1]`,
121137 "export const views = {",
122 ...viewItems.map((view, i) => [
123 ` ${JSON.stringify(view.id)}: {`,
124 ` component: view${i}.default,`,
125 ` meta: view${i}.meta,`,
126 ` layout: view${i}.layout?.default ?? null,`,
127 ` inlineCss: styles[${magicWord}[${i}]]`,
128 ` },`,
129 ].join("\n")),
138 ...viewItems.map((view, i) =>
139 [
140 ` ${JSON.stringify(view.id)}: {`,
141 ` component: view${i}.default,`,
142 ` meta: view${i}.meta,`,
143 ` layout: view${i}.layout?.default ?? null,`,
144 ` inlineCss: styles[${magicWord}[${i}]]`,
145 ` },`,
146 ].join("\n"),
147 ),
130148 "}",
131 ].join("\n")
149 ].join("\n"),
132150 };
133 }, viewItems)
151 }, viewItems);
134152
135 const wBundles = entries.map(entry => [entry, incr.work(async (io, entry) => {
136 const pkg = await io.readJson<{ dependencies: Record<string, string>; }>("package.json");
153 const wBundles = entries.map(
154 (entry) =>
155 [
156 entry,
157 incr.work(async (io, entry) => {
158 const pkg = await io.readJson<{
159 dependencies: Record<string, string>;
160 }>("package.json");
137161
138 let magicWord = null as string | null;
139 // -- plugins --
140 const serverPlugins: esbuild.Plugin[] = [
141 virtualFiles({
142 // only add dependency when imported.
143 "$views": async () => {
144 const view = await io.readWork(wViewSource);
145 ({ magicWord } = view);
146 return view.file;
147 },
148 }),
149 projectRelativeResolution(),
150 markoViaBuildCache(),
151 {
152 name: "replace client references",
153 setup(b) {
154 b.onLoad({ filter: /\.tsx?$/ }, async ({ path: file }) => ({
155 contents:
156 hot.resolveClientRefs(await fs.readFile(file, "utf-8"), file).code,
157 loader: path.extname(file).slice(1) as esbuild.Loader,
158 }));
159 },
160 },
161 {
162 name: "mark css external",
163 setup(b) {
164 b.onResolve(
165 { filter: /\.css$/ },
166 () => ({ path: ".", namespace: "dropped" }),
167 );
168 b.onLoad(
169 { filter: /./, namespace: "dropped" },
170 () => ({ contents: "" }),
171 );
172 },
173 },
174 ];
162 let magicWord = null as string | null;
163 // -- plugins --
164 const serverPlugins: esbuild.Plugin[] = [
165 virtualFiles({
166 // only add dependency when imported.
167 $views: async () => {
168 const view = await io.readWork(wViewSource);
169 ({ magicWord } = view);
170 return view.file;
171 },
172 }),
173 projectRelativeResolution(),
174 markoViaBuildCache(),
175 {
176 name: "replace client references",
177 setup(b) {
178 b.onLoad({ filter: /\.tsx?$/ }, async ({ path: file }) => ({
179 contents: hot.resolveClientRefs(
180 await fs.readFile(file, "utf-8"),
181 file,
182 ).code,
183 loader: path.extname(file).slice(1) as esbuild.Loader,
184 }));
185 },
186 },
187 {
188 name: "mark css external",
189 setup(b) {
190 b.onResolve({ filter: /\.css$/ }, () => ({
191 path: ".",
192 namespace: "dropped",
193 }));
194 b.onLoad({ filter: /./, namespace: "dropped" }, () => ({
195 contents: "",
196 }));
197 },
198 },
199 ];
175200
176 const { metafile, outputFiles, errors, warnings } = await esbuild.build({
177 bundle: true,
178 chunkNames: "c.[hash]",
179 entryNames: path.basename(entry, path.extname(entry)),
180 entryPoints: [
181 path.join(import.meta.dirname, "backend/entry-" + platform + ".ts"),
182 ],
183 platform: "node",
184 format: "esm",
185 minify: false,
186 outdir: "out!",
187 plugins: serverPlugins,
188 splitting: true,
189 logLevel: "silent",
190 write: false,
191 metafile: true,
192 jsx: "automatic",
193 jsxImportSource: "#ssr",
194 jsxDev: false,
195 define: {
196 MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText),
197 'globalThis.CLOVER_SERVER_ENTRY': JSON.stringify(entry),
198 },
199 external: Object.keys(pkg.dependencies)
200 .filter((x) => !x.startsWith("@paperclover")),
201 });
202 await trackEsbuild(io, metafile)
201 const { metafile, outputFiles } = await esbuild.build({
202 bundle: true,
203 chunkNames: "c.[hash]",
204 entryNames: path.basename(entry, path.extname(entry)),
205 entryPoints: [
206 path.join(
207 import.meta.dirname,
208 "backend/entry-" + platform + ".ts",
209 ),
210 ],
211 platform: "node",
212 format: "esm",
213 minify: false,
214 outdir: "out!",
215 plugins: serverPlugins,
216 splitting: true,
217 logLevel: "silent",
218 write: false,
219 metafile: true,
220 jsx: "automatic",
221 jsxImportSource: "#engine",
222 jsxDev: false,
223 define: {
224 MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText),
225 "globalThis.CLOVER_SERVER_ENTRY": JSON.stringify(entry),
226 },
227 external: Object.keys(pkg.dependencies).filter(
228 (x) => !x.startsWith("@paperclover"),
229 ),
230 });
231 await trackEsbuild(io, metafile);
203232
204 let fileWithMagicWord: {
205 bytes: Buffer;
206 basename: string;
207 magicWord: string;
208 } | null = null;
209 for (const output of outputFiles) {
210 const basename = output.path.replace(/^.*?!(?:\/|\\)/, "");
211 const key = "out!/" + basename.replaceAll("\\", "/");
212 // If this contains the generated "$views" file, then
213 // mark this file as the one for replacement. Because
214 // `splitting` is `true`, esbuild will not emit this
215 // file in more than one chunk.
216 if (magicWord && metafile.outputs[key].inputs["framework/lib/view.ts"]) {
217 ASSERT(!fileWithMagicWord);
218 fileWithMagicWord = {
219 basename,
220 bytes: Buffer.from(output.contents),
221 magicWord,
222 };
223 } else {
224 io.writeFile(basename, Buffer.from(output.contents))
225 }
226 }
227 return fileWithMagicWord;
228 }, entry)] as const);
233 let fileWithMagicWord: {
234 bytes: Buffer;
235 basename: string;
236 magicWord: string;
237 } | null = null;
238 for (const output of outputFiles) {
239 const basename = output.path.replace(/^.*?!(?:\/|\\)/, "");
240 const key = "out!/" + basename.replaceAll("\\", "/");
241 // If this contains the generated "$views" file, then
242 // mark this file as the one for replacement. Because
243 // `splitting` is `true`, esbuild will not emit this
244 // file in more than one chunk.
245 if (
246 magicWord &&
247 metafile.outputs[key].inputs["framework/lib/view.ts"]
248 ) {
249 ASSERT(!fileWithMagicWord);
250 fileWithMagicWord = {
251 basename,
252 bytes: Buffer.from(output.contents),
253 magicWord,
254 };
255 } else {
256 io.writeFile(basename, Buffer.from(output.contents));
257 }
258 }
259 return fileWithMagicWord;
260 }, entry),
261 ] as const,
262 );
229263
230 const wProcessed = wBundles.map(async([entry, wBundle]) => {
231 if (!await wBundle) return;
264 const wProcessed = wBundles.map(async ([entry, wBundle]) => {
265 if (!(await wBundle)) return;
232266 await incr.work(async (io) => {
233267 // Only the reachable resources need to be read and inserted into the bundle.
234268 // This is what Map<string, incr.Ref> is for
235269 const { basename, bytes, magicWord } = UNWRAP(await io.readWork(wBundle));
236 const views = await Promise.all(viewRefs.map(ref => io.readWork(ref)));
270 const views = await Promise.all(viewRefs.map((ref) => io.readWork(ref)));
237271
238272 // Client JS
239273 const scriptList = Object.entries(await io.readWork(wScriptMap));
240 const viewScriptsList = new Set(views.flatMap(view => view.clientRefs));
274 const viewScriptsList = new Set(views.flatMap((view) => view.clientRefs));
241275 const neededScripts = scriptList.filter(([k]) => viewScriptsList.has(k));
242276
243277 // CSS
244278 const viewStyleKeys = views.map((view) => view.styleKey);
245279 const viewCssBundles = await Promise.all(
246 viewStyleKeys.map((key) => io.readWork(UNWRAP(styleMap.get(key), "Style key: " + key))));
280 viewStyleKeys.map((key) =>
281 io.readWork(UNWRAP(styleMap.get(key), "Style key: " + key)),
282 ),
283 );
247284 const styleList = Array.from(new Set(viewCssBundles));
248285
249286 // Replace the magic word
250 const text = bytes.toString("utf-8").replace(
251 new RegExp(magicWord + "\\[(-?\\d+)\\]", "gs"),
252 (_, i) => {
287 const text = bytes
288 .toString("utf-8")
289 .replace(new RegExp(magicWord + "\\[(-?\\d+)\\]", "gs"), (_, i) => {
253290 i = Number(i);
254291 // Inline the styling data
255292 if (i === -2) {
......@@ -261,17 +298,15 @@ export async function bundleServerJavaScript(
261298 }
262299 // Reference an index into `styleList`
263300 return `${styleList.indexOf(viewCssBundles[i])}`;
264 },
265 );
301 });
266302
267303 io.writeFile(basename, text);
268304 });
269 })
305 });
270306
271307 await Promise.all(wProcessed);
272308}
273309
274
275310import * as esbuild from "esbuild";
276311import * as path from "node:path";
277312import process from "node:process";
......@@ -282,9 +317,8 @@ import {
282317 projectRelativeResolution,
283318 virtualFiles,
284319} from "./esbuild-support.ts";
285import { Io, toAbs, toRel } from "./incremental.ts";
286import * as css from "./css.ts";
320import { Io, toAbs } from "./incremental.ts";
287321import * as fs from "#sitegen/fs";
288322import * as mime from "#sitegen/mime";
289323import * as incr from "./incremental.ts";
290import * as sg from "#sitegen";import type { PreparedView } from "./generate2.ts";import { meta } from "@/file-viewer/pages/file.cotyledon_speedbump.tsx";
324import * as sg from "#sitegen";
framework/engine/jsx-runtime.ts+14-14
......@@ -1,37 +1,37 @@
1export const Fragment = ({ children }: { children: engine.Node[] }) => children;
1export const Fragment = ({ children }: { children: render.Node[] }) => children;
22
33export function jsx(
4 type: string | engine.Component,
4 type: string | render.Component,
55 props: Record<string, unknown>,
6): engine.Element {
6): render.Element {
77 if (typeof type !== "function" && typeof type !== "string") {
8 throw new Error("Invalid component type: " + engine.inspect(type));
8 throw new Error("Invalid component type: " + render.inspect(type));
99 }
10 return [engine.kElement, type, props];
10 return [render.kElement, type, props];
1111}
1212
1313export function jsxDEV(
14 type: string | engine.Component,
14 type: string | render.Component,
1515 props: Record<string, unknown>,
1616 // Unused with the clover engine
1717 _key: string,
1818 // Unused with the clover engine
1919 _isStaticChildren: boolean,
20 source: engine.SrcLoc,
21): engine.Element {
20 source: render.SrcLoc,
21): render.Element {
2222 const { fileName, lineNumber, columnNumber } = source;
2323
2424 // Assert the component type is valid to render.
2525 if (typeof type !== "function" && typeof type !== "string") {
2626 throw new Error(
2727 `Invalid component type at ${fileName}:${lineNumber}:${columnNumber}: ` +
28 engine.inspect(type) +
28 render.inspect(type) +
2929 ". Clover SSR element must be a function or string",
3030 );
3131 }
3232
3333 // Construct an `ssr.Element`
34 return [engine.kElement, type, props, "", source];
34 return [render.kElement, type, props, "", source];
3535}
3636
3737// jsxs
......@@ -45,10 +45,10 @@ declare global {
4545 interface ElementChildrenAttribute {
4646 children: Node;
4747 }
48 type Element = engine.Element;
49 type ElementType = keyof IntrinsicElements | engine.Component;
50 type ElementClass = ReturnType<engine.Component>;
48 type Element = render.Element;
49 type ElementType = keyof IntrinsicElements | render.Component;
50 type ElementClass = ReturnType<render.Component>;
5151 }
5252}
5353
54import * as engine from "./ssr.ts";
54import * as render from "./render.ts";
framework/engine/marko-runtime.ts+27-36
......@@ -11,29 +11,22 @@ export const createTemplate = (
1111 templateId: string,
1212 renderer: ServerRenderer,
1313) => {
14 const { render } = marko.createTemplate(templateId, renderer);
14 const { render: renderFn } = marko.createTemplate(templateId, renderer);
1515 function wrap(props: Record<string, unknown>, n: number) {
1616 // Marko Custom Tags
1717 const cloverAsyncMarker = { isAsync: false };
18 let r: engine.Render | undefined = undefined;
19 try {
20 r = engine.getCurrentRender();
21 } catch {}
18 const r = render.current;
2219 // Support using Marko outside of Clover SSR
23 if (r) {
24 engine.setCurrentRender(null);
25 const markoResult = render.call(renderer, {
26 ...props,
27 $global: { clover: r, cloverAsyncMarker },
28 });
29 if (cloverAsyncMarker.isAsync) {
30 return markoResult.then(engine.html);
31 }
32 const rr = markoResult.toString();
33 return engine.html(rr);
34 } else {
35 return renderer(props, n);
20 if (!r) return renderer(props, n);
21 const markoResult = renderFn.call(renderer, {
22 ...props,
23 $global: { clover: r, cloverAsyncMarker },
24 });
25 if (cloverAsyncMarker.isAsync) {
26 return markoResult.then(render.raw);
3627 }
28 const rr = markoResult.toString();
29 return render.raw(rr);
3730 }
3831 wrap.render = render;
3932 wrap.unwrapped = renderer;
......@@ -56,20 +49,16 @@ export const dynamicTag = (
5649 tag = unwrapped;
5750 break clover;
5851 }
59 let r: engine.Render;
60 try {
61 r = engine.getCurrentRender();
62 if (!r) throw 0;
63 } catch {
64 r = marko.$global().clover as engine.Render;
65 }
52 const r = render.current ?? (marko.$global().clover as render.State);
6653 if (!r) throw new Error("No Clover Render Active");
67 const subRender = engine.initRender(r.async !== -1, r.addon);
68 const resolved = engine.resolveNode(subRender, [
69 engine.kElement,
70 tag,
71 inputOrArgs,
72 ]);
54 const subRender = render.init(r.async !== -1, r.addon);
55 const resolved = render.resolveNode(
56 subRender,
57 render.element(
58 tag as render.Component,
59 inputOrArgs as Record<any, any>,
60 ),
61 );
7362
7463 if (subRender.async > 0) {
7564 const marker = marko.$global().cloverAsyncMarker as Async;
......@@ -79,7 +68,7 @@ export const dynamicTag = (
7968 const { resolve, reject, promise } = Promise.withResolvers<string>();
8069 subRender.asyncDone = () => {
8170 const rejections = subRender.rejections;
82 if (!rejections) return resolve(engine.renderNode(resolved));
71 if (!rejections) return resolve(render.stringifyNode(resolved));
8372 (r.rejections ??= []).push(...rejections);
8473 return reject(new Error("Render had errors"));
8574 };
......@@ -91,7 +80,7 @@ export const dynamicTag = (
9180 0,
9281 );
9382 } else {
94 marko.write(engine.renderNode(resolved));
83 marko.write(render.stringifyNode(resolved));
9584 }
9685 return;
9786 }
......@@ -124,13 +113,15 @@ export function escapeXML(input: unknown) {
124113 // creating `[object Object]` is universally useless to any end user.
125114 if (
126115 input == null ||
127 (typeof input === "object" && input &&
116 (typeof input === "object" &&
117 input &&
128118 // only block this if it's the default `toString`
129119 input.toString === Object.prototype.toString)
130120 ) {
131121 throw new Error(
132122 `Unexpected value in template placeholder: '` +
133 engine.inspect(input) + "'. " +
123 render.inspect(input) +
124 "'. " +
134125 `To emit a literal '${input}', use \${String(value)}`,
135126 );
136127 }
......@@ -141,7 +132,7 @@ interface Async {
141132 isAsync: boolean;
142133}
143134
144import * as engine from "./ssr.ts";
135import * as render from "#engine/render";
145136import type { ServerRenderer } from "marko/html/template";
146137import { type Accessor } from "marko/common/types";
147138import * as marko from "#marko/html";
framework/engine/render.test.tsx created+41
......@@ -0,0 +1,41 @@
1import { test } from "node:test";
2import * as render from "./render.ts";
3
4test("sanity", (t) => t.assert.equal(render.sync("gm <3").text, "gm &lt;3"));
5test("simple tree", (t) =>
6 t.assert.equal(
7 render.sync(
8 <main class={["a", "b"]}>
9 <h1 style="background-color:red">hello world</h1>
10 <p>haha</p>
11 {1}|{0}|{true}|{false}|{null}|{undefined}|
12 </main>,
13 ).text,
14 '<main class="a b"><h1 style=background-color:red>hello world</h1><p>haha</p>1|0|||||</main>',
15 ));
16test("unescaped/escaped html", (t) =>
17 t.assert.equal(
18 render.sync(
19 <div>
20 {render.raw("<fuck>")}
21 {"\"&'`<>"}
22 </div>,
23 ).text,
24 "<div><fuck>&quot;&amp;&#x27;&#x60;&lt;&gt;</div>",
25 ));
26test("clsx built-in", (t) =>
27 t.assert.equal(
28 render.sync(
29 <>
30 <a class="a" />
31 <b class={null} />
32 <c class={undefined} />
33 <d class={["a", "b", null]} />
34 <e class={{ a: true, b: false }} />
35 <e
36 class={[null, "x", { z: true }, [{ m: true }, null, { v: false }]]}
37 />
38 </>,
39 ).text,
40 '<a class=a></a><b></b><c></c><d class="a b"></d><e class=a></e><e class="x z m"></e>',
41 ));
framework/engine/render.ts created+321
......@@ -0,0 +1,321 @@
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.
9export let current: State | null = null;
10export function setCurrent(r: State | null) {
11 current = r ?? null;
12}
13
14/* Convert a UI description into a string synchronously. */
15export function sync<A extends Addons>(node: Node, addon: A = {} as A) {
16 const state = init(false, addon);
17 const resolved = resolveNode(state, node);
18 return { text: stringifyNode(resolved), addon };
19}
20
21/* Convert a UI description into a string asynchronously. */
22export function async<A extends Addons>(node: Node, addon: A = {} as A) {
23 const state = init(true, addon);
24 const resolved = resolveNode(state, node);
25 if (state.async === 0) {
26 return Promise.resolve({ text: stringifyNode(resolved), addon });
27 }
28 const { resolve, reject, promise } = Promise.withResolvers<Result<A>>();
29 state.asyncDone = () => {
30 const rejections = state.rejections;
31 if (!rejections) return resolve({ text: stringifyNode(resolved), addon });
32 if (rejections.length === 1) return reject(rejections[0]);
33 return reject(new AggregateError(rejections));
34 };
35 return promise;
36}
37
38export type Addons = Record<symbol, unknown>;
39export interface Result<A extends Addons = Addons> {
40 text: string;
41 addon: A;
42}
43
44export function userData<T>(def: () => T) {
45 const k: unique symbol = Symbol();
46 return {
47 key: k,
48 get: () => ((UNWRAP(current).addon[k] as T) ??= def() as T),
49 set: (value: T) => void (UNWRAP(current).addon[k] = value),
50 };
51}
52
53/** Inline HTML into a render without escaping it */
54export function raw(node: ResolvedNode): Raw {
55 return [kRaw, node];
56}
57export function element(type: Element[1], props: Element[2] = {}): Element {
58 return [kElement, type, props];
59}
60export function resolvedElement(
61 type: ResolvedElement[1],
62 props: ResolvedElement[2],
63 children: ResolvedElement[3],
64): ResolvedElement {
65 return [kElement, type, props, children];
66}
67
68export interface State {
69 /**
70 * Set to '-1' if rendering synchronously
71 * Number of async promises the render is waiting on.
72 */
73 async: number | -1;
74 asyncDone: null | (() => void);
75 /** When components reject, those are logged here */
76 rejections: unknown[] | null;
77 /** Add-ons to the rendering engine store state here */
78 addon: Addons;
79}
80
81export const kElement = Symbol("Element");
82export const kRaw = Symbol("Raw");
83
84/** Node represents a webpage that can be 'rendered' into HTML. */
85export type Node =
86 | number
87 | string // Escape HTML
88 | Node[] // Concat
89 | Element // Stringify
90 | Raw // Insert
91 | Promise<Node> // Await
92 // Ignore
93 | (undefined | null | boolean);
94export type Element = [
95 tag: typeof kElement,
96 type: string | Component,
97 props: Record<string, unknown>,
98 _?: "", // children
99 source?: SrcLoc,
100];
101export type Raw = [tag: typeof kRaw, html: ResolvedNode];
102/** Components must return a value; 'undefined' is prohibited here
103 * to avoid functions that are missing a return statement. */
104export type Component = (props: Record<any, any>) => Exclude<Node, undefined>;
105/** Emitted by JSX runtime */
106export interface SrcLoc {
107 fileName: string;
108 lineNumber: number;
109 columnNumber: number;
110}
111
112/**
113 * Resolution narrows the type 'Node' into 'ResolvedNode'. Async trees are
114 * marked in 'State'. This operation performs everything besides the final
115 * string concatenation. This function is agnostic across async/sync modes.
116 */
117export function resolveNode(r: State, node: unknown): ResolvedNode {
118 if (!node && node !== 0) return ""; // falsy, non numeric
119 if (typeof node !== "object") {
120 if (node === true) return ""; // booleans are ignored
121 if (typeof node === "string") return escapeHtml(node);
122 if (typeof node === "number") return String(node); // no escaping ever
123 throw new Error(`Cannot render ${inspect(node)} to HTML`);
124 }
125 if (node instanceof Promise) {
126 if (r.async === -1) {
127 throw new Error(`Asynchronous rendering is not supported here.`);
128 }
129 const placeholder: InsertionPoint = [null];
130 r.async += 1;
131 node
132 .then((result) => void (placeholder[0] = resolveNode(r, result)))
133 // Intentionally catching errors in `resolveNode`
134 .catch((e) => (r.rejections ??= []).push(e))
135 .finally(() => {
136 if (--r.async == 0) {
137 if (r.asyncDone == null) throw new Error("r.asyncDone == null");
138 r.asyncDone();
139 r.asyncDone = null;
140 }
141 });
142 // This lie is checked with an assertion in `renderNode`
143 return placeholder as [ResolvedNode];
144 }
145 if (!Array.isArray(node)) {
146 throw new Error(`Invalid node type: ${inspect(node)}`);
147 }
148 const type = node[0];
149 if (type === kElement) {
150 const { 1: tag, 2: props } = node;
151 if (typeof tag === "function") {
152 current = r;
153 try {
154 return resolveNode(r, tag(props));
155 } catch (e) {
156 const { 4: src } = node;
157 if (e && typeof e === "object") (e as { src?: string }).src = src;
158 throw e;
159 } finally {
160 current = null;
161 }
162 }
163 if (typeof tag !== "string") throw new Error("Unexpected " + inspect(type));
164 const children = props?.children;
165 if (children) return [kElement, tag, props, resolveNode(r, children)];
166 return node;
167 }
168 if (type === kRaw) return node[1];
169 return node.map((elem) => resolveNode(r, elem));
170}
171
172export type ResolvedNode =
173 | ResolvedNode[] // Concat
174 | ResolvedElement // Render
175 | string; // Raw HTML
176export type ResolvedElement = [
177 tag: typeof kElement,
178 type: string,
179 props: Record<string, unknown>,
180 children: ResolvedNode,
181];
182/**
183 * Async rendering is done by creating an array of one item,
184 * which is already a valid 'Node', but the element is written
185 * once the data is available. The 'Render' contains a count
186 * of how many async jobs are left.
187 */
188export type InsertionPoint = [null | ResolvedNode];
189
190/**
191 * Convert 'ResolvedNode' into HTML text. This operation happens after all
192 * async work is settled. The HTML is emitted as concisely as possible.
193 */
194export function stringifyNode(node: ResolvedNode): string {
195 if (typeof node === "string") return node;
196 ASSERT(node, "Unresolved Render Node");
197 const type = node[0];
198 if (type === kElement) {
199 return stringifyElement(node as ResolvedElement);
200 }
201 node = node as ResolvedNode[]; // TS cannot infer.
202 let out = type ? stringifyNode(type) : "";
203 let len = node.length;
204 for (let i = 1; i < len; i++) {
205 const elem = node[i];
206 if (elem) out += stringifyNode(elem);
207 }
208 return out;
209}
210function stringifyElement(element: ResolvedElement) {
211 const { 1: tag, 2: props, 3: children } = element;
212 let out = "<" + tag;
213 let needSpace = true;
214 for (const prop in props) {
215 const value = props[prop];
216 if (!value || typeof value === "function") continue;
217 let attr;
218 switch (prop) {
219 default:
220 attr = `${prop}=${quoteIfNeeded(escapeHtml(String(value)))}`;
221 break;
222 case "className":
223 // Legacy React Compat
224 case "class":
225 attr = `class=${quoteIfNeeded(escapeHtml(clsx(value as ClsxInput)))}`;
226 break;
227 case "htmlFor":
228 throw new Error("Do not use the `htmlFor` attribute. Use `for`");
229 // Do not process these
230 case "children":
231 case "ref":
232 case "dangerouslySetInnerHTML":
233 case "key":
234 continue;
235 }
236 if (needSpace) (out += " "), (needSpace = !attr.endsWith('"'));
237 out += attr;
238 }
239 out += ">";
240 if (children) out += stringifyNode(children);
241 if (
242 tag !== "br" &&
243 tag !== "img" &&
244 tag !== "input" &&
245 tag !== "meta" &&
246 tag !== "link" &&
247 tag !== "hr"
248 ) {
249 out += `</${tag}>`;
250 }
251 return out;
252}
253export function stringifyStyleAttribute(style: Record<string, string>) {
254 let out = ``;
255 for (const styleName in style) {
256 if (out) out += ";";
257 out += `${styleName.replace(/[A-Z]/g, "-$&").toLowerCase()}:${escapeHtml(
258 String(style[styleName]),
259 )}`;
260 }
261 return "style=" + quoteIfNeeded(out);
262}
263export function quoteIfNeeded(text: string) {
264 if (text.includes(" ")) return '"' + text + '"';
265 return text;
266}
267
268// -- utility functions --
269
270export function init(allowAsync: boolean, addon: Addons): State {
271 return {
272 async: allowAsync ? 0 : -1,
273 rejections: null,
274 asyncDone: null,
275 addon,
276 };
277}
278
279export type ClsxInput = string | Record<string, boolean | null> | ClsxInput[];
280export function clsx(mix: ClsxInput) {
281 var k,
282 y,
283 str = "";
284 if (typeof mix === "string") {
285 return mix;
286 } else if (typeof mix === "object") {
287 if (Array.isArray(mix)) {
288 for (k = 0; k < mix.length; k++) {
289 if (mix[k] && (y = clsx(mix[k]))) {
290 str && (str += " ");
291 str += y;
292 }
293 }
294 } else {
295 for (k in mix) {
296 if (mix[k]) {
297 str && (str += " ");
298 str += k;
299 }
300 }
301 }
302 }
303 return str;
304}
305
306export const escapeHtml = (unsafeText: string) =>
307 String(unsafeText)
308 .replace(/&/g, "&amp;")
309 .replace(/</g, "&lt;")
310 .replace(/>/g, "&gt;")
311 .replace(/"/g, "&quot;")
312 .replace(/'/g, "&#x27;")
313 .replace(/`/g, "&#x60;");
314
315export function inspect(object: unknown) {
316 try {
317 return require("node:util").inspect(object);
318 } catch {
319 return typeof object;
320 }
321}
framework/engine/ssr.test.tsx deleted-41
......@@ -1,41 +0,0 @@
1import { test } from "node:test";
2import * as engine from "./ssr.ts";
3
4test("sanity", (t) => t.assert.equal(engine.ssrSync("gm <3").text, "gm &lt;3"));
5test("simple tree", (t) =>
6 t.assert.equal(
7 engine.ssrSync(
8 <main class={["a", "b"]}>
9 <h1 style="background-color:red">hello world</h1>
10 <p>haha</p>
11 {1}|
12 {0}|
13 {true}|
14 {false}|
15 {null}|
16 {undefined}|
17 </main>,
18 ).text,
19 '<main class="a b"><h1 style=background-color:red>hello world</h1><p>haha</p>1|0|||||</main>',
20 ));
21test("unescaped/escaped html", (t) =>
22 t.assert.equal(
23 engine.ssrSync(<div>{engine.html("<fuck>")}{"\"&'`<>"}</div>).text,
24 "<div><fuck>&quot;&amp;&#x27;&#x60;&lt;&gt;</div>",
25 ));
26test("clsx built-in", (t) =>
27 t.assert.equal(
28 engine.ssrSync(
29 <>
30 <a class="a" />
31 <b class={null} />
32 <c class={undefined} />
33 <d class={["a", "b", null]} />
34 <e class={{ a: true, b: false }} />
35 <e
36 class={[null, "x", { z: true }, [{ m: true }, null, { v: false }]]}
37 />
38 </>,
39 ).text,
40 '<a class=a></a><b></b><c></c><d class="a b"></d><e class=a></e><e class="x z m"></e>',
41 ));
framework/engine/ssr.ts deleted-306
......@@ -1,306 +0,0 @@
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.
9export type Addons = Record<string | symbol, unknown>;
10
11export function ssrSync<A extends Addons>(node: Node, addon: A = {} as A) {
12 const r = initRender(false, addon);
13 const resolved = resolveNode(r, node);
14 return { text: renderNode(resolved), addon };
15}
16export { ssrSync as sync };
17
18export function ssrAsync<A extends Addons>(node: Node, addon: A = {} as A) {
19 const r = initRender(true, addon);
20 const resolved = resolveNode(r, node);
21 if (r.async === 0) {
22 return Promise.resolve({ text: renderNode(resolved), addon });
23 }
24 const { resolve, reject, promise } = Promise.withResolvers<Result<A>>();
25 r.asyncDone = () => {
26 const rejections = r.rejections;
27 if (!rejections) return resolve({ text: renderNode(resolved), addon });
28 if (rejections.length === 1) return reject(rejections[0]);
29 return reject(new AggregateError(rejections));
30 };
31 return promise;
32}
33export { ssrAsync as async };
34
35/** Inline HTML into a render without escaping it */
36export function html(rawText: ResolvedNode): DirectHtml {
37 return [kDirectHtml, rawText];
38}
39
40interface Result<A extends Addons = Addons> {
41 text: string;
42 addon: A;
43}
44
45export interface Render {
46 /**
47 * Set to '-1' if rendering synchronously
48 * Number of async promises the render is waiting on.
49 */
50 async: number | -1;
51 asyncDone: null | (() => void);
52 /** When components reject, those are logged here */
53 rejections: unknown[] | null;
54 /** Add-ons to the rendering engine store state here */
55 addon: Addons;
56}
57
58export const kElement = Symbol("Element");
59export const kDirectHtml = Symbol("DirectHtml");
60
61/** Node represents a webpage that can be 'rendered' into HTML. */
62export type Node =
63 | number
64 | string // Escape HTML
65 | Node[] // Concat
66 | Element // Render
67 | DirectHtml // Insert
68 | Promise<Node> // Await
69 // Ignore
70 | undefined
71 | null
72 | boolean;
73export type Element = [
74 tag: typeof kElement,
75 type: string | Component,
76 props: Record<string, unknown>,
77 _?: "",
78 source?: SrcLoc,
79];
80export type DirectHtml = [tag: typeof kDirectHtml, html: ResolvedNode];
81/**
82 * Components must return a value; 'undefined' is prohibited here
83 * to avoid functions that are missing a return statement.
84 */
85export type Component = (
86 props: Record<any, any>,
87) => Exclude<Node, undefined>;
88/** Emitted by JSX runtime */
89export interface SrcLoc {
90 fileName: string;
91 lineNumber: number;
92 columnNumber: number;
93}
94
95/**
96 * Resolution narrows the type 'Node' into 'ResolvedNode'. Async trees are
97 * marked in the 'Render'. This operation performs everything besides the final
98 * string concatenation. This function is agnostic across async/sync modes.
99 */
100export function resolveNode(r: Render, node: unknown): ResolvedNode {
101 if (!node && node !== 0) return ""; // falsy, non numeric
102 if (typeof node !== "object") {
103 if (node === true) return ""; // booleans are ignored
104 if (typeof node === "string") return escapeHtml(node);
105 if (typeof node === "number") return String(node); // no escaping ever
106 throw new Error(`Cannot render ${inspect(node)} to HTML`);
107 }
108 if (node instanceof Promise) {
109 if (r.async === -1) {
110 throw new Error(`Asynchronous rendering is not supported here.`);
111 }
112 const placeholder: InsertionPoint = [null];
113 r.async += 1;
114 node
115 .then((result) => void (placeholder[0] = resolveNode(r, result)))
116 // Intentionally catching errors in `resolveNode`
117 .catch((e) => (r.rejections ??= []).push(e))
118 .finally(() => {
119 if (--r.async == 0) {
120 if (r.asyncDone == null) throw new Error("r.asyncDone == null");
121 r.asyncDone();
122 r.asyncDone = null;
123 }
124 });
125 // This lie is checked with an assertion in `renderNode`
126 return placeholder as [ResolvedNode];
127 }
128 if (!Array.isArray(node)) {
129 throw new Error(`Invalid node type: ${inspect(node)}`);
130 }
131 const type = node[0];
132 if (type === kElement) {
133 const { 1: tag, 2: props } = node;
134 if (typeof tag === "function") {
135 currentRender = r;
136 try {
137 return resolveNode(r, tag(props));
138 } catch (e) {
139 const { 4: src } = node;
140 if (e && typeof e === "object") (e as { src?: string }).src = src;
141 throw e;
142 } finally {
143 currentRender = null;
144 }
145 }
146 if (typeof tag !== "string") throw new Error("Unexpected " + inspect(type));
147 const children = props?.children;
148 if (children) return [kElement, tag, props, resolveNode(r, children)];
149 return node;
150 }
151 if (type === kDirectHtml) return node[1];
152 return node.map((elem) => resolveNode(r, elem));
153}
154
155export type ResolvedNode =
156 | ResolvedNode[] // Concat
157 | ResolvedElement // Render
158 | string; // Direct HTML
159export type ResolvedElement = [
160 tag: typeof kElement,
161 type: string,
162 props: Record<string, unknown>,
163 children: ResolvedNode,
164];
165/**
166 * Async rendering is done by creating an array of one item,
167 * which is already a valid 'Node', but the element is written
168 * once the data is available. The 'Render' contains a count
169 * of how many async jobs are left.
170 */
171export type InsertionPoint = [null | ResolvedNode];
172
173/**
174 * Convert 'ResolvedNode' into HTML text. This operation happens after all
175 * async work is settled. The HTML is emitted as concisely as possible.
176 */
177export function renderNode(node: ResolvedNode): string {
178 if (typeof node === "string") return node;
179 ASSERT(node, "Unresolved Render Node");
180 const type = node[0];
181 if (type === kElement) {
182 return renderElement(node as ResolvedElement);
183 }
184 node = node as ResolvedNode[]; // TS cannot infer.
185 let out = type ? renderNode(type) : "";
186 let len = node.length;
187 for (let i = 1; i < len; i++) {
188 const elem = node[i];
189 if (elem) out += renderNode(elem);
190 }
191 return out;
192}
193function renderElement(element: ResolvedElement) {
194 const { 1: tag, 2: props, 3: children } = element;
195 let out = "<" + tag;
196 let needSpace = true;
197 for (const prop in props) {
198 const value = props[prop];
199 if (!value || typeof value === "function") continue;
200 let attr;
201 switch (prop) {
202 default:
203 attr = `${prop}=${quoteIfNeeded(escapeHtml(String(value)))}`;
204 break;
205 case "className":
206 // Legacy React Compat
207 case "class":
208 attr = `class=${quoteIfNeeded(escapeHtml(clsx(value as ClsxInput)))}`;
209 break;
210 case "htmlFor":
211 throw new Error("Do not use the `htmlFor` attribute. Use `for`");
212 // Do not process these
213 case "children":
214 case "ref":
215 case "dangerouslySetInnerHTML":
216 case "key":
217 continue;
218 }
219 if (needSpace) out += " ", needSpace = !attr.endsWith('"');
220 out += attr;
221 }
222 out += ">";
223 if (children) out += renderNode(children);
224 if (
225 tag !== "br" && tag !== "img" && tag !== "input" && tag !== "meta" &&
226 tag !== "link" && tag !== "hr"
227 ) {
228 out += `</${tag}>`;
229 }
230 return out;
231}
232export function renderStyleAttribute(style: Record<string, string>) {
233 let out = ``;
234 for (const styleName in style) {
235 if (out) out += ";";
236 out += `${styleName.replace(/[A-Z]/g, "-$&").toLowerCase()}:${
237 escapeHtml(String(style[styleName]))
238 }`;
239 }
240 return "style=" + quoteIfNeeded(out);
241}
242export function quoteIfNeeded(text: string) {
243 if (text.includes(" ")) return '"' + text + '"';
244 return text;
245}
246
247// -- utility functions --
248
249export function initRender(allowAsync: boolean, addon: Addons): Render {
250 return {
251 async: allowAsync ? 0 : -1,
252 rejections: null,
253 asyncDone: null,
254 addon,
255 };
256}
257
258let currentRender: Render | null = null;
259export function getCurrentRender() {
260 if (!currentRender) throw new Error("No Render Active");
261 return currentRender;
262}
263export function setCurrentRender(r?: Render | null) {
264 currentRender = r ?? null;
265}
266export function getUserData<T>(namespace: PropertyKey, def: () => T): T {
267 return (getCurrentRender().addon[namespace] ??= def()) as T;
268}
269
270export function inspect(object: unknown) {
271 try {
272 return require("node:util").inspect(object);
273 } catch {
274 return typeof object;
275 }
276}
277
278export type ClsxInput = string | Record<string, boolean | null> | ClsxInput[];
279export function clsx(mix: ClsxInput) {
280 var k, y, str = "";
281 if (typeof mix === "string") {
282 return mix;
283 } else if (typeof mix === "object") {
284 if (Array.isArray(mix)) {
285 for (k = 0; k < mix.length; k++) {
286 if (mix[k] && (y = clsx(mix[k]))) {
287 str && (str += " ");
288 str += y;
289 }
290 }
291 } else {
292 for (k in mix) {
293 if (mix[k]) {
294 str && (str += " ");
295 str += k;
296 }
297 }
298 }
299 }
300 return str;
301}
302
303export const escapeHtml = (unsafeText: string) =>
304 String(unsafeText)
305 .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
306 .replace(/"/g, "&quot;").replace(/'/g, "&#x27;").replace(/`/g, "&#x60;");
framework/engine/suspense.ts+37-37
......@@ -7,46 +7,44 @@
77//
88// I would link to an article from Next.js or React, but their examples
99// are too verbose and not informative to what they actually do.
10const kState = Symbol("SuspenseState");
10const userData = render.userData<null | State>(() => null);
1111
1212interface SuspenseProps {
13 children: ssr.Node;
14 fallback?: ssr.Node;
13 children: render.Node;
14 fallback?: render.Node;
1515}
1616
1717interface State {
1818 nested: boolean;
1919 nextId: number;
2020 completed: number;
21 pushChunk(name: string, node: ssr.ResolvedNode): void;
21 pushChunk(name: string, node: render.ResolvedNode): void;
2222}
2323
24export function Suspense({ children, fallback }: SuspenseProps): ssr.Node {
25 const state = ssr.getUserData<State>(kState, () => {
26 throw new Error("Can only use <Suspense> with 'renderStreaming'");
27 });
24export function Suspense({ children, fallback }: SuspenseProps): render.Node {
25 const state = userData.get();
26 if (!state) return children;
2827 if (state.nested) throw new Error("<Suspense> cannot be nested");
29 const parent = ssr.getCurrentRender()!;
30 const r = ssr.initRender(true, { [kState]: { nested: true } });
31 const resolved = ssr.resolveNode(r, children);
32 if (r.async == 0) return ssr.html(resolved);
33 const name = "suspended_" + (++state.nextId);
28 const parent = UNWRAP(render.current);
29 const r = render.initRender(true, { [userData.key]: { nested: true } });
30 const resolved = render.resolveNode(r, children);
31 if (r.async == 0) return render.raw(resolved);
32 const name = "suspended_" + ++state.nextId;
3433 state.nested = true;
35 const ip: [ssr.ResolvedNode] = [
36 [
37 ssr.kElement,
34 const ip: [render.ResolvedNode] = [
35 render.resolvedElement(
3836 "slot",
3937 { name },
40 fallback ? ssr.resolveNode(parent, fallback) : "",
41 ],
38 fallback ? render.resolveNode(parent, fallback) : "",
39 ),
4240 ];
4341 state.nested = false;
4442 r.asyncDone = () => {
4543 const rejections = r.rejections;
4644 if (rejections && rejections.length > 0) throw new Error("TODO");
47 state.pushChunk?.(name, ip[0] = resolved);
45 state.pushChunk?.(name, (ip[0] = resolved));
4846 };
49 return ssr.html(ip);
47 return render.raw(ip);
5048}
5149
5250// TODO: add a User-Agent parameter, which is used to determine if a
......@@ -54,17 +52,14 @@ export function Suspense({ children, fallback }: SuspenseProps): ssr.Node {
5452// - Before ~2024 needs to use a JS implementation.
5553// - IE should probably bail out entirely.
5654export async function* renderStreaming<
57 T extends ssr.Addons = Record<never, unknown>,
58>(
59 node: ssr.Node,
60 addon: T = {} as T,
61) {
55 T extends render.Addons = Record<never, unknown>,
56>(node: render.Node, addon: T = {} as T) {
6257 const {
6358 text: begin,
64 addon: { [kState]: state, ...addonOutput },
65 } = await ssr.ssrAsync(node, {
59 addon: { [userData.key]: state, ...addonOutput },
60 } = await render.async(node, {
6661 ...addon,
67 [kState]: {
62 [userData.key]: {
6863 nested: false,
6964 nextId: 0,
7065 completed: 0,
......@@ -79,24 +74,29 @@ export async function* renderStreaming<
7974 let chunks: string[] = [];
8075 state.pushChunk = (slot, node) => {
8176 while (node.length === 1 && Array.isArray(node)) node = node[0];
82 if (node[0] === ssr.kElement) {
83 (node as ssr.ResolvedElement)[2].slot = slot;
77 if (node[0] === render.kElement) {
78 (node as render.ResolvedElement)[2].slot = slot;
8479 } else {
85 node = [ssr.kElement, "clover-suspense", {
86 style: "display:contents",
87 slot,
88 }, node];
80 node = [
81 render.kElement,
82 "clover-suspense",
83 {
84 style: "display:contents",
85 slot,
86 },
87 node,
88 ];
8989 }
90 chunks.push(ssr.renderNode(node));
90 chunks.push(render.stringifyNode(node));
9191 resolve?.();
9292 };
9393 yield `<template shadowrootmode=open>${begin}</template>`;
9494 do {
95 await new Promise<void>((done) => resolve = done);
95 await new Promise<void>((done) => (resolve = done));
9696 yield* chunks;
9797 chunks = [];
9898 } while (state.nextId < state.completed);
9999 return addonOutput as unknown as T;
100100}
101101
102import * as ssr from "./ssr.ts";
102import * as render from "./render.ts";
framework/generate.ts+51-78
......@@ -14,12 +14,8 @@ export async function main() {
1414export async function generate() {
1515 // -- read config and discover files --
1616 const siteConfig = await incr.work(readManifest);
17 const {
18 staticFiles,
19 scripts,
20 views,
21 pages,
22 } = await discoverAllFiles(siteConfig);
17 const { staticFiles, scripts, views, pages } =
18 await discoverAllFiles(siteConfig);
2319
2420 // TODO: make sure that `static` and `pages` does not overlap
2521
......@@ -28,12 +24,15 @@ export async function generate() {
2824 // -- perform build-time rendering --
2925 const builtPages = pages.map((item) => incr.work(preparePage, item));
3026 const builtViews = views.map((item) => incr.work(prepareView, item));
31 const builtStaticFiles = Promise.all((staticFiles.map((item) =>
32 incr.work(
33 async (io, { id, file }) => void await io.writeAsset(id, await io.readFile(file)),
34 item,
35 )
36 )));
27 const builtStaticFiles = Promise.all(
28 staticFiles.map((item) =>
29 incr.work(
30 async (io, { id, file }) =>
31 void (await io.writeAsset(id, await io.readFile(file))),
32 item,
33 ),
34 ),
35 );
3736 const routes = await Promise.all([...builtViews, ...builtPages]);
3837
3938 // -- page resources --
......@@ -47,23 +46,19 @@ export async function generate() {
4746 // -- backend --
4847 const builtBackend = bundle.bundleServerJavaScript({
4948 entries: siteConfig.backends,
50 platform: 'node',
49 platform: "node",
5150 styleMap,
5251 scriptMap,
5352 viewItems: views,
5453 viewRefs: builtViews,
55 })
54 });
5655
5756 // -- assemble page assets --
5857 const pAssemblePages = builtPages.map((page) =>
59 assembleAndWritePage(page, styleMap, scriptMap)
58 assembleAndWritePage(page, styleMap, scriptMap),
6059 );
6160
62 await Promise.all([
63 builtBackend,
64 builtStaticFiles,
65 ...pAssemblePages,
66 ]);
61 await Promise.all([builtBackend, builtStaticFiles, ...pAssemblePages]);
6762}
6863
6964export async function readManifest(io: Io) {
......@@ -82,7 +77,7 @@ export async function discoverAllFiles(
8277 return (
8378 await Promise.all(
8479 siteConfig.siteSections.map(({ root: sectionRoot }) =>
85 incr.work(scanSiteSection, toAbs(sectionRoot))
80 incr.work(scanSiteSection, toAbs(sectionRoot)),
8681 ),
8782 )
8883 ).reduce((acc, next) => ({
......@@ -110,9 +105,10 @@ export async function scanSiteSection(io: Io, sectionRoot: string) {
110105 let scripts: FileItem[] = [];
111106
112107 const sectionPath = (...sub: string[]) => path.join(sectionRoot, ...sub);
113 const rootPrefix = hot.projectSrc === sectionRoot
114 ? ""
115 : path.relative(hot.projectSrc, sectionRoot) + "/";
108 const rootPrefix =
109 hot.projectSrc === sectionRoot
110 ? ""
111 : path.relative(hot.projectSrc, sectionRoot) + "/";
116112 const kinds = [
117113 {
118114 dir: sectionPath("pages"),
......@@ -186,12 +182,8 @@ export async function preparePage(io: Io, item: sg.FileItem) {
186182 theme: pageTheme,
187183 layout,
188184 } = await io.import<any>(item.file);
189 if (!Page) {
190 throw new Error("Page is missing a 'default' export.");
191 }
192 if (!metadata) {
193 throw new Error("Page is missing 'meta' export with a title.");
194 }
185 if (!Page) throw new Error("Page is missing a 'default' export.");
186 if (!metadata) throw new Error("Page is missing 'meta' export with a title.");
195187
196188 // -- css --
197189 if (layout?.theme) pageTheme = layout.theme;
......@@ -210,12 +202,12 @@ export async function preparePage(io: Io, item: sg.FileItem) {
210202 ).then((m) => meta.renderMeta(m));
211203
212204 // -- html --
213 let page = [engine.kElement, Page, {}];
205 let page = render.element(Page);
214206 if (layout?.default) {
215 page = [engine.kElement, layout.default, { children: page }];
207 page = render.element(layout.default, { children: page });
216208 }
217 const bodyPromise = engine.ssrAsync(page, {
218 sitegen: sg.initRender(),
209 const bodyPromise = render.async(page, {
210 [sg.userData.key]: sg.initRender(),
219211 });
220212
221213 const [{ text, addon }, renderedMeta] = await Promise.all([
......@@ -235,23 +227,16 @@ export async function preparePage(io: Io, item: sg.FileItem) {
235227 cssImports,
236228 theme: theme ?? null,
237229 styleKey,
238 clientRefs: Array.from(addon.sitegen.scripts),
230 clientRefs: Array.from(addon[sg.userData.key].scripts),
239231 };
240232}
241233
242234export async function prepareView(io: Io, item: sg.FileItem) {
243235 const module = await io.import<any>(item.file);
244 if (!module.meta) {
245 throw new Error(`${item.file} is missing 'export const meta'`);
246 }
247 if (!module.default) {
248 throw new Error(`${item.file} is missing a default export.`);
249 }
236 if (!module.meta) throw new Error(`View is missing 'export const meta'`);
237 if (!module.default) throw new Error(`View is missing a default export.`);
250238 const pageTheme = module.layout?.theme ?? module.theme;
251 const theme: css.Theme = {
252 ...css.defaultTheme,
253 ...pageTheme,
254 };
239 const theme: css.Theme = { ...css.defaultTheme, ...pageTheme };
255240 const cssImports = Array.from(
256241 new Set([globalCssPath, ...hot.getCssImports(item.file)]),
257242 (file) => path.relative(hot.projectSrc, file),
......@@ -269,22 +254,14 @@ export async function prepareView(io: Io, item: sg.FileItem) {
269254export type PreparedView = Awaited<ReturnType<typeof prepareView>>;
270255
271256export function prepareInlineCss(
272 items: Array<{
273 styleKey: string;
274 cssImports: string[];
275 theme: css.Theme;
276 }>,
257 items: Array<{ styleKey: string; cssImports: string[]; theme: css.Theme }>,
277258) {
278259 const map = new Map<string, incr.Ref<string>>();
279260 for (const { styleKey, cssImports, theme } of items) {
280261 if (map.has(styleKey)) continue;
281262 map.set(
282263 styleKey,
283 incr.work(css.bundleCssFiles, {
284 cssImports,
285 theme,
286 dev: false,
287 }),
264 incr.work(css.bundleCssFiles, { cssImports, theme, dev: false }),
288265 );
289266 }
290267 return map;
......@@ -297,29 +274,26 @@ export async function assembleAndWritePage(
297274 scriptWork: incr.Ref<Record<string, string>>,
298275) {
299276 const page = await pageWork;
300 return incr.work(
301 async (io, { id, html, meta, styleKey, clientRefs }) => {
302 const inlineCss = await io.readWork(UNWRAP(styleMap.get(styleKey)));
277 return incr.work(async (io, { id, html, meta, styleKey, clientRefs }) => {
278 const inlineCss = await io.readWork(UNWRAP(styleMap.get(styleKey)));
303279
304 const scriptIds = clientRefs.map(hot.getScriptId);
305 const scriptMap = await io.readWork(scriptWork);
306 const scripts = scriptIds.map((ref) =>
307 UNWRAP(scriptMap[ref], `Missing script ${ref}`)
308 )
309 .map((x) => `{${x}}`).join("\n");
280 const scriptIds = clientRefs.map(hot.getScriptId);
281 const scriptMap = await io.readWork(scriptWork);
282 const scripts = scriptIds
283 .map((ref) => UNWRAP(scriptMap[ref], `Missing script ${ref}`))
284 .map((x) => `{${x}}`)
285 .join("\n");
310286
311 const doc = wrapDocument({
312 body: html,
313 head: meta,
314 inlineCss,
315 scripts,
316 });
317 await io.writeAsset(id, doc, {
318 "Content-Type": "text/html",
319 });
320 },
321 page,
322 );
287 const doc = wrapDocument({
288 body: html,
289 head: meta,
290 inlineCss,
291 scripts,
292 });
293 await io.writeAsset(id, doc, {
294 "Content-Type": "text/html",
295 });
296 }, page);
323297}
324298
325299import * as sg from "#sitegen";
......@@ -327,11 +301,10 @@ import * as incr from "./incremental.ts";
327301import { Io } from "./incremental.ts";
328302import * as bundle from "./bundle.ts";
329303import * as css from "./css.ts";
330import * as engine from "./engine/ssr.ts";
304import * as render from "#engine/render";
331305import * as hot from "./hot.ts";
332306import * as fs from "#sitegen/fs";
333307import type { FileItem } from "#sitegen";
334308import * as path from "node:path";
335309import * as meta from "#sitegen/meta";
336import { Spinner, withSpinner } from "@paperclover/console/Spinner";
337310import { wrapDocument } from "./lib/view.ts";
framework/hot.ts+28-29
......@@ -19,7 +19,7 @@ export const load = createRequire(
1919};
2020export const { cache } = load;
2121
22load<any>("source-map-support").install({hookRequire: true});
22load<any>("source-map-support").install({ hookRequire: true });
2323
2424// Register extensions by overwriting `require.extensions`
2525const require = load;
......@@ -75,15 +75,15 @@ Module.prototype._compile = function (
7575 imports.push(file);
7676 (childModule.cloverImporters ??= []).push(this);
7777 if (cloverClientRefs && cloverClientRefs.length > 0) {
78 (this.cloverClientRefs ??= [])
79 .push(...cloverClientRefs);
78 (this.cloverClientRefs ??= []).push(...cloverClientRefs);
8079 }
8180 }
8281 }
8382 fileStats.set(filename, {
84 cssImportsRecursive: cssImportsMaybe.length > 0
85 ? Array.from(new Set(cssImportsMaybe))
86 : null,
83 cssImportsRecursive:
84 cssImportsMaybe.length > 0
85 ? Array.from(new Set(cssImportsMaybe))
86 : null,
8787 imports,
8888 lastModified: Math.floor(stat.mtimeMs),
8989 });
......@@ -100,7 +100,8 @@ Module._resolveFilename = (...args) => {
100100 return require.resolve(replacedPath, { paths: [projectSrc] });
101101 } catch (err: any) {
102102 if (
103 err.code === "MODULE_NOT_FOUND" && (err?.requireStack?.length ?? 0) <= 1
103 err.code === "MODULE_NOT_FOUND" &&
104 (err?.requireStack?.length ?? 0) <= 1
104105 ) {
105106 err.message.replace(replacedPath, args[0]);
106107 }
......@@ -138,45 +139,45 @@ export function loadEsbuildCode(
138139 src = code;
139140 }
140141 if (src.includes("import.meta")) {
141 src = `
142 import.meta.url = ${JSON.stringify(pathToFileURL(filepath).toString())};
143 import.meta.dirname = ${JSON.stringify(path.dirname(filepath))};
144 import.meta.filename = ${JSON.stringify(filepath)};
145 `.trim().replace(/[\n\s]/g, "") + src;
142 src =
143 `
144 import.meta.url = ${JSON.stringify(pathToFileURL(filepath).toString())};
145 import.meta.dirname = ${JSON.stringify(path.dirname(filepath))};
146 import.meta.filename = ${JSON.stringify(filepath)};
147 `
148 .trim()
149 .replace(/[\n\s]/g, "") + src;
146150 }
147151 src = esbuild.transformSync(src, {
148152 loader,
149153 format: "cjs",
150154 target: "esnext",
151155 jsx: "automatic",
152 jsxImportSource: "#ssr",
156 jsxImportSource: "#engine",
153157 jsxDev: true,
154158 sourcefile: filepath,
155 sourcemap: 'inline',
159 sourcemap: "inline",
156160 }).code;
157161 return module._compile(src, filepath, "commonjs");
158162}
159163
160164export function resolveClientRef(sourcePath: string, ref: string) {
161165 const filePath = resolveFrom(sourcePath, ref);
162 if (
163 !filePath.endsWith(".client.ts") &&
164 !filePath.endsWith(".client.tsx")
165 ) {
166 if (!filePath.endsWith(".client.ts") && !filePath.endsWith(".client.tsx")) {
166167 throw new Error("addScript must be a .client.ts or .client.tsx");
167168 }
168169 return path.relative(projectSrc, filePath);
169170}
170171
171let lazyMarko: typeof import('./marko.ts') | null = null;
172let lazyMarko: typeof import("./marko.ts") | null = null;
172173function loadMarko(module: NodeJS.Module, filepath: string) {
173 lazyMarko ??= require<typeof import('./marko.ts')>("./framework/marko.ts");
174 lazyMarko ??= require<typeof import("./marko.ts")>("./framework/marko.ts");
174175 lazyMarko.loadMarko(module, filepath);
175176}
176177
177178function loadMdx(module: NodeJS.Module, filepath: string) {
178179 const input = fs.readFileSync(filepath);
179 const out = mdx.compileSync(input, { jsxImportSource: "#ssr" }).value;
180 const out = mdx.compileSync(input, { jsxImportSource: "#engine" }).value;
180181 const src = typeof out === "string" ? out : Buffer.from(out).toString("utf8");
181182 return loadEsbuildCode(module, filepath, src);
182183}
......@@ -194,7 +195,7 @@ export function reloadRecursive(filepath: string) {
194195}
195196
196197export function unload(filepath: string) {
197 lazyMarko?.markoCache.delete(filepath)
198 lazyMarko?.markoCache.delete(filepath);
198199 filepath = path.resolve(filepath);
199200 const module = cache[filepath];
200201 if (!module) return;
......@@ -291,8 +292,9 @@ export function resolveClientRefs(
291292}
292293
293294export function getScriptId(file: string) {
294 return (path.isAbsolute(file) ? path.relative(projectSrc, file) : file)
295 .replaceAll("\\", "/");
295 return (
296 path.isAbsolute(file) ? path.relative(projectSrc, file) : file
297 ).replaceAll("\\", "/");
296298}
297299
298300declare global {
......@@ -300,7 +302,7 @@ declare global {
300302 interface Module {
301303 cloverClientRefs?: string[];
302304 cloverSourceCode?: string;
303 cloverImporters?: Module[],
305 cloverImporters?: Module[];
304306
305307 _compile(
306308 this: NodeJS.Module,
......@@ -312,10 +314,7 @@ declare global {
312314 }
313315}
314316declare module "node:module" {
315 export function _resolveFilename(
316 id: string,
317 parent: NodeJS.Module,
318 ): unknown;
317 export function _resolveFilename(id: string, parent: NodeJS.Module): unknown;
319318}
320319
321320import * as fs from "#sitegen/fs";
framework/lib/assets.ts+9-19
......@@ -11,30 +11,22 @@ export async function reload() {
1111 fs.readFile(path.join(import.meta.dirname, "static.json"), "utf8"),
1212 fs.readFile(path.join(import.meta.dirname, "static.blob")),
1313 ]);
14 assets = {
15 map: JSON.parse(map),
16 buf,
17 };
14 return (assets = { map: JSON.parse(map), buf });
1815}
1916
20export async function reloadSync() {
17export function reloadSync() {
2118 const map = fs.readFileSync(
2219 path.join(import.meta.dirname, "static.json"),
2320 "utf8",
2421 );
2522 const buf = fs.readFileSync(path.join(import.meta.dirname, "static.blob"));
26 assets = {
27 map: JSON.parse(map),
28 buf,
29 };
23 return (assets = { map: JSON.parse(map), buf });
3024}
3125
3226export async function middleware(c: Context, next: Next) {
3327 if (!assets) await reload();
3428 const asset = assets!.map[c.req.path];
35 if (asset) {
36 return assetInner(c, asset, 200);
37 }
29 if (asset) return assetInner(c, asset, 200);
3830 return next();
3931}
4032
......@@ -56,13 +48,11 @@ export async function serveAsset(
5648 id: StaticPageId,
5749 status: StatusCode,
5850) {
59 assets ?? await reload();
60 return assetInner(c, assets!.map[id], status);
51 return assetInner(c, (assets ?? (await reload())).map[id], status);
6152}
6253
6354export function hasAsset(id: string) {
64 if (!assets) reloadSync();
65 return assets!.map[id] !== undefined;
55 return (assets ?? reloadSync()).map[id] !== undefined;
6656}
6757
6858export function etagMatches(etag: string, ifNoneMatch: string) {
......@@ -78,13 +68,13 @@ function assetInner(c: Context, asset: BuiltAsset, status: StatusCode) {
7868 if (ifnonematch) {
7969 const etag = asset.headers.ETag;
8070 if (etagMatches(etag, ifnonematch)) {
81 return c.res = new Response(null, {
71 return (c.res = new Response(null, {
8272 status: 304,
8373 statusText: "Not Modified",
8474 headers: {
8575 ETag: etag,
8676 },
87 });
77 }));
8878 }
8979 }
9080 const acceptEncoding = c.req.header("Accept-Encoding") ?? "";
......@@ -105,7 +95,7 @@ function assetInner(c: Context, asset: BuiltAsset, status: StatusCode) {
10595 } else {
10696 body = subarrayAsset(asset.raw);
10797 }
108 return c.res = new Response(body, { headers, status });
98 return (c.res = new Response(body, { headers, status }));
10999}
110100
111101process.on("message", (msg: any) => {
framework/lib/markdown.tsx+23-19
......@@ -5,21 +5,21 @@
55 * via clover's SSR engine. This way, generation optimizations, async
66 * components, and other features are gained for free here.
77 */
8function parse(src: string, options: Partial<ParseOpts> = {}) {
9}
8function parse(src: string, options: Partial<ParseOpts> = {}) {}
109
1110/* Render markdown content. Same function as 'parse', but JSX components
1211 * only take one argument and must start with a capital letter. */
13export function Markdown(
14 { src, ...options }: { src: string } & Partial<ParseOpts>,
15) {
12export function Markdown({
13 src,
14 ...options
15}: { src: string } & Partial<ParseOpts>) {
1616 return parse(src, options);
1717}
1818
1919function parseInline(src: string, options: Partial<InlineOpts> = {}) {
2020 const { rules = inlineRules, links = new Map() } = options;
2121 const opts: InlineOpts = { rules, links };
22 const parts: engine.Node[] = [];
22 const parts: render.Node[] = [];
2323 const ruleList = Object.values(rules);
2424 parse: while (true) {
2525 for (const rule of ruleList) {
......@@ -96,7 +96,9 @@ export const inlineRules: Record<string, InlineRule> = {
9696 if (!splitText) return null;
9797 if (splitText.delim !== "]") return null;
9898 const { first: textSrc, rest: afterText } = splitText;
99 let href: string, title: string | null = null, rest: string;
99 let href: string,
100 title: string | null = null,
101 rest: string;
100102 if (afterText[0] === "(") {
101103 // Inline link
102104 const splitTarget = splitFirst(afterText.slice(1), /\)/);
......@@ -108,11 +110,12 @@ export const inlineRules: Record<string, InlineRule> = {
108110 } else if (afterText[0] === "[") {
109111 const splitTarget = splitFirst(afterText.slice(1), /]/);
110112 if (!splitTarget) return null;
111 const name = splitTarget.first.trim().length === 0
112 // Collapsed reference link
113 ? textSrc.trim()
114 // Full Reference Link
115 : splitTarget.first.trim();
113 const name =
114 splitTarget.first.trim().length === 0
115 ? // Collapsed reference link
116 textSrc.trim()
117 : // Full Reference Link
118 splitTarget.first.trim();
116119 const target = opts.links.get(name);
117120 if (!target) return null;
118121 ({ href, title } = target);
......@@ -149,7 +152,6 @@ export const inlineRules: Record<string, InlineRule> = {
149152 // 6.2 - emphasis and strong emphasis
150153 parse({ before, match, after, opts }) {
151154 // find out how long the delim sequence is
152
153155 // look for 'ends'
154156 },
155157 },
......@@ -164,14 +166,17 @@ export const inlineRules: Record<string, InlineRule> = {
164166};
165167
166168function parseLinkTarget(src: string) {
167 let href: string, title: string | null = null;
169 let href: string,
170 title: string | null = null;
168171 href = src;
169172 return { href, title };
170173}
171174
172175/* Find a delimiter while considering backslash escapes. */
173176function splitFirst(text: string, match: RegExp) {
174 let first = "", delim: string, escaped: boolean;
177 let first = "",
178 delim: string,
179 escaped: boolean;
175180 do {
176181 const find = text.match(match);
177182 if (!find) return null;
......@@ -179,14 +184,13 @@ function splitFirst(text: string, match: RegExp) {
179184 const index = UNWRAP(find.index);
180185 let i = index - 1;
181186 escaped = false;
182 while (i >= 0 && text[i] === "\\") escaped = !escaped, i -= 1;
187 while (i >= 0 && text[i] === "\\") (escaped = !escaped), (i -= 1);
183188 first += text.slice(0, index - +escaped);
184189 text = text.slice(index + find[0].length);
185190 } while (escaped);
186191 return { first, delim, rest: text };
187192}
188193
189console.log(engine.ssrSync(parseInline("meow `bwaa` `` ` `` `` `z``")));
194console.log(render.sync(parseInline("meow `bwaa` `` ` `` `` `z``")));
190195
191import * as engine from "#ssr";
192import type { ParseOptions } from "node:querystring";
196import * as render from "#engine/render";
framework/lib/meta.ts+1-1
......@@ -21,4 +21,4 @@ export interface AlternateType {
2121export function renderMeta({ title }: Meta): string {
2222 return `<title>${esc(title)}</title>`;
2323}
24import { escapeHtml as esc } from "../engine/ssr.ts";
24import { escapeHtml as esc } from "#engine/render";
framework/lib/sitegen.ts+10-21
......@@ -9,36 +9,25 @@ export interface FileItem {
99 id: string;
1010 file: string;
1111}
12export interface Section {
13 root: string;
14}
15
16export const userData = render.userData<SitegenRender>(() => {
17 throw new Error("This function can only be used in a page (static or view)");
18});
1219
1320export interface SitegenRender {
1421 scripts: Set<string>;
1522}
1623
1724export function initRender(): SitegenRender {
18 return {
19 scripts: new Set(),
20 };
21}
22
23export function getRender() {
24 return ssr.getUserData<SitegenRender>("sitegen", () => {
25 throw new Error(
26 "This function can only be used in a page (static or view)",
27 );
28 });
29}
30
31export function inRender() {
32 return "sitegen" in ssr.getCurrentRender();
25 return { scripts: new Set() };
3326}
3427
3528/** Add a client-side script to the page. */
3629export function addScript(id: ScriptId | { value: ScriptId }) {
37 getRender().scripts.add(typeof id === "string" ? id : id.value);
38}
39
40export interface Section {
41 root: string;
30 userData.get().scripts.add(typeof id === "string" ? id : id.value);
4231}
4332
44import * as ssr from "../engine/ssr.ts";
33import * as render from "#engine/render";
framework/lib/view.ts+12-17
......@@ -1,10 +1,9 @@
1// This import is generated by code 'bundle.ts'
21export interface View {
3 component: engine.Component;
2 component: render.Component;
43 meta:
54 | meta.Meta
65 | ((props: { context?: hono.Context }) => Promise<meta.Meta> | meta.Meta);
7 layout?: engine.Component;
6 layout?: render.Component;
87 inlineCss: string;
98 scripts: Record<string, string>;
109}
......@@ -12,9 +11,6 @@ export interface View {
1211let views: Record<string, View> = null!;
1312let scripts: Record<string, string> = null!;
1413
15// An older version of the Clover Engine supported streaming suspense
16// boundaries, but those were never used. Pages will wait until they
17// are fully rendered before sending.
1814export async function renderView(
1915 context: hono.Context,
2016 id: string,
......@@ -44,11 +40,12 @@ export async function renderViewToString(
4440 ).then((m) => meta.renderMeta(m));
4541
4642 // -- html --
47 let page: engine.Element = [engine.kElement, component, props];
48 if (layout) page = [engine.kElement, layout, { children: page }];
49 const { text: body, addon: { sitegen } } = await engine.ssrAsync(page, {
50 sitegen: sg.initRender(),
51 });
43 let page: render.Element = render.element(component, props);
44 if (layout) page = render.element(layout, { children: page });
45 const {
46 text: body,
47 addon: { [sg.userData.key]: sitegen },
48 } = await render.async(page, { [sg.userData.key]: sg.initRender() });
5249
5350 // -- join document and send --
5451 return wrapDocument({
......@@ -56,17 +53,15 @@ export async function renderViewToString(
5653 head: await renderedMetaPromise,
5754 inlineCss,
5855 scripts: joinScripts(
59 Array.from(
60 sitegen.scripts,
61 (id) => UNWRAP(scripts[id], `Missing script ${id}`),
56 Array.from(sitegen.scripts, (id) =>
57 UNWRAP(scripts[id], `Missing script ${id}`),
6258 ),
6359 ),
6460 });
6561}
6662
6763export function provideViewData(v: typeof views, s: typeof scripts) {
68 views = v;
69 scripts = s;
64 (views = v), (scripts = s);
7065}
7166
7267export function joinScripts(scriptSources: string[]) {
......@@ -96,5 +91,5 @@ export function wrapDocument({
9691
9792import * as meta from "./meta.ts";
9893import type * as hono from "#hono";
99import * as engine from "../engine/ssr.ts";
94import * as render from "#engine/render";
10095import * as sg from "./sitegen.ts";
framework/marko.ts+13-14
......@@ -1,4 +1,3 @@
1console.log("MARKO");
21export interface MarkoCacheEntry {
32 src: string;
43 scannedClientRefs: string[];
......@@ -8,7 +7,6 @@ export const markoCache = new Map<string, MarkoCacheEntry>();
87
98export function loadMarko(module: NodeJS.Module, filepath: string) {
109 let cache = markoCache.get(filepath);
11 console.log({ filepath, has: !!cache })
1210 if (!cache) {
1311 let src = fs.readFileSync(filepath, "utf8");
1412 // A non-standard thing here is Clover Sitegen implements
......@@ -16,21 +14,22 @@ export function loadMarko(module: NodeJS.Module, filepath: string) {
1614 // bare client import statements to it's own usage.
1715 const scannedClientRefs = new Set<string>();
1816 if (src.match(/^\s*client\s+import\s+["']/m)) {
19 src = src.replace(
20 /^\s*client\s+import\s+("[^"]+"|'[^']+')[^\n]+/m,
21 (_, src) => {
22 const ref = JSON.parse(`"${src.slice(1, -1)}"`);
23 const resolved = hot.resolveClientRef(filepath, ref);
24 scannedClientRefs.add(resolved);
25 return `<CloverScriptInclude=${
26 JSON.stringify(hot.getScriptId(resolved))
27 } />`;
28 },
29 ) + '\nimport { addScript as CloverScriptInclude } from "#sitegen";\n';
17 src =
18 src.replace(
19 /^\s*client\s+import\s+("[^"]+"|'[^']+')[^\n]+/m,
20 (_, src) => {
21 const ref = JSON.parse(`"${src.slice(1, -1)}"`);
22 const resolved = hot.resolveClientRef(filepath, ref);
23 scannedClientRefs.add(resolved);
24 return `<CloverScriptInclude=${JSON.stringify(
25 hot.getScriptId(resolved),
26 )} />`;
27 },
28 ) + '\nimport { addScript as CloverScriptInclude } from "#sitegen";\n';
3029 }
3130
3231 src = marko.compileSync(src, filepath).code;
33 src = src.replace("marko/debug/html", "#ssr/marko");
32 src = src.replace("marko/debug/html", "#engine/marko-runtime");
3433 cache = { src, scannedClientRefs: Array.from(scannedClientRefs) };
3534 markoCache.set(filepath, cache);
3635 }
package.json+2-4
......@@ -28,10 +28,8 @@
2828 "#debug": "./framework/debug.safe.ts",
2929 "#sitegen": "./framework/lib/sitegen.ts",
3030 "#sitegen/*": "./framework/lib/*.ts",
31 "#ssr": "./framework/engine/ssr.ts",
32 "#ssr/jsx-dev-runtime": "./framework/engine/jsx-runtime.ts",
33 "#ssr/jsx-runtime": "./framework/engine/jsx-runtime.ts",
34 "#ssr/marko": "./framework/engine/marko-runtime.ts",
31 "#engine/jsx-dev-runtime": "./framework/engine/jsx-runtime.ts",
32 "#engine/*": "./framework/engine/*.ts",
3533 "#marko/html": {
3634 "types": "marko/html",
3735 "production": "marko/production",
readme.md+2-10
......@@ -3,7 +3,7 @@
33this repository contains clover's "sitegen" framework, which is a set of tools
44that assist building websites. these tools power <https://paperclover.net>.
55
6- **HTML "Server Side Rendering") engine written from scratch.** (~500 lines)
6- **HTML ("Server Side Rendering") engine written from scratch.** (~500 lines)
77 - A more practical JSX runtime (`class` instead of `className`, built-in
88 `clsx`, `html()` helper over `dangerouslySetInnerHTML` prop, etc).
99 - Integration with [Marko] for concisely written components.
......@@ -57,19 +57,11 @@ my development machine, for example, is Dell Inspiron 7348 with Core i7
5757
5858 # production generation
5959 node run generate
60 node .clover/out/server
60 node .clover/o/backend
6161
6262 # "development" watch mode
6363 node run watch
6464
65<!-- `repl.js` will open a read-eval-print-loop where plugin state is cached (on my -->
66<!-- 2014 dev laptop, startup time is 600-1000ms). every file in `framework` and -->
67<!-- `src` besides `hot.ts` can be edited and quickly re-run. for example, to run -->
68<!-- `framework/generate.ts`, you can type "generate" into the shell. since -->
69<!-- top-level await is not supported (plugins are built on `require` as Node has -->
70<!-- poor module support), CLIs can include a `main` function, which is executed -->
71<!-- when the REPL runs it. -->
72
7365for unix systems, the provided `flake.nix` can be used with `nix develop` to
7466open a shell with all needed system dependencies.
7567
src/file-viewer/backend.tsx+14-12
......@@ -125,10 +125,11 @@ app.get("/file/*", async (c, next) => {
125125
126126 let encoding = decideEncoding(c.req.header("Accept-Encoding"));
127127
128 let sizeHeader = encoding === "raw"
129 ? expectedSize
130 // Size cannot be known because of compression modes
131 : undefined;
128 let sizeHeader =
129 encoding === "raw"
130 ? expectedSize
131 : // Size cannot be known because of compression modes
132 undefined;
132133
133134 // Etag
134135 {
......@@ -294,9 +295,10 @@ function handleRanges(
294295): Response {
295296 // TODO: multiple ranges
296297 const rangeSize = ranges.reduce((a, b) => a + (b[1] - b[0] + 1), 0);
297 const rangeBody = streamOrBuffer instanceof ReadableStream
298 ? applySingleRangeToStream(streamOrBuffer, ranges)
299 : applyRangesToBuffer(streamOrBuffer, ranges, rangeSize);
298 const rangeBody =
299 streamOrBuffer instanceof ReadableStream
300 ? applySingleRangeToStream(streamOrBuffer, ranges)
301 : applyRangesToBuffer(streamOrBuffer, ranges, rangeSize);
300302 return new Response(rangeBody, {
301303 status: 206,
302304 headers: {
......@@ -380,9 +382,9 @@ function getPartialPage(c: Context, rawFilePath: string) {
380382 if (!checkCotyledonCookie(c)) {
381383 let root = Speedbump();
382384 // Remove the root element, it's created client side!
383 root = root[2].children as ssr.Element;
385 root = root[2].children as render.Element;
384386
385 const html = ssr.ssrSync(root).text;
387 const html = render.sync(root).text;
386388 c.header("X-Cotyledon", "true");
387389 return c.html(html);
388390 }
......@@ -408,15 +410,15 @@ function getPartialPage(c: Context, rawFilePath: string) {
408410 hasCotyledonCookie: rawFilePath === "" && checkCotyledonCookie(c),
409411 });
410412 // Remove the root element, it's created client side!
411 root = root[2].children as ssr.Element;
413 root = root[2].children as render.Element;
412414
413 const html = ssr.ssrSync(root).text;
415 const html = render.sync(root).text;
414416 return c.html(html);
415417}
416418
417419import { type Context, Hono } from "hono";
418420
419import * as ssr from "#ssr";
421import * as render from "#engine/render";
420422import { etagMatches, hasAsset, serveAsset } from "#sitegen/assets";
421423import { renderView } from "#sitegen/view";
422424import { contentTypeFor } from "#sitegen/mime";
src/file-viewer/format.ts+25-36
......@@ -100,22 +100,21 @@ export function highlightLinksInTextView(
100100 // Case 1: https:// or http:// URLs
101101 if (match.startsWith("http")) {
102102 if (match.includes(findDomain)) {
103 return `<a href="${
104 match
105 .replace(/https?:\/\/paperclover\.net\/+/, "/")
106 .replace(/\/\/+/g, "/")
107 }">${match}</a>`;
103 return `<a href="${match
104 .replace(/https?:\/\/paperclover\.net\/+/, "/")
105 .replace(/\/\/+/g, "/")}">${match}</a>`;
108106 }
109 return `<a href="${
110 match.replace(/\/\/+/g, "/")
111 }" target="_blank" rel="noopener noreferrer">${match}</a>`;
107 return `<a href="${match.replace(
108 /\/\/+/g,
109 "/",
110 )}" target="_blank" rel="noopener noreferrer">${match}</a>`;
112111 }
113112
114113 // Case 2: domain URLs without protocol
115114 if (match.startsWith(findDomain)) {
116 return `<a href="${
117 match.replace(findDomain + "/", "/").replace(/\/\/+/g, "/")
118 }">${match}</a>`;
115 return `<a href="${match
116 .replace(findDomain + "/", "/")
117 .replace(/\/\/+/g, "/")}">${match}</a>`;
119118 }
120119
121120 // Case 3: /file/ URLs
......@@ -146,7 +145,7 @@ export function highlightLinksInTextView(
146145 // Match sibling file names (only if they're not already part of a link)
147146 if (siblingFiles.length > 0) {
148147 const escapedBasenames = siblingFiles.map((f) =>
149 f.basename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
148 f.basename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
150149 );
151150 const pattern = new RegExp(`\\b(${escapedBasenames.join("|")})\\b`, "g");
152151 const parts = processedText.split(/(<[^>]*>)/);
......@@ -156,9 +155,9 @@ export function highlightLinksInTextView(
156155 parts[i] = parts[i].replace(pattern, (match: string) => {
157156 const file = siblingLookup[match];
158157 if (file) {
159 return `<a href="/file/${
160 file.path.replace(/^\//, "").replace(/\/\/+/g, "/")
161 }">${match}</a>`;
158 return `<a href="/file/${file.path
159 .replace(/^\//, "")
160 .replace(/\/\/+/g, "/")}">${match}</a>`;
162161 }
163162 return match;
164163 });
......@@ -241,11 +240,9 @@ export function highlightConvo(text: string) {
241240
242241 return paras
243242 .map(({ speaker, lines }) => {
244 return `<div class="s-${speaker}">${
245 lines
246 .map((line) => `<div class="line">${line}</div>`)
247 .join("\n")
248 }</div>`;
243 return `<div class="s-${speaker}">${lines
244 .map((line) => `<div class="line">${line}</div>`)
245 .join("\n")}</div>`;
249246 })
250247 .join("\n");
251248}
......@@ -267,22 +264,14 @@ const unknownDateWithKnownYear = new Date("1970-02-20");
267264
268265export function formatDate(dateTime: Date) {
269266 return dateTime < unknownDateWithKnownYear
270 ? (
271 dateTime < unknownDate
272 ? (
273 "??.??.??"
274 )
275 : `xx.xx.${21 + Math.floor(dateTime.getTime() / 86400000)}`
276 )
277 : (
278 `${(dateTime.getMonth() + 1).toString().padStart(2, "0")}.${
279 dateTime
280 .getDate()
281 .toString()
282 .padStart(2, "0")
283 }.${dateTime.getFullYear().toString().slice(2)}`
284 );
267 ? dateTime < unknownDate
268 ? "??.??.??"
269 : `xx.xx.${21 + Math.floor(dateTime.getTime() / 86400000)}`
270 : `${(dateTime.getMonth() + 1).toString().padStart(2, "0")}.${dateTime
271 .getDate()
272 .toString()
273 .padStart(2, "0")}.${dateTime.getFullYear().toString().slice(2)}`;
285274}
286275
287276import type { MediaFile } from "@/file-viewer/models/MediaFile.ts";
288import { escapeHtml } from "#ssr";
277import { escapeHtml } from "#engine/render";
src/file-viewer/highlight.ts+3-3
......@@ -113,7 +113,7 @@ function highlightLines({
113113 const str = lines[i].slice(token.startIndex, token.endIndex);
114114 if (str.trim().length === 0) {
115115 // Emit but do not consider scope changes
116 html += ssr.escapeHtml(str);
116 html += render.escapeHtml(str);
117117 continue;
118118 }
119119
......@@ -122,7 +122,7 @@ function highlightLines({
122122 if (lastHtmlStyle) html += "</span>";
123123 if (style) html += `<span class='${style}'>`;
124124 }
125 html += ssr.escapeHtml(str);
125 html += render.escapeHtml(str);
126126 lastHtmlStyle = style;
127127 }
128128 html += "\n";
......@@ -197,4 +197,4 @@ import * as fs from "#sitegen/fs";
197197import * as path from "node:path";
198198import * as oniguruma from "vscode-oniguruma";
199199import * as textmate from "vscode-textmate";
200import * as ssr from "#ssr";
200import * as render from "#engine/render";
src/q+a/clover-markdown.tsx+23-11
......@@ -104,13 +104,15 @@ questionRules.insertBefore("paragraph", {
104104 assert(x.startsWith("q: "));
105105 return x.slice(3);
106106 });
107 const content = lines.map((line, i) => {
108 const parsed = parseInline(parse, line, state);
109 if (i < lines.length - 1) {
110 parsed.push({ type: "br" });
111 }
112 return parsed;
113 }).flat();
107 const content = lines
108 .map((line, i) => {
109 const parsed = parseInline(parse, line, state);
110 if (i < lines.length - 1) {
111 parsed.push({ type: "br" });
112 }
113 return parsed;
114 })
115 .flat();
114116 return {
115117 content,
116118 };
......@@ -140,7 +142,13 @@ function BasicNode(node: ASTNode, children: any) {
140142
141143function ListRenderer(node: ASTNode, children: any[]) {
142144 const T = node.ordered ? "ol" : "ul";
143 return <T>{children.map((child) => <li>{child}</li>)}</T>;
145 return (
146 <T>
147 {children.map((child) => (
148 <li>{child}</li>
149 ))}
150 </T>
151 );
144152}
145153
146154function MarkdownLink(node: ASTNode, children: any[]) {
......@@ -174,7 +182,11 @@ function ArtifactRef(node: ASTNode) {
174182 if (!url) {
175183 return <span>{title}</span>;
176184 }
177 return <a class={`custom artifact artifact-${type}`} href={url}>{title}</a>;
185 return (
186 <a class={`custom artifact artifact-${type}`} href={url}>
187 {title}
188 </a>
189 );
178190}
179191
180192const br = <br />;
......@@ -202,7 +214,7 @@ const ssrFunctions: any = {
202214
203215 // Custom Elements
204216 question: BasicNode,
205 html: (node: any) => ssr.html(node.data),
217 html: (node: any) => render.raw(node.data),
206218 mentionQuestion: QuestionRef,
207219 mentionArtifact: ArtifactRef,
208220};
......@@ -247,4 +259,4 @@ import {
247259 parseCaptureInline,
248260 parseInline,
249261} from "./simple-markdown.ts";
250import * as ssr from "#ssr";
262import * as render from "#engine/render";
src/q+a/scripts/editor.client.tsx+5-5
......@@ -1,6 +1,6 @@
11import { EditorState } from "@codemirror/state";
22import { basicSetup, EditorView } from "codemirror";
3import { ssrSync } from "#ssr";
3import * as render from "#engine/render";
44import type { ScriptPayload } from "@/q+a/views/editor.marko";
55import QuestionRender from "@/q+a/tags/question.marko";
66
......@@ -11,7 +11,7 @@ const main = document.getElementById("edit-grid")! as HTMLDivElement;
1111const preview = document.getElementById("preview")! as HTMLDivElement;
1212
1313function updatePreview(text: string) {
14 preview.innerHTML = ssrSync(
14 preview.innerHTML = render.sync(
1515 <QuestionRender
1616 question={{
1717 id: payload.id,
......@@ -96,9 +96,9 @@ function wrapAction(cb: () => Promise<void>) {
9696 return async () => {
9797 main.style.opacity = "0.5";
9898 main.style.pointerEvents = "none";
99 const inputs = main.querySelectorAll("button,select,input") as NodeListOf<
100 HTMLButtonElement
101 >;
99 const inputs = main.querySelectorAll(
100 "button,select,input",
101 ) as NodeListOf<HTMLButtonElement>;
102102 inputs.forEach((b) => {
103103 b.disabled = true;
104104 });
tsconfig.json+1-1
......@@ -4,7 +4,7 @@
44 "baseUrl": ".",
55 "incremental": true,
66 "jsx": "react-jsxdev",
7 "jsxImportSource": "#ssr",
7 "jsxImportSource": "#engine",
88 "lib": ["dom", "esnext", "esnext.iterator"],
99 "module": "nodenext",
1010 "noEmit": true,