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:
parent
8c0bd4c6c6
commit
30ad9c27ff
22 changed files with 606 additions and 624 deletions
|
@ -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,7 +41,8 @@ export async function bundleClientJavaScript(
|
||||||
markoViaBuildCache(),
|
markoViaBuildCache(),
|
||||||
];
|
];
|
||||||
|
|
||||||
const bundle = await esbuild.build({
|
const bundle = await esbuild
|
||||||
|
.build({
|
||||||
assetNames: "/asset/[hash]",
|
assetNames: "/asset/[hash]",
|
||||||
bundle: true,
|
bundle: true,
|
||||||
chunkNames: "/js/c.[hash]",
|
chunkNames: "/js/c.[hash]",
|
||||||
|
@ -44,7 +51,7 @@ export async function bundleClientJavaScript(
|
||||||
format: "esm",
|
format: "esm",
|
||||||
jsx: "automatic",
|
jsx: "automatic",
|
||||||
jsxDev: dev,
|
jsxDev: dev,
|
||||||
jsxImportSource: "#ssr",
|
jsxImportSource: "#engine",
|
||||||
logLevel: "silent",
|
logLevel: "silent",
|
||||||
metafile: true,
|
metafile: true,
|
||||||
minify: !dev,
|
minify: !dev,
|
||||||
|
@ -52,10 +59,11 @@ export async function bundleClientJavaScript(
|
||||||
plugins: clientPlugins,
|
plugins: clientPlugins,
|
||||||
write: false,
|
write: false,
|
||||||
define: {
|
define: {
|
||||||
"ASSERT": "console.assert",
|
ASSERT: "console.assert",
|
||||||
MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText),
|
MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText),
|
||||||
},
|
},
|
||||||
}).catch((err: any) => {
|
})
|
||||||
|
.catch((err: any) => {
|
||||||
err.message = `Client ${err.message}`;
|
err.message = `Client ${err.message}`;
|
||||||
throw err;
|
throw err;
|
||||||
});
|
});
|
||||||
|
@ -65,15 +73,16 @@ export async function bundleClientJavaScript(
|
||||||
"JS bundle failed",
|
"JS bundle failed",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const publicScriptRoutes = extraPublicScripts.map((file) =>
|
const publicScriptRoutes = extraPublicScripts.map(
|
||||||
|
(file) =>
|
||||||
"/js/" +
|
"/js/" +
|
||||||
path.relative(hot.projectSrc, file).replaceAll("\\", "/").replace(
|
path
|
||||||
/\.client\.[tj]sx?/,
|
.relative(hot.projectSrc, file)
|
||||||
".js",
|
.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,47 +109,62 @@ 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)}: {`,
|
` ${JSON.stringify(view.id)}: {`,
|
||||||
` component: view${i}.default,`,
|
` component: view${i}.default,`,
|
||||||
` meta: view${i}.meta,`,
|
` meta: view${i}.meta,`,
|
||||||
` layout: view${i}.layout?.default ?? null,`,
|
` layout: view${i}.layout?.default ?? null,`,
|
||||||
` inlineCss: styles[${magicWord}[${i}]]`,
|
` 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;
|
||||||
|
@ -152,8 +176,10 @@ export async function bundleServerJavaScript(
|
||||||
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"),
|
||||||
|
file,
|
||||||
|
).code,
|
||||||
loader: path.extname(file).slice(1) as esbuild.Loader,
|
loader: path.extname(file).slice(1) as esbuild.Loader,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
@ -161,24 +187,26 @@ export async function bundleServerJavaScript(
|
||||||
{
|
{
|
||||||
name: "mark css external",
|
name: "mark css external",
|
||||||
setup(b) {
|
setup(b) {
|
||||||
b.onResolve(
|
b.onResolve({ filter: /\.css$/ }, () => ({
|
||||||
{ filter: /\.css$/ },
|
path: ".",
|
||||||
() => ({ path: ".", namespace: "dropped" }),
|
namespace: "dropped",
|
||||||
);
|
}));
|
||||||
b.onLoad(
|
b.onLoad({ filter: /./, namespace: "dropped" }, () => ({
|
||||||
{ filter: /./, namespace: "dropped" },
|
contents: "",
|
||||||
() => ({ 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,
|
||||||
|
"backend/entry-" + platform + ".ts",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
platform: "node",
|
platform: "node",
|
||||||
format: "esm",
|
format: "esm",
|
||||||
|
@ -190,16 +218,17 @@ export async function bundleServerJavaScript(
|
||||||
write: false,
|
write: false,
|
||||||
metafile: true,
|
metafile: true,
|
||||||
jsx: "automatic",
|
jsx: "automatic",
|
||||||
jsxImportSource: "#ssr",
|
jsxImportSource: "#engine",
|
||||||
jsxDev: false,
|
jsxDev: false,
|
||||||
define: {
|
define: {
|
||||||
MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText),
|
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)
|
external: Object.keys(pkg.dependencies).filter(
|
||||||
.filter((x) => !x.startsWith("@paperclover")),
|
(x) => !x.startsWith("@paperclover"),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
await trackEsbuild(io, metafile)
|
await trackEsbuild(io, metafile);
|
||||||
|
|
||||||
let fileWithMagicWord: {
|
let fileWithMagicWord: {
|
||||||
bytes: Buffer;
|
bytes: Buffer;
|
||||||
|
@ -213,7 +242,10 @@ export async function bundleServerJavaScript(
|
||||||
// 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 (
|
||||||
|
magicWord &&
|
||||||
|
metafile.outputs[key].inputs["framework/lib/view.ts"]
|
||||||
|
) {
|
||||||
ASSERT(!fileWithMagicWord);
|
ASSERT(!fileWithMagicWord);
|
||||||
fileWithMagicWord = {
|
fileWithMagicWord = {
|
||||||
basename,
|
basename,
|
||||||
|
@ -221,35 +253,40 @@ export async function bundleServerJavaScript(
|
||||||
magicWord,
|
magicWord,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
io.writeFile(basename, Buffer.from(output.contents))
|
io.writeFile(basename, Buffer.from(output.contents));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return fileWithMagicWord;
|
return fileWithMagicWord;
|
||||||
}, entry)] as const);
|
}, 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";
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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(engine.html);
|
return markoResult.then(render.raw);
|
||||||
}
|
}
|
||||||
const rr = markoResult.toString();
|
const rr = markoResult.toString();
|
||||||
return engine.html(rr);
|
return render.raw(rr);
|
||||||
} else {
|
|
||||||
return renderer(props, n);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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";
|
||||||
|
|
|
@ -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 <3"));
|
test("sanity", (t) => t.assert.equal(render.sync("gm <3").text, "gm <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>"&'`<></div>",
|
"<div><fuck>"&'`<></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} />
|
|
@ -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, "&").replace(/</g, "<").replace(/>/g, ">")
|
.replace(/&/g, "&")
|
||||||
.replace(/"/g, """).replace(/'/g, "'").replace(/`/g, "`");
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/`/g, "`");
|
||||||
|
|
||||||
|
export function inspect(object: unknown) {
|
||||||
|
try {
|
||||||
|
return require("node:util").inspect(object);
|
||||||
|
} catch {
|
||||||
|
return typeof object;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 = [
|
||||||
|
render.kElement,
|
||||||
|
"clover-suspense",
|
||||||
|
{
|
||||||
style: "display:contents",
|
style: "display:contents",
|
||||||
slot,
|
slot,
|
||||||
}, node];
|
},
|
||||||
|
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";
|
||||||
|
|
|
@ -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(
|
||||||
|
staticFiles.map((item) =>
|
||||||
incr.work(
|
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,
|
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,7 +105,8 @@ 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 = [
|
||||||
|
@ -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,16 +274,15 @@ 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,
|
||||||
|
@ -317,9 +293,7 @@ export async function assembleAndWritePage(
|
||||||
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";
|
||||||
|
|
|
@ -75,13 +75,13 @@ 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:
|
||||||
|
cssImportsMaybe.length > 0
|
||||||
? Array.from(new Set(cssImportsMaybe))
|
? Array.from(new Set(cssImportsMaybe))
|
||||||
: null,
|
: null,
|
||||||
imports,
|
imports,
|
||||||
|
@ -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.url = ${JSON.stringify(pathToFileURL(filepath).toString())};
|
||||||
import.meta.dirname = ${JSON.stringify(path.dirname(filepath))};
|
import.meta.dirname = ${JSON.stringify(path.dirname(filepath))};
|
||||||
import.meta.filename = ${JSON.stringify(filepath)};
|
import.meta.filename = ${JSON.stringify(filepath)};
|
||||||
`.trim().replace(/[\n\s]/g, "") + src;
|
`
|
||||||
|
.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";
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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";
|
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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";
|
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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 =
|
||||||
|
src.replace(
|
||||||
/^\s*client\s+import\s+("[^"]+"|'[^']+')[^\n]+/m,
|
/^\s*client\s+import\s+("[^"]+"|'[^']+')[^\n]+/m,
|
||||||
(_, src) => {
|
(_, src) => {
|
||||||
const ref = JSON.parse(`"${src.slice(1, -1)}"`);
|
const ref = JSON.parse(`"${src.slice(1, -1)}"`);
|
||||||
const resolved = hot.resolveClientRef(filepath, ref);
|
const resolved = hot.resolveClientRef(filepath, ref);
|
||||||
scannedClientRefs.add(resolved);
|
scannedClientRefs.add(resolved);
|
||||||
return `<CloverScriptInclude=${
|
return `<CloverScriptInclude=${JSON.stringify(
|
||||||
JSON.stringify(hot.getScriptId(resolved))
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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",
|
||||||
|
|
12
readme.md
12
readme.md
|
@ -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.
|
||||||
|
|
||||||
|
|
|
@ -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 =
|
||||||
|
encoding === "raw"
|
||||||
? expectedSize
|
? expectedSize
|
||||||
// Size cannot be known because of compression modes
|
: // Size cannot be known because of compression modes
|
||||||
: undefined;
|
undefined;
|
||||||
|
|
||||||
// Etag
|
// Etag
|
||||||
{
|
{
|
||||||
|
@ -294,7 +295,8 @@ 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 =
|
||||||
|
streamOrBuffer instanceof ReadableStream
|
||||||
? applySingleRangeToStream(streamOrBuffer, ranges)
|
? applySingleRangeToStream(streamOrBuffer, ranges)
|
||||||
: applyRangesToBuffer(streamOrBuffer, ranges, rangeSize);
|
: applyRangesToBuffer(streamOrBuffer, ranges, rangeSize);
|
||||||
return new Response(rangeBody, {
|
return new Response(rangeBody, {
|
||||||
|
@ -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";
|
||||||
|
|
|
@ -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, "/")
|
.replace(/\/\/+/g, "/")}">${match}</a>`;
|
||||||
}">${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")
|
.join("\n")}</div>`;
|
||||||
}</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)}`
|
: `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()
|
.getDate()
|
||||||
.toString()
|
.toString()
|
||||||
.padStart(2, "0")
|
.padStart(2, "0")}.${dateTime.getFullYear().toString().slice(2)}`;
|
||||||
}.${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";
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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
|
||||||
|
.map((line, i) => {
|
||||||
const parsed = parseInline(parse, line, state);
|
const parsed = parseInline(parse, line, state);
|
||||||
if (i < lines.length - 1) {
|
if (i < lines.length - 1) {
|
||||||
parsed.push({ type: "br" });
|
parsed.push({ type: "br" });
|
||||||
}
|
}
|
||||||
return parsed;
|
return parsed;
|
||||||
}).flat();
|
})
|
||||||
|
.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";
|
||||||
|
|
|
@ -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;
|
||||||
});
|
});
|
||||||
|
|
|
@ -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,
|
||||||
|
|
Loading…
Reference in a new issue