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 @@...@@ -1,27 +1,33 @@
1async function trackEsbuild(io: Io, metafile: esbuild.Metafile) {1async function trackEsbuild(io: Io, metafile: esbuild.Metafile) {
2 await Promise.all(Object.keys(metafile.inputs)2 await Promise.all(
3 .filter(file => !isIgnoredSource(file))3 Object.keys(metafile.inputs)
4 .map(file => io.trackFile(file)));4 .filter((file) => !isIgnoredSource(file))
5 .map((file) => io.trackFile(file)),
6 );
5}7}
68
7// This file implements client-side bundling, mostly wrapping esbuild.9// This file implements client-side bundling, mostly wrapping esbuild.
8export async function bundleClientJavaScript(10export async function bundleClientJavaScript(
9 io: Io,11 io: Io,
10 { clientRefs, extraPublicScripts, dev = false }: {12 {
13 clientRefs,
14 extraPublicScripts,
15 dev = false,
16 }: {
11 clientRefs: string[];17 clientRefs: string[];
12 extraPublicScripts: string[];18 extraPublicScripts: string[];
13 dev: boolean;19 dev: boolean;
14 },20 },
15) {21) {
16 const entryPoints = [22 const entryPoints = [
17 ...new Set([23 ...new Set(
18 ...clientRefs.map((x) => `src/${x}`),24 [...clientRefs.map((x) => `src/${x}`), ...extraPublicScripts].map(toAbs),
19 ...extraPublicScripts,25 ),
20 ].map(toAbs)),
21 ];26 ];
22 if (entryPoints.length === 0) return {};27 if (entryPoints.length === 0) return {};
23 const invalidFiles = entryPoints28 const invalidFiles = entryPoints.filter(
24 .filter((file) => !file.match(/\.client\.[tj]sx?/));29 (file) => !file.match(/\.client\.[tj]sx?/),
30 );
25 if (invalidFiles.length > 0) {31 if (invalidFiles.length > 0) {
26 const cwd = process.cwd();32 const cwd = process.cwd();
27 throw new Error(33 throw new Error(
...@@ -35,45 +41,48 @@ export async function bundleClientJavaScript(...@@ -35,45 +41,48 @@ export async function bundleClientJavaScript(
35 markoViaBuildCache(),41 markoViaBuildCache(),
36 ];42 ];
3743
38 const bundle = await esbuild.build({44 const bundle = await esbuild
39 assetNames: "/asset/[hash]",45 .build({
40 bundle: true,46 assetNames: "/asset/[hash]",
41 chunkNames: "/js/c.[hash]",47 bundle: true,
42 entryNames: "/js/[name]",48 chunkNames: "/js/c.[hash]",
43 entryPoints,49 entryNames: "/js/[name]",
44 format: "esm",50 entryPoints,
45 jsx: "automatic",51 format: "esm",
46 jsxDev: dev,52 jsx: "automatic",
47 jsxImportSource: "#ssr",53 jsxDev: dev,
48 logLevel: "silent",54 jsxImportSource: "#engine",
49 metafile: true,55 logLevel: "silent",
50 minify: !dev,56 metafile: true,
51 outdir: "out!",57 minify: !dev,
52 plugins: clientPlugins,58 outdir: "out!",
53 write: false,59 plugins: clientPlugins,
54 define: {60 write: false,
55 "ASSERT": "console.assert",61 define: {
56 MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText),62 ASSERT: "console.assert",
57 },63 MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText),
58 }).catch((err: any) => {64 },
59 err.message = `Client ${err.message}`;65 })
60 throw err;66 .catch((err: any) => {
61 });67 err.message = `Client ${err.message}`;
68 throw err;
69 });
62 if (bundle.errors.length || bundle.warnings.length) {70 if (bundle.errors.length || bundle.warnings.length) {
63 throw new AggregateError(71 throw new AggregateError(
64 bundle.errors.concat(bundle.warnings),72 bundle.errors.concat(bundle.warnings),
65 "JS bundle failed",73 "JS bundle failed",
66 );74 );
67 }75 }
68 const publicScriptRoutes = extraPublicScripts.map((file) =>76 const publicScriptRoutes = extraPublicScripts.map(
69 "/js/" +77 (file) =>
70 path.relative(hot.projectSrc, file).replaceAll("\\", "/").replace(78 "/js/" +
71 /\.client\.[tj]sx?/,79 path
72 ".js",80 .relative(hot.projectSrc, file)
73 )81 .replaceAll("\\", "/")
82 .replace(/\.client\.[tj]sx?/, ".js"),
74 );83 );
75 const { metafile, outputFiles } = bundle;84 const { metafile, outputFiles } = bundle;
76 const p = []85 const p = [];
77 p.push(trackEsbuild(io, metafile));86 p.push(trackEsbuild(io, metafile));
78 const scripts: Record<string, string> = {};87 const scripts: Record<string, string> = {};
79 for (const file of outputFiles) {88 for (const file of outputFiles) {
...@@ -100,156 +109,184 @@ export async function bundleClientJavaScript(...@@ -100,156 +109,184 @@ export async function bundleClientJavaScript(
100109
101export type ServerPlatform = "node" | "passthru";110export type ServerPlatform = "node" | "passthru";
102export interface ServerSideOptions {111export interface ServerSideOptions {
103 entries: string[],112 entries: string[];
104 viewItems: sg.FileItem[]113 viewItems: sg.FileItem[];
105 viewRefs: incr.Ref<PreparedView>[],114 viewRefs: incr.Ref<PreparedView>[];
106 styleMap: Map<string, incr.Ref<string>>;115 styleMap: Map<string, incr.Ref<string>>;
107 scriptMap: incr.Ref<Record<string, string>>;116 scriptMap: incr.Ref<Record<string, string>>;
108 platform: ServerPlatform,117 platform: ServerPlatform;
109}118}
110export async function bundleServerJavaScript(119export async function bundleServerJavaScript({
111 { viewItems, viewRefs, styleMap, scriptMap: wScriptMap, entries, platform }: ServerSideOptions120 viewItems,
112) {121 viewRefs,
122 styleMap,
123 scriptMap: wScriptMap,
124 entries,
125 platform,
126}: ServerSideOptions) {
113 const wViewSource = incr.work(async (_, viewItems: sg.FileItem[]) => {127 const wViewSource = incr.work(async (_, viewItems: sg.FileItem[]) => {
114 const magicWord = "C_" + crypto.randomUUID().replaceAll("-", "_");128 const magicWord = "C_" + crypto.randomUUID().replaceAll("-", "_");
115 return {129 return {
116 magicWord,130 magicWord,
117 file: [131 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 ),
119 `const styles = ${magicWord}[-2]`,135 `const styles = ${magicWord}[-2]`,
120 `export const scripts = ${magicWord}[-1]`,136 `export const scripts = ${magicWord}[-1]`,
121 "export const views = {",137 "export const views = {",
122 ...viewItems.map((view, i) => [138 ...viewItems.map((view, i) =>
123 ` ${JSON.stringify(view.id)}: {`,139 [
124 ` component: view${i}.default,`,140 ` ${JSON.stringify(view.id)}: {`,
125 ` meta: view${i}.meta,`,141 ` component: view${i}.default,`,
126 ` layout: view${i}.layout?.default ?? null,`,142 ` meta: view${i}.meta,`,
127 ` inlineCss: styles[${magicWord}[${i}]]`,143 ` layout: view${i}.layout?.default ?? null,`,
128 ` },`,144 ` inlineCss: styles[${magicWord}[${i}]]`,
129 ].join("\n")),145 ` },`,
146 ].join("\n"),
147 ),
130 "}",148 "}",
131 ].join("\n")149 ].join("\n"),
132 };150 };
133 }, viewItems)151 }, viewItems);
134152
135 const wBundles = entries.map(entry => [entry, incr.work(async (io, entry) => {153 const wBundles = entries.map(
136 const pkg = await io.readJson<{ dependencies: Record<string, string>; }>("package.json");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;162 let magicWord = null as string | null;
139 // -- plugins --163 // -- plugins --
140 const serverPlugins: esbuild.Plugin[] = [164 const serverPlugins: esbuild.Plugin[] = [
141 virtualFiles({165 virtualFiles({
142 // only add dependency when imported.166 // only add dependency when imported.
143 "$views": async () => {167 $views: async () => {
144 const view = await io.readWork(wViewSource);168 const view = await io.readWork(wViewSource);
145 ({ magicWord } = view);169 ({ magicWord } = view);
146 return view.file;170 return view.file;
147 },171 },
148 }),172 }),
149 projectRelativeResolution(),173 projectRelativeResolution(),
150 markoViaBuildCache(),174 markoViaBuildCache(),
151 {175 {
152 name: "replace client references",176 name: "replace client references",
153 setup(b) {177 setup(b) {
154 b.onLoad({ filter: /\.tsx?$/ }, async ({ path: file }) => ({178 b.onLoad({ filter: /\.tsx?$/ }, async ({ path: file }) => ({
155 contents:179 contents: hot.resolveClientRefs(
156 hot.resolveClientRefs(await fs.readFile(file, "utf-8"), file).code,180 await fs.readFile(file, "utf-8"),
157 loader: path.extname(file).slice(1) as esbuild.Loader,181 file,
158 }));182 ).code,
159 },183 loader: path.extname(file).slice(1) as esbuild.Loader,
160 },184 }));
161 {185 },
162 name: "mark css external",186 },
163 setup(b) {187 {
164 b.onResolve(188 name: "mark css external",
165 { filter: /\.css$/ },189 setup(b) {
166 () => ({ path: ".", namespace: "dropped" }),190 b.onResolve({ filter: /\.css$/ }, () => ({
167 );191 path: ".",
168 b.onLoad(192 namespace: "dropped",
169 { filter: /./, namespace: "dropped" },193 }));
170 () => ({ contents: "" }),194 b.onLoad({ filter: /./, namespace: "dropped" }, () => ({
171 );195 contents: "",
172 },196 }));
173 },197 },
174 ];198 },
199 ];
175200
176 const { metafile, outputFiles, errors, warnings } = await esbuild.build({201 const { metafile, outputFiles } = await esbuild.build({
177 bundle: true,202 bundle: true,
178 chunkNames: "c.[hash]",203 chunkNames: "c.[hash]",
179 entryNames: path.basename(entry, path.extname(entry)),204 entryNames: path.basename(entry, path.extname(entry)),
180 entryPoints: [205 entryPoints: [
181 path.join(import.meta.dirname, "backend/entry-" + platform + ".ts"),206 path.join(
182 ],207 import.meta.dirname,
183 platform: "node",208 "backend/entry-" + platform + ".ts",
184 format: "esm",209 ),
185 minify: false,210 ],
186 outdir: "out!",211 platform: "node",
187 plugins: serverPlugins,212 format: "esm",
188 splitting: true,213 minify: false,
189 logLevel: "silent",214 outdir: "out!",
190 write: false,215 plugins: serverPlugins,
191 metafile: true,216 splitting: true,
192 jsx: "automatic",217 logLevel: "silent",
193 jsxImportSource: "#ssr",218 write: false,
194 jsxDev: false,219 metafile: true,
195 define: {220 jsx: "automatic",
196 MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText),221 jsxImportSource: "#engine",
197 'globalThis.CLOVER_SERVER_ENTRY': JSON.stringify(entry),222 jsxDev: false,
198 },223 define: {
199 external: Object.keys(pkg.dependencies)224 MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText),
200 .filter((x) => !x.startsWith("@paperclover")),225 "globalThis.CLOVER_SERVER_ENTRY": JSON.stringify(entry),
201 });226 },
202 await trackEsbuild(io, metafile)227 external: Object.keys(pkg.dependencies).filter(
228 (x) => !x.startsWith("@paperclover"),
229 ),
230 });
231 await trackEsbuild(io, metafile);
203232
204 let fileWithMagicWord: {233 let fileWithMagicWord: {
205 bytes: Buffer;234 bytes: Buffer;
206 basename: string;235 basename: string;
207 magicWord: string;236 magicWord: string;
208 } | null = null;237 } | null = null;
209 for (const output of outputFiles) {238 for (const output of outputFiles) {
210 const basename = output.path.replace(/^.*?!(?:\/|\\)/, "");239 const basename = output.path.replace(/^.*?!(?:\/|\\)/, "");
211 const key = "out!/" + basename.replaceAll("\\", "/");240 const key = "out!/" + basename.replaceAll("\\", "/");
212 // If this contains the generated "$views" file, then241 // If this contains the generated "$views" file, then
213 // mark this file as the one for replacement. Because242 // mark this file as the one for replacement. Because
214 // `splitting` is `true`, esbuild will not emit this243 // `splitting` is `true`, esbuild will not emit this
215 // file in more than one chunk.244 // file in more than one chunk.
216 if (magicWord && metafile.outputs[key].inputs["framework/lib/view.ts"]) {245 if (
217 ASSERT(!fileWithMagicWord);246 magicWord &&
218 fileWithMagicWord = {247 metafile.outputs[key].inputs["framework/lib/view.ts"]
219 basename,248 ) {
220 bytes: Buffer.from(output.contents),249 ASSERT(!fileWithMagicWord);
221 magicWord,250 fileWithMagicWord = {
222 };251 basename,
223 } else {252 bytes: Buffer.from(output.contents),
224 io.writeFile(basename, Buffer.from(output.contents))253 magicWord,
225 }254 };
226 }255 } else {
227 return fileWithMagicWord;256 io.writeFile(basename, Buffer.from(output.contents));
228 }, entry)] as const);257 }
258 }
259 return fileWithMagicWord;
260 }, entry),
261 ] as const,
262 );
229263
230 const wProcessed = wBundles.map(async([entry, wBundle]) => {264 const wProcessed = wBundles.map(async ([entry, wBundle]) => {
231 if (!await wBundle) return;265 if (!(await wBundle)) return;
232 await incr.work(async (io) => {266 await incr.work(async (io) => {
233 // Only the reachable resources need to be read and inserted into the bundle.267 // Only the reachable resources need to be read and inserted into the bundle.
234 // This is what Map<string, incr.Ref> is for268 // This is what Map<string, incr.Ref> is for
235 const { basename, bytes, magicWord } = UNWRAP(await io.readWork(wBundle));269 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
238 // Client JS272 // Client JS
239 const scriptList = Object.entries(await io.readWork(wScriptMap));273 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));
241 const neededScripts = scriptList.filter(([k]) => viewScriptsList.has(k));275 const neededScripts = scriptList.filter(([k]) => viewScriptsList.has(k));
242276
243 // CSS277 // CSS
244 const viewStyleKeys = views.map((view) => view.styleKey);278 const viewStyleKeys = views.map((view) => view.styleKey);
245 const viewCssBundles = await Promise.all(279 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 );
247 const styleList = Array.from(new Set(viewCssBundles));284 const styleList = Array.from(new Set(viewCssBundles));
248285
249 // Replace the magic word286 // Replace the magic word
250 const text = bytes.toString("utf-8").replace(287 const text = bytes
251 new RegExp(magicWord + "\\[(-?\\d+)\\]", "gs"),288 .toString("utf-8")
252 (_, i) => {289 .replace(new RegExp(magicWord + "\\[(-?\\d+)\\]", "gs"), (_, i) => {
253 i = Number(i);290 i = Number(i);
254 // Inline the styling data291 // Inline the styling data
255 if (i === -2) {292 if (i === -2) {
...@@ -261,17 +298,15 @@ export async function bundleServerJavaScript(...@@ -261,17 +298,15 @@ export async function bundleServerJavaScript(
261 }298 }
262 // Reference an index into `styleList`299 // Reference an index into `styleList`
263 return `${styleList.indexOf(viewCssBundles[i])}`;300 return `${styleList.indexOf(viewCssBundles[i])}`;
264 },301 });
265 );
266302
267 io.writeFile(basename, text);303 io.writeFile(basename, text);
268 });304 });
269 })305 });
270306
271 await Promise.all(wProcessed);307 await Promise.all(wProcessed);
272}308}
273309
274
275import * as esbuild from "esbuild";310import * as esbuild from "esbuild";
276import * as path from "node:path";311import * as path from "node:path";
277import process from "node:process";312import process from "node:process";
...@@ -282,9 +317,8 @@ import {...@@ -282,9 +317,8 @@ import {
282 projectRelativeResolution,317 projectRelativeResolution,
283 virtualFiles,318 virtualFiles,
284} from "./esbuild-support.ts";319} from "./esbuild-support.ts";
285import { Io, toAbs, toRel } from "./incremental.ts";320import { Io, toAbs } from "./incremental.ts";
286import * as css from "./css.ts";
287import * as fs from "#sitegen/fs";321import * as fs from "#sitegen/fs";
288import * as mime from "#sitegen/mime";322import * as mime from "#sitegen/mime";
289import * as incr from "./incremental.ts";323import * 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 @@...@@ -1,37 +1,37 @@
1export const Fragment = ({ children }: { children: engine.Node[] }) => children;1export const Fragment = ({ children }: { children: render.Node[] }) => children;
22
3export function jsx(3export function jsx(
4 type: string | engine.Component,4 type: string | render.Component,
5 props: Record<string, unknown>,5 props: Record<string, unknown>,
6): engine.Element {6): render.Element {
7 if (typeof type !== "function" && typeof type !== "string") {7 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));
9 }9 }
10 return [engine.kElement, type, props];10 return [render.kElement, type, props];
11}11}
1212
13export function jsxDEV(13export function jsxDEV(
14 type: string | engine.Component,14 type: string | render.Component,
15 props: Record<string, unknown>,15 props: Record<string, unknown>,
16 // Unused with the clover engine16 // Unused with the clover engine
17 _key: string,17 _key: string,
18 // Unused with the clover engine18 // Unused with the clover engine
19 _isStaticChildren: boolean,19 _isStaticChildren: boolean,
20 source: engine.SrcLoc,20 source: render.SrcLoc,
21): engine.Element {21): render.Element {
22 const { fileName, lineNumber, columnNumber } = source;22 const { fileName, lineNumber, columnNumber } = source;
2323
24 // Assert the component type is valid to render.24 // Assert the component type is valid to render.
25 if (typeof type !== "function" && typeof type !== "string") {25 if (typeof type !== "function" && typeof type !== "string") {
26 throw new Error(26 throw new Error(
27 `Invalid component type at ${fileName}:${lineNumber}:${columnNumber}: ` +27 `Invalid component type at ${fileName}:${lineNumber}:${columnNumber}: ` +
28 engine.inspect(type) +28 render.inspect(type) +
29 ". Clover SSR element must be a function or string",29 ". Clover SSR element must be a function or string",
30 );30 );
31 }31 }
3232
33 // Construct an `ssr.Element`33 // Construct an `ssr.Element`
34 return [engine.kElement, type, props, "", source];34 return [render.kElement, type, props, "", source];
35}35}
3636
37// jsxs37// jsxs
...@@ -45,10 +45,10 @@ declare global {...@@ -45,10 +45,10 @@ declare global {
45 interface ElementChildrenAttribute {45 interface ElementChildrenAttribute {
46 children: Node;46 children: Node;
47 }47 }
48 type Element = engine.Element;48 type Element = render.Element;
49 type ElementType = keyof IntrinsicElements | engine.Component;49 type ElementType = keyof IntrinsicElements | render.Component;
50 type ElementClass = ReturnType<engine.Component>;50 type ElementClass = ReturnType<render.Component>;
51 }51 }
52}52}
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 = (...@@ -11,29 +11,22 @@ export const createTemplate = (
11 templateId: string,11 templateId: string,
12 renderer: ServerRenderer,12 renderer: ServerRenderer,
13) => {13) => {
14 const { render } = marko.createTemplate(templateId, renderer);14 const { render: renderFn } = marko.createTemplate(templateId, renderer);
15 function wrap(props: Record<string, unknown>, n: number) {15 function wrap(props: Record<string, unknown>, n: number) {
16 // Marko Custom Tags16 // Marko Custom Tags
17 const cloverAsyncMarker = { isAsync: false };17 const cloverAsyncMarker = { isAsync: false };
18 let r: engine.Render | undefined = undefined;18 const r = render.current;
19 try {
20 r = engine.getCurrentRender();
21 } catch {}
22 // Support using Marko outside of Clover SSR19 // Support using Marko outside of Clover SSR
23 if (r) {20 if (!r) return renderer(props, n);
24 engine.setCurrentRender(null);21 const markoResult = renderFn.call(renderer, {
25 const markoResult = render.call(renderer, {22 ...props,
26 ...props,23 $global: { clover: r, cloverAsyncMarker },
27 $global: { clover: r, cloverAsyncMarker },24 });
28 });25 if (cloverAsyncMarker.isAsync) {
29 if (cloverAsyncMarker.isAsync) {26 return markoResult.then(render.raw);
30 return markoResult.then(engine.html);
31 }
32 const rr = markoResult.toString();
33 return engine.html(rr);
34 } else {
35 return renderer(props, n);
36 }27 }
28 const rr = markoResult.toString();
29 return render.raw(rr);
37 }30 }
38 wrap.render = render;31 wrap.render = render;
39 wrap.unwrapped = renderer;32 wrap.unwrapped = renderer;
...@@ -56,20 +49,16 @@ export const dynamicTag = (...@@ -56,20 +49,16 @@ export const dynamicTag = (
56 tag = unwrapped;49 tag = unwrapped;
57 break clover;50 break clover;
58 }51 }
59 let r: engine.Render;52 const r = render.current ?? (marko.$global().clover as render.State);
60 try {
61 r = engine.getCurrentRender();
62 if (!r) throw 0;
63 } catch {
64 r = marko.$global().clover as engine.Render;
65 }
66 if (!r) throw new Error("No Clover Render Active");53 if (!r) throw new Error("No Clover Render Active");
67 const subRender = engine.initRender(r.async !== -1, r.addon);54 const subRender = render.init(r.async !== -1, r.addon);
68 const resolved = engine.resolveNode(subRender, [55 const resolved = render.resolveNode(
69 engine.kElement,56 subRender,
70 tag,57 render.element(
71 inputOrArgs,58 tag as render.Component,
72 ]);59 inputOrArgs as Record<any, any>,
60 ),
61 );
7362
74 if (subRender.async > 0) {63 if (subRender.async > 0) {
75 const marker = marko.$global().cloverAsyncMarker as Async;64 const marker = marko.$global().cloverAsyncMarker as Async;
...@@ -79,7 +68,7 @@ export const dynamicTag = (...@@ -79,7 +68,7 @@ export const dynamicTag = (
79 const { resolve, reject, promise } = Promise.withResolvers<string>();68 const { resolve, reject, promise } = Promise.withResolvers<string>();
80 subRender.asyncDone = () => {69 subRender.asyncDone = () => {
81 const rejections = subRender.rejections;70 const rejections = subRender.rejections;
82 if (!rejections) return resolve(engine.renderNode(resolved));71 if (!rejections) return resolve(render.stringifyNode(resolved));
83 (r.rejections ??= []).push(...rejections);72 (r.rejections ??= []).push(...rejections);
84 return reject(new Error("Render had errors"));73 return reject(new Error("Render had errors"));
85 };74 };
...@@ -91,7 +80,7 @@ export const dynamicTag = (...@@ -91,7 +80,7 @@ export const dynamicTag = (
91 0,80 0,
92 );81 );
93 } else {82 } else {
94 marko.write(engine.renderNode(resolved));83 marko.write(render.stringifyNode(resolved));
95 }84 }
96 return;85 return;
97 }86 }
...@@ -124,13 +113,15 @@ export function escapeXML(input: unknown) {...@@ -124,13 +113,15 @@ export function escapeXML(input: unknown) {
124 // creating `[object Object]` is universally useless to any end user.113 // creating `[object Object]` is universally useless to any end user.
125 if (114 if (
126 input == null ||115 input == null ||
127 (typeof input === "object" && input &&116 (typeof input === "object" &&
117 input &&
128 // only block this if it's the default `toString`118 // only block this if it's the default `toString`
129 input.toString === Object.prototype.toString)119 input.toString === Object.prototype.toString)
130 ) {120 ) {
131 throw new Error(121 throw new Error(
132 `Unexpected value in template placeholder: '` +122 `Unexpected value in template placeholder: '` +
133 engine.inspect(input) + "'. " +123 render.inspect(input) +
124 "'. " +
134 `To emit a literal '${input}', use \${String(value)}`,125 `To emit a literal '${input}', use \${String(value)}`,
135 );126 );
136 }127 }
...@@ -141,7 +132,7 @@ interface Async {...@@ -141,7 +132,7 @@ interface Async {
141 isAsync: boolean;132 isAsync: boolean;
142}133}
143134
144import * as engine from "./ssr.ts";135import * as render from "#engine/render";
145import type { ServerRenderer } from "marko/html/template";136import type { ServerRenderer } from "marko/html/template";
146import { type Accessor } from "marko/common/types";137import { type Accessor } from "marko/common/types";
147import * as marko from "#marko/html";138import * 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 @@...@@ -7,46 +7,44 @@
7//7//
8// I would link to an article from Next.js or React, but their examples8// I would link to an article from Next.js or React, but their examples
9// are too verbose and not informative to what they actually do.9// are too verbose and not informative to what they actually do.
10const kState = Symbol("SuspenseState");10const userData = render.userData<null | State>(() => null);
1111
12interface SuspenseProps {12interface SuspenseProps {
13 children: ssr.Node;13 children: render.Node;
14 fallback?: ssr.Node;14 fallback?: render.Node;
15}15}
1616
17interface State {17interface State {
18 nested: boolean;18 nested: boolean;
19 nextId: number;19 nextId: number;
20 completed: number;20 completed: number;
21 pushChunk(name: string, node: ssr.ResolvedNode): void;21 pushChunk(name: string, node: render.ResolvedNode): void;
22}22}
2323
24export function Suspense({ children, fallback }: SuspenseProps): ssr.Node {24export function Suspense({ children, fallback }: SuspenseProps): render.Node {
25 const state = ssr.getUserData<State>(kState, () => {25 const state = userData.get();
26 throw new Error("Can only use <Suspense> with 'renderStreaming'");26 if (!state) return children;
27 });
28 if (state.nested) throw new Error("<Suspense> cannot be nested");27 if (state.nested) throw new Error("<Suspense> cannot be nested");
29 const parent = ssr.getCurrentRender()!;28 const parent = UNWRAP(render.current);
30 const r = ssr.initRender(true, { [kState]: { nested: true } });29 const r = render.initRender(true, { [userData.key]: { nested: true } });
31 const resolved = ssr.resolveNode(r, children);30 const resolved = render.resolveNode(r, children);
32 if (r.async == 0) return ssr.html(resolved);31 if (r.async == 0) return render.raw(resolved);
33 const name = "suspended_" + (++state.nextId);32 const name = "suspended_" + ++state.nextId;
34 state.nested = true;33 state.nested = true;
35 const ip: [ssr.ResolvedNode] = [34 const ip: [render.ResolvedNode] = [
36 [35 render.resolvedElement(
37 ssr.kElement,
38 "slot",36 "slot",
39 { name },37 { name },
40 fallback ? ssr.resolveNode(parent, fallback) : "",38 fallback ? render.resolveNode(parent, fallback) : "",
41 ],39 ),
42 ];40 ];
43 state.nested = false;41 state.nested = false;
44 r.asyncDone = () => {42 r.asyncDone = () => {
45 const rejections = r.rejections;43 const rejections = r.rejections;
46 if (rejections && rejections.length > 0) throw new Error("TODO");44 if (rejections && rejections.length > 0) throw new Error("TODO");
47 state.pushChunk?.(name, ip[0] = resolved);45 state.pushChunk?.(name, (ip[0] = resolved));
48 };46 };
49 return ssr.html(ip);47 return render.raw(ip);
50}48}
5149
52// TODO: add a User-Agent parameter, which is used to determine if a50// 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 {...@@ -54,17 +52,14 @@ export function Suspense({ children, fallback }: SuspenseProps): ssr.Node {
54// - Before ~2024 needs to use a JS implementation.52// - Before ~2024 needs to use a JS implementation.
55// - IE should probably bail out entirely.53// - IE should probably bail out entirely.
56export async function* renderStreaming<54export async function* renderStreaming<
57 T extends ssr.Addons = Record<never, unknown>,55 T extends render.Addons = Record<never, unknown>,
58>(56>(node: render.Node, addon: T = {} as T) {
59 node: ssr.Node,
60 addon: T = {} as T,
61) {
62 const {57 const {
63 text: begin,58 text: begin,
64 addon: { [kState]: state, ...addonOutput },59 addon: { [userData.key]: state, ...addonOutput },
65 } = await ssr.ssrAsync(node, {60 } = await render.async(node, {
66 ...addon,61 ...addon,
67 [kState]: {62 [userData.key]: {
68 nested: false,63 nested: false,
69 nextId: 0,64 nextId: 0,
70 completed: 0,65 completed: 0,
...@@ -79,24 +74,29 @@ export async function* renderStreaming<...@@ -79,24 +74,29 @@ export async function* renderStreaming<
79 let chunks: string[] = [];74 let chunks: string[] = [];
80 state.pushChunk = (slot, node) => {75 state.pushChunk = (slot, node) => {
81 while (node.length === 1 && Array.isArray(node)) node = node[0];76 while (node.length === 1 && Array.isArray(node)) node = node[0];
82 if (node[0] === ssr.kElement) {77 if (node[0] === render.kElement) {
83 (node as ssr.ResolvedElement)[2].slot = slot;78 (node as render.ResolvedElement)[2].slot = slot;
84 } else {79 } else {
85 node = [ssr.kElement, "clover-suspense", {80 node = [
86 style: "display:contents",81 render.kElement,
87 slot,82 "clover-suspense",
88 }, node];83 {
84 style: "display:contents",
85 slot,
86 },
87 node,
88 ];
89 }89 }
90 chunks.push(ssr.renderNode(node));90 chunks.push(render.stringifyNode(node));
91 resolve?.();91 resolve?.();
92 };92 };
93 yield `<template shadowrootmode=open>${begin}</template>`;93 yield `<template shadowrootmode=open>${begin}</template>`;
94 do {94 do {
95 await new Promise<void>((done) => resolve = done);95 await new Promise<void>((done) => (resolve = done));
96 yield* chunks;96 yield* chunks;
97 chunks = [];97 chunks = [];
98 } while (state.nextId < state.completed);98 } while (state.nextId < state.completed);
99 return addonOutput as unknown as T;99 return addonOutput as unknown as T;
100}100}
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() {...@@ -14,12 +14,8 @@ export async function main() {
14export async function generate() {14export async function generate() {
15 // -- read config and discover files --15 // -- read config and discover files --
16 const siteConfig = await incr.work(readManifest);16 const siteConfig = await incr.work(readManifest);
17 const {17 const { staticFiles, scripts, views, pages } =
18 staticFiles,18 await discoverAllFiles(siteConfig);
19 scripts,
20 views,
21 pages,
22 } = await discoverAllFiles(siteConfig);
2319
24 // TODO: make sure that `static` and `pages` does not overlap20 // TODO: make sure that `static` and `pages` does not overlap
2521
...@@ -28,12 +24,15 @@ export async function generate() {...@@ -28,12 +24,15 @@ export async function generate() {
28 // -- perform build-time rendering --24 // -- perform build-time rendering --
29 const builtPages = pages.map((item) => incr.work(preparePage, item));25 const builtPages = pages.map((item) => incr.work(preparePage, item));
30 const builtViews = views.map((item) => incr.work(prepareView, item));26 const builtViews = views.map((item) => incr.work(prepareView, item));
31 const builtStaticFiles = Promise.all((staticFiles.map((item) =>27 const builtStaticFiles = Promise.all(
32 incr.work(28 staticFiles.map((item) =>
33 async (io, { id, file }) => void await io.writeAsset(id, await io.readFile(file)),29 incr.work(
34 item,30 async (io, { id, file }) =>
35 )31 void (await io.writeAsset(id, await io.readFile(file))),
36 )));32 item,
33 ),
34 ),
35 );
37 const routes = await Promise.all([...builtViews, ...builtPages]);36 const routes = await Promise.all([...builtViews, ...builtPages]);
3837
39 // -- page resources --38 // -- page resources --
...@@ -47,23 +46,19 @@ export async function generate() {...@@ -47,23 +46,19 @@ export async function generate() {
47 // -- backend --46 // -- backend --
48 const builtBackend = bundle.bundleServerJavaScript({47 const builtBackend = bundle.bundleServerJavaScript({
49 entries: siteConfig.backends,48 entries: siteConfig.backends,
50 platform: 'node',49 platform: "node",
51 styleMap,50 styleMap,
52 scriptMap,51 scriptMap,
53 viewItems: views,52 viewItems: views,
54 viewRefs: builtViews,53 viewRefs: builtViews,
55 })54 });
5655
57 // -- assemble page assets --56 // -- assemble page assets --
58 const pAssemblePages = builtPages.map((page) =>57 const pAssemblePages = builtPages.map((page) =>
59 assembleAndWritePage(page, styleMap, scriptMap)58 assembleAndWritePage(page, styleMap, scriptMap),
60 );59 );
6160
62 await Promise.all([61 await Promise.all([builtBackend, builtStaticFiles, ...pAssemblePages]);
63 builtBackend,
64 builtStaticFiles,
65 ...pAssemblePages,
66 ]);
67}62}
6863
69export async function readManifest(io: Io) {64export async function readManifest(io: Io) {
...@@ -82,7 +77,7 @@ export async function discoverAllFiles(...@@ -82,7 +77,7 @@ export async function discoverAllFiles(
82 return (77 return (
83 await Promise.all(78 await Promise.all(
84 siteConfig.siteSections.map(({ root: sectionRoot }) =>79 siteConfig.siteSections.map(({ root: sectionRoot }) =>
85 incr.work(scanSiteSection, toAbs(sectionRoot))80 incr.work(scanSiteSection, toAbs(sectionRoot)),
86 ),81 ),
87 )82 )
88 ).reduce((acc, next) => ({83 ).reduce((acc, next) => ({
...@@ -110,9 +105,10 @@ export async function scanSiteSection(io: Io, sectionRoot: string) {...@@ -110,9 +105,10 @@ export async function scanSiteSection(io: Io, sectionRoot: string) {
110 let scripts: FileItem[] = [];105 let scripts: FileItem[] = [];
111106
112 const sectionPath = (...sub: string[]) => path.join(sectionRoot, ...sub);107 const sectionPath = (...sub: string[]) => path.join(sectionRoot, ...sub);
113 const rootPrefix = hot.projectSrc === sectionRoot108 const rootPrefix =
114 ? ""109 hot.projectSrc === sectionRoot
115 : path.relative(hot.projectSrc, sectionRoot) + "/";110 ? ""
111 : path.relative(hot.projectSrc, sectionRoot) + "/";
116 const kinds = [112 const kinds = [
117 {113 {
118 dir: sectionPath("pages"),114 dir: sectionPath("pages"),
...@@ -186,12 +182,8 @@ export async function preparePage(io: Io, item: sg.FileItem) {...@@ -186,12 +182,8 @@ export async function preparePage(io: Io, item: sg.FileItem) {
186 theme: pageTheme,182 theme: pageTheme,
187 layout,183 layout,
188 } = await io.import<any>(item.file);184 } = await io.import<any>(item.file);
189 if (!Page) {185 if (!Page) throw new Error("Page is missing a 'default' export.");
190 throw new Error("Page is missing a 'default' export.");186 if (!metadata) throw new Error("Page is missing 'meta' export with a title.");
191 }
192 if (!metadata) {
193 throw new Error("Page is missing 'meta' export with a title.");
194 }
195187
196 // -- css --188 // -- css --
197 if (layout?.theme) pageTheme = layout.theme;189 if (layout?.theme) pageTheme = layout.theme;
...@@ -210,12 +202,12 @@ export async function preparePage(io: Io, item: sg.FileItem) {...@@ -210,12 +202,12 @@ export async function preparePage(io: Io, item: sg.FileItem) {
210 ).then((m) => meta.renderMeta(m));202 ).then((m) => meta.renderMeta(m));
211203
212 // -- html --204 // -- html --
213 let page = [engine.kElement, Page, {}];205 let page = render.element(Page);
214 if (layout?.default) {206 if (layout?.default) {
215 page = [engine.kElement, layout.default, { children: page }];207 page = render.element(layout.default, { children: page });
216 }208 }
217 const bodyPromise = engine.ssrAsync(page, {209 const bodyPromise = render.async(page, {
218 sitegen: sg.initRender(),210 [sg.userData.key]: sg.initRender(),
219 });211 });
220212
221 const [{ text, addon }, renderedMeta] = await Promise.all([213 const [{ text, addon }, renderedMeta] = await Promise.all([
...@@ -235,23 +227,16 @@ export async function preparePage(io: Io, item: sg.FileItem) {...@@ -235,23 +227,16 @@ export async function preparePage(io: Io, item: sg.FileItem) {
235 cssImports,227 cssImports,
236 theme: theme ?? null,228 theme: theme ?? null,
237 styleKey,229 styleKey,
238 clientRefs: Array.from(addon.sitegen.scripts),230 clientRefs: Array.from(addon[sg.userData.key].scripts),
239 };231 };
240}232}
241233
242export async function prepareView(io: Io, item: sg.FileItem) {234export async function prepareView(io: Io, item: sg.FileItem) {
243 const module = await io.import<any>(item.file);235 const module = await io.import<any>(item.file);
244 if (!module.meta) {236 if (!module.meta) throw new Error(`View is missing 'export const meta'`);
245 throw new Error(`${item.file} is missing 'export const meta'`);237 if (!module.default) throw new Error(`View is missing a default export.`);
246 }
247 if (!module.default) {
248 throw new Error(`${item.file} is missing a default export.`);
249 }
250 const pageTheme = module.layout?.theme ?? module.theme;238 const pageTheme = module.layout?.theme ?? module.theme;
251 const theme: css.Theme = {239 const theme: css.Theme = { ...css.defaultTheme, ...pageTheme };
252 ...css.defaultTheme,
253 ...pageTheme,
254 };
255 const cssImports = Array.from(240 const cssImports = Array.from(
256 new Set([globalCssPath, ...hot.getCssImports(item.file)]),241 new Set([globalCssPath, ...hot.getCssImports(item.file)]),
257 (file) => path.relative(hot.projectSrc, file),242 (file) => path.relative(hot.projectSrc, file),
...@@ -269,22 +254,14 @@ export async function prepareView(io: Io, item: sg.FileItem) {...@@ -269,22 +254,14 @@ export async function prepareView(io: Io, item: sg.FileItem) {
269export type PreparedView = Awaited<ReturnType<typeof prepareView>>;254export type PreparedView = Awaited<ReturnType<typeof prepareView>>;
270255
271export function prepareInlineCss(256export function prepareInlineCss(
272 items: Array<{257 items: Array<{ styleKey: string; cssImports: string[]; theme: css.Theme }>,
273 styleKey: string;
274 cssImports: string[];
275 theme: css.Theme;
276 }>,
277) {258) {
278 const map = new Map<string, incr.Ref<string>>();259 const map = new Map<string, incr.Ref<string>>();
279 for (const { styleKey, cssImports, theme } of items) {260 for (const { styleKey, cssImports, theme } of items) {
280 if (map.has(styleKey)) continue;261 if (map.has(styleKey)) continue;
281 map.set(262 map.set(
282 styleKey,263 styleKey,
283 incr.work(css.bundleCssFiles, {264 incr.work(css.bundleCssFiles, { cssImports, theme, dev: false }),
284 cssImports,
285 theme,
286 dev: false,
287 }),
288 );265 );
289 }266 }
290 return map;267 return map;
...@@ -297,29 +274,26 @@ export async function assembleAndWritePage(...@@ -297,29 +274,26 @@ export async function assembleAndWritePage(
297 scriptWork: incr.Ref<Record<string, string>>,274 scriptWork: incr.Ref<Record<string, string>>,
298) {275) {
299 const page = await pageWork;276 const page = await pageWork;
300 return incr.work(277 return incr.work(async (io, { id, html, meta, styleKey, clientRefs }) => {
301 async (io, { id, html, meta, styleKey, clientRefs }) => {278 const inlineCss = await io.readWork(UNWRAP(styleMap.get(styleKey)));
302 const inlineCss = await io.readWork(UNWRAP(styleMap.get(styleKey)));
303279
304 const scriptIds = clientRefs.map(hot.getScriptId);280 const scriptIds = clientRefs.map(hot.getScriptId);
305 const scriptMap = await io.readWork(scriptWork);281 const scriptMap = await io.readWork(scriptWork);
306 const scripts = scriptIds.map((ref) =>282 const scripts = scriptIds
307 UNWRAP(scriptMap[ref], `Missing script ${ref}`)283 .map((ref) => UNWRAP(scriptMap[ref], `Missing script ${ref}`))
308 )284 .map((x) => `{${x}}`)
309 .map((x) => `{${x}}`).join("\n");285 .join("\n");
310286
311 const doc = wrapDocument({287 const doc = wrapDocument({
312 body: html,288 body: html,
313 head: meta,289 head: meta,
314 inlineCss,290 inlineCss,
315 scripts,291 scripts,
316 });292 });
317 await io.writeAsset(id, doc, {293 await io.writeAsset(id, doc, {
318 "Content-Type": "text/html",294 "Content-Type": "text/html",
319 });295 });
320 },296 }, page);
321 page,
322 );
323}297}
324298
325import * as sg from "#sitegen";299import * as sg from "#sitegen";
...@@ -327,11 +301,10 @@ import * as incr from "./incremental.ts";...@@ -327,11 +301,10 @@ import * as incr from "./incremental.ts";
327import { Io } from "./incremental.ts";301import { Io } from "./incremental.ts";
328import * as bundle from "./bundle.ts";302import * as bundle from "./bundle.ts";
329import * as css from "./css.ts";303import * as css from "./css.ts";
330import * as engine from "./engine/ssr.ts";304import * as render from "#engine/render";
331import * as hot from "./hot.ts";305import * as hot from "./hot.ts";
332import * as fs from "#sitegen/fs";306import * as fs from "#sitegen/fs";
333import type { FileItem } from "#sitegen";307import type { FileItem } from "#sitegen";
334import * as path from "node:path";308import * as path from "node:path";
335import * as meta from "#sitegen/meta";309import * as meta from "#sitegen/meta";
336import { Spinner, withSpinner } from "@paperclover/console/Spinner";
337import { wrapDocument } from "./lib/view.ts";310import { wrapDocument } from "./lib/view.ts";
framework/hot.ts+28-29
...@@ -19,7 +19,7 @@ export const load = createRequire(...@@ -19,7 +19,7 @@ export const load = createRequire(
19};19};
20export const { cache } = load;20export const { cache } = load;
2121
22load<any>("source-map-support").install({hookRequire: true});22load<any>("source-map-support").install({ hookRequire: true });
2323
24// Register extensions by overwriting `require.extensions`24// Register extensions by overwriting `require.extensions`
25const require = load;25const require = load;
...@@ -75,15 +75,15 @@ Module.prototype._compile = function (...@@ -75,15 +75,15 @@ Module.prototype._compile = function (
75 imports.push(file);75 imports.push(file);
76 (childModule.cloverImporters ??= []).push(this);76 (childModule.cloverImporters ??= []).push(this);
77 if (cloverClientRefs && cloverClientRefs.length > 0) {77 if (cloverClientRefs && cloverClientRefs.length > 0) {
78 (this.cloverClientRefs ??= [])78 (this.cloverClientRefs ??= []).push(...cloverClientRefs);
79 .push(...cloverClientRefs);
80 }79 }
81 }80 }
82 }81 }
83 fileStats.set(filename, {82 fileStats.set(filename, {
84 cssImportsRecursive: cssImportsMaybe.length > 083 cssImportsRecursive:
85 ? Array.from(new Set(cssImportsMaybe))84 cssImportsMaybe.length > 0
86 : null,85 ? Array.from(new Set(cssImportsMaybe))
86 : null,
87 imports,87 imports,
88 lastModified: Math.floor(stat.mtimeMs),88 lastModified: Math.floor(stat.mtimeMs),
89 });89 });
...@@ -100,7 +100,8 @@ Module._resolveFilename = (...args) => {...@@ -100,7 +100,8 @@ Module._resolveFilename = (...args) => {
100 return require.resolve(replacedPath, { paths: [projectSrc] });100 return require.resolve(replacedPath, { paths: [projectSrc] });
101 } catch (err: any) {101 } catch (err: any) {
102 if (102 if (
103 err.code === "MODULE_NOT_FOUND" && (err?.requireStack?.length ?? 0) <= 1103 err.code === "MODULE_NOT_FOUND" &&
104 (err?.requireStack?.length ?? 0) <= 1
104 ) {105 ) {
105 err.message.replace(replacedPath, args[0]);106 err.message.replace(replacedPath, args[0]);
106 }107 }
...@@ -138,45 +139,45 @@ export function loadEsbuildCode(...@@ -138,45 +139,45 @@ export function loadEsbuildCode(
138 src = code;139 src = code;
139 }140 }
140 if (src.includes("import.meta")) {141 if (src.includes("import.meta")) {
141 src = `142 src =
142 import.meta.url = ${JSON.stringify(pathToFileURL(filepath).toString())};143 `
143 import.meta.dirname = ${JSON.stringify(path.dirname(filepath))};144 import.meta.url = ${JSON.stringify(pathToFileURL(filepath).toString())};
144 import.meta.filename = ${JSON.stringify(filepath)};145 import.meta.dirname = ${JSON.stringify(path.dirname(filepath))};
145 `.trim().replace(/[\n\s]/g, "") + src;146 import.meta.filename = ${JSON.stringify(filepath)};
147 `
148 .trim()
149 .replace(/[\n\s]/g, "") + src;
146 }150 }
147 src = esbuild.transformSync(src, {151 src = esbuild.transformSync(src, {
148 loader,152 loader,
149 format: "cjs",153 format: "cjs",
150 target: "esnext",154 target: "esnext",
151 jsx: "automatic",155 jsx: "automatic",
152 jsxImportSource: "#ssr",156 jsxImportSource: "#engine",
153 jsxDev: true,157 jsxDev: true,
154 sourcefile: filepath,158 sourcefile: filepath,
155 sourcemap: 'inline',159 sourcemap: "inline",
156 }).code;160 }).code;
157 return module._compile(src, filepath, "commonjs");161 return module._compile(src, filepath, "commonjs");
158}162}
159163
160export function resolveClientRef(sourcePath: string, ref: string) {164export function resolveClientRef(sourcePath: string, ref: string) {
161 const filePath = resolveFrom(sourcePath, ref);165 const filePath = resolveFrom(sourcePath, ref);
162 if (166 if (!filePath.endsWith(".client.ts") && !filePath.endsWith(".client.tsx")) {
163 !filePath.endsWith(".client.ts") &&
164 !filePath.endsWith(".client.tsx")
165 ) {
166 throw new Error("addScript must be a .client.ts or .client.tsx");167 throw new Error("addScript must be a .client.ts or .client.tsx");
167 }168 }
168 return path.relative(projectSrc, filePath);169 return path.relative(projectSrc, filePath);
169}170}
170171
171let lazyMarko: typeof import('./marko.ts') | null = null;172let lazyMarko: typeof import("./marko.ts") | null = null;
172function loadMarko(module: NodeJS.Module, filepath: string) {173function 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");
174 lazyMarko.loadMarko(module, filepath);175 lazyMarko.loadMarko(module, filepath);
175}176}
176177
177function loadMdx(module: NodeJS.Module, filepath: string) {178function loadMdx(module: NodeJS.Module, filepath: string) {
178 const input = fs.readFileSync(filepath);179 const input = fs.readFileSync(filepath);
179 const out = mdx.compileSync(input, { jsxImportSource: "#ssr" }).value;180 const out = mdx.compileSync(input, { jsxImportSource: "#engine" }).value;
180 const src = typeof out === "string" ? out : Buffer.from(out).toString("utf8");181 const src = typeof out === "string" ? out : Buffer.from(out).toString("utf8");
181 return loadEsbuildCode(module, filepath, src);182 return loadEsbuildCode(module, filepath, src);
182}183}
...@@ -194,7 +195,7 @@ export function reloadRecursive(filepath: string) {...@@ -194,7 +195,7 @@ export function reloadRecursive(filepath: string) {
194}195}
195196
196export function unload(filepath: string) {197export function unload(filepath: string) {
197 lazyMarko?.markoCache.delete(filepath)198 lazyMarko?.markoCache.delete(filepath);
198 filepath = path.resolve(filepath);199 filepath = path.resolve(filepath);
199 const module = cache[filepath];200 const module = cache[filepath];
200 if (!module) return;201 if (!module) return;
...@@ -291,8 +292,9 @@ export function resolveClientRefs(...@@ -291,8 +292,9 @@ export function resolveClientRefs(
291}292}
292293
293export function getScriptId(file: string) {294export function getScriptId(file: string) {
294 return (path.isAbsolute(file) ? path.relative(projectSrc, file) : file)295 return (
295 .replaceAll("\\", "/");296 path.isAbsolute(file) ? path.relative(projectSrc, file) : file
297 ).replaceAll("\\", "/");
296}298}
297299
298declare global {300declare global {
...@@ -300,7 +302,7 @@ declare global {...@@ -300,7 +302,7 @@ declare global {
300 interface Module {302 interface Module {
301 cloverClientRefs?: string[];303 cloverClientRefs?: string[];
302 cloverSourceCode?: string;304 cloverSourceCode?: string;
303 cloverImporters?: Module[],305 cloverImporters?: Module[];
304306
305 _compile(307 _compile(
306 this: NodeJS.Module,308 this: NodeJS.Module,
...@@ -312,10 +314,7 @@ declare global {...@@ -312,10 +314,7 @@ declare global {
312 }314 }
313}315}
314declare module "node:module" {316declare module "node:module" {
315 export function _resolveFilename(317 export function _resolveFilename(id: string, parent: NodeJS.Module): unknown;
316 id: string,
317 parent: NodeJS.Module,
318 ): unknown;
319}318}
320319
321import * as fs from "#sitegen/fs";320import * as fs from "#sitegen/fs";
framework/lib/assets.ts+9-19
...@@ -11,30 +11,22 @@ export async function reload() {...@@ -11,30 +11,22 @@ export async function reload() {
11 fs.readFile(path.join(import.meta.dirname, "static.json"), "utf8"),11 fs.readFile(path.join(import.meta.dirname, "static.json"), "utf8"),
12 fs.readFile(path.join(import.meta.dirname, "static.blob")),12 fs.readFile(path.join(import.meta.dirname, "static.blob")),
13 ]);13 ]);
14 assets = {14 return (assets = { map: JSON.parse(map), buf });
15 map: JSON.parse(map),
16 buf,
17 };
18}15}
1916
20export async function reloadSync() {17export function reloadSync() {
21 const map = fs.readFileSync(18 const map = fs.readFileSync(
22 path.join(import.meta.dirname, "static.json"),19 path.join(import.meta.dirname, "static.json"),
23 "utf8",20 "utf8",
24 );21 );
25 const buf = fs.readFileSync(path.join(import.meta.dirname, "static.blob"));22 const buf = fs.readFileSync(path.join(import.meta.dirname, "static.blob"));
26 assets = {23 return (assets = { map: JSON.parse(map), buf });
27 map: JSON.parse(map),
28 buf,
29 };
30}24}
3125
32export async function middleware(c: Context, next: Next) {26export async function middleware(c: Context, next: Next) {
33 if (!assets) await reload();27 if (!assets) await reload();
34 const asset = assets!.map[c.req.path];28 const asset = assets!.map[c.req.path];
35 if (asset) {29 if (asset) return assetInner(c, asset, 200);
36 return assetInner(c, asset, 200);
37 }
38 return next();30 return next();
39}31}
4032
...@@ -56,13 +48,11 @@ export async function serveAsset(...@@ -56,13 +48,11 @@ export async function serveAsset(
56 id: StaticPageId,48 id: StaticPageId,
57 status: StatusCode,49 status: StatusCode,
58) {50) {
59 assets ?? await reload();51 return assetInner(c, (assets ?? (await reload())).map[id], status);
60 return assetInner(c, assets!.map[id], status);
61}52}
6253
63export function hasAsset(id: string) {54export function hasAsset(id: string) {
64 if (!assets) reloadSync();55 return (assets ?? reloadSync()).map[id] !== undefined;
65 return assets!.map[id] !== undefined;
66}56}
6757
68export function etagMatches(etag: string, ifNoneMatch: string) {58export function etagMatches(etag: string, ifNoneMatch: string) {
...@@ -78,13 +68,13 @@ function assetInner(c: Context, asset: BuiltAsset, status: StatusCode) {...@@ -78,13 +68,13 @@ function assetInner(c: Context, asset: BuiltAsset, status: StatusCode) {
78 if (ifnonematch) {68 if (ifnonematch) {
79 const etag = asset.headers.ETag;69 const etag = asset.headers.ETag;
80 if (etagMatches(etag, ifnonematch)) {70 if (etagMatches(etag, ifnonematch)) {
81 return c.res = new Response(null, {71 return (c.res = new Response(null, {
82 status: 304,72 status: 304,
83 statusText: "Not Modified",73 statusText: "Not Modified",
84 headers: {74 headers: {
85 ETag: etag,75 ETag: etag,
86 },76 },
87 });77 }));
88 }78 }
89 }79 }
90 const acceptEncoding = c.req.header("Accept-Encoding") ?? "";80 const acceptEncoding = c.req.header("Accept-Encoding") ?? "";
...@@ -105,7 +95,7 @@ function assetInner(c: Context, asset: BuiltAsset, status: StatusCode) {...@@ -105,7 +95,7 @@ function assetInner(c: Context, asset: BuiltAsset, status: StatusCode) {
105 } else {95 } else {
106 body = subarrayAsset(asset.raw);96 body = subarrayAsset(asset.raw);
107 }97 }
108 return c.res = new Response(body, { headers, status });98 return (c.res = new Response(body, { headers, status }));
109}99}
110100
111process.on("message", (msg: any) => {101process.on("message", (msg: any) => {
framework/lib/markdown.tsx+23-19
...@@ -5,21 +5,21 @@...@@ -5,21 +5,21 @@
5 * via clover's SSR engine. This way, generation optimizations, async5 * via clover's SSR engine. This way, generation optimizations, async
6 * components, and other features are gained for free here.6 * components, and other features are gained for free here.
7 */7 */
8function parse(src: string, options: Partial<ParseOpts> = {}) {8function parse(src: string, options: Partial<ParseOpts> = {}) {}
9}
109
11/* Render markdown content. Same function as 'parse', but JSX components10/* Render markdown content. Same function as 'parse', but JSX components
12 * only take one argument and must start with a capital letter. */11 * only take one argument and must start with a capital letter. */
13export function Markdown(12export function Markdown({
14 { src, ...options }: { src: string } & Partial<ParseOpts>,13 src,
15) {14 ...options
15}: { src: string } & Partial<ParseOpts>) {
16 return parse(src, options);16 return parse(src, options);
17}17}
1818
19function parseInline(src: string, options: Partial<InlineOpts> = {}) {19function parseInline(src: string, options: Partial<InlineOpts> = {}) {
20 const { rules = inlineRules, links = new Map() } = options;20 const { rules = inlineRules, links = new Map() } = options;
21 const opts: InlineOpts = { rules, links };21 const opts: InlineOpts = { rules, links };
22 const parts: engine.Node[] = [];22 const parts: render.Node[] = [];
23 const ruleList = Object.values(rules);23 const ruleList = Object.values(rules);
24 parse: while (true) {24 parse: while (true) {
25 for (const rule of ruleList) {25 for (const rule of ruleList) {
...@@ -96,7 +96,9 @@ export const inlineRules: Record<string, InlineRule> = {...@@ -96,7 +96,9 @@ export const inlineRules: Record<string, InlineRule> = {
96 if (!splitText) return null;96 if (!splitText) return null;
97 if (splitText.delim !== "]") return null;97 if (splitText.delim !== "]") return null;
98 const { first: textSrc, rest: afterText } = splitText;98 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;
100 if (afterText[0] === "(") {102 if (afterText[0] === "(") {
101 // Inline link103 // Inline link
102 const splitTarget = splitFirst(afterText.slice(1), /\)/);104 const splitTarget = splitFirst(afterText.slice(1), /\)/);
...@@ -108,11 +110,12 @@ export const inlineRules: Record<string, InlineRule> = {...@@ -108,11 +110,12 @@ export const inlineRules: Record<string, InlineRule> = {
108 } else if (afterText[0] === "[") {110 } else if (afterText[0] === "[") {
109 const splitTarget = splitFirst(afterText.slice(1), /]/);111 const splitTarget = splitFirst(afterText.slice(1), /]/);
110 if (!splitTarget) return null;112 if (!splitTarget) return null;
111 const name = splitTarget.first.trim().length === 0113 const name =
112 // Collapsed reference link114 splitTarget.first.trim().length === 0
113 ? textSrc.trim()115 ? // Collapsed reference link
114 // Full Reference Link116 textSrc.trim()
115 : splitTarget.first.trim();117 : // Full Reference Link
118 splitTarget.first.trim();
116 const target = opts.links.get(name);119 const target = opts.links.get(name);
117 if (!target) return null;120 if (!target) return null;
118 ({ href, title } = target);121 ({ href, title } = target);
...@@ -149,7 +152,6 @@ export const inlineRules: Record<string, InlineRule> = {...@@ -149,7 +152,6 @@ export const inlineRules: Record<string, InlineRule> = {
149 // 6.2 - emphasis and strong emphasis152 // 6.2 - emphasis and strong emphasis
150 parse({ before, match, after, opts }) {153 parse({ before, match, after, opts }) {
151 // find out how long the delim sequence is154 // find out how long the delim sequence is
152
153 // look for 'ends'155 // look for 'ends'
154 },156 },
155 },157 },
...@@ -164,14 +166,17 @@ export const inlineRules: Record<string, InlineRule> = {...@@ -164,14 +166,17 @@ export const inlineRules: Record<string, InlineRule> = {
164};166};
165167
166function parseLinkTarget(src: string) {168function parseLinkTarget(src: string) {
167 let href: string, title: string | null = null;169 let href: string,
170 title: string | null = null;
168 href = src;171 href = src;
169 return { href, title };172 return { href, title };
170}173}
171174
172/* Find a delimiter while considering backslash escapes. */175/* Find a delimiter while considering backslash escapes. */
173function splitFirst(text: string, match: RegExp) {176function splitFirst(text: string, match: RegExp) {
174 let first = "", delim: string, escaped: boolean;177 let first = "",
178 delim: string,
179 escaped: boolean;
175 do {180 do {
176 const find = text.match(match);181 const find = text.match(match);
177 if (!find) return null;182 if (!find) return null;
...@@ -179,14 +184,13 @@ function splitFirst(text: string, match: RegExp) {...@@ -179,14 +184,13 @@ function splitFirst(text: string, match: RegExp) {
179 const index = UNWRAP(find.index);184 const index = UNWRAP(find.index);
180 let i = index - 1;185 let i = index - 1;
181 escaped = false;186 escaped = false;
182 while (i >= 0 && text[i] === "\\") escaped = !escaped, i -= 1;187 while (i >= 0 && text[i] === "\\") (escaped = !escaped), (i -= 1);
183 first += text.slice(0, index - +escaped);188 first += text.slice(0, index - +escaped);
184 text = text.slice(index + find[0].length);189 text = text.slice(index + find[0].length);
185 } while (escaped);190 } while (escaped);
186 return { first, delim, rest: text };191 return { first, delim, rest: text };
187}192}
188193
189console.log(engine.ssrSync(parseInline("meow `bwaa` `` ` `` `` `z``")));194console.log(render.sync(parseInline("meow `bwaa` `` ` `` `` `z``")));
190195
191import * as engine from "#ssr";196import * as render from "#engine/render";
192import type { ParseOptions } from "node:querystring";
framework/lib/meta.ts+1-1
...@@ -21,4 +21,4 @@ export interface AlternateType {...@@ -21,4 +21,4 @@ export interface AlternateType {
21export function renderMeta({ title }: Meta): string {21export function renderMeta({ title }: Meta): string {
22 return `<title>${esc(title)}</title>`;22 return `<title>${esc(title)}</title>`;
23}23}
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 {...@@ -9,36 +9,25 @@ export interface FileItem {
9 id: string;9 id: string;
10 file: string;10 file: string;
11}11}
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
13export interface SitegenRender {20export interface SitegenRender {
14 scripts: Set<string>;21 scripts: Set<string>;
15}22}
1623
17export function initRender(): SitegenRender {24export function initRender(): SitegenRender {
18 return {25 return { scripts: new Set() };
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();
33}26}
3427
35/** Add a client-side script to the page. */28/** Add a client-side script to the page. */
36export function addScript(id: ScriptId | { value: ScriptId }) {29export function addScript(id: ScriptId | { value: ScriptId }) {
37 getRender().scripts.add(typeof id === "string" ? id : id.value);30 userData.get().scripts.add(typeof id === "string" ? id : id.value);
38}
39
40export interface Section {
41 root: string;
42}31}
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,10 +1,9 @@
1// This import is generated by code 'bundle.ts'
2export interface View {1export interface View {
3 component: engine.Component;2 component: render.Component;
4 meta:3 meta:
5 | meta.Meta4 | meta.Meta
6 | ((props: { context?: hono.Context }) => Promise<meta.Meta> | meta.Meta);5 | ((props: { context?: hono.Context }) => Promise<meta.Meta> | meta.Meta);
7 layout?: engine.Component;6 layout?: render.Component;
8 inlineCss: string;7 inlineCss: string;
9 scripts: Record<string, string>;8 scripts: Record<string, string>;
10}9}
...@@ -12,9 +11,6 @@ export interface View {...@@ -12,9 +11,6 @@ export interface View {
12let views: Record<string, View> = null!;11let views: Record<string, View> = null!;
13let scripts: Record<string, string> = null!;12let 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.
18export async function renderView(14export async function renderView(
19 context: hono.Context,15 context: hono.Context,
20 id: string,16 id: string,
...@@ -44,11 +40,12 @@ export async function renderViewToString(...@@ -44,11 +40,12 @@ export async function renderViewToString(
44 ).then((m) => meta.renderMeta(m));40 ).then((m) => meta.renderMeta(m));
4541
46 // -- html --42 // -- html --
47 let page: engine.Element = [engine.kElement, component, props];43 let page: render.Element = render.element(component, props);
48 if (layout) page = [engine.kElement, layout, { children: page }];44 if (layout) page = render.element(layout, { children: page });
49 const { text: body, addon: { sitegen } } = await engine.ssrAsync(page, {45 const {
50 sitegen: sg.initRender(),46 text: body,
51 });47 addon: { [sg.userData.key]: sitegen },
48 } = await render.async(page, { [sg.userData.key]: sg.initRender() });
5249
53 // -- join document and send --50 // -- join document and send --
54 return wrapDocument({51 return wrapDocument({
...@@ -56,17 +53,15 @@ export async function renderViewToString(...@@ -56,17 +53,15 @@ export async function renderViewToString(
56 head: await renderedMetaPromise,53 head: await renderedMetaPromise,
57 inlineCss,54 inlineCss,
58 scripts: joinScripts(55 scripts: joinScripts(
59 Array.from(56 Array.from(sitegen.scripts, (id) =>
60 sitegen.scripts,57 UNWRAP(scripts[id], `Missing script ${id}`),
61 (id) => UNWRAP(scripts[id], `Missing script ${id}`),
62 ),58 ),
63 ),59 ),
64 });60 });
65}61}
6662
67export function provideViewData(v: typeof views, s: typeof scripts) {63export function provideViewData(v: typeof views, s: typeof scripts) {
68 views = v;64 (views = v), (scripts = s);
69 scripts = s;
70}65}
7166
72export function joinScripts(scriptSources: string[]) {67export function joinScripts(scriptSources: string[]) {
...@@ -96,5 +91,5 @@ export function wrapDocument({...@@ -96,5 +91,5 @@ export function wrapDocument({
9691
97import * as meta from "./meta.ts";92import * as meta from "./meta.ts";
98import type * as hono from "#hono";93import type * as hono from "#hono";
99import * as engine from "../engine/ssr.ts";94import * as render from "#engine/render";
100import * as sg from "./sitegen.ts";95import * as sg from "./sitegen.ts";
framework/marko.ts+13-14
...@@ -1,4 +1,3 @@...@@ -1,4 +1,3 @@
1console.log("MARKO");
2export interface MarkoCacheEntry {1export interface MarkoCacheEntry {
3 src: string;2 src: string;
4 scannedClientRefs: string[];3 scannedClientRefs: string[];
...@@ -8,7 +7,6 @@ export const markoCache = new Map<string, MarkoCacheEntry>();...@@ -8,7 +7,6 @@ export const markoCache = new Map<string, MarkoCacheEntry>();
87
9export function loadMarko(module: NodeJS.Module, filepath: string) {8export function loadMarko(module: NodeJS.Module, filepath: string) {
10 let cache = markoCache.get(filepath);9 let cache = markoCache.get(filepath);
11 console.log({ filepath, has: !!cache })
12 if (!cache) {10 if (!cache) {
13 let src = fs.readFileSync(filepath, "utf8");11 let src = fs.readFileSync(filepath, "utf8");
14 // A non-standard thing here is Clover Sitegen implements12 // A non-standard thing here is Clover Sitegen implements
...@@ -16,21 +14,22 @@ export function loadMarko(module: NodeJS.Module, filepath: string) {...@@ -16,21 +14,22 @@ export function loadMarko(module: NodeJS.Module, filepath: string) {
16 // bare client import statements to it's own usage.14 // bare client import statements to it's own usage.
17 const scannedClientRefs = new Set<string>();15 const scannedClientRefs = new Set<string>();
18 if (src.match(/^\s*client\s+import\s+["']/m)) {16 if (src.match(/^\s*client\s+import\s+["']/m)) {
19 src = src.replace(17 src =
20 /^\s*client\s+import\s+("[^"]+"|'[^']+')[^\n]+/m,18 src.replace(
21 (_, src) => {19 /^\s*client\s+import\s+("[^"]+"|'[^']+')[^\n]+/m,
22 const ref = JSON.parse(`"${src.slice(1, -1)}"`);20 (_, src) => {
23 const resolved = hot.resolveClientRef(filepath, ref);21 const ref = JSON.parse(`"${src.slice(1, -1)}"`);
24 scannedClientRefs.add(resolved);22 const resolved = hot.resolveClientRef(filepath, ref);
25 return `<CloverScriptInclude=${23 scannedClientRefs.add(resolved);
26 JSON.stringify(hot.getScriptId(resolved))24 return `<CloverScriptInclude=${JSON.stringify(
27 } />`;25 hot.getScriptId(resolved),
28 },26 )} />`;
29 ) + '\nimport { addScript as CloverScriptInclude } from "#sitegen";\n';27 },
28 ) + '\nimport { addScript as CloverScriptInclude } from "#sitegen";\n';
30 }29 }
3130
32 src = marko.compileSync(src, filepath).code;31 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");
34 cache = { src, scannedClientRefs: Array.from(scannedClientRefs) };33 cache = { src, scannedClientRefs: Array.from(scannedClientRefs) };
35 markoCache.set(filepath, cache);34 markoCache.set(filepath, cache);
36 }35 }
package.json+2-4
...@@ -28,10 +28,8 @@...@@ -28,10 +28,8 @@
28 "#debug": "./framework/debug.safe.ts",28 "#debug": "./framework/debug.safe.ts",
29 "#sitegen": "./framework/lib/sitegen.ts",29 "#sitegen": "./framework/lib/sitegen.ts",
30 "#sitegen/*": "./framework/lib/*.ts",30 "#sitegen/*": "./framework/lib/*.ts",
31 "#ssr": "./framework/engine/ssr.ts",31 "#engine/jsx-dev-runtime": "./framework/engine/jsx-runtime.ts",
32 "#ssr/jsx-dev-runtime": "./framework/engine/jsx-runtime.ts",32 "#engine/*": "./framework/engine/*.ts",
33 "#ssr/jsx-runtime": "./framework/engine/jsx-runtime.ts",
34 "#ssr/marko": "./framework/engine/marko-runtime.ts",
35 "#marko/html": {33 "#marko/html": {
36 "types": "marko/html",34 "types": "marko/html",
37 "production": "marko/production",35 "production": "marko/production",
readme.md+2-10
...@@ -3,7 +3,7 @@...@@ -3,7 +3,7 @@
3this repository contains clover's "sitegen" framework, which is a set of tools3this repository contains clover's "sitegen" framework, which is a set of tools
4that assist building websites. these tools power <https://paperclover.net>.4that 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)
7 - A more practical JSX runtime (`class` instead of `className`, built-in7 - A more practical JSX runtime (`class` instead of `className`, built-in
8 `clsx`, `html()` helper over `dangerouslySetInnerHTML` prop, etc).8 `clsx`, `html()` helper over `dangerouslySetInnerHTML` prop, etc).
9 - Integration with [Marko] for concisely written components.9 - Integration with [Marko] for concisely written components.
...@@ -57,19 +57,11 @@ my development machine, for example, is Dell Inspiron 7348 with Core i7...@@ -57,19 +57,11 @@ my development machine, for example, is Dell Inspiron 7348 with Core i7
5757
58 # production generation58 # production generation
59 node run generate59 node run generate
60 node .clover/out/server60 node .clover/o/backend
6161
62 # "development" watch mode62 # "development" watch mode
63 node run watch63 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
73for unix systems, the provided `flake.nix` can be used with `nix develop` to65for unix systems, the provided `flake.nix` can be used with `nix develop` to
74open a shell with all needed system dependencies.66open 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) => {...@@ -125,10 +125,11 @@ app.get("/file/*", async (c, next) => {
125125
126 let encoding = decideEncoding(c.req.header("Accept-Encoding"));126 let encoding = decideEncoding(c.req.header("Accept-Encoding"));
127127
128 let sizeHeader = encoding === "raw"128 let sizeHeader =
129 ? expectedSize129 encoding === "raw"
130 // Size cannot be known because of compression modes130 ? expectedSize
131 : undefined;131 : // Size cannot be known because of compression modes
132 undefined;
132133
133 // Etag134 // Etag
134 {135 {
...@@ -294,9 +295,10 @@ function handleRanges(...@@ -294,9 +295,10 @@ function handleRanges(
294): Response {295): Response {
295 // TODO: multiple ranges296 // TODO: multiple ranges
296 const rangeSize = ranges.reduce((a, b) => a + (b[1] - b[0] + 1), 0);297 const rangeSize = ranges.reduce((a, b) => a + (b[1] - b[0] + 1), 0);
297 const rangeBody = streamOrBuffer instanceof ReadableStream298 const rangeBody =
298 ? applySingleRangeToStream(streamOrBuffer, ranges)299 streamOrBuffer instanceof ReadableStream
299 : applyRangesToBuffer(streamOrBuffer, ranges, rangeSize);300 ? applySingleRangeToStream(streamOrBuffer, ranges)
301 : applyRangesToBuffer(streamOrBuffer, ranges, rangeSize);
300 return new Response(rangeBody, {302 return new Response(rangeBody, {
301 status: 206,303 status: 206,
302 headers: {304 headers: {
...@@ -380,9 +382,9 @@ function getPartialPage(c: Context, rawFilePath: string) {...@@ -380,9 +382,9 @@ function getPartialPage(c: Context, rawFilePath: string) {
380 if (!checkCotyledonCookie(c)) {382 if (!checkCotyledonCookie(c)) {
381 let root = Speedbump();383 let root = Speedbump();
382 // Remove the root element, it's created client side!384 // 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;
386 c.header("X-Cotyledon", "true");388 c.header("X-Cotyledon", "true");
387 return c.html(html);389 return c.html(html);
388 }390 }
...@@ -408,15 +410,15 @@ function getPartialPage(c: Context, rawFilePath: string) {...@@ -408,15 +410,15 @@ function getPartialPage(c: Context, rawFilePath: string) {
408 hasCotyledonCookie: rawFilePath === "" && checkCotyledonCookie(c),410 hasCotyledonCookie: rawFilePath === "" && checkCotyledonCookie(c),
409 });411 });
410 // Remove the root element, it's created client side!412 // 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;
414 return c.html(html);416 return c.html(html);
415}417}
416418
417import { type Context, Hono } from "hono";419import { type Context, Hono } from "hono";
418420
419import * as ssr from "#ssr";421import * as render from "#engine/render";
420import { etagMatches, hasAsset, serveAsset } from "#sitegen/assets";422import { etagMatches, hasAsset, serveAsset } from "#sitegen/assets";
421import { renderView } from "#sitegen/view";423import { renderView } from "#sitegen/view";
422import { contentTypeFor } from "#sitegen/mime";424import { contentTypeFor } from "#sitegen/mime";
src/file-viewer/format.ts+25-36
...@@ -100,22 +100,21 @@ export function highlightLinksInTextView(...@@ -100,22 +100,21 @@ export function highlightLinksInTextView(
100 // Case 1: https:// or http:// URLs100 // Case 1: https:// or http:// URLs
101 if (match.startsWith("http")) {101 if (match.startsWith("http")) {
102 if (match.includes(findDomain)) {102 if (match.includes(findDomain)) {
103 return `<a href="${103 return `<a href="${match
104 match104 .replace(/https?:\/\/paperclover\.net\/+/, "/")
105 .replace(/https?:\/\/paperclover\.net\/+/, "/")105 .replace(/\/\/+/g, "/")}">${match}</a>`;
106 .replace(/\/\/+/g, "/")
107 }">${match}</a>`;
108 }106 }
109 return `<a href="${107 return `<a href="${match.replace(
110 match.replace(/\/\/+/g, "/")108 /\/\/+/g,
111 }" target="_blank" rel="noopener noreferrer">${match}</a>`;109 "/",
110 )}" target="_blank" rel="noopener noreferrer">${match}</a>`;
112 }111 }
113112
114 // Case 2: domain URLs without protocol113 // Case 2: domain URLs without protocol
115 if (match.startsWith(findDomain)) {114 if (match.startsWith(findDomain)) {
116 return `<a href="${115 return `<a href="${match
117 match.replace(findDomain + "/", "/").replace(/\/\/+/g, "/")116 .replace(findDomain + "/", "/")
118 }">${match}</a>`;117 .replace(/\/\/+/g, "/")}">${match}</a>`;
119 }118 }
120119
121 // Case 3: /file/ URLs120 // Case 3: /file/ URLs
...@@ -146,7 +145,7 @@ export function highlightLinksInTextView(...@@ -146,7 +145,7 @@ export function highlightLinksInTextView(
146 // Match sibling file names (only if they're not already part of a link)145 // Match sibling file names (only if they're not already part of a link)
147 if (siblingFiles.length > 0) {146 if (siblingFiles.length > 0) {
148 const escapedBasenames = siblingFiles.map((f) =>147 const escapedBasenames = siblingFiles.map((f) =>
149 f.basename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")148 f.basename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
150 );149 );
151 const pattern = new RegExp(`\\b(${escapedBasenames.join("|")})\\b`, "g");150 const pattern = new RegExp(`\\b(${escapedBasenames.join("|")})\\b`, "g");
152 const parts = processedText.split(/(<[^>]*>)/);151 const parts = processedText.split(/(<[^>]*>)/);
...@@ -156,9 +155,9 @@ export function highlightLinksInTextView(...@@ -156,9 +155,9 @@ export function highlightLinksInTextView(
156 parts[i] = parts[i].replace(pattern, (match: string) => {155 parts[i] = parts[i].replace(pattern, (match: string) => {
157 const file = siblingLookup[match];156 const file = siblingLookup[match];
158 if (file) {157 if (file) {
159 return `<a href="/file/${158 return `<a href="/file/${file.path
160 file.path.replace(/^\//, "").replace(/\/\/+/g, "/")159 .replace(/^\//, "")
161 }">${match}</a>`;160 .replace(/\/\/+/g, "/")}">${match}</a>`;
162 }161 }
163 return match;162 return match;
164 });163 });
...@@ -241,11 +240,9 @@ export function highlightConvo(text: string) {...@@ -241,11 +240,9 @@ export function highlightConvo(text: string) {
241240
242 return paras241 return paras
243 .map(({ speaker, lines }) => {242 .map(({ speaker, lines }) => {
244 return `<div class="s-${speaker}">${243 return `<div class="s-${speaker}">${lines
245 lines244 .map((line) => `<div class="line">${line}</div>`)
246 .map((line) => `<div class="line">${line}</div>`)245 .join("\n")}</div>`;
247 .join("\n")
248 }</div>`;
249 })246 })
250 .join("\n");247 .join("\n");
251}248}
...@@ -267,22 +264,14 @@ const unknownDateWithKnownYear = new Date("1970-02-20");...@@ -267,22 +264,14 @@ const unknownDateWithKnownYear = new Date("1970-02-20");
267264
268export function formatDate(dateTime: Date) {265export function formatDate(dateTime: Date) {
269 return dateTime < unknownDateWithKnownYear266 return dateTime < unknownDateWithKnownYear
270 ? (267 ? dateTime < unknownDate
271 dateTime < unknownDate268 ? "??.??.??"
272 ? (269 : `xx.xx.${21 + Math.floor(dateTime.getTime() / 86400000)}`
273 "??.??.??"270 : `${(dateTime.getMonth() + 1).toString().padStart(2, "0")}.${dateTime
274 )271 .getDate()
275 : `xx.xx.${21 + Math.floor(dateTime.getTime() / 86400000)}`272 .toString()
276 )273 .padStart(2, "0")}.${dateTime.getFullYear().toString().slice(2)}`;
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 );
285}274}
286275
287import type { MediaFile } from "@/file-viewer/models/MediaFile.ts";276import 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({...@@ -113,7 +113,7 @@ function highlightLines({
113 const str = lines[i].slice(token.startIndex, token.endIndex);113 const str = lines[i].slice(token.startIndex, token.endIndex);
114 if (str.trim().length === 0) {114 if (str.trim().length === 0) {
115 // Emit but do not consider scope changes115 // Emit but do not consider scope changes
116 html += ssr.escapeHtml(str);116 html += render.escapeHtml(str);
117 continue;117 continue;
118 }118 }
119119
...@@ -122,7 +122,7 @@ function highlightLines({...@@ -122,7 +122,7 @@ function highlightLines({
122 if (lastHtmlStyle) html += "</span>";122 if (lastHtmlStyle) html += "</span>";
123 if (style) html += `<span class='${style}'>`;123 if (style) html += `<span class='${style}'>`;
124 }124 }
125 html += ssr.escapeHtml(str);125 html += render.escapeHtml(str);
126 lastHtmlStyle = style;126 lastHtmlStyle = style;
127 }127 }
128 html += "\n";128 html += "\n";
...@@ -197,4 +197,4 @@ import * as fs from "#sitegen/fs";...@@ -197,4 +197,4 @@ import * as fs from "#sitegen/fs";
197import * as path from "node:path";197import * as path from "node:path";
198import * as oniguruma from "vscode-oniguruma";198import * as oniguruma from "vscode-oniguruma";
199import * as textmate from "vscode-textmate";199import * 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", {...@@ -104,13 +104,15 @@ questionRules.insertBefore("paragraph", {
104 assert(x.startsWith("q: "));104 assert(x.startsWith("q: "));
105 return x.slice(3);105 return x.slice(3);
106 });106 });
107 const content = lines.map((line, i) => {107 const content = lines
108 const parsed = parseInline(parse, line, state);108 .map((line, i) => {
109 if (i < lines.length - 1) {109 const parsed = parseInline(parse, line, state);
110 parsed.push({ type: "br" });110 if (i < lines.length - 1) {
111 }111 parsed.push({ type: "br" });
112 return parsed;112 }
113 }).flat();113 return parsed;
114 })
115 .flat();
114 return {116 return {
115 content,117 content,
116 };118 };
...@@ -140,7 +142,13 @@ function BasicNode(node: ASTNode, children: any) {...@@ -140,7 +142,13 @@ function BasicNode(node: ASTNode, children: any) {
140142
141function ListRenderer(node: ASTNode, children: any[]) {143function ListRenderer(node: ASTNode, children: any[]) {
142 const T = node.ordered ? "ol" : "ul";144 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 );
144}152}
145153
146function MarkdownLink(node: ASTNode, children: any[]) {154function MarkdownLink(node: ASTNode, children: any[]) {
...@@ -174,7 +182,11 @@ function ArtifactRef(node: ASTNode) {...@@ -174,7 +182,11 @@ function ArtifactRef(node: ASTNode) {
174 if (!url) {182 if (!url) {
175 return <span>{title}</span>;183 return <span>{title}</span>;
176 }184 }
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 );
178}190}
179191
180const br = <br />;192const br = <br />;
...@@ -202,7 +214,7 @@ const ssrFunctions: any = {...@@ -202,7 +214,7 @@ const ssrFunctions: any = {
202214
203 // Custom Elements215 // Custom Elements
204 question: BasicNode,216 question: BasicNode,
205 html: (node: any) => ssr.html(node.data),217 html: (node: any) => render.raw(node.data),
206 mentionQuestion: QuestionRef,218 mentionQuestion: QuestionRef,
207 mentionArtifact: ArtifactRef,219 mentionArtifact: ArtifactRef,
208};220};
...@@ -247,4 +259,4 @@ import {...@@ -247,4 +259,4 @@ import {
247 parseCaptureInline,259 parseCaptureInline,
248 parseInline,260 parseInline,
249} from "./simple-markdown.ts";261} 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 @@...@@ -1,6 +1,6 @@
1import { EditorState } from "@codemirror/state";1import { EditorState } from "@codemirror/state";
2import { basicSetup, EditorView } from "codemirror";2import { basicSetup, EditorView } from "codemirror";
3import { ssrSync } from "#ssr";3import * as render from "#engine/render";
4import type { ScriptPayload } from "@/q+a/views/editor.marko";4import type { ScriptPayload } from "@/q+a/views/editor.marko";
5import QuestionRender from "@/q+a/tags/question.marko";5import QuestionRender from "@/q+a/tags/question.marko";
66
...@@ -11,7 +11,7 @@ const main = document.getElementById("edit-grid")! as HTMLDivElement;...@@ -11,7 +11,7 @@ const main = document.getElementById("edit-grid")! as HTMLDivElement;
11const preview = document.getElementById("preview")! as HTMLDivElement;11const preview = document.getElementById("preview")! as HTMLDivElement;
1212
13function updatePreview(text: string) {13function updatePreview(text: string) {
14 preview.innerHTML = ssrSync(14 preview.innerHTML = render.sync(
15 <QuestionRender15 <QuestionRender
16 question={{16 question={{
17 id: payload.id,17 id: payload.id,
...@@ -96,9 +96,9 @@ function wrapAction(cb: () => Promise<void>) {...@@ -96,9 +96,9 @@ function wrapAction(cb: () => Promise<void>) {
96 return async () => {96 return async () => {
97 main.style.opacity = "0.5";97 main.style.opacity = "0.5";
98 main.style.pointerEvents = "none";98 main.style.pointerEvents = "none";
99 const inputs = main.querySelectorAll("button,select,input") as NodeListOf<99 const inputs = main.querySelectorAll(
100 HTMLButtonElement100 "button,select,input",
101 >;101 ) as NodeListOf<HTMLButtonElement>;
102 inputs.forEach((b) => {102 inputs.forEach((b) => {
103 b.disabled = true;103 b.disabled = true;
104 });104 });
tsconfig.json+1-1
...@@ -4,7 +4,7 @@...@@ -4,7 +4,7 @@
4 "baseUrl": ".",4 "baseUrl": ".",
5 "incremental": true,5 "incremental": true,
6 "jsx": "react-jsxdev",6 "jsx": "react-jsxdev",
7 "jsxImportSource": "#ssr",7 "jsxImportSource": "#engine",
8 "lib": ["dom", "esnext", "esnext.iterator"],8 "lib": ["dom", "esnext", "esnext.iterator"],
9 "module": "nodenext",9 "module": "nodenext",
10 "noEmit": true,10 "noEmit": true,