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) {
await Promise.all(Object.keys(metafile.inputs)
.filter(file => !isIgnoredSource(file))
.map(file => io.trackFile(file)));
await Promise.all(
Object.keys(metafile.inputs)
.filter((file) => !isIgnoredSource(file))
.map((file) => io.trackFile(file)),
);
}
// This file implements client-side bundling, mostly wrapping esbuild.
export async function bundleClientJavaScript(
io: Io,
{ clientRefs, extraPublicScripts, dev = false }: {
{
clientRefs,
extraPublicScripts,
dev = false,
}: {
clientRefs: string[];
extraPublicScripts: string[];
dev: boolean;
},
) {
const entryPoints = [
...new Set([
...clientRefs.map((x) => `src/${x}`),
...extraPublicScripts,
].map(toAbs)),
...new Set(
[...clientRefs.map((x) => `src/${x}`), ...extraPublicScripts].map(toAbs),
),
];
if (entryPoints.length === 0) return {};
const invalidFiles = entryPoints
.filter((file) => !file.match(/\.client\.[tj]sx?/));
const invalidFiles = entryPoints.filter(
(file) => !file.match(/\.client\.[tj]sx?/),
);
if (invalidFiles.length > 0) {
const cwd = process.cwd();
throw new Error(
@ -35,7 +41,8 @@ export async function bundleClientJavaScript(
markoViaBuildCache(),
];
const bundle = await esbuild.build({
const bundle = await esbuild
.build({
assetNames: "/asset/[hash]",
bundle: true,
chunkNames: "/js/c.[hash]",
@ -44,7 +51,7 @@ export async function bundleClientJavaScript(
format: "esm",
jsx: "automatic",
jsxDev: dev,
jsxImportSource: "#ssr",
jsxImportSource: "#engine",
logLevel: "silent",
metafile: true,
minify: !dev,
@ -52,10 +59,11 @@ export async function bundleClientJavaScript(
plugins: clientPlugins,
write: false,
define: {
"ASSERT": "console.assert",
ASSERT: "console.assert",
MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText),
},
}).catch((err: any) => {
})
.catch((err: any) => {
err.message = `Client ${err.message}`;
throw err;
});
@ -65,15 +73,16 @@ export async function bundleClientJavaScript(
"JS bundle failed",
);
}
const publicScriptRoutes = extraPublicScripts.map((file) =>
const publicScriptRoutes = extraPublicScripts.map(
(file) =>
"/js/" +
path.relative(hot.projectSrc, file).replaceAll("\\", "/").replace(
/\.client\.[tj]sx?/,
".js",
)
path
.relative(hot.projectSrc, file)
.replaceAll("\\", "/")
.replace(/\.client\.[tj]sx?/, ".js"),
);
const { metafile, outputFiles } = bundle;
const p = []
const p = [];
p.push(trackEsbuild(io, metafile));
const scripts: Record<string, string> = {};
for (const file of outputFiles) {
@ -100,47 +109,62 @@ export async function bundleClientJavaScript(
export type ServerPlatform = "node" | "passthru";
export interface ServerSideOptions {
entries: string[],
viewItems: sg.FileItem[]
viewRefs: incr.Ref<PreparedView>[],
entries: string[];
viewItems: sg.FileItem[];
viewRefs: incr.Ref<PreparedView>[];
styleMap: Map<string, incr.Ref<string>>;
scriptMap: incr.Ref<Record<string, string>>;
platform: ServerPlatform,
platform: ServerPlatform;
}
export async function bundleServerJavaScript(
{ viewItems, viewRefs, styleMap, scriptMap: wScriptMap, entries, platform }: ServerSideOptions
) {
export async function bundleServerJavaScript({
viewItems,
viewRefs,
styleMap,
scriptMap: wScriptMap,
entries,
platform,
}: ServerSideOptions) {
const wViewSource = incr.work(async (_, viewItems: sg.FileItem[]) => {
const magicWord = "C_" + crypto.randomUUID().replaceAll("-", "_");
return {
magicWord,
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]`,
`export const scripts = ${magicWord}[-1]`,
"export const views = {",
...viewItems.map((view, i) => [
...viewItems.map((view, i) =>
[
` ${JSON.stringify(view.id)}: {`,
` component: view${i}.default,`,
` meta: view${i}.meta,`,
` 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 pkg = await io.readJson<{ dependencies: Record<string, string>; }>("package.json");
const wBundles = entries.map(
(entry) =>
[
entry,
incr.work(async (io, entry) => {
const pkg = await io.readJson<{
dependencies: Record<string, string>;
}>("package.json");
let magicWord = null as string | null;
// -- plugins --
const serverPlugins: esbuild.Plugin[] = [
virtualFiles({
// only add dependency when imported.
"$views": async () => {
$views: async () => {
const view = await io.readWork(wViewSource);
({ magicWord } = view);
return view.file;
@ -152,8 +176,10 @@ export async function bundleServerJavaScript(
name: "replace client references",
setup(b) {
b.onLoad({ filter: /\.tsx?$/ }, async ({ path: file }) => ({
contents:
hot.resolveClientRefs(await fs.readFile(file, "utf-8"), file).code,
contents: hot.resolveClientRefs(
await fs.readFile(file, "utf-8"),
file,
).code,
loader: path.extname(file).slice(1) as esbuild.Loader,
}));
},
@ -161,24 +187,26 @@ export async function bundleServerJavaScript(
{
name: "mark css external",
setup(b) {
b.onResolve(
{ filter: /\.css$/ },
() => ({ path: ".", namespace: "dropped" }),
);
b.onLoad(
{ filter: /./, namespace: "dropped" },
() => ({ contents: "" }),
);
b.onResolve({ filter: /\.css$/ }, () => ({
path: ".",
namespace: "dropped",
}));
b.onLoad({ filter: /./, namespace: "dropped" }, () => ({
contents: "",
}));
},
},
];
const { metafile, outputFiles, errors, warnings } = await esbuild.build({
const { metafile, outputFiles } = await esbuild.build({
bundle: true,
chunkNames: "c.[hash]",
entryNames: path.basename(entry, path.extname(entry)),
entryPoints: [
path.join(import.meta.dirname, "backend/entry-" + platform + ".ts"),
path.join(
import.meta.dirname,
"backend/entry-" + platform + ".ts",
),
],
platform: "node",
format: "esm",
@ -190,16 +218,17 @@ export async function bundleServerJavaScript(
write: false,
metafile: true,
jsx: "automatic",
jsxImportSource: "#ssr",
jsxImportSource: "#engine",
jsxDev: false,
define: {
MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText),
'globalThis.CLOVER_SERVER_ENTRY': JSON.stringify(entry),
"globalThis.CLOVER_SERVER_ENTRY": JSON.stringify(entry),
},
external: Object.keys(pkg.dependencies)
.filter((x) => !x.startsWith("@paperclover")),
external: Object.keys(pkg.dependencies).filter(
(x) => !x.startsWith("@paperclover"),
),
});
await trackEsbuild(io, metafile)
await trackEsbuild(io, metafile);
let fileWithMagicWord: {
bytes: Buffer;
@ -213,7 +242,10 @@ export async function bundleServerJavaScript(
// mark this file as the one for replacement. Because
// `splitting` is `true`, esbuild will not emit this
// file in more than one chunk.
if (magicWord && metafile.outputs[key].inputs["framework/lib/view.ts"]) {
if (
magicWord &&
metafile.outputs[key].inputs["framework/lib/view.ts"]
) {
ASSERT(!fileWithMagicWord);
fileWithMagicWord = {
basename,
@ -221,35 +253,40 @@ export async function bundleServerJavaScript(
magicWord,
};
} else {
io.writeFile(basename, Buffer.from(output.contents))
io.writeFile(basename, Buffer.from(output.contents));
}
}
return fileWithMagicWord;
}, entry)] as const);
}, entry),
] as const,
);
const wProcessed = wBundles.map(async ([entry, wBundle]) => {
if (!await wBundle) return;
if (!(await wBundle)) return;
await incr.work(async (io) => {
// Only the reachable resources need to be read and inserted into the bundle.
// This is what Map<string, incr.Ref> is for
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
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));
// CSS
const viewStyleKeys = views.map((view) => view.styleKey);
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));
// Replace the magic word
const text = bytes.toString("utf-8").replace(
new RegExp(magicWord + "\\[(-?\\d+)\\]", "gs"),
(_, i) => {
const text = bytes
.toString("utf-8")
.replace(new RegExp(magicWord + "\\[(-?\\d+)\\]", "gs"), (_, i) => {
i = Number(i);
// Inline the styling data
if (i === -2) {
@ -261,17 +298,15 @@ export async function bundleServerJavaScript(
}
// Reference an index into `styleList`
return `${styleList.indexOf(viewCssBundles[i])}`;
},
);
});
io.writeFile(basename, text);
});
})
});
await Promise.all(wProcessed);
}
import * as esbuild from "esbuild";
import * as path from "node:path";
import process from "node:process";
@ -282,9 +317,8 @@ import {
projectRelativeResolution,
virtualFiles,
} from "./esbuild-support.ts";
import { Io, toAbs, toRel } from "./incremental.ts";
import * as css from "./css.ts";
import { Io, toAbs } from "./incremental.ts";
import * as fs from "#sitegen/fs";
import * as mime from "#sitegen/mime";
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(
type: string | engine.Component,
type: string | render.Component,
props: Record<string, unknown>,
): engine.Element {
): render.Element {
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(
type: string | engine.Component,
type: string | render.Component,
props: Record<string, unknown>,
// Unused with the clover engine
_key: string,
// Unused with the clover engine
_isStaticChildren: boolean,
source: engine.SrcLoc,
): engine.Element {
source: render.SrcLoc,
): render.Element {
const { fileName, lineNumber, columnNumber } = source;
// Assert the component type is valid to render.
if (typeof type !== "function" && typeof type !== "string") {
throw new Error(
`Invalid component type at ${fileName}:${lineNumber}:${columnNumber}: ` +
engine.inspect(type) +
render.inspect(type) +
". Clover SSR element must be a function or string",
);
}
// Construct an `ssr.Element`
return [engine.kElement, type, props, "", source];
return [render.kElement, type, props, "", source];
}
// jsxs
@ -45,10 +45,10 @@ declare global {
interface ElementChildrenAttribute {
children: Node;
}
type Element = engine.Element;
type ElementType = keyof IntrinsicElements | engine.Component;
type ElementClass = ReturnType<engine.Component>;
type Element = render.Element;
type ElementType = keyof IntrinsicElements | render.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,
renderer: ServerRenderer,
) => {
const { render } = marko.createTemplate(templateId, renderer);
const { render: renderFn } = marko.createTemplate(templateId, renderer);
function wrap(props: Record<string, unknown>, n: number) {
// Marko Custom Tags
const cloverAsyncMarker = { isAsync: false };
let r: engine.Render | undefined = undefined;
try {
r = engine.getCurrentRender();
} catch {}
const r = render.current;
// Support using Marko outside of Clover SSR
if (r) {
engine.setCurrentRender(null);
const markoResult = render.call(renderer, {
if (!r) return renderer(props, n);
const markoResult = renderFn.call(renderer, {
...props,
$global: { clover: r, cloverAsyncMarker },
});
if (cloverAsyncMarker.isAsync) {
return markoResult.then(engine.html);
return markoResult.then(render.raw);
}
const rr = markoResult.toString();
return engine.html(rr);
} else {
return renderer(props, n);
}
return render.raw(rr);
}
wrap.render = render;
wrap.unwrapped = renderer;
@ -56,20 +49,16 @@ export const dynamicTag = (
tag = unwrapped;
break clover;
}
let r: engine.Render;
try {
r = engine.getCurrentRender();
if (!r) throw 0;
} catch {
r = marko.$global().clover as engine.Render;
}
const r = render.current ?? (marko.$global().clover as render.State);
if (!r) throw new Error("No Clover Render Active");
const subRender = engine.initRender(r.async !== -1, r.addon);
const resolved = engine.resolveNode(subRender, [
engine.kElement,
tag,
inputOrArgs,
]);
const subRender = render.init(r.async !== -1, r.addon);
const resolved = render.resolveNode(
subRender,
render.element(
tag as render.Component,
inputOrArgs as Record<any, any>,
),
);
if (subRender.async > 0) {
const marker = marko.$global().cloverAsyncMarker as Async;
@ -79,7 +68,7 @@ export const dynamicTag = (
const { resolve, reject, promise } = Promise.withResolvers<string>();
subRender.asyncDone = () => {
const rejections = subRender.rejections;
if (!rejections) return resolve(engine.renderNode(resolved));
if (!rejections) return resolve(render.stringifyNode(resolved));
(r.rejections ??= []).push(...rejections);
return reject(new Error("Render had errors"));
};
@ -91,7 +80,7 @@ export const dynamicTag = (
0,
);
} else {
marko.write(engine.renderNode(resolved));
marko.write(render.stringifyNode(resolved));
}
return;
}
@ -124,13 +113,15 @@ export function escapeXML(input: unknown) {
// creating `[object Object]` is universally useless to any end user.
if (
input == null ||
(typeof input === "object" && input &&
(typeof input === "object" &&
input &&
// only block this if it's the default `toString`
input.toString === Object.prototype.toString)
) {
throw new Error(
`Unexpected value in template placeholder: '` +
engine.inspect(input) + "'. " +
render.inspect(input) +
"'. " +
`To emit a literal '${input}', use \${String(value)}`,
);
}
@ -141,7 +132,7 @@ interface Async {
isAsync: boolean;
}
import * as engine from "./ssr.ts";
import * as render from "#engine/render";
import type { ServerRenderer } from "marko/html/template";
import { type Accessor } from "marko/common/types";
import * as marko from "#marko/html";

View file

@ -1,31 +1,31 @@
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) =>
t.assert.equal(
engine.ssrSync(
render.sync(
<main class={["a", "b"]}>
<h1 style="background-color:red">hello world</h1>
<p>haha</p>
{1}|
{0}|
{true}|
{false}|
{null}|
{undefined}|
{1}|{0}|{true}|{false}|{null}|{undefined}|
</main>,
).text,
'<main class="a b"><h1 style=background-color:red>hello world</h1><p>haha</p>1|0|||||</main>',
));
test("unescaped/escaped html", (t) =>
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>",
));
test("clsx built-in", (t) =>
t.assert.equal(
engine.ssrSync(
render.sync(
<>
<a class="a" />
<b class={null} />

View file

@ -6,43 +6,66 @@
// Add-ons to the rendering engine can provide opaque data, And retrieve it
// within component calls with 'getAddonData'. For example, 'sitegen' uses this
// to track needed client scripts without introducing patches to the engine.
export type Addons = Record<string | symbol, unknown>;
export function ssrSync<A extends Addons>(node: Node, addon: A = {} as A) {
const r = initRender(false, addon);
const resolved = resolveNode(r, node);
return { text: renderNode(resolved), addon };
export let current: State | null = null;
export function setCurrent(r: State | null) {
current = r ?? null;
}
export { ssrSync as sync };
export function ssrAsync<A extends Addons>(node: Node, addon: A = {} as A) {
const r = initRender(true, addon);
const resolved = resolveNode(r, node);
if (r.async === 0) {
return Promise.resolve({ text: renderNode(resolved), addon });
/* Convert a UI description into a string synchronously. */
export function sync<A extends Addons>(node: Node, addon: A = {} as A) {
const state = init(false, addon);
const resolved = resolveNode(state, node);
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>>();
r.asyncDone = () => {
const rejections = r.rejections;
if (!rejections) return resolve({ text: renderNode(resolved), addon });
state.asyncDone = () => {
const rejections = state.rejections;
if (!rejections) return resolve({ text: stringifyNode(resolved), addon });
if (rejections.length === 1) return reject(rejections[0]);
return reject(new AggregateError(rejections));
};
return promise;
}
export { ssrAsync as async };
/** Inline HTML into a render without escaping it */
export function html(rawText: ResolvedNode): DirectHtml {
return [kDirectHtml, rawText];
}
interface Result<A extends Addons = Addons> {
export type Addons = Record<symbol, unknown>;
export interface Result<A extends Addons = Addons> {
text: string;
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
* Number of async promises the render is waiting on.
@ -56,35 +79,29 @@ export interface Render {
}
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. */
export type Node =
| number
| string // Escape HTML
| Node[] // Concat
| Element // Render
| DirectHtml // Insert
| Element // Stringify
| Raw // Insert
| Promise<Node> // Await
// Ignore
| undefined
| null
| boolean;
| (undefined | null | boolean);
export type Element = [
tag: typeof kElement,
type: string | Component,
props: Record<string, unknown>,
_?: "",
_?: "", // children
source?: SrcLoc,
];
export type DirectHtml = [tag: typeof kDirectHtml, html: ResolvedNode];
/**
* Components must return a value; 'undefined' is prohibited here
* to avoid functions that are missing a return statement.
*/
export type Component = (
props: Record<any, any>,
) => Exclude<Node, undefined>;
export type Raw = [tag: typeof kRaw, html: ResolvedNode];
/** Components must return a value; 'undefined' is prohibited here
* to avoid functions that are missing a return statement. */
export type Component = (props: Record<any, any>) => Exclude<Node, undefined>;
/** Emitted by JSX runtime */
export interface SrcLoc {
fileName: string;
@ -94,10 +111,10 @@ export interface SrcLoc {
/**
* 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.
*/
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 (typeof node !== "object") {
if (node === true) return ""; // booleans are ignored
@ -132,7 +149,7 @@ export function resolveNode(r: Render, node: unknown): ResolvedNode {
if (type === kElement) {
const { 1: tag, 2: props } = node;
if (typeof tag === "function") {
currentRender = r;
current = r;
try {
return resolveNode(r, tag(props));
} 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;
throw e;
} finally {
currentRender = null;
current = null;
}
}
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)];
return node;
}
if (type === kDirectHtml) return node[1];
if (type === kRaw) return node[1];
return node.map((elem) => resolveNode(r, elem));
}
export type ResolvedNode =
| ResolvedNode[] // Concat
| ResolvedElement // Render
| string; // Direct HTML
| string; // Raw HTML
export type ResolvedElement = [
tag: typeof kElement,
type: string,
@ -174,23 +191,23 @@ export type InsertionPoint = [null | ResolvedNode];
* Convert 'ResolvedNode' into HTML text. This operation happens after all
* async work is settled. The HTML is emitted as concisely as possible.
*/
export function renderNode(node: ResolvedNode): string {
export function stringifyNode(node: ResolvedNode): string {
if (typeof node === "string") return node;
ASSERT(node, "Unresolved Render Node");
const type = node[0];
if (type === kElement) {
return renderElement(node as ResolvedElement);
return stringifyElement(node as ResolvedElement);
}
node = node as ResolvedNode[]; // TS cannot infer.
let out = type ? renderNode(type) : "";
let out = type ? stringifyNode(type) : "";
let len = node.length;
for (let i = 1; i < len; i++) {
const elem = node[i];
if (elem) out += renderNode(elem);
if (elem) out += stringifyNode(elem);
}
return out;
}
function renderElement(element: ResolvedElement) {
function stringifyElement(element: ResolvedElement) {
const { 1: tag, 2: props, 3: children } = element;
let out = "<" + tag;
let needSpace = true;
@ -216,26 +233,30 @@ function renderElement(element: ResolvedElement) {
case "key":
continue;
}
if (needSpace) out += " ", needSpace = !attr.endsWith('"');
if (needSpace) (out += " "), (needSpace = !attr.endsWith('"'));
out += attr;
}
out += ">";
if (children) out += renderNode(children);
if (children) out += stringifyNode(children);
if (
tag !== "br" && tag !== "img" && tag !== "input" && tag !== "meta" &&
tag !== "link" && tag !== "hr"
tag !== "br" &&
tag !== "img" &&
tag !== "input" &&
tag !== "meta" &&
tag !== "link" &&
tag !== "hr"
) {
out += `</${tag}>`;
}
return out;
}
export function renderStyleAttribute(style: Record<string, string>) {
export function stringifyStyleAttribute(style: Record<string, string>) {
let out = ``;
for (const styleName in style) {
if (out) out += ";";
out += `${styleName.replace(/[A-Z]/g, "-$&").toLowerCase()}:${
escapeHtml(String(style[styleName]))
}`;
out += `${styleName.replace(/[A-Z]/g, "-$&").toLowerCase()}:${escapeHtml(
String(style[styleName]),
)}`;
}
return "style=" + quoteIfNeeded(out);
}
@ -246,7 +267,7 @@ export function quoteIfNeeded(text: string) {
// -- utility functions --
export function initRender(allowAsync: boolean, addon: Addons): Render {
export function init(allowAsync: boolean, addon: Addons): State {
return {
async: allowAsync ? 0 : -1,
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 function clsx(mix: ClsxInput) {
var k, y, str = "";
var k,
y,
str = "";
if (typeof mix === "string") {
return mix;
} else if (typeof mix === "object") {
@ -302,5 +305,17 @@ export function clsx(mix: ClsxInput) {
export const escapeHtml = (unsafeText: string) =>
String(unsafeText)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#x27;").replace(/`/g, "&#x60;");
.replace(/&/g, "&amp;")
.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
// are too verbose and not informative to what they actually do.
const kState = Symbol("SuspenseState");
const userData = render.userData<null | State>(() => null);
interface SuspenseProps {
children: ssr.Node;
fallback?: ssr.Node;
children: render.Node;
fallback?: render.Node;
}
interface State {
nested: boolean;
nextId: 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 {
const state = ssr.getUserData<State>(kState, () => {
throw new Error("Can only use <Suspense> with 'renderStreaming'");
});
export function Suspense({ children, fallback }: SuspenseProps): render.Node {
const state = userData.get();
if (!state) return children;
if (state.nested) throw new Error("<Suspense> cannot be nested");
const parent = ssr.getCurrentRender()!;
const r = ssr.initRender(true, { [kState]: { nested: true } });
const resolved = ssr.resolveNode(r, children);
if (r.async == 0) return ssr.html(resolved);
const name = "suspended_" + (++state.nextId);
const parent = UNWRAP(render.current);
const r = render.initRender(true, { [userData.key]: { nested: true } });
const resolved = render.resolveNode(r, children);
if (r.async == 0) return render.raw(resolved);
const name = "suspended_" + ++state.nextId;
state.nested = true;
const ip: [ssr.ResolvedNode] = [
[
ssr.kElement,
const ip: [render.ResolvedNode] = [
render.resolvedElement(
"slot",
{ name },
fallback ? ssr.resolveNode(parent, fallback) : "",
],
fallback ? render.resolveNode(parent, fallback) : "",
),
];
state.nested = false;
r.asyncDone = () => {
const rejections = r.rejections;
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
@ -54,17 +52,14 @@ export function Suspense({ children, fallback }: SuspenseProps): ssr.Node {
// - Before ~2024 needs to use a JS implementation.
// - IE should probably bail out entirely.
export async function* renderStreaming<
T extends ssr.Addons = Record<never, unknown>,
>(
node: ssr.Node,
addon: T = {} as T,
) {
T extends render.Addons = Record<never, unknown>,
>(node: render.Node, addon: T = {} as T) {
const {
text: begin,
addon: { [kState]: state, ...addonOutput },
} = await ssr.ssrAsync(node, {
addon: { [userData.key]: state, ...addonOutput },
} = await render.async(node, {
...addon,
[kState]: {
[userData.key]: {
nested: false,
nextId: 0,
completed: 0,
@ -79,24 +74,29 @@ export async function* renderStreaming<
let chunks: string[] = [];
state.pushChunk = (slot, node) => {
while (node.length === 1 && Array.isArray(node)) node = node[0];
if (node[0] === ssr.kElement) {
(node as ssr.ResolvedElement)[2].slot = slot;
if (node[0] === render.kElement) {
(node as render.ResolvedElement)[2].slot = slot;
} else {
node = [ssr.kElement, "clover-suspense", {
node = [
render.kElement,
"clover-suspense",
{
style: "display:contents",
slot,
}, node];
},
node,
];
}
chunks.push(ssr.renderNode(node));
chunks.push(render.stringifyNode(node));
resolve?.();
};
yield `<template shadowrootmode=open>${begin}</template>`;
do {
await new Promise<void>((done) => resolve = done);
await new Promise<void>((done) => (resolve = done));
yield* chunks;
chunks = [];
} while (state.nextId < state.completed);
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() {
// -- read config and discover files --
const siteConfig = await incr.work(readManifest);
const {
staticFiles,
scripts,
views,
pages,
} = await discoverAllFiles(siteConfig);
const { staticFiles, scripts, views, pages } =
await discoverAllFiles(siteConfig);
// TODO: make sure that `static` and `pages` does not overlap
@ -28,12 +24,15 @@ export async function generate() {
// -- perform build-time rendering --
const builtPages = pages.map((item) => incr.work(preparePage, item));
const builtViews = views.map((item) => incr.work(prepareView, item));
const builtStaticFiles = Promise.all((staticFiles.map((item) =>
const builtStaticFiles = Promise.all(
staticFiles.map((item) =>
incr.work(
async (io, { id, file }) => void await io.writeAsset(id, await io.readFile(file)),
async (io, { id, file }) =>
void (await io.writeAsset(id, await io.readFile(file))),
item,
)
)));
),
),
);
const routes = await Promise.all([...builtViews, ...builtPages]);
// -- page resources --
@ -47,23 +46,19 @@ export async function generate() {
// -- backend --
const builtBackend = bundle.bundleServerJavaScript({
entries: siteConfig.backends,
platform: 'node',
platform: "node",
styleMap,
scriptMap,
viewItems: views,
viewRefs: builtViews,
})
});
// -- assemble page assets --
const pAssemblePages = builtPages.map((page) =>
assembleAndWritePage(page, styleMap, scriptMap)
assembleAndWritePage(page, styleMap, scriptMap),
);
await Promise.all([
builtBackend,
builtStaticFiles,
...pAssemblePages,
]);
await Promise.all([builtBackend, builtStaticFiles, ...pAssemblePages]);
}
export async function readManifest(io: Io) {
@ -82,7 +77,7 @@ export async function discoverAllFiles(
return (
await Promise.all(
siteConfig.siteSections.map(({ root: sectionRoot }) =>
incr.work(scanSiteSection, toAbs(sectionRoot))
incr.work(scanSiteSection, toAbs(sectionRoot)),
),
)
).reduce((acc, next) => ({
@ -110,7 +105,8 @@ export async function scanSiteSection(io: Io, sectionRoot: string) {
let scripts: FileItem[] = [];
const sectionPath = (...sub: string[]) => path.join(sectionRoot, ...sub);
const rootPrefix = hot.projectSrc === sectionRoot
const rootPrefix =
hot.projectSrc === sectionRoot
? ""
: path.relative(hot.projectSrc, sectionRoot) + "/";
const kinds = [
@ -186,12 +182,8 @@ export async function preparePage(io: Io, item: sg.FileItem) {
theme: pageTheme,
layout,
} = await io.import<any>(item.file);
if (!Page) {
throw new Error("Page is missing a 'default' export.");
}
if (!metadata) {
throw new Error("Page is missing 'meta' export with a title.");
}
if (!Page) throw new Error("Page is missing a 'default' export.");
if (!metadata) throw new Error("Page is missing 'meta' export with a title.");
// -- css --
if (layout?.theme) pageTheme = layout.theme;
@ -210,12 +202,12 @@ export async function preparePage(io: Io, item: sg.FileItem) {
).then((m) => meta.renderMeta(m));
// -- html --
let page = [engine.kElement, Page, {}];
let page = render.element(Page);
if (layout?.default) {
page = [engine.kElement, layout.default, { children: page }];
page = render.element(layout.default, { children: page });
}
const bodyPromise = engine.ssrAsync(page, {
sitegen: sg.initRender(),
const bodyPromise = render.async(page, {
[sg.userData.key]: sg.initRender(),
});
const [{ text, addon }, renderedMeta] = await Promise.all([
@ -235,23 +227,16 @@ export async function preparePage(io: Io, item: sg.FileItem) {
cssImports,
theme: theme ?? null,
styleKey,
clientRefs: Array.from(addon.sitegen.scripts),
clientRefs: Array.from(addon[sg.userData.key].scripts),
};
}
export async function prepareView(io: Io, item: sg.FileItem) {
const module = await io.import<any>(item.file);
if (!module.meta) {
throw new Error(`${item.file} is missing 'export const meta'`);
}
if (!module.default) {
throw new Error(`${item.file} is missing a default export.`);
}
if (!module.meta) throw new Error(`View is missing 'export const meta'`);
if (!module.default) throw new Error(`View is missing a default export.`);
const pageTheme = module.layout?.theme ?? module.theme;
const theme: css.Theme = {
...css.defaultTheme,
...pageTheme,
};
const theme: css.Theme = { ...css.defaultTheme, ...pageTheme };
const cssImports = Array.from(
new Set([globalCssPath, ...hot.getCssImports(item.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 function prepareInlineCss(
items: Array<{
styleKey: string;
cssImports: string[];
theme: css.Theme;
}>,
items: Array<{ styleKey: string; cssImports: string[]; theme: css.Theme }>,
) {
const map = new Map<string, incr.Ref<string>>();
for (const { styleKey, cssImports, theme } of items) {
if (map.has(styleKey)) continue;
map.set(
styleKey,
incr.work(css.bundleCssFiles, {
cssImports,
theme,
dev: false,
}),
incr.work(css.bundleCssFiles, { cssImports, theme, dev: false }),
);
}
return map;
@ -297,16 +274,15 @@ export async function assembleAndWritePage(
scriptWork: incr.Ref<Record<string, string>>,
) {
const page = await pageWork;
return incr.work(
async (io, { id, html, meta, styleKey, clientRefs }) => {
return incr.work(async (io, { id, html, meta, styleKey, clientRefs }) => {
const inlineCss = await io.readWork(UNWRAP(styleMap.get(styleKey)));
const scriptIds = clientRefs.map(hot.getScriptId);
const scriptMap = await io.readWork(scriptWork);
const scripts = scriptIds.map((ref) =>
UNWRAP(scriptMap[ref], `Missing script ${ref}`)
)
.map((x) => `{${x}}`).join("\n");
const scripts = scriptIds
.map((ref) => UNWRAP(scriptMap[ref], `Missing script ${ref}`))
.map((x) => `{${x}}`)
.join("\n");
const doc = wrapDocument({
body: html,
@ -317,9 +293,7 @@ export async function assembleAndWritePage(
await io.writeAsset(id, doc, {
"Content-Type": "text/html",
});
},
page,
);
}, page);
}
import * as sg from "#sitegen";
@ -327,11 +301,10 @@ import * as incr from "./incremental.ts";
import { Io } from "./incremental.ts";
import * as bundle from "./bundle.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 fs from "#sitegen/fs";
import type { FileItem } from "#sitegen";
import * as path from "node:path";
import * as meta from "#sitegen/meta";
import { Spinner, withSpinner } from "@paperclover/console/Spinner";
import { wrapDocument } from "./lib/view.ts";

View file

@ -75,13 +75,13 @@ Module.prototype._compile = function (
imports.push(file);
(childModule.cloverImporters ??= []).push(this);
if (cloverClientRefs && cloverClientRefs.length > 0) {
(this.cloverClientRefs ??= [])
.push(...cloverClientRefs);
(this.cloverClientRefs ??= []).push(...cloverClientRefs);
}
}
}
fileStats.set(filename, {
cssImportsRecursive: cssImportsMaybe.length > 0
cssImportsRecursive:
cssImportsMaybe.length > 0
? Array.from(new Set(cssImportsMaybe))
: null,
imports,
@ -100,7 +100,8 @@ Module._resolveFilename = (...args) => {
return require.resolve(replacedPath, { paths: [projectSrc] });
} catch (err: any) {
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]);
}
@ -138,45 +139,45 @@ export function loadEsbuildCode(
src = code;
}
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.filename = ${JSON.stringify(filepath)};
`.trim().replace(/[\n\s]/g, "") + src;
`
.trim()
.replace(/[\n\s]/g, "") + src;
}
src = esbuild.transformSync(src, {
loader,
format: "cjs",
target: "esnext",
jsx: "automatic",
jsxImportSource: "#ssr",
jsxImportSource: "#engine",
jsxDev: true,
sourcefile: filepath,
sourcemap: 'inline',
sourcemap: "inline",
}).code;
return module._compile(src, filepath, "commonjs");
}
export function resolveClientRef(sourcePath: string, ref: string) {
const filePath = resolveFrom(sourcePath, ref);
if (
!filePath.endsWith(".client.ts") &&
!filePath.endsWith(".client.tsx")
) {
if (!filePath.endsWith(".client.ts") && !filePath.endsWith(".client.tsx")) {
throw new Error("addScript must be a .client.ts or .client.tsx");
}
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) {
lazyMarko ??= require<typeof import('./marko.ts')>("./framework/marko.ts");
lazyMarko ??= require<typeof import("./marko.ts")>("./framework/marko.ts");
lazyMarko.loadMarko(module, filepath);
}
function loadMdx(module: NodeJS.Module, filepath: string) {
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");
return loadEsbuildCode(module, filepath, src);
}
@ -194,7 +195,7 @@ export function reloadRecursive(filepath: string) {
}
export function unload(filepath: string) {
lazyMarko?.markoCache.delete(filepath)
lazyMarko?.markoCache.delete(filepath);
filepath = path.resolve(filepath);
const module = cache[filepath];
if (!module) return;
@ -291,8 +292,9 @@ export function resolveClientRefs(
}
export function getScriptId(file: string) {
return (path.isAbsolute(file) ? path.relative(projectSrc, file) : file)
.replaceAll("\\", "/");
return (
path.isAbsolute(file) ? path.relative(projectSrc, file) : file
).replaceAll("\\", "/");
}
declare global {
@ -300,7 +302,7 @@ declare global {
interface Module {
cloverClientRefs?: string[];
cloverSourceCode?: string;
cloverImporters?: Module[],
cloverImporters?: Module[];
_compile(
this: NodeJS.Module,
@ -312,10 +314,7 @@ declare global {
}
}
declare module "node:module" {
export function _resolveFilename(
id: string,
parent: NodeJS.Module,
): unknown;
export function _resolveFilename(id: string, parent: NodeJS.Module): unknown;
}
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.blob")),
]);
assets = {
map: JSON.parse(map),
buf,
};
return (assets = { map: JSON.parse(map), buf });
}
export async function reloadSync() {
export function reloadSync() {
const map = fs.readFileSync(
path.join(import.meta.dirname, "static.json"),
"utf8",
);
const buf = fs.readFileSync(path.join(import.meta.dirname, "static.blob"));
assets = {
map: JSON.parse(map),
buf,
};
return (assets = { map: JSON.parse(map), buf });
}
export async function middleware(c: Context, next: Next) {
if (!assets) await reload();
const asset = assets!.map[c.req.path];
if (asset) {
return assetInner(c, asset, 200);
}
if (asset) return assetInner(c, asset, 200);
return next();
}
@ -56,13 +48,11 @@ export async function serveAsset(
id: StaticPageId,
status: StatusCode,
) {
assets ?? await reload();
return assetInner(c, assets!.map[id], status);
return assetInner(c, (assets ?? (await reload())).map[id], status);
}
export function hasAsset(id: string) {
if (!assets) reloadSync();
return assets!.map[id] !== undefined;
return (assets ?? reloadSync()).map[id] !== undefined;
}
export function etagMatches(etag: string, ifNoneMatch: string) {
@ -78,13 +68,13 @@ function assetInner(c: Context, asset: BuiltAsset, status: StatusCode) {
if (ifnonematch) {
const etag = asset.headers.ETag;
if (etagMatches(etag, ifnonematch)) {
return c.res = new Response(null, {
return (c.res = new Response(null, {
status: 304,
statusText: "Not Modified",
headers: {
ETag: etag,
},
});
}));
}
}
const acceptEncoding = c.req.header("Accept-Encoding") ?? "";
@ -105,7 +95,7 @@ function assetInner(c: Context, asset: BuiltAsset, status: StatusCode) {
} else {
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) => {

View file

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

View file

@ -21,4 +21,4 @@ export interface AlternateType {
export function renderMeta({ title }: Meta): string {
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;
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 {
scripts: Set<string>;
}
export function initRender(): SitegenRender {
return {
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();
return { scripts: new Set() };
}
/** Add a client-side script to the page. */
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 {
root: string;
}
import * as ssr from "../engine/ssr.ts";
import * as render from "#engine/render";

View file

@ -1,10 +1,9 @@
// This import is generated by code 'bundle.ts'
export interface View {
component: engine.Component;
component: render.Component;
meta:
| meta.Meta
| ((props: { context?: hono.Context }) => Promise<meta.Meta> | meta.Meta);
layout?: engine.Component;
layout?: render.Component;
inlineCss: string;
scripts: Record<string, string>;
}
@ -12,9 +11,6 @@ export interface View {
let views: Record<string, View> = 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(
context: hono.Context,
id: string,
@ -44,11 +40,12 @@ export async function renderViewToString(
).then((m) => meta.renderMeta(m));
// -- html --
let page: engine.Element = [engine.kElement, component, props];
if (layout) page = [engine.kElement, layout, { children: page }];
const { text: body, addon: { sitegen } } = await engine.ssrAsync(page, {
sitegen: sg.initRender(),
});
let page: render.Element = render.element(component, props);
if (layout) page = render.element(layout, { children: page });
const {
text: body,
addon: { [sg.userData.key]: sitegen },
} = await render.async(page, { [sg.userData.key]: sg.initRender() });
// -- join document and send --
return wrapDocument({
@ -56,17 +53,15 @@ export async function renderViewToString(
head: await renderedMetaPromise,
inlineCss,
scripts: joinScripts(
Array.from(
sitegen.scripts,
(id) => UNWRAP(scripts[id], `Missing script ${id}`),
Array.from(sitegen.scripts, (id) =>
UNWRAP(scripts[id], `Missing script ${id}`),
),
),
});
}
export function provideViewData(v: typeof views, s: typeof scripts) {
views = v;
scripts = s;
(views = v), (scripts = s);
}
export function joinScripts(scriptSources: string[]) {
@ -96,5 +91,5 @@ export function wrapDocument({
import * as meta from "./meta.ts";
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";

View file

@ -1,4 +1,3 @@
console.log("MARKO");
export interface MarkoCacheEntry {
src: string;
scannedClientRefs: string[];
@ -8,7 +7,6 @@ export const markoCache = new Map<string, MarkoCacheEntry>();
export function loadMarko(module: NodeJS.Module, filepath: string) {
let cache = markoCache.get(filepath);
console.log({ filepath, has: !!cache })
if (!cache) {
let src = fs.readFileSync(filepath, "utf8");
// 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.
const scannedClientRefs = new Set<string>();
if (src.match(/^\s*client\s+import\s+["']/m)) {
src = src.replace(
src =
src.replace(
/^\s*client\s+import\s+("[^"]+"|'[^']+')[^\n]+/m,
(_, src) => {
const ref = JSON.parse(`"${src.slice(1, -1)}"`);
const resolved = hot.resolveClientRef(filepath, ref);
scannedClientRefs.add(resolved);
return `<CloverScriptInclude=${
JSON.stringify(hot.getScriptId(resolved))
} />`;
return `<CloverScriptInclude=${JSON.stringify(
hot.getScriptId(resolved),
)} />`;
},
) + '\nimport { addScript as CloverScriptInclude } from "#sitegen";\n';
}
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) };
markoCache.set(filepath, cache);
}

View file

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

View file

@ -3,7 +3,7 @@
this repository contains clover's "sitegen" framework, which is a set of tools
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
`clsx`, `html()` helper over `dangerouslySetInnerHTML` prop, etc).
- 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
node run generate
node .clover/out/server
node .clover/o/backend
# "development" watch mode
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
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 sizeHeader = encoding === "raw"
let sizeHeader =
encoding === "raw"
? expectedSize
// Size cannot be known because of compression modes
: undefined;
: // Size cannot be known because of compression modes
undefined;
// Etag
{
@ -294,7 +295,8 @@ function handleRanges(
): Response {
// TODO: multiple ranges
const rangeSize = ranges.reduce((a, b) => a + (b[1] - b[0] + 1), 0);
const rangeBody = streamOrBuffer instanceof ReadableStream
const rangeBody =
streamOrBuffer instanceof ReadableStream
? applySingleRangeToStream(streamOrBuffer, ranges)
: applyRangesToBuffer(streamOrBuffer, ranges, rangeSize);
return new Response(rangeBody, {
@ -380,9 +382,9 @@ function getPartialPage(c: Context, rawFilePath: string) {
if (!checkCotyledonCookie(c)) {
let root = Speedbump();
// 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");
return c.html(html);
}
@ -408,15 +410,15 @@ function getPartialPage(c: Context, rawFilePath: string) {
hasCotyledonCookie: rawFilePath === "" && checkCotyledonCookie(c),
});
// 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);
}
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 { renderView } from "#sitegen/view";
import { contentTypeFor } from "#sitegen/mime";

View file

@ -100,22 +100,21 @@ export function highlightLinksInTextView(
// Case 1: https:// or http:// URLs
if (match.startsWith("http")) {
if (match.includes(findDomain)) {
return `<a href="${
match
return `<a href="${match
.replace(/https?:\/\/paperclover\.net\/+/, "/")
.replace(/\/\/+/g, "/")
}">${match}</a>`;
.replace(/\/\/+/g, "/")}">${match}</a>`;
}
return `<a href="${
match.replace(/\/\/+/g, "/")
}" target="_blank" rel="noopener noreferrer">${match}</a>`;
return `<a href="${match.replace(
/\/\/+/g,
"/",
)}" target="_blank" rel="noopener noreferrer">${match}</a>`;
}
// Case 2: domain URLs without protocol
if (match.startsWith(findDomain)) {
return `<a href="${
match.replace(findDomain + "/", "/").replace(/\/\/+/g, "/")
}">${match}</a>`;
return `<a href="${match
.replace(findDomain + "/", "/")
.replace(/\/\/+/g, "/")}">${match}</a>`;
}
// 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)
if (siblingFiles.length > 0) {
const escapedBasenames = siblingFiles.map((f) =>
f.basename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
f.basename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
);
const pattern = new RegExp(`\\b(${escapedBasenames.join("|")})\\b`, "g");
const parts = processedText.split(/(<[^>]*>)/);
@ -156,9 +155,9 @@ export function highlightLinksInTextView(
parts[i] = parts[i].replace(pattern, (match: string) => {
const file = siblingLookup[match];
if (file) {
return `<a href="/file/${
file.path.replace(/^\//, "").replace(/\/\/+/g, "/")
}">${match}</a>`;
return `<a href="/file/${file.path
.replace(/^\//, "")
.replace(/\/\/+/g, "/")}">${match}</a>`;
}
return match;
});
@ -241,11 +240,9 @@ export function highlightConvo(text: string) {
return paras
.map(({ speaker, lines }) => {
return `<div class="s-${speaker}">${
lines
return `<div class="s-${speaker}">${lines
.map((line) => `<div class="line">${line}</div>`)
.join("\n")
}</div>`;
.join("\n")}</div>`;
})
.join("\n");
}
@ -267,22 +264,14 @@ const unknownDateWithKnownYear = new Date("1970-02-20");
export function formatDate(dateTime: Date) {
return dateTime < unknownDateWithKnownYear
? (
dateTime < unknownDate
? (
"??.??.??"
)
? dateTime < unknownDate
? "??.??.??"
: `xx.xx.${21 + Math.floor(dateTime.getTime() / 86400000)}`
)
: (
`${(dateTime.getMonth() + 1).toString().padStart(2, "0")}.${
dateTime
: `${(dateTime.getMonth() + 1).toString().padStart(2, "0")}.${dateTime
.getDate()
.toString()
.padStart(2, "0")
}.${dateTime.getFullYear().toString().slice(2)}`
);
.padStart(2, "0")}.${dateTime.getFullYear().toString().slice(2)}`;
}
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);
if (str.trim().length === 0) {
// Emit but do not consider scope changes
html += ssr.escapeHtml(str);
html += render.escapeHtml(str);
continue;
}
@ -122,7 +122,7 @@ function highlightLines({
if (lastHtmlStyle) html += "</span>";
if (style) html += `<span class='${style}'>`;
}
html += ssr.escapeHtml(str);
html += render.escapeHtml(str);
lastHtmlStyle = style;
}
html += "\n";
@ -197,4 +197,4 @@ import * as fs from "#sitegen/fs";
import * as path from "node:path";
import * as oniguruma from "vscode-oniguruma";
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: "));
return x.slice(3);
});
const content = lines.map((line, i) => {
const content = lines
.map((line, i) => {
const parsed = parseInline(parse, line, state);
if (i < lines.length - 1) {
parsed.push({ type: "br" });
}
return parsed;
}).flat();
})
.flat();
return {
content,
};
@ -140,7 +142,13 @@ function BasicNode(node: ASTNode, children: any) {
function ListRenderer(node: ASTNode, children: any[]) {
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[]) {
@ -174,7 +182,11 @@ function ArtifactRef(node: ASTNode) {
if (!url) {
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 />;
@ -202,7 +214,7 @@ const ssrFunctions: any = {
// Custom Elements
question: BasicNode,
html: (node: any) => ssr.html(node.data),
html: (node: any) => render.raw(node.data),
mentionQuestion: QuestionRef,
mentionArtifact: ArtifactRef,
};
@ -247,4 +259,4 @@ import {
parseCaptureInline,
parseInline,
} 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 { basicSetup, EditorView } from "codemirror";
import { ssrSync } from "#ssr";
import * as render from "#engine/render";
import type { ScriptPayload } from "@/q+a/views/editor.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;
function updatePreview(text: string) {
preview.innerHTML = ssrSync(
preview.innerHTML = render.sync(
<QuestionRender
question={{
id: payload.id,
@ -96,9 +96,9 @@ function wrapAction(cb: () => Promise<void>) {
return async () => {
main.style.opacity = "0.5";
main.style.pointerEvents = "none";
const inputs = main.querySelectorAll("button,select,input") as NodeListOf<
HTMLButtonElement
>;
const inputs = main.querySelectorAll(
"button,select,input",
) as NodeListOf<HTMLButtonElement>;
inputs.forEach((b) => {
b.disabled = true;
});

View file

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