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.
This commit is contained in:
clover caruso 2025-08-02 22:22:07 -04:00
parent 66da129036
commit a8d7efe9ec
22 changed files with 606 additions and 624 deletions

View file

@ -1,27 +1,33 @@
async function trackEsbuild(io: Io, metafile: esbuild.Metafile) { async function trackEsbuild(io: Io, metafile: esbuild.Metafile) {
await Promise.all(Object.keys(metafile.inputs) await Promise.all(
.filter(file => !isIgnoredSource(file)) Object.keys(metafile.inputs)
.map(file => io.trackFile(file))); .filter((file) => !isIgnoredSource(file))
.map((file) => io.trackFile(file)),
);
} }
// This file implements client-side bundling, mostly wrapping esbuild. // This file implements client-side bundling, mostly wrapping esbuild.
export async function bundleClientJavaScript( export async function bundleClientJavaScript(
io: Io, io: Io,
{ clientRefs, extraPublicScripts, dev = false }: { {
clientRefs,
extraPublicScripts,
dev = false,
}: {
clientRefs: string[]; clientRefs: string[];
extraPublicScripts: string[]; extraPublicScripts: string[];
dev: boolean; dev: boolean;
}, },
) { ) {
const entryPoints = [ const entryPoints = [
...new Set([ ...new Set(
...clientRefs.map((x) => `src/${x}`), [...clientRefs.map((x) => `src/${x}`), ...extraPublicScripts].map(toAbs),
...extraPublicScripts, ),
].map(toAbs)),
]; ];
if (entryPoints.length === 0) return {}; if (entryPoints.length === 0) return {};
const invalidFiles = entryPoints const invalidFiles = entryPoints.filter(
.filter((file) => !file.match(/\.client\.[tj]sx?/)); (file) => !file.match(/\.client\.[tj]sx?/),
);
if (invalidFiles.length > 0) { if (invalidFiles.length > 0) {
const cwd = process.cwd(); const cwd = process.cwd();
throw new Error( throw new Error(
@ -35,45 +41,48 @@ export async function bundleClientJavaScript(
markoViaBuildCache(), markoViaBuildCache(),
]; ];
const bundle = await esbuild.build({ const bundle = await esbuild
assetNames: "/asset/[hash]", .build({
bundle: true, assetNames: "/asset/[hash]",
chunkNames: "/js/c.[hash]", bundle: true,
entryNames: "/js/[name]", chunkNames: "/js/c.[hash]",
entryPoints, entryNames: "/js/[name]",
format: "esm", entryPoints,
jsx: "automatic", format: "esm",
jsxDev: dev, jsx: "automatic",
jsxImportSource: "#ssr", jsxDev: dev,
logLevel: "silent", jsxImportSource: "#engine",
metafile: true, logLevel: "silent",
minify: !dev, metafile: true,
outdir: "out!", minify: !dev,
plugins: clientPlugins, outdir: "out!",
write: false, plugins: clientPlugins,
define: { write: false,
"ASSERT": "console.assert", define: {
MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText), ASSERT: "console.assert",
}, MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText),
}).catch((err: any) => { },
err.message = `Client ${err.message}`; })
throw err; .catch((err: any) => {
}); err.message = `Client ${err.message}`;
throw err;
});
if (bundle.errors.length || bundle.warnings.length) { if (bundle.errors.length || bundle.warnings.length) {
throw new AggregateError( throw new AggregateError(
bundle.errors.concat(bundle.warnings), bundle.errors.concat(bundle.warnings),
"JS bundle failed", "JS bundle failed",
); );
} }
const publicScriptRoutes = extraPublicScripts.map((file) => const publicScriptRoutes = extraPublicScripts.map(
"/js/" + (file) =>
path.relative(hot.projectSrc, file).replaceAll("\\", "/").replace( "/js/" +
/\.client\.[tj]sx?/, path
".js", .relative(hot.projectSrc, file)
) .replaceAll("\\", "/")
.replace(/\.client\.[tj]sx?/, ".js"),
); );
const { metafile, outputFiles } = bundle; const { metafile, outputFiles } = bundle;
const p = [] const p = [];
p.push(trackEsbuild(io, metafile)); p.push(trackEsbuild(io, metafile));
const scripts: Record<string, string> = {}; const scripts: Record<string, string> = {};
for (const file of outputFiles) { for (const file of outputFiles) {
@ -100,156 +109,184 @@ export async function bundleClientJavaScript(
export type ServerPlatform = "node" | "passthru"; export type ServerPlatform = "node" | "passthru";
export interface ServerSideOptions { export interface ServerSideOptions {
entries: string[], entries: string[];
viewItems: sg.FileItem[] viewItems: sg.FileItem[];
viewRefs: incr.Ref<PreparedView>[], viewRefs: incr.Ref<PreparedView>[];
styleMap: Map<string, incr.Ref<string>>; styleMap: Map<string, incr.Ref<string>>;
scriptMap: incr.Ref<Record<string, string>>; scriptMap: incr.Ref<Record<string, string>>;
platform: ServerPlatform, platform: ServerPlatform;
} }
export async function bundleServerJavaScript( export async function bundleServerJavaScript({
{ viewItems, viewRefs, styleMap, scriptMap: wScriptMap, entries, platform }: ServerSideOptions viewItems,
) { viewRefs,
styleMap,
scriptMap: wScriptMap,
entries,
platform,
}: ServerSideOptions) {
const wViewSource = incr.work(async (_, viewItems: sg.FileItem[]) => { const wViewSource = incr.work(async (_, viewItems: sg.FileItem[]) => {
const magicWord = "C_" + crypto.randomUUID().replaceAll("-", "_"); const magicWord = "C_" + crypto.randomUUID().replaceAll("-", "_");
return { return {
magicWord, magicWord,
file: [ file: [
...viewItems.map((view, i) => `import * as view${i} from ${JSON.stringify(view.file)}`), ...viewItems.map(
(view, i) => `import * as view${i} from ${JSON.stringify(view.file)}`,
),
`const styles = ${magicWord}[-2]`, `const styles = ${magicWord}[-2]`,
`export const scripts = ${magicWord}[-1]`, `export const scripts = ${magicWord}[-1]`,
"export const views = {", "export const views = {",
...viewItems.map((view, i) => [ ...viewItems.map((view, i) =>
` ${JSON.stringify(view.id)}: {`, [
` component: view${i}.default,`, ` ${JSON.stringify(view.id)}: {`,
` meta: view${i}.meta,`, ` component: view${i}.default,`,
` layout: view${i}.layout?.default ?? null,`, ` meta: view${i}.meta,`,
` inlineCss: styles[${magicWord}[${i}]]`, ` layout: view${i}.layout?.default ?? null,`,
` },`, ` inlineCss: styles[${magicWord}[${i}]]`,
].join("\n")), ` },`,
].join("\n"),
),
"}", "}",
].join("\n") ].join("\n"),
}; };
}, viewItems) }, viewItems);
const wBundles = entries.map(entry => [entry, incr.work(async (io, entry) => { const wBundles = entries.map(
const pkg = await io.readJson<{ dependencies: Record<string, string>; }>("package.json"); (entry) =>
[
entry,
incr.work(async (io, entry) => {
const pkg = await io.readJson<{
dependencies: Record<string, string>;
}>("package.json");
let magicWord = null as string | null; let magicWord = null as string | null;
// -- plugins -- // -- plugins --
const serverPlugins: esbuild.Plugin[] = [ const serverPlugins: esbuild.Plugin[] = [
virtualFiles({ virtualFiles({
// only add dependency when imported. // only add dependency when imported.
"$views": async () => { $views: async () => {
const view = await io.readWork(wViewSource); const view = await io.readWork(wViewSource);
({ magicWord } = view); ({ magicWord } = view);
return view.file; return view.file;
}, },
}), }),
projectRelativeResolution(), projectRelativeResolution(),
markoViaBuildCache(), markoViaBuildCache(),
{ {
name: "replace client references", name: "replace client references",
setup(b) { setup(b) {
b.onLoad({ filter: /\.tsx?$/ }, async ({ path: file }) => ({ b.onLoad({ filter: /\.tsx?$/ }, async ({ path: file }) => ({
contents: contents: hot.resolveClientRefs(
hot.resolveClientRefs(await fs.readFile(file, "utf-8"), file).code, await fs.readFile(file, "utf-8"),
loader: path.extname(file).slice(1) as esbuild.Loader, file,
})); ).code,
}, loader: path.extname(file).slice(1) as esbuild.Loader,
}, }));
{ },
name: "mark css external", },
setup(b) { {
b.onResolve( name: "mark css external",
{ filter: /\.css$/ }, setup(b) {
() => ({ path: ".", namespace: "dropped" }), b.onResolve({ filter: /\.css$/ }, () => ({
); path: ".",
b.onLoad( namespace: "dropped",
{ filter: /./, namespace: "dropped" }, }));
() => ({ contents: "" }), b.onLoad({ filter: /./, namespace: "dropped" }, () => ({
); contents: "",
}, }));
}, },
]; },
];
const { metafile, outputFiles, errors, warnings } = await esbuild.build({ const { metafile, outputFiles } = await esbuild.build({
bundle: true, bundle: true,
chunkNames: "c.[hash]", chunkNames: "c.[hash]",
entryNames: path.basename(entry, path.extname(entry)), entryNames: path.basename(entry, path.extname(entry)),
entryPoints: [ entryPoints: [
path.join(import.meta.dirname, "backend/entry-" + platform + ".ts"), path.join(
], import.meta.dirname,
platform: "node", "backend/entry-" + platform + ".ts",
format: "esm", ),
minify: false, ],
outdir: "out!", platform: "node",
plugins: serverPlugins, format: "esm",
splitting: true, minify: false,
logLevel: "silent", outdir: "out!",
write: false, plugins: serverPlugins,
metafile: true, splitting: true,
jsx: "automatic", logLevel: "silent",
jsxImportSource: "#ssr", write: false,
jsxDev: false, metafile: true,
define: { jsx: "automatic",
MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText), jsxImportSource: "#engine",
'globalThis.CLOVER_SERVER_ENTRY': JSON.stringify(entry), jsxDev: false,
}, define: {
external: Object.keys(pkg.dependencies) MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText),
.filter((x) => !x.startsWith("@paperclover")), "globalThis.CLOVER_SERVER_ENTRY": JSON.stringify(entry),
}); },
await trackEsbuild(io, metafile) external: Object.keys(pkg.dependencies).filter(
(x) => !x.startsWith("@paperclover"),
),
});
await trackEsbuild(io, metafile);
let fileWithMagicWord: { let fileWithMagicWord: {
bytes: Buffer; bytes: Buffer;
basename: string; basename: string;
magicWord: string; magicWord: string;
} | null = null; } | null = null;
for (const output of outputFiles) { for (const output of outputFiles) {
const basename = output.path.replace(/^.*?!(?:\/|\\)/, ""); const basename = output.path.replace(/^.*?!(?:\/|\\)/, "");
const key = "out!/" + basename.replaceAll("\\", "/"); const key = "out!/" + basename.replaceAll("\\", "/");
// If this contains the generated "$views" file, then // If this contains the generated "$views" file, then
// mark this file as the one for replacement. Because // mark this file as the one for replacement. Because
// `splitting` is `true`, esbuild will not emit this // `splitting` is `true`, esbuild will not emit this
// file in more than one chunk. // file in more than one chunk.
if (magicWord && metafile.outputs[key].inputs["framework/lib/view.ts"]) { if (
ASSERT(!fileWithMagicWord); magicWord &&
fileWithMagicWord = { metafile.outputs[key].inputs["framework/lib/view.ts"]
basename, ) {
bytes: Buffer.from(output.contents), ASSERT(!fileWithMagicWord);
magicWord, fileWithMagicWord = {
}; basename,
} else { bytes: Buffer.from(output.contents),
io.writeFile(basename, Buffer.from(output.contents)) magicWord,
} };
} } else {
return fileWithMagicWord; io.writeFile(basename, Buffer.from(output.contents));
}, entry)] as const); }
}
return fileWithMagicWord;
}, entry),
] as const,
);
const wProcessed = wBundles.map(async([entry, wBundle]) => { const wProcessed = wBundles.map(async ([entry, wBundle]) => {
if (!await wBundle) return; if (!(await wBundle)) return;
await incr.work(async (io) => { await incr.work(async (io) => {
// Only the reachable resources need to be read and inserted into the bundle. // Only the reachable resources need to be read and inserted into the bundle.
// This is what Map<string, incr.Ref> is for // This is what Map<string, incr.Ref> is for
const { basename, bytes, magicWord } = UNWRAP(await io.readWork(wBundle)); const { basename, bytes, magicWord } = UNWRAP(await io.readWork(wBundle));
const views = await Promise.all(viewRefs.map(ref => io.readWork(ref))); const views = await Promise.all(viewRefs.map((ref) => io.readWork(ref)));
// Client JS // Client JS
const scriptList = Object.entries(await io.readWork(wScriptMap)); const scriptList = Object.entries(await io.readWork(wScriptMap));
const viewScriptsList = new Set(views.flatMap(view => view.clientRefs)); const viewScriptsList = new Set(views.flatMap((view) => view.clientRefs));
const neededScripts = scriptList.filter(([k]) => viewScriptsList.has(k)); const neededScripts = scriptList.filter(([k]) => viewScriptsList.has(k));
// CSS // CSS
const viewStyleKeys = views.map((view) => view.styleKey); const viewStyleKeys = views.map((view) => view.styleKey);
const viewCssBundles = await Promise.all( const viewCssBundles = await Promise.all(
viewStyleKeys.map((key) => io.readWork(UNWRAP(styleMap.get(key), "Style key: " + key)))); viewStyleKeys.map((key) =>
io.readWork(UNWRAP(styleMap.get(key), "Style key: " + key)),
),
);
const styleList = Array.from(new Set(viewCssBundles)); const styleList = Array.from(new Set(viewCssBundles));
// Replace the magic word // Replace the magic word
const text = bytes.toString("utf-8").replace( const text = bytes
new RegExp(magicWord + "\\[(-?\\d+)\\]", "gs"), .toString("utf-8")
(_, i) => { .replace(new RegExp(magicWord + "\\[(-?\\d+)\\]", "gs"), (_, i) => {
i = Number(i); i = Number(i);
// Inline the styling data // Inline the styling data
if (i === -2) { if (i === -2) {
@ -261,17 +298,15 @@ export async function bundleServerJavaScript(
} }
// Reference an index into `styleList` // Reference an index into `styleList`
return `${styleList.indexOf(viewCssBundles[i])}`; return `${styleList.indexOf(viewCssBundles[i])}`;
}, });
);
io.writeFile(basename, text); io.writeFile(basename, text);
}); });
}) });
await Promise.all(wProcessed); await Promise.all(wProcessed);
} }
import * as esbuild from "esbuild"; import * as esbuild from "esbuild";
import * as path from "node:path"; import * as path from "node:path";
import process from "node:process"; import process from "node:process";
@ -282,9 +317,8 @@ import {
projectRelativeResolution, projectRelativeResolution,
virtualFiles, virtualFiles,
} from "./esbuild-support.ts"; } from "./esbuild-support.ts";
import { Io, toAbs, toRel } from "./incremental.ts"; import { Io, toAbs } from "./incremental.ts";
import * as css from "./css.ts";
import * as fs from "#sitegen/fs"; import * as fs from "#sitegen/fs";
import * as mime from "#sitegen/mime"; import * as mime from "#sitegen/mime";
import * as incr from "./incremental.ts"; import * as incr from "./incremental.ts";
import * as sg from "#sitegen";import type { PreparedView } from "./generate2.ts";import { meta } from "@/file-viewer/pages/file.cotyledon_speedbump.tsx"; import * as sg from "#sitegen";

View file

@ -1,37 +1,37 @@
export const Fragment = ({ children }: { children: engine.Node[] }) => children; export const Fragment = ({ children }: { children: render.Node[] }) => children;
export function jsx( export function jsx(
type: string | engine.Component, type: string | render.Component,
props: Record<string, unknown>, props: Record<string, unknown>,
): engine.Element { ): render.Element {
if (typeof type !== "function" && typeof type !== "string") { if (typeof type !== "function" && typeof type !== "string") {
throw new Error("Invalid component type: " + engine.inspect(type)); throw new Error("Invalid component type: " + render.inspect(type));
} }
return [engine.kElement, type, props]; return [render.kElement, type, props];
} }
export function jsxDEV( export function jsxDEV(
type: string | engine.Component, type: string | render.Component,
props: Record<string, unknown>, props: Record<string, unknown>,
// Unused with the clover engine // Unused with the clover engine
_key: string, _key: string,
// Unused with the clover engine // Unused with the clover engine
_isStaticChildren: boolean, _isStaticChildren: boolean,
source: engine.SrcLoc, source: render.SrcLoc,
): engine.Element { ): render.Element {
const { fileName, lineNumber, columnNumber } = source; const { fileName, lineNumber, columnNumber } = source;
// Assert the component type is valid to render. // Assert the component type is valid to render.
if (typeof type !== "function" && typeof type !== "string") { if (typeof type !== "function" && typeof type !== "string") {
throw new Error( throw new Error(
`Invalid component type at ${fileName}:${lineNumber}:${columnNumber}: ` + `Invalid component type at ${fileName}:${lineNumber}:${columnNumber}: ` +
engine.inspect(type) + render.inspect(type) +
". Clover SSR element must be a function or string", ". Clover SSR element must be a function or string",
); );
} }
// Construct an `ssr.Element` // Construct an `ssr.Element`
return [engine.kElement, type, props, "", source]; return [render.kElement, type, props, "", source];
} }
// jsxs // jsxs
@ -45,10 +45,10 @@ declare global {
interface ElementChildrenAttribute { interface ElementChildrenAttribute {
children: Node; children: Node;
} }
type Element = engine.Element; type Element = render.Element;
type ElementType = keyof IntrinsicElements | engine.Component; type ElementType = keyof IntrinsicElements | render.Component;
type ElementClass = ReturnType<engine.Component>; type ElementClass = ReturnType<render.Component>;
} }
} }
import * as engine from "./ssr.ts"; import * as render from "./render.ts";

View file

@ -11,29 +11,22 @@ export const createTemplate = (
templateId: string, templateId: string,
renderer: ServerRenderer, renderer: ServerRenderer,
) => { ) => {
const { render } = marko.createTemplate(templateId, renderer); const { render: renderFn } = marko.createTemplate(templateId, renderer);
function wrap(props: Record<string, unknown>, n: number) { function wrap(props: Record<string, unknown>, n: number) {
// Marko Custom Tags // Marko Custom Tags
const cloverAsyncMarker = { isAsync: false }; const cloverAsyncMarker = { isAsync: false };
let r: engine.Render | undefined = undefined; const r = render.current;
try {
r = engine.getCurrentRender();
} catch {}
// Support using Marko outside of Clover SSR // Support using Marko outside of Clover SSR
if (r) { if (!r) return renderer(props, n);
engine.setCurrentRender(null); const markoResult = renderFn.call(renderer, {
const markoResult = render.call(renderer, { ...props,
...props, $global: { clover: r, cloverAsyncMarker },
$global: { clover: r, cloverAsyncMarker }, });
}); if (cloverAsyncMarker.isAsync) {
if (cloverAsyncMarker.isAsync) { return markoResult.then(render.raw);
return markoResult.then(engine.html);
}
const rr = markoResult.toString();
return engine.html(rr);
} else {
return renderer(props, n);
} }
const rr = markoResult.toString();
return render.raw(rr);
} }
wrap.render = render; wrap.render = render;
wrap.unwrapped = renderer; wrap.unwrapped = renderer;
@ -56,20 +49,16 @@ export const dynamicTag = (
tag = unwrapped; tag = unwrapped;
break clover; break clover;
} }
let r: engine.Render; const r = render.current ?? (marko.$global().clover as render.State);
try {
r = engine.getCurrentRender();
if (!r) throw 0;
} catch {
r = marko.$global().clover as engine.Render;
}
if (!r) throw new Error("No Clover Render Active"); if (!r) throw new Error("No Clover Render Active");
const subRender = engine.initRender(r.async !== -1, r.addon); const subRender = render.init(r.async !== -1, r.addon);
const resolved = engine.resolveNode(subRender, [ const resolved = render.resolveNode(
engine.kElement, subRender,
tag, render.element(
inputOrArgs, tag as render.Component,
]); inputOrArgs as Record<any, any>,
),
);
if (subRender.async > 0) { if (subRender.async > 0) {
const marker = marko.$global().cloverAsyncMarker as Async; const marker = marko.$global().cloverAsyncMarker as Async;
@ -79,7 +68,7 @@ export const dynamicTag = (
const { resolve, reject, promise } = Promise.withResolvers<string>(); const { resolve, reject, promise } = Promise.withResolvers<string>();
subRender.asyncDone = () => { subRender.asyncDone = () => {
const rejections = subRender.rejections; const rejections = subRender.rejections;
if (!rejections) return resolve(engine.renderNode(resolved)); if (!rejections) return resolve(render.stringifyNode(resolved));
(r.rejections ??= []).push(...rejections); (r.rejections ??= []).push(...rejections);
return reject(new Error("Render had errors")); return reject(new Error("Render had errors"));
}; };
@ -91,7 +80,7 @@ export const dynamicTag = (
0, 0,
); );
} else { } else {
marko.write(engine.renderNode(resolved)); marko.write(render.stringifyNode(resolved));
} }
return; return;
} }
@ -124,13 +113,15 @@ export function escapeXML(input: unknown) {
// creating `[object Object]` is universally useless to any end user. // creating `[object Object]` is universally useless to any end user.
if ( if (
input == null || input == null ||
(typeof input === "object" && input && (typeof input === "object" &&
input &&
// only block this if it's the default `toString` // only block this if it's the default `toString`
input.toString === Object.prototype.toString) input.toString === Object.prototype.toString)
) { ) {
throw new Error( throw new Error(
`Unexpected value in template placeholder: '` + `Unexpected value in template placeholder: '` +
engine.inspect(input) + "'. " + render.inspect(input) +
"'. " +
`To emit a literal '${input}', use \${String(value)}`, `To emit a literal '${input}', use \${String(value)}`,
); );
} }
@ -141,7 +132,7 @@ interface Async {
isAsync: boolean; isAsync: boolean;
} }
import * as engine from "./ssr.ts"; import * as render from "#engine/render";
import type { ServerRenderer } from "marko/html/template"; import type { ServerRenderer } from "marko/html/template";
import { type Accessor } from "marko/common/types"; import { type Accessor } from "marko/common/types";
import * as marko from "#marko/html"; import * as marko from "#marko/html";

View file

@ -1,31 +1,31 @@
import { test } from "node:test"; import { test } from "node:test";
import * as engine from "./ssr.ts"; import * as render from "./render.ts";
test("sanity", (t) => t.assert.equal(engine.ssrSync("gm <3").text, "gm &lt;3")); test("sanity", (t) => t.assert.equal(render.sync("gm <3").text, "gm &lt;3"));
test("simple tree", (t) => test("simple tree", (t) =>
t.assert.equal( t.assert.equal(
engine.ssrSync( render.sync(
<main class={["a", "b"]}> <main class={["a", "b"]}>
<h1 style="background-color:red">hello world</h1> <h1 style="background-color:red">hello world</h1>
<p>haha</p> <p>haha</p>
{1}| {1}|{0}|{true}|{false}|{null}|{undefined}|
{0}|
{true}|
{false}|
{null}|
{undefined}|
</main>, </main>,
).text, ).text,
'<main class="a b"><h1 style=background-color:red>hello world</h1><p>haha</p>1|0|||||</main>', '<main class="a b"><h1 style=background-color:red>hello world</h1><p>haha</p>1|0|||||</main>',
)); ));
test("unescaped/escaped html", (t) => test("unescaped/escaped html", (t) =>
t.assert.equal( t.assert.equal(
engine.ssrSync(<div>{engine.html("<fuck>")}{"\"&'`<>"}</div>).text, render.sync(
<div>
{render.raw("<fuck>")}
{"\"&'`<>"}
</div>,
).text,
"<div><fuck>&quot;&amp;&#x27;&#x60;&lt;&gt;</div>", "<div><fuck>&quot;&amp;&#x27;&#x60;&lt;&gt;</div>",
)); ));
test("clsx built-in", (t) => test("clsx built-in", (t) =>
t.assert.equal( t.assert.equal(
engine.ssrSync( render.sync(
<> <>
<a class="a" /> <a class="a" />
<b class={null} /> <b class={null} />

View file

@ -6,43 +6,66 @@
// Add-ons to the rendering engine can provide opaque data, And retrieve it // Add-ons to the rendering engine can provide opaque data, And retrieve it
// within component calls with 'getAddonData'. For example, 'sitegen' uses this // within component calls with 'getAddonData'. For example, 'sitegen' uses this
// to track needed client scripts without introducing patches to the engine. // to track needed client scripts without introducing patches to the engine.
export type Addons = Record<string | symbol, unknown>; export let current: State | null = null;
export function setCurrent(r: State | null) {
export function ssrSync<A extends Addons>(node: Node, addon: A = {} as A) { current = r ?? null;
const r = initRender(false, addon);
const resolved = resolveNode(r, node);
return { text: renderNode(resolved), addon };
} }
export { ssrSync as sync };
export function ssrAsync<A extends Addons>(node: Node, addon: A = {} as A) { /* Convert a UI description into a string synchronously. */
const r = initRender(true, addon); export function sync<A extends Addons>(node: Node, addon: A = {} as A) {
const resolved = resolveNode(r, node); const state = init(false, addon);
if (r.async === 0) { const resolved = resolveNode(state, node);
return Promise.resolve({ text: renderNode(resolved), addon }); return { text: stringifyNode(resolved), addon };
}
/* Convert a UI description into a string asynchronously. */
export function async<A extends Addons>(node: Node, addon: A = {} as A) {
const state = init(true, addon);
const resolved = resolveNode(state, node);
if (state.async === 0) {
return Promise.resolve({ text: stringifyNode(resolved), addon });
} }
const { resolve, reject, promise } = Promise.withResolvers<Result<A>>(); const { resolve, reject, promise } = Promise.withResolvers<Result<A>>();
r.asyncDone = () => { state.asyncDone = () => {
const rejections = r.rejections; const rejections = state.rejections;
if (!rejections) return resolve({ text: renderNode(resolved), addon }); if (!rejections) return resolve({ text: stringifyNode(resolved), addon });
if (rejections.length === 1) return reject(rejections[0]); if (rejections.length === 1) return reject(rejections[0]);
return reject(new AggregateError(rejections)); return reject(new AggregateError(rejections));
}; };
return promise; return promise;
} }
export { ssrAsync as async };
/** Inline HTML into a render without escaping it */ export type Addons = Record<symbol, unknown>;
export function html(rawText: ResolvedNode): DirectHtml { export interface Result<A extends Addons = Addons> {
return [kDirectHtml, rawText];
}
interface Result<A extends Addons = Addons> {
text: string; text: string;
addon: A; addon: A;
} }
export interface Render { export function userData<T>(def: () => T) {
const k: unique symbol = Symbol();
return {
key: k,
get: () => ((UNWRAP(current).addon[k] as T) ??= def() as T),
set: (value: T) => void (UNWRAP(current).addon[k] = value),
};
}
/** Inline HTML into a render without escaping it */
export function raw(node: ResolvedNode): Raw {
return [kRaw, node];
}
export function element(type: Element[1], props: Element[2] = {}): Element {
return [kElement, type, props];
}
export function resolvedElement(
type: ResolvedElement[1],
props: ResolvedElement[2],
children: ResolvedElement[3],
): ResolvedElement {
return [kElement, type, props, children];
}
export interface State {
/** /**
* Set to '-1' if rendering synchronously * Set to '-1' if rendering synchronously
* Number of async promises the render is waiting on. * Number of async promises the render is waiting on.
@ -56,35 +79,29 @@ export interface Render {
} }
export const kElement = Symbol("Element"); export const kElement = Symbol("Element");
export const kDirectHtml = Symbol("DirectHtml"); export const kRaw = Symbol("Raw");
/** Node represents a webpage that can be 'rendered' into HTML. */ /** Node represents a webpage that can be 'rendered' into HTML. */
export type Node = export type Node =
| number | number
| string // Escape HTML | string // Escape HTML
| Node[] // Concat | Node[] // Concat
| Element // Render | Element // Stringify
| DirectHtml // Insert | Raw // Insert
| Promise<Node> // Await | Promise<Node> // Await
// Ignore // Ignore
| undefined | (undefined | null | boolean);
| null
| boolean;
export type Element = [ export type Element = [
tag: typeof kElement, tag: typeof kElement,
type: string | Component, type: string | Component,
props: Record<string, unknown>, props: Record<string, unknown>,
_?: "", _?: "", // children
source?: SrcLoc, source?: SrcLoc,
]; ];
export type DirectHtml = [tag: typeof kDirectHtml, html: ResolvedNode]; export type Raw = [tag: typeof kRaw, html: ResolvedNode];
/** /** Components must return a value; 'undefined' is prohibited here
* Components must return a value; 'undefined' is prohibited here * to avoid functions that are missing a return statement. */
* to avoid functions that are missing a return statement. export type Component = (props: Record<any, any>) => Exclude<Node, undefined>;
*/
export type Component = (
props: Record<any, any>,
) => Exclude<Node, undefined>;
/** Emitted by JSX runtime */ /** Emitted by JSX runtime */
export interface SrcLoc { export interface SrcLoc {
fileName: string; fileName: string;
@ -94,10 +111,10 @@ export interface SrcLoc {
/** /**
* Resolution narrows the type 'Node' into 'ResolvedNode'. Async trees are * Resolution narrows the type 'Node' into 'ResolvedNode'. Async trees are
* marked in the 'Render'. This operation performs everything besides the final * marked in 'State'. This operation performs everything besides the final
* string concatenation. This function is agnostic across async/sync modes. * string concatenation. This function is agnostic across async/sync modes.
*/ */
export function resolveNode(r: Render, node: unknown): ResolvedNode { export function resolveNode(r: State, node: unknown): ResolvedNode {
if (!node && node !== 0) return ""; // falsy, non numeric if (!node && node !== 0) return ""; // falsy, non numeric
if (typeof node !== "object") { if (typeof node !== "object") {
if (node === true) return ""; // booleans are ignored if (node === true) return ""; // booleans are ignored
@ -132,7 +149,7 @@ export function resolveNode(r: Render, node: unknown): ResolvedNode {
if (type === kElement) { if (type === kElement) {
const { 1: tag, 2: props } = node; const { 1: tag, 2: props } = node;
if (typeof tag === "function") { if (typeof tag === "function") {
currentRender = r; current = r;
try { try {
return resolveNode(r, tag(props)); return resolveNode(r, tag(props));
} catch (e) { } catch (e) {
@ -140,7 +157,7 @@ export function resolveNode(r: Render, node: unknown): ResolvedNode {
if (e && typeof e === "object") (e as { src?: string }).src = src; if (e && typeof e === "object") (e as { src?: string }).src = src;
throw e; throw e;
} finally { } finally {
currentRender = null; current = null;
} }
} }
if (typeof tag !== "string") throw new Error("Unexpected " + inspect(type)); if (typeof tag !== "string") throw new Error("Unexpected " + inspect(type));
@ -148,14 +165,14 @@ export function resolveNode(r: Render, node: unknown): ResolvedNode {
if (children) return [kElement, tag, props, resolveNode(r, children)]; if (children) return [kElement, tag, props, resolveNode(r, children)];
return node; return node;
} }
if (type === kDirectHtml) return node[1]; if (type === kRaw) return node[1];
return node.map((elem) => resolveNode(r, elem)); return node.map((elem) => resolveNode(r, elem));
} }
export type ResolvedNode = export type ResolvedNode =
| ResolvedNode[] // Concat | ResolvedNode[] // Concat
| ResolvedElement // Render | ResolvedElement // Render
| string; // Direct HTML | string; // Raw HTML
export type ResolvedElement = [ export type ResolvedElement = [
tag: typeof kElement, tag: typeof kElement,
type: string, type: string,
@ -174,23 +191,23 @@ export type InsertionPoint = [null | ResolvedNode];
* Convert 'ResolvedNode' into HTML text. This operation happens after all * Convert 'ResolvedNode' into HTML text. This operation happens after all
* async work is settled. The HTML is emitted as concisely as possible. * async work is settled. The HTML is emitted as concisely as possible.
*/ */
export function renderNode(node: ResolvedNode): string { export function stringifyNode(node: ResolvedNode): string {
if (typeof node === "string") return node; if (typeof node === "string") return node;
ASSERT(node, "Unresolved Render Node"); ASSERT(node, "Unresolved Render Node");
const type = node[0]; const type = node[0];
if (type === kElement) { if (type === kElement) {
return renderElement(node as ResolvedElement); return stringifyElement(node as ResolvedElement);
} }
node = node as ResolvedNode[]; // TS cannot infer. node = node as ResolvedNode[]; // TS cannot infer.
let out = type ? renderNode(type) : ""; let out = type ? stringifyNode(type) : "";
let len = node.length; let len = node.length;
for (let i = 1; i < len; i++) { for (let i = 1; i < len; i++) {
const elem = node[i]; const elem = node[i];
if (elem) out += renderNode(elem); if (elem) out += stringifyNode(elem);
} }
return out; return out;
} }
function renderElement(element: ResolvedElement) { function stringifyElement(element: ResolvedElement) {
const { 1: tag, 2: props, 3: children } = element; const { 1: tag, 2: props, 3: children } = element;
let out = "<" + tag; let out = "<" + tag;
let needSpace = true; let needSpace = true;
@ -216,26 +233,30 @@ function renderElement(element: ResolvedElement) {
case "key": case "key":
continue; continue;
} }
if (needSpace) out += " ", needSpace = !attr.endsWith('"'); if (needSpace) (out += " "), (needSpace = !attr.endsWith('"'));
out += attr; out += attr;
} }
out += ">"; out += ">";
if (children) out += renderNode(children); if (children) out += stringifyNode(children);
if ( if (
tag !== "br" && tag !== "img" && tag !== "input" && tag !== "meta" && tag !== "br" &&
tag !== "link" && tag !== "hr" tag !== "img" &&
tag !== "input" &&
tag !== "meta" &&
tag !== "link" &&
tag !== "hr"
) { ) {
out += `</${tag}>`; out += `</${tag}>`;
} }
return out; return out;
} }
export function renderStyleAttribute(style: Record<string, string>) { export function stringifyStyleAttribute(style: Record<string, string>) {
let out = ``; let out = ``;
for (const styleName in style) { for (const styleName in style) {
if (out) out += ";"; if (out) out += ";";
out += `${styleName.replace(/[A-Z]/g, "-$&").toLowerCase()}:${ out += `${styleName.replace(/[A-Z]/g, "-$&").toLowerCase()}:${escapeHtml(
escapeHtml(String(style[styleName])) String(style[styleName]),
}`; )}`;
} }
return "style=" + quoteIfNeeded(out); return "style=" + quoteIfNeeded(out);
} }
@ -246,7 +267,7 @@ export function quoteIfNeeded(text: string) {
// -- utility functions -- // -- utility functions --
export function initRender(allowAsync: boolean, addon: Addons): Render { export function init(allowAsync: boolean, addon: Addons): State {
return { return {
async: allowAsync ? 0 : -1, async: allowAsync ? 0 : -1,
rejections: null, rejections: null,
@ -255,29 +276,11 @@ export function initRender(allowAsync: boolean, addon: Addons): Render {
}; };
} }
let currentRender: Render | null = null;
export function getCurrentRender() {
if (!currentRender) throw new Error("No Render Active");
return currentRender;
}
export function setCurrentRender(r?: Render | null) {
currentRender = r ?? null;
}
export function getUserData<T>(namespace: PropertyKey, def: () => T): T {
return (getCurrentRender().addon[namespace] ??= def()) as T;
}
export function inspect(object: unknown) {
try {
return require("node:util").inspect(object);
} catch {
return typeof object;
}
}
export type ClsxInput = string | Record<string, boolean | null> | ClsxInput[]; export type ClsxInput = string | Record<string, boolean | null> | ClsxInput[];
export function clsx(mix: ClsxInput) { export function clsx(mix: ClsxInput) {
var k, y, str = ""; var k,
y,
str = "";
if (typeof mix === "string") { if (typeof mix === "string") {
return mix; return mix;
} else if (typeof mix === "object") { } else if (typeof mix === "object") {
@ -302,5 +305,17 @@ export function clsx(mix: ClsxInput) {
export const escapeHtml = (unsafeText: string) => export const escapeHtml = (unsafeText: string) =>
String(unsafeText) String(unsafeText)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;") .replace(/&/g, "&amp;")
.replace(/"/g, "&quot;").replace(/'/g, "&#x27;").replace(/`/g, "&#x60;"); .replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#x27;")
.replace(/`/g, "&#x60;");
export function inspect(object: unknown) {
try {
return require("node:util").inspect(object);
} catch {
return typeof object;
}
}

View file

@ -7,46 +7,44 @@
// //
// I would link to an article from Next.js or React, but their examples // I would link to an article from Next.js or React, but their examples
// are too verbose and not informative to what they actually do. // are too verbose and not informative to what they actually do.
const kState = Symbol("SuspenseState"); const userData = render.userData<null | State>(() => null);
interface SuspenseProps { interface SuspenseProps {
children: ssr.Node; children: render.Node;
fallback?: ssr.Node; fallback?: render.Node;
} }
interface State { interface State {
nested: boolean; nested: boolean;
nextId: number; nextId: number;
completed: number; completed: number;
pushChunk(name: string, node: ssr.ResolvedNode): void; pushChunk(name: string, node: render.ResolvedNode): void;
} }
export function Suspense({ children, fallback }: SuspenseProps): ssr.Node { export function Suspense({ children, fallback }: SuspenseProps): render.Node {
const state = ssr.getUserData<State>(kState, () => { const state = userData.get();
throw new Error("Can only use <Suspense> with 'renderStreaming'"); if (!state) return children;
});
if (state.nested) throw new Error("<Suspense> cannot be nested"); if (state.nested) throw new Error("<Suspense> cannot be nested");
const parent = ssr.getCurrentRender()!; const parent = UNWRAP(render.current);
const r = ssr.initRender(true, { [kState]: { nested: true } }); const r = render.initRender(true, { [userData.key]: { nested: true } });
const resolved = ssr.resolveNode(r, children); const resolved = render.resolveNode(r, children);
if (r.async == 0) return ssr.html(resolved); if (r.async == 0) return render.raw(resolved);
const name = "suspended_" + (++state.nextId); const name = "suspended_" + ++state.nextId;
state.nested = true; state.nested = true;
const ip: [ssr.ResolvedNode] = [ const ip: [render.ResolvedNode] = [
[ render.resolvedElement(
ssr.kElement,
"slot", "slot",
{ name }, { name },
fallback ? ssr.resolveNode(parent, fallback) : "", fallback ? render.resolveNode(parent, fallback) : "",
], ),
]; ];
state.nested = false; state.nested = false;
r.asyncDone = () => { r.asyncDone = () => {
const rejections = r.rejections; const rejections = r.rejections;
if (rejections && rejections.length > 0) throw new Error("TODO"); if (rejections && rejections.length > 0) throw new Error("TODO");
state.pushChunk?.(name, ip[0] = resolved); state.pushChunk?.(name, (ip[0] = resolved));
}; };
return ssr.html(ip); return render.raw(ip);
} }
// TODO: add a User-Agent parameter, which is used to determine if a // 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 {
// - Before ~2024 needs to use a JS implementation. // - Before ~2024 needs to use a JS implementation.
// - IE should probably bail out entirely. // - IE should probably bail out entirely.
export async function* renderStreaming< export async function* renderStreaming<
T extends ssr.Addons = Record<never, unknown>, T extends render.Addons = Record<never, unknown>,
>( >(node: render.Node, addon: T = {} as T) {
node: ssr.Node,
addon: T = {} as T,
) {
const { const {
text: begin, text: begin,
addon: { [kState]: state, ...addonOutput }, addon: { [userData.key]: state, ...addonOutput },
} = await ssr.ssrAsync(node, { } = await render.async(node, {
...addon, ...addon,
[kState]: { [userData.key]: {
nested: false, nested: false,
nextId: 0, nextId: 0,
completed: 0, completed: 0,
@ -79,24 +74,29 @@ export async function* renderStreaming<
let chunks: string[] = []; let chunks: string[] = [];
state.pushChunk = (slot, node) => { state.pushChunk = (slot, node) => {
while (node.length === 1 && Array.isArray(node)) node = node[0]; while (node.length === 1 && Array.isArray(node)) node = node[0];
if (node[0] === ssr.kElement) { if (node[0] === render.kElement) {
(node as ssr.ResolvedElement)[2].slot = slot; (node as render.ResolvedElement)[2].slot = slot;
} else { } else {
node = [ssr.kElement, "clover-suspense", { node = [
style: "display:contents", render.kElement,
slot, "clover-suspense",
}, node]; {
style: "display:contents",
slot,
},
node,
];
} }
chunks.push(ssr.renderNode(node)); chunks.push(render.stringifyNode(node));
resolve?.(); resolve?.();
}; };
yield `<template shadowrootmode=open>${begin}</template>`; yield `<template shadowrootmode=open>${begin}</template>`;
do { do {
await new Promise<void>((done) => resolve = done); await new Promise<void>((done) => (resolve = done));
yield* chunks; yield* chunks;
chunks = []; chunks = [];
} while (state.nextId < state.completed); } while (state.nextId < state.completed);
return addonOutput as unknown as T; return addonOutput as unknown as T;
} }
import * as ssr from "./ssr.ts"; import * as render from "./render.ts";

View file

@ -14,12 +14,8 @@ export async function main() {
export async function generate() { export async function generate() {
// -- read config and discover files -- // -- read config and discover files --
const siteConfig = await incr.work(readManifest); const siteConfig = await incr.work(readManifest);
const { const { staticFiles, scripts, views, pages } =
staticFiles, await discoverAllFiles(siteConfig);
scripts,
views,
pages,
} = await discoverAllFiles(siteConfig);
// TODO: make sure that `static` and `pages` does not overlap // TODO: make sure that `static` and `pages` does not overlap
@ -28,12 +24,15 @@ export async function generate() {
// -- perform build-time rendering -- // -- perform build-time rendering --
const builtPages = pages.map((item) => incr.work(preparePage, item)); const builtPages = pages.map((item) => incr.work(preparePage, item));
const builtViews = views.map((item) => incr.work(prepareView, item)); const builtViews = views.map((item) => incr.work(prepareView, item));
const builtStaticFiles = Promise.all((staticFiles.map((item) => const builtStaticFiles = Promise.all(
incr.work( staticFiles.map((item) =>
async (io, { id, file }) => void await io.writeAsset(id, await io.readFile(file)), incr.work(
item, async (io, { id, file }) =>
) void (await io.writeAsset(id, await io.readFile(file))),
))); item,
),
),
);
const routes = await Promise.all([...builtViews, ...builtPages]); const routes = await Promise.all([...builtViews, ...builtPages]);
// -- page resources -- // -- page resources --
@ -47,23 +46,19 @@ export async function generate() {
// -- backend -- // -- backend --
const builtBackend = bundle.bundleServerJavaScript({ const builtBackend = bundle.bundleServerJavaScript({
entries: siteConfig.backends, entries: siteConfig.backends,
platform: 'node', platform: "node",
styleMap, styleMap,
scriptMap, scriptMap,
viewItems: views, viewItems: views,
viewRefs: builtViews, viewRefs: builtViews,
}) });
// -- assemble page assets -- // -- assemble page assets --
const pAssemblePages = builtPages.map((page) => const pAssemblePages = builtPages.map((page) =>
assembleAndWritePage(page, styleMap, scriptMap) assembleAndWritePage(page, styleMap, scriptMap),
); );
await Promise.all([ await Promise.all([builtBackend, builtStaticFiles, ...pAssemblePages]);
builtBackend,
builtStaticFiles,
...pAssemblePages,
]);
} }
export async function readManifest(io: Io) { export async function readManifest(io: Io) {
@ -82,7 +77,7 @@ export async function discoverAllFiles(
return ( return (
await Promise.all( await Promise.all(
siteConfig.siteSections.map(({ root: sectionRoot }) => siteConfig.siteSections.map(({ root: sectionRoot }) =>
incr.work(scanSiteSection, toAbs(sectionRoot)) incr.work(scanSiteSection, toAbs(sectionRoot)),
), ),
) )
).reduce((acc, next) => ({ ).reduce((acc, next) => ({
@ -110,9 +105,10 @@ export async function scanSiteSection(io: Io, sectionRoot: string) {
let scripts: FileItem[] = []; let scripts: FileItem[] = [];
const sectionPath = (...sub: string[]) => path.join(sectionRoot, ...sub); const sectionPath = (...sub: string[]) => path.join(sectionRoot, ...sub);
const rootPrefix = hot.projectSrc === sectionRoot const rootPrefix =
? "" hot.projectSrc === sectionRoot
: path.relative(hot.projectSrc, sectionRoot) + "/"; ? ""
: path.relative(hot.projectSrc, sectionRoot) + "/";
const kinds = [ const kinds = [
{ {
dir: sectionPath("pages"), dir: sectionPath("pages"),
@ -186,12 +182,8 @@ export async function preparePage(io: Io, item: sg.FileItem) {
theme: pageTheme, theme: pageTheme,
layout, layout,
} = await io.import<any>(item.file); } = await io.import<any>(item.file);
if (!Page) { if (!Page) throw new Error("Page is missing a 'default' export.");
throw new Error("Page is missing a 'default' export."); if (!metadata) throw new Error("Page is missing 'meta' export with a title.");
}
if (!metadata) {
throw new Error("Page is missing 'meta' export with a title.");
}
// -- css -- // -- css --
if (layout?.theme) pageTheme = layout.theme; if (layout?.theme) pageTheme = layout.theme;
@ -210,12 +202,12 @@ export async function preparePage(io: Io, item: sg.FileItem) {
).then((m) => meta.renderMeta(m)); ).then((m) => meta.renderMeta(m));
// -- html -- // -- html --
let page = [engine.kElement, Page, {}]; let page = render.element(Page);
if (layout?.default) { if (layout?.default) {
page = [engine.kElement, layout.default, { children: page }]; page = render.element(layout.default, { children: page });
} }
const bodyPromise = engine.ssrAsync(page, { const bodyPromise = render.async(page, {
sitegen: sg.initRender(), [sg.userData.key]: sg.initRender(),
}); });
const [{ text, addon }, renderedMeta] = await Promise.all([ const [{ text, addon }, renderedMeta] = await Promise.all([
@ -235,23 +227,16 @@ export async function preparePage(io: Io, item: sg.FileItem) {
cssImports, cssImports,
theme: theme ?? null, theme: theme ?? null,
styleKey, styleKey,
clientRefs: Array.from(addon.sitegen.scripts), clientRefs: Array.from(addon[sg.userData.key].scripts),
}; };
} }
export async function prepareView(io: Io, item: sg.FileItem) { export async function prepareView(io: Io, item: sg.FileItem) {
const module = await io.import<any>(item.file); const module = await io.import<any>(item.file);
if (!module.meta) { if (!module.meta) throw new Error(`View is missing 'export const meta'`);
throw new Error(`${item.file} is missing 'export const meta'`); if (!module.default) throw new Error(`View is missing a default export.`);
}
if (!module.default) {
throw new Error(`${item.file} is missing a default export.`);
}
const pageTheme = module.layout?.theme ?? module.theme; const pageTheme = module.layout?.theme ?? module.theme;
const theme: css.Theme = { const theme: css.Theme = { ...css.defaultTheme, ...pageTheme };
...css.defaultTheme,
...pageTheme,
};
const cssImports = Array.from( const cssImports = Array.from(
new Set([globalCssPath, ...hot.getCssImports(item.file)]), new Set([globalCssPath, ...hot.getCssImports(item.file)]),
(file) => path.relative(hot.projectSrc, file), (file) => path.relative(hot.projectSrc, file),
@ -269,22 +254,14 @@ export async function prepareView(io: Io, item: sg.FileItem) {
export type PreparedView = Awaited<ReturnType<typeof prepareView>>; export type PreparedView = Awaited<ReturnType<typeof prepareView>>;
export function prepareInlineCss( export function prepareInlineCss(
items: Array<{ items: Array<{ styleKey: string; cssImports: string[]; theme: css.Theme }>,
styleKey: string;
cssImports: string[];
theme: css.Theme;
}>,
) { ) {
const map = new Map<string, incr.Ref<string>>(); const map = new Map<string, incr.Ref<string>>();
for (const { styleKey, cssImports, theme } of items) { for (const { styleKey, cssImports, theme } of items) {
if (map.has(styleKey)) continue; if (map.has(styleKey)) continue;
map.set( map.set(
styleKey, styleKey,
incr.work(css.bundleCssFiles, { incr.work(css.bundleCssFiles, { cssImports, theme, dev: false }),
cssImports,
theme,
dev: false,
}),
); );
} }
return map; return map;
@ -297,29 +274,26 @@ export async function assembleAndWritePage(
scriptWork: incr.Ref<Record<string, string>>, scriptWork: incr.Ref<Record<string, string>>,
) { ) {
const page = await pageWork; const page = await pageWork;
return incr.work( return incr.work(async (io, { id, html, meta, styleKey, clientRefs }) => {
async (io, { id, html, meta, styleKey, clientRefs }) => { const inlineCss = await io.readWork(UNWRAP(styleMap.get(styleKey)));
const inlineCss = await io.readWork(UNWRAP(styleMap.get(styleKey)));
const scriptIds = clientRefs.map(hot.getScriptId); const scriptIds = clientRefs.map(hot.getScriptId);
const scriptMap = await io.readWork(scriptWork); const scriptMap = await io.readWork(scriptWork);
const scripts = scriptIds.map((ref) => const scripts = scriptIds
UNWRAP(scriptMap[ref], `Missing script ${ref}`) .map((ref) => UNWRAP(scriptMap[ref], `Missing script ${ref}`))
) .map((x) => `{${x}}`)
.map((x) => `{${x}}`).join("\n"); .join("\n");
const doc = wrapDocument({ const doc = wrapDocument({
body: html, body: html,
head: meta, head: meta,
inlineCss, inlineCss,
scripts, scripts,
}); });
await io.writeAsset(id, doc, { await io.writeAsset(id, doc, {
"Content-Type": "text/html", "Content-Type": "text/html",
}); });
}, }, page);
page,
);
} }
import * as sg from "#sitegen"; import * as sg from "#sitegen";
@ -327,11 +301,10 @@ import * as incr from "./incremental.ts";
import { Io } from "./incremental.ts"; import { Io } from "./incremental.ts";
import * as bundle from "./bundle.ts"; import * as bundle from "./bundle.ts";
import * as css from "./css.ts"; import * as css from "./css.ts";
import * as engine from "./engine/ssr.ts"; import * as render from "#engine/render";
import * as hot from "./hot.ts"; import * as hot from "./hot.ts";
import * as fs from "#sitegen/fs"; import * as fs from "#sitegen/fs";
import type { FileItem } from "#sitegen"; import type { FileItem } from "#sitegen";
import * as path from "node:path"; import * as path from "node:path";
import * as meta from "#sitegen/meta"; import * as meta from "#sitegen/meta";
import { Spinner, withSpinner } from "@paperclover/console/Spinner";
import { wrapDocument } from "./lib/view.ts"; import { wrapDocument } from "./lib/view.ts";

View file

@ -19,7 +19,7 @@ export const load = createRequire(
}; };
export const { cache } = load; export const { cache } = load;
load<any>("source-map-support").install({hookRequire: true}); load<any>("source-map-support").install({ hookRequire: true });
// Register extensions by overwriting `require.extensions` // Register extensions by overwriting `require.extensions`
const require = load; const require = load;
@ -75,15 +75,15 @@ Module.prototype._compile = function (
imports.push(file); imports.push(file);
(childModule.cloverImporters ??= []).push(this); (childModule.cloverImporters ??= []).push(this);
if (cloverClientRefs && cloverClientRefs.length > 0) { if (cloverClientRefs && cloverClientRefs.length > 0) {
(this.cloverClientRefs ??= []) (this.cloverClientRefs ??= []).push(...cloverClientRefs);
.push(...cloverClientRefs);
} }
} }
} }
fileStats.set(filename, { fileStats.set(filename, {
cssImportsRecursive: cssImportsMaybe.length > 0 cssImportsRecursive:
? Array.from(new Set(cssImportsMaybe)) cssImportsMaybe.length > 0
: null, ? Array.from(new Set(cssImportsMaybe))
: null,
imports, imports,
lastModified: Math.floor(stat.mtimeMs), lastModified: Math.floor(stat.mtimeMs),
}); });
@ -100,7 +100,8 @@ Module._resolveFilename = (...args) => {
return require.resolve(replacedPath, { paths: [projectSrc] }); return require.resolve(replacedPath, { paths: [projectSrc] });
} catch (err: any) { } catch (err: any) {
if ( if (
err.code === "MODULE_NOT_FOUND" && (err?.requireStack?.length ?? 0) <= 1 err.code === "MODULE_NOT_FOUND" &&
(err?.requireStack?.length ?? 0) <= 1
) { ) {
err.message.replace(replacedPath, args[0]); err.message.replace(replacedPath, args[0]);
} }
@ -138,45 +139,45 @@ export function loadEsbuildCode(
src = code; src = code;
} }
if (src.includes("import.meta")) { if (src.includes("import.meta")) {
src = ` src =
import.meta.url = ${JSON.stringify(pathToFileURL(filepath).toString())}; `
import.meta.dirname = ${JSON.stringify(path.dirname(filepath))}; import.meta.url = ${JSON.stringify(pathToFileURL(filepath).toString())};
import.meta.filename = ${JSON.stringify(filepath)}; import.meta.dirname = ${JSON.stringify(path.dirname(filepath))};
`.trim().replace(/[\n\s]/g, "") + src; import.meta.filename = ${JSON.stringify(filepath)};
`
.trim()
.replace(/[\n\s]/g, "") + src;
} }
src = esbuild.transformSync(src, { src = esbuild.transformSync(src, {
loader, loader,
format: "cjs", format: "cjs",
target: "esnext", target: "esnext",
jsx: "automatic", jsx: "automatic",
jsxImportSource: "#ssr", jsxImportSource: "#engine",
jsxDev: true, jsxDev: true,
sourcefile: filepath, sourcefile: filepath,
sourcemap: 'inline', sourcemap: "inline",
}).code; }).code;
return module._compile(src, filepath, "commonjs"); return module._compile(src, filepath, "commonjs");
} }
export function resolveClientRef(sourcePath: string, ref: string) { export function resolveClientRef(sourcePath: string, ref: string) {
const filePath = resolveFrom(sourcePath, ref); const filePath = resolveFrom(sourcePath, ref);
if ( if (!filePath.endsWith(".client.ts") && !filePath.endsWith(".client.tsx")) {
!filePath.endsWith(".client.ts") &&
!filePath.endsWith(".client.tsx")
) {
throw new Error("addScript must be a .client.ts or .client.tsx"); throw new Error("addScript must be a .client.ts or .client.tsx");
} }
return path.relative(projectSrc, filePath); return path.relative(projectSrc, filePath);
} }
let lazyMarko: typeof import('./marko.ts') | null = null; let lazyMarko: typeof import("./marko.ts") | null = null;
function loadMarko(module: NodeJS.Module, filepath: string) { function loadMarko(module: NodeJS.Module, filepath: string) {
lazyMarko ??= require<typeof import('./marko.ts')>("./framework/marko.ts"); lazyMarko ??= require<typeof import("./marko.ts")>("./framework/marko.ts");
lazyMarko.loadMarko(module, filepath); lazyMarko.loadMarko(module, filepath);
} }
function loadMdx(module: NodeJS.Module, filepath: string) { function loadMdx(module: NodeJS.Module, filepath: string) {
const input = fs.readFileSync(filepath); const input = fs.readFileSync(filepath);
const out = mdx.compileSync(input, { jsxImportSource: "#ssr" }).value; const out = mdx.compileSync(input, { jsxImportSource: "#engine" }).value;
const src = typeof out === "string" ? out : Buffer.from(out).toString("utf8"); const src = typeof out === "string" ? out : Buffer.from(out).toString("utf8");
return loadEsbuildCode(module, filepath, src); return loadEsbuildCode(module, filepath, src);
} }
@ -194,7 +195,7 @@ export function reloadRecursive(filepath: string) {
} }
export function unload(filepath: string) { export function unload(filepath: string) {
lazyMarko?.markoCache.delete(filepath) lazyMarko?.markoCache.delete(filepath);
filepath = path.resolve(filepath); filepath = path.resolve(filepath);
const module = cache[filepath]; const module = cache[filepath];
if (!module) return; if (!module) return;
@ -291,8 +292,9 @@ export function resolveClientRefs(
} }
export function getScriptId(file: string) { export function getScriptId(file: string) {
return (path.isAbsolute(file) ? path.relative(projectSrc, file) : file) return (
.replaceAll("\\", "/"); path.isAbsolute(file) ? path.relative(projectSrc, file) : file
).replaceAll("\\", "/");
} }
declare global { declare global {
@ -300,7 +302,7 @@ declare global {
interface Module { interface Module {
cloverClientRefs?: string[]; cloverClientRefs?: string[];
cloverSourceCode?: string; cloverSourceCode?: string;
cloverImporters?: Module[], cloverImporters?: Module[];
_compile( _compile(
this: NodeJS.Module, this: NodeJS.Module,
@ -312,10 +314,7 @@ declare global {
} }
} }
declare module "node:module" { declare module "node:module" {
export function _resolveFilename( export function _resolveFilename(id: string, parent: NodeJS.Module): unknown;
id: string,
parent: NodeJS.Module,
): unknown;
} }
import * as fs from "#sitegen/fs"; import * as fs from "#sitegen/fs";

View file

@ -11,30 +11,22 @@ export async function reload() {
fs.readFile(path.join(import.meta.dirname, "static.json"), "utf8"), fs.readFile(path.join(import.meta.dirname, "static.json"), "utf8"),
fs.readFile(path.join(import.meta.dirname, "static.blob")), fs.readFile(path.join(import.meta.dirname, "static.blob")),
]); ]);
assets = { return (assets = { map: JSON.parse(map), buf });
map: JSON.parse(map),
buf,
};
} }
export async function reloadSync() { export function reloadSync() {
const map = fs.readFileSync( const map = fs.readFileSync(
path.join(import.meta.dirname, "static.json"), path.join(import.meta.dirname, "static.json"),
"utf8", "utf8",
); );
const buf = fs.readFileSync(path.join(import.meta.dirname, "static.blob")); const buf = fs.readFileSync(path.join(import.meta.dirname, "static.blob"));
assets = { return (assets = { map: JSON.parse(map), buf });
map: JSON.parse(map),
buf,
};
} }
export async function middleware(c: Context, next: Next) { export async function middleware(c: Context, next: Next) {
if (!assets) await reload(); if (!assets) await reload();
const asset = assets!.map[c.req.path]; const asset = assets!.map[c.req.path];
if (asset) { if (asset) return assetInner(c, asset, 200);
return assetInner(c, asset, 200);
}
return next(); return next();
} }
@ -56,13 +48,11 @@ export async function serveAsset(
id: StaticPageId, id: StaticPageId,
status: StatusCode, status: StatusCode,
) { ) {
assets ?? await reload(); return assetInner(c, (assets ?? (await reload())).map[id], status);
return assetInner(c, assets!.map[id], status);
} }
export function hasAsset(id: string) { export function hasAsset(id: string) {
if (!assets) reloadSync(); return (assets ?? reloadSync()).map[id] !== undefined;
return assets!.map[id] !== undefined;
} }
export function etagMatches(etag: string, ifNoneMatch: string) { export function etagMatches(etag: string, ifNoneMatch: string) {
@ -78,13 +68,13 @@ function assetInner(c: Context, asset: BuiltAsset, status: StatusCode) {
if (ifnonematch) { if (ifnonematch) {
const etag = asset.headers.ETag; const etag = asset.headers.ETag;
if (etagMatches(etag, ifnonematch)) { if (etagMatches(etag, ifnonematch)) {
return c.res = new Response(null, { return (c.res = new Response(null, {
status: 304, status: 304,
statusText: "Not Modified", statusText: "Not Modified",
headers: { headers: {
ETag: etag, ETag: etag,
}, },
}); }));
} }
} }
const acceptEncoding = c.req.header("Accept-Encoding") ?? ""; const acceptEncoding = c.req.header("Accept-Encoding") ?? "";
@ -105,7 +95,7 @@ function assetInner(c: Context, asset: BuiltAsset, status: StatusCode) {
} else { } else {
body = subarrayAsset(asset.raw); body = subarrayAsset(asset.raw);
} }
return c.res = new Response(body, { headers, status }); return (c.res = new Response(body, { headers, status }));
} }
process.on("message", (msg: any) => { process.on("message", (msg: any) => {

View file

@ -5,21 +5,21 @@
* via clover's SSR engine. This way, generation optimizations, async * via clover's SSR engine. This way, generation optimizations, async
* components, and other features are gained for free here. * components, and other features are gained for free here.
*/ */
function parse(src: string, options: Partial<ParseOpts> = {}) { function parse(src: string, options: Partial<ParseOpts> = {}) {}
}
/* Render markdown content. Same function as 'parse', but JSX components /* Render markdown content. Same function as 'parse', but JSX components
* only take one argument and must start with a capital letter. */ * only take one argument and must start with a capital letter. */
export function Markdown( export function Markdown({
{ src, ...options }: { src: string } & Partial<ParseOpts>, src,
) { ...options
}: { src: string } & Partial<ParseOpts>) {
return parse(src, options); return parse(src, options);
} }
function parseInline(src: string, options: Partial<InlineOpts> = {}) { function parseInline(src: string, options: Partial<InlineOpts> = {}) {
const { rules = inlineRules, links = new Map() } = options; const { rules = inlineRules, links = new Map() } = options;
const opts: InlineOpts = { rules, links }; const opts: InlineOpts = { rules, links };
const parts: engine.Node[] = []; const parts: render.Node[] = [];
const ruleList = Object.values(rules); const ruleList = Object.values(rules);
parse: while (true) { parse: while (true) {
for (const rule of ruleList) { for (const rule of ruleList) {
@ -96,7 +96,9 @@ export const inlineRules: Record<string, InlineRule> = {
if (!splitText) return null; if (!splitText) return null;
if (splitText.delim !== "]") return null; if (splitText.delim !== "]") return null;
const { first: textSrc, rest: afterText } = splitText; const { first: textSrc, rest: afterText } = splitText;
let href: string, title: string | null = null, rest: string; let href: string,
title: string | null = null,
rest: string;
if (afterText[0] === "(") { if (afterText[0] === "(") {
// Inline link // Inline link
const splitTarget = splitFirst(afterText.slice(1), /\)/); const splitTarget = splitFirst(afterText.slice(1), /\)/);
@ -108,11 +110,12 @@ export const inlineRules: Record<string, InlineRule> = {
} else if (afterText[0] === "[") { } else if (afterText[0] === "[") {
const splitTarget = splitFirst(afterText.slice(1), /]/); const splitTarget = splitFirst(afterText.slice(1), /]/);
if (!splitTarget) return null; if (!splitTarget) return null;
const name = splitTarget.first.trim().length === 0 const name =
// Collapsed reference link splitTarget.first.trim().length === 0
? textSrc.trim() ? // Collapsed reference link
// Full Reference Link textSrc.trim()
: splitTarget.first.trim(); : // Full Reference Link
splitTarget.first.trim();
const target = opts.links.get(name); const target = opts.links.get(name);
if (!target) return null; if (!target) return null;
({ href, title } = target); ({ href, title } = target);
@ -149,7 +152,6 @@ export const inlineRules: Record<string, InlineRule> = {
// 6.2 - emphasis and strong emphasis // 6.2 - emphasis and strong emphasis
parse({ before, match, after, opts }) { parse({ before, match, after, opts }) {
// find out how long the delim sequence is // find out how long the delim sequence is
// look for 'ends' // look for 'ends'
}, },
}, },
@ -164,14 +166,17 @@ export const inlineRules: Record<string, InlineRule> = {
}; };
function parseLinkTarget(src: string) { function parseLinkTarget(src: string) {
let href: string, title: string | null = null; let href: string,
title: string | null = null;
href = src; href = src;
return { href, title }; return { href, title };
} }
/* Find a delimiter while considering backslash escapes. */ /* Find a delimiter while considering backslash escapes. */
function splitFirst(text: string, match: RegExp) { function splitFirst(text: string, match: RegExp) {
let first = "", delim: string, escaped: boolean; let first = "",
delim: string,
escaped: boolean;
do { do {
const find = text.match(match); const find = text.match(match);
if (!find) return null; if (!find) return null;
@ -179,14 +184,13 @@ function splitFirst(text: string, match: RegExp) {
const index = UNWRAP(find.index); const index = UNWRAP(find.index);
let i = index - 1; let i = index - 1;
escaped = false; escaped = false;
while (i >= 0 && text[i] === "\\") escaped = !escaped, i -= 1; while (i >= 0 && text[i] === "\\") (escaped = !escaped), (i -= 1);
first += text.slice(0, index - +escaped); first += text.slice(0, index - +escaped);
text = text.slice(index + find[0].length); text = text.slice(index + find[0].length);
} while (escaped); } while (escaped);
return { first, delim, rest: text }; return { first, delim, rest: text };
} }
console.log(engine.ssrSync(parseInline("meow `bwaa` `` ` `` `` `z``"))); console.log(render.sync(parseInline("meow `bwaa` `` ` `` `` `z``")));
import * as engine from "#ssr"; import * as render from "#engine/render";
import type { ParseOptions } from "node:querystring";

View file

@ -21,4 +21,4 @@ export interface AlternateType {
export function renderMeta({ title }: Meta): string { export function renderMeta({ title }: Meta): string {
return `<title>${esc(title)}</title>`; return `<title>${esc(title)}</title>`;
} }
import { escapeHtml as esc } from "../engine/ssr.ts"; import { escapeHtml as esc } from "#engine/render";

View file

@ -9,36 +9,25 @@ export interface FileItem {
id: string; id: string;
file: string; file: string;
} }
export interface Section {
root: string;
}
export const userData = render.userData<SitegenRender>(() => {
throw new Error("This function can only be used in a page (static or view)");
});
export interface SitegenRender { export interface SitegenRender {
scripts: Set<string>; scripts: Set<string>;
} }
export function initRender(): SitegenRender { export function initRender(): SitegenRender {
return { return { scripts: new Set() };
scripts: new Set(),
};
}
export function getRender() {
return ssr.getUserData<SitegenRender>("sitegen", () => {
throw new Error(
"This function can only be used in a page (static or view)",
);
});
}
export function inRender() {
return "sitegen" in ssr.getCurrentRender();
} }
/** Add a client-side script to the page. */ /** Add a client-side script to the page. */
export function addScript(id: ScriptId | { value: ScriptId }) { export function addScript(id: ScriptId | { value: ScriptId }) {
getRender().scripts.add(typeof id === "string" ? id : id.value); userData.get().scripts.add(typeof id === "string" ? id : id.value);
} }
export interface Section { import * as render from "#engine/render";
root: string;
}
import * as ssr from "../engine/ssr.ts";

View file

@ -1,10 +1,9 @@
// This import is generated by code 'bundle.ts'
export interface View { export interface View {
component: engine.Component; component: render.Component;
meta: meta:
| meta.Meta | meta.Meta
| ((props: { context?: hono.Context }) => Promise<meta.Meta> | meta.Meta); | ((props: { context?: hono.Context }) => Promise<meta.Meta> | meta.Meta);
layout?: engine.Component; layout?: render.Component;
inlineCss: string; inlineCss: string;
scripts: Record<string, string>; scripts: Record<string, string>;
} }
@ -12,9 +11,6 @@ export interface View {
let views: Record<string, View> = null!; let views: Record<string, View> = null!;
let scripts: Record<string, string> = null!; let scripts: Record<string, string> = null!;
// An older version of the Clover Engine supported streaming suspense
// boundaries, but those were never used. Pages will wait until they
// are fully rendered before sending.
export async function renderView( export async function renderView(
context: hono.Context, context: hono.Context,
id: string, id: string,
@ -44,11 +40,12 @@ export async function renderViewToString(
).then((m) => meta.renderMeta(m)); ).then((m) => meta.renderMeta(m));
// -- html -- // -- html --
let page: engine.Element = [engine.kElement, component, props]; let page: render.Element = render.element(component, props);
if (layout) page = [engine.kElement, layout, { children: page }]; if (layout) page = render.element(layout, { children: page });
const { text: body, addon: { sitegen } } = await engine.ssrAsync(page, { const {
sitegen: sg.initRender(), text: body,
}); addon: { [sg.userData.key]: sitegen },
} = await render.async(page, { [sg.userData.key]: sg.initRender() });
// -- join document and send -- // -- join document and send --
return wrapDocument({ return wrapDocument({
@ -56,17 +53,15 @@ export async function renderViewToString(
head: await renderedMetaPromise, head: await renderedMetaPromise,
inlineCss, inlineCss,
scripts: joinScripts( scripts: joinScripts(
Array.from( Array.from(sitegen.scripts, (id) =>
sitegen.scripts, UNWRAP(scripts[id], `Missing script ${id}`),
(id) => UNWRAP(scripts[id], `Missing script ${id}`),
), ),
), ),
}); });
} }
export function provideViewData(v: typeof views, s: typeof scripts) { export function provideViewData(v: typeof views, s: typeof scripts) {
views = v; (views = v), (scripts = s);
scripts = s;
} }
export function joinScripts(scriptSources: string[]) { export function joinScripts(scriptSources: string[]) {
@ -96,5 +91,5 @@ export function wrapDocument({
import * as meta from "./meta.ts"; import * as meta from "./meta.ts";
import type * as hono from "#hono"; import type * as hono from "#hono";
import * as engine from "../engine/ssr.ts"; import * as render from "#engine/render";
import * as sg from "./sitegen.ts"; import * as sg from "./sitegen.ts";

View file

@ -1,4 +1,3 @@
console.log("MARKO");
export interface MarkoCacheEntry { export interface MarkoCacheEntry {
src: string; src: string;
scannedClientRefs: string[]; scannedClientRefs: string[];
@ -8,7 +7,6 @@ export const markoCache = new Map<string, MarkoCacheEntry>();
export function loadMarko(module: NodeJS.Module, filepath: string) { export function loadMarko(module: NodeJS.Module, filepath: string) {
let cache = markoCache.get(filepath); let cache = markoCache.get(filepath);
console.log({ filepath, has: !!cache })
if (!cache) { if (!cache) {
let src = fs.readFileSync(filepath, "utf8"); let src = fs.readFileSync(filepath, "utf8");
// A non-standard thing here is Clover Sitegen implements // A non-standard thing here is Clover Sitegen implements
@ -16,21 +14,22 @@ export function loadMarko(module: NodeJS.Module, filepath: string) {
// bare client import statements to it's own usage. // bare client import statements to it's own usage.
const scannedClientRefs = new Set<string>(); const scannedClientRefs = new Set<string>();
if (src.match(/^\s*client\s+import\s+["']/m)) { if (src.match(/^\s*client\s+import\s+["']/m)) {
src = src.replace( src =
/^\s*client\s+import\s+("[^"]+"|'[^']+')[^\n]+/m, src.replace(
(_, src) => { /^\s*client\s+import\s+("[^"]+"|'[^']+')[^\n]+/m,
const ref = JSON.parse(`"${src.slice(1, -1)}"`); (_, src) => {
const resolved = hot.resolveClientRef(filepath, ref); const ref = JSON.parse(`"${src.slice(1, -1)}"`);
scannedClientRefs.add(resolved); const resolved = hot.resolveClientRef(filepath, ref);
return `<CloverScriptInclude=${ scannedClientRefs.add(resolved);
JSON.stringify(hot.getScriptId(resolved)) return `<CloverScriptInclude=${JSON.stringify(
} />`; hot.getScriptId(resolved),
}, )} />`;
) + '\nimport { addScript as CloverScriptInclude } from "#sitegen";\n'; },
) + '\nimport { addScript as CloverScriptInclude } from "#sitegen";\n';
} }
src = marko.compileSync(src, filepath).code; src = marko.compileSync(src, filepath).code;
src = src.replace("marko/debug/html", "#ssr/marko"); src = src.replace("marko/debug/html", "#engine/marko-runtime");
cache = { src, scannedClientRefs: Array.from(scannedClientRefs) }; cache = { src, scannedClientRefs: Array.from(scannedClientRefs) };
markoCache.set(filepath, cache); markoCache.set(filepath, cache);
} }

View file

@ -28,10 +28,8 @@
"#debug": "./framework/debug.safe.ts", "#debug": "./framework/debug.safe.ts",
"#sitegen": "./framework/lib/sitegen.ts", "#sitegen": "./framework/lib/sitegen.ts",
"#sitegen/*": "./framework/lib/*.ts", "#sitegen/*": "./framework/lib/*.ts",
"#ssr": "./framework/engine/ssr.ts", "#engine/jsx-dev-runtime": "./framework/engine/jsx-runtime.ts",
"#ssr/jsx-dev-runtime": "./framework/engine/jsx-runtime.ts", "#engine/*": "./framework/engine/*.ts",
"#ssr/jsx-runtime": "./framework/engine/jsx-runtime.ts",
"#ssr/marko": "./framework/engine/marko-runtime.ts",
"#marko/html": { "#marko/html": {
"types": "marko/html", "types": "marko/html",
"production": "marko/production", "production": "marko/production",

View file

@ -3,7 +3,7 @@
this repository contains clover's "sitegen" framework, which is a set of tools this repository contains clover's "sitegen" framework, which is a set of tools
that assist building websites. these tools power <https://paperclover.net>. that assist building websites. these tools power <https://paperclover.net>.
- **HTML "Server Side Rendering") engine written from scratch.** (~500 lines) - **HTML ("Server Side Rendering") engine written from scratch.** (~500 lines)
- A more practical JSX runtime (`class` instead of `className`, built-in - A more practical JSX runtime (`class` instead of `className`, built-in
`clsx`, `html()` helper over `dangerouslySetInnerHTML` prop, etc). `clsx`, `html()` helper over `dangerouslySetInnerHTML` prop, etc).
- Integration with [Marko] for concisely written components. - Integration with [Marko] for concisely written components.
@ -57,19 +57,11 @@ my development machine, for example, is Dell Inspiron 7348 with Core i7
# production generation # production generation
node run generate node run generate
node .clover/out/server node .clover/o/backend
# "development" watch mode # "development" watch mode
node run watch node run watch
<!-- `repl.js` will open a read-eval-print-loop where plugin state is cached (on my -->
<!-- 2014 dev laptop, startup time is 600-1000ms). every file in `framework` and -->
<!-- `src` besides `hot.ts` can be edited and quickly re-run. for example, to run -->
<!-- `framework/generate.ts`, you can type "generate" into the shell. since -->
<!-- top-level await is not supported (plugins are built on `require` as Node has -->
<!-- poor module support), CLIs can include a `main` function, which is executed -->
<!-- when the REPL runs it. -->
for unix systems, the provided `flake.nix` can be used with `nix develop` to for unix systems, the provided `flake.nix` can be used with `nix develop` to
open a shell with all needed system dependencies. open a shell with all needed system dependencies.

View file

@ -125,10 +125,11 @@ app.get("/file/*", async (c, next) => {
let encoding = decideEncoding(c.req.header("Accept-Encoding")); let encoding = decideEncoding(c.req.header("Accept-Encoding"));
let sizeHeader = encoding === "raw" let sizeHeader =
? expectedSize encoding === "raw"
// Size cannot be known because of compression modes ? expectedSize
: undefined; : // Size cannot be known because of compression modes
undefined;
// Etag // Etag
{ {
@ -294,9 +295,10 @@ function handleRanges(
): Response { ): Response {
// TODO: multiple ranges // TODO: multiple ranges
const rangeSize = ranges.reduce((a, b) => a + (b[1] - b[0] + 1), 0); const rangeSize = ranges.reduce((a, b) => a + (b[1] - b[0] + 1), 0);
const rangeBody = streamOrBuffer instanceof ReadableStream const rangeBody =
? applySingleRangeToStream(streamOrBuffer, ranges) streamOrBuffer instanceof ReadableStream
: applyRangesToBuffer(streamOrBuffer, ranges, rangeSize); ? applySingleRangeToStream(streamOrBuffer, ranges)
: applyRangesToBuffer(streamOrBuffer, ranges, rangeSize);
return new Response(rangeBody, { return new Response(rangeBody, {
status: 206, status: 206,
headers: { headers: {
@ -380,9 +382,9 @@ function getPartialPage(c: Context, rawFilePath: string) {
if (!checkCotyledonCookie(c)) { if (!checkCotyledonCookie(c)) {
let root = Speedbump(); let root = Speedbump();
// Remove the root element, it's created client side! // Remove the root element, it's created client side!
root = root[2].children as ssr.Element; root = root[2].children as render.Element;
const html = ssr.ssrSync(root).text; const html = render.sync(root).text;
c.header("X-Cotyledon", "true"); c.header("X-Cotyledon", "true");
return c.html(html); return c.html(html);
} }
@ -408,15 +410,15 @@ function getPartialPage(c: Context, rawFilePath: string) {
hasCotyledonCookie: rawFilePath === "" && checkCotyledonCookie(c), hasCotyledonCookie: rawFilePath === "" && checkCotyledonCookie(c),
}); });
// Remove the root element, it's created client side! // Remove the root element, it's created client side!
root = root[2].children as ssr.Element; root = root[2].children as render.Element;
const html = ssr.ssrSync(root).text; const html = render.sync(root).text;
return c.html(html); return c.html(html);
} }
import { type Context, Hono } from "hono"; import { type Context, Hono } from "hono";
import * as ssr from "#ssr"; import * as render from "#engine/render";
import { etagMatches, hasAsset, serveAsset } from "#sitegen/assets"; import { etagMatches, hasAsset, serveAsset } from "#sitegen/assets";
import { renderView } from "#sitegen/view"; import { renderView } from "#sitegen/view";
import { contentTypeFor } from "#sitegen/mime"; import { contentTypeFor } from "#sitegen/mime";

View file

@ -100,22 +100,21 @@ export function highlightLinksInTextView(
// Case 1: https:// or http:// URLs // Case 1: https:// or http:// URLs
if (match.startsWith("http")) { if (match.startsWith("http")) {
if (match.includes(findDomain)) { if (match.includes(findDomain)) {
return `<a href="${ return `<a href="${match
match .replace(/https?:\/\/paperclover\.net\/+/, "/")
.replace(/https?:\/\/paperclover\.net\/+/, "/") .replace(/\/\/+/g, "/")}">${match}</a>`;
.replace(/\/\/+/g, "/")
}">${match}</a>`;
} }
return `<a href="${ return `<a href="${match.replace(
match.replace(/\/\/+/g, "/") /\/\/+/g,
}" target="_blank" rel="noopener noreferrer">${match}</a>`; "/",
)}" target="_blank" rel="noopener noreferrer">${match}</a>`;
} }
// Case 2: domain URLs without protocol // Case 2: domain URLs without protocol
if (match.startsWith(findDomain)) { if (match.startsWith(findDomain)) {
return `<a href="${ return `<a href="${match
match.replace(findDomain + "/", "/").replace(/\/\/+/g, "/") .replace(findDomain + "/", "/")
}">${match}</a>`; .replace(/\/\/+/g, "/")}">${match}</a>`;
} }
// Case 3: /file/ URLs // Case 3: /file/ URLs
@ -146,7 +145,7 @@ export function highlightLinksInTextView(
// Match sibling file names (only if they're not already part of a link) // Match sibling file names (only if they're not already part of a link)
if (siblingFiles.length > 0) { if (siblingFiles.length > 0) {
const escapedBasenames = siblingFiles.map((f) => const escapedBasenames = siblingFiles.map((f) =>
f.basename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") f.basename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
); );
const pattern = new RegExp(`\\b(${escapedBasenames.join("|")})\\b`, "g"); const pattern = new RegExp(`\\b(${escapedBasenames.join("|")})\\b`, "g");
const parts = processedText.split(/(<[^>]*>)/); const parts = processedText.split(/(<[^>]*>)/);
@ -156,9 +155,9 @@ export function highlightLinksInTextView(
parts[i] = parts[i].replace(pattern, (match: string) => { parts[i] = parts[i].replace(pattern, (match: string) => {
const file = siblingLookup[match]; const file = siblingLookup[match];
if (file) { if (file) {
return `<a href="/file/${ return `<a href="/file/${file.path
file.path.replace(/^\//, "").replace(/\/\/+/g, "/") .replace(/^\//, "")
}">${match}</a>`; .replace(/\/\/+/g, "/")}">${match}</a>`;
} }
return match; return match;
}); });
@ -241,11 +240,9 @@ export function highlightConvo(text: string) {
return paras return paras
.map(({ speaker, lines }) => { .map(({ speaker, lines }) => {
return `<div class="s-${speaker}">${ return `<div class="s-${speaker}">${lines
lines .map((line) => `<div class="line">${line}</div>`)
.map((line) => `<div class="line">${line}</div>`) .join("\n")}</div>`;
.join("\n")
}</div>`;
}) })
.join("\n"); .join("\n");
} }
@ -267,22 +264,14 @@ const unknownDateWithKnownYear = new Date("1970-02-20");
export function formatDate(dateTime: Date) { export function formatDate(dateTime: Date) {
return dateTime < unknownDateWithKnownYear return dateTime < unknownDateWithKnownYear
? ( ? dateTime < unknownDate
dateTime < unknownDate ? "??.??.??"
? ( : `xx.xx.${21 + Math.floor(dateTime.getTime() / 86400000)}`
"??.??.??" : `${(dateTime.getMonth() + 1).toString().padStart(2, "0")}.${dateTime
) .getDate()
: `xx.xx.${21 + Math.floor(dateTime.getTime() / 86400000)}` .toString()
) .padStart(2, "0")}.${dateTime.getFullYear().toString().slice(2)}`;
: (
`${(dateTime.getMonth() + 1).toString().padStart(2, "0")}.${
dateTime
.getDate()
.toString()
.padStart(2, "0")
}.${dateTime.getFullYear().toString().slice(2)}`
);
} }
import type { MediaFile } from "@/file-viewer/models/MediaFile.ts"; import type { MediaFile } from "@/file-viewer/models/MediaFile.ts";
import { escapeHtml } from "#ssr"; import { escapeHtml } from "#engine/render";

View file

@ -113,7 +113,7 @@ function highlightLines({
const str = lines[i].slice(token.startIndex, token.endIndex); const str = lines[i].slice(token.startIndex, token.endIndex);
if (str.trim().length === 0) { if (str.trim().length === 0) {
// Emit but do not consider scope changes // Emit but do not consider scope changes
html += ssr.escapeHtml(str); html += render.escapeHtml(str);
continue; continue;
} }
@ -122,7 +122,7 @@ function highlightLines({
if (lastHtmlStyle) html += "</span>"; if (lastHtmlStyle) html += "</span>";
if (style) html += `<span class='${style}'>`; if (style) html += `<span class='${style}'>`;
} }
html += ssr.escapeHtml(str); html += render.escapeHtml(str);
lastHtmlStyle = style; lastHtmlStyle = style;
} }
html += "\n"; html += "\n";
@ -197,4 +197,4 @@ import * as fs from "#sitegen/fs";
import * as path from "node:path"; import * as path from "node:path";
import * as oniguruma from "vscode-oniguruma"; import * as oniguruma from "vscode-oniguruma";
import * as textmate from "vscode-textmate"; import * as textmate from "vscode-textmate";
import * as ssr from "#ssr"; import * as render from "#engine/render";

View file

@ -104,13 +104,15 @@ questionRules.insertBefore("paragraph", {
assert(x.startsWith("q: ")); assert(x.startsWith("q: "));
return x.slice(3); return x.slice(3);
}); });
const content = lines.map((line, i) => { const content = lines
const parsed = parseInline(parse, line, state); .map((line, i) => {
if (i < lines.length - 1) { const parsed = parseInline(parse, line, state);
parsed.push({ type: "br" }); if (i < lines.length - 1) {
} parsed.push({ type: "br" });
return parsed; }
}).flat(); return parsed;
})
.flat();
return { return {
content, content,
}; };
@ -140,7 +142,13 @@ function BasicNode(node: ASTNode, children: any) {
function ListRenderer(node: ASTNode, children: any[]) { function ListRenderer(node: ASTNode, children: any[]) {
const T = node.ordered ? "ol" : "ul"; const T = node.ordered ? "ol" : "ul";
return <T>{children.map((child) => <li>{child}</li>)}</T>; return (
<T>
{children.map((child) => (
<li>{child}</li>
))}
</T>
);
} }
function MarkdownLink(node: ASTNode, children: any[]) { function MarkdownLink(node: ASTNode, children: any[]) {
@ -174,7 +182,11 @@ function ArtifactRef(node: ASTNode) {
if (!url) { if (!url) {
return <span>{title}</span>; return <span>{title}</span>;
} }
return <a class={`custom artifact artifact-${type}`} href={url}>{title}</a>; return (
<a class={`custom artifact artifact-${type}`} href={url}>
{title}
</a>
);
} }
const br = <br />; const br = <br />;
@ -202,7 +214,7 @@ const ssrFunctions: any = {
// Custom Elements // Custom Elements
question: BasicNode, question: BasicNode,
html: (node: any) => ssr.html(node.data), html: (node: any) => render.raw(node.data),
mentionQuestion: QuestionRef, mentionQuestion: QuestionRef,
mentionArtifact: ArtifactRef, mentionArtifact: ArtifactRef,
}; };
@ -247,4 +259,4 @@ import {
parseCaptureInline, parseCaptureInline,
parseInline, parseInline,
} from "./simple-markdown.ts"; } from "./simple-markdown.ts";
import * as ssr from "#ssr"; import * as render from "#engine/render";

View file

@ -1,6 +1,6 @@
import { EditorState } from "@codemirror/state"; import { EditorState } from "@codemirror/state";
import { basicSetup, EditorView } from "codemirror"; import { basicSetup, EditorView } from "codemirror";
import { ssrSync } from "#ssr"; import * as render from "#engine/render";
import type { ScriptPayload } from "@/q+a/views/editor.marko"; import type { ScriptPayload } from "@/q+a/views/editor.marko";
import QuestionRender from "@/q+a/tags/question.marko"; import QuestionRender from "@/q+a/tags/question.marko";
@ -11,7 +11,7 @@ const main = document.getElementById("edit-grid")! as HTMLDivElement;
const preview = document.getElementById("preview")! as HTMLDivElement; const preview = document.getElementById("preview")! as HTMLDivElement;
function updatePreview(text: string) { function updatePreview(text: string) {
preview.innerHTML = ssrSync( preview.innerHTML = render.sync(
<QuestionRender <QuestionRender
question={{ question={{
id: payload.id, id: payload.id,
@ -96,9 +96,9 @@ function wrapAction(cb: () => Promise<void>) {
return async () => { return async () => {
main.style.opacity = "0.5"; main.style.opacity = "0.5";
main.style.pointerEvents = "none"; main.style.pointerEvents = "none";
const inputs = main.querySelectorAll("button,select,input") as NodeListOf< const inputs = main.querySelectorAll(
HTMLButtonElement "button,select,input",
>; ) as NodeListOf<HTMLButtonElement>;
inputs.forEach((b) => { inputs.forEach((b) => {
b.disabled = true; b.disabled = true;
}); });

View file

@ -4,7 +4,7 @@
"baseUrl": ".", "baseUrl": ".",
"incremental": true, "incremental": true,
"jsx": "react-jsxdev", "jsx": "react-jsxdev",
"jsxImportSource": "#ssr", "jsxImportSource": "#engine",
"lib": ["dom", "esnext", "esnext.iterator"], "lib": ["dom", "esnext", "esnext.iterator"],
"module": "nodenext", "module": "nodenext",
"noEmit": true, "noEmit": true,