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

hello world

haha

- {1}| - {0}| - {true}| - {false}| - {null}| - {undefined}| + {1}|{0}|{true}|{false}|{null}|{undefined}|
, ).text, '

hello world

haha

1|0|||||
', )); test("unescaped/escaped html", (t) => t.assert.equal( - engine.ssrSync(
{engine.html("")}{"\"&'`<>"}
).text, + render.sync( +
+ {render.raw("")} + {"\"&'`<>"} +
, + ).text, "
"&'`<>
", )); test("clsx built-in", (t) => t.assert.equal( - engine.ssrSync( + render.sync( <> diff --git a/framework/engine/ssr.ts b/framework/engine/render.ts similarity index 68% rename from framework/engine/ssr.ts rename to framework/engine/render.ts index 48a0ea2..dbb51e7 100644 --- a/framework/engine/ssr.ts +++ b/framework/engine/render.ts @@ -6,43 +6,66 @@ // Add-ons to the rendering engine can provide opaque data, And retrieve it // within component calls with 'getAddonData'. For example, 'sitegen' uses this // to track needed client scripts without introducing patches to the engine. -export type Addons = Record; - -export function ssrSync(node: Node, addon: A = {} as A) { - const r = initRender(false, addon); - const resolved = resolveNode(r, node); - return { text: renderNode(resolved), addon }; +export let current: State | null = null; +export function setCurrent(r: State | null) { + current = r ?? null; } -export { ssrSync as sync }; -export function ssrAsync(node: Node, addon: A = {} as A) { - const r = initRender(true, addon); - const resolved = resolveNode(r, node); - if (r.async === 0) { - return Promise.resolve({ text: renderNode(resolved), addon }); +/* Convert a UI description into a string synchronously. */ +export function sync(node: Node, addon: A = {} as A) { + const state = init(false, addon); + const resolved = resolveNode(state, node); + return { text: stringifyNode(resolved), addon }; +} + +/* Convert a UI description into a string asynchronously. */ +export function async(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>(); - r.asyncDone = () => { - const rejections = r.rejections; - if (!rejections) return resolve({ text: renderNode(resolved), addon }); + state.asyncDone = () => { + const rejections = state.rejections; + if (!rejections) return resolve({ text: stringifyNode(resolved), addon }); if (rejections.length === 1) return reject(rejections[0]); return reject(new AggregateError(rejections)); }; return promise; } -export { ssrAsync as async }; -/** Inline HTML into a render without escaping it */ -export function html(rawText: ResolvedNode): DirectHtml { - return [kDirectHtml, rawText]; -} - -interface Result { +export type Addons = Record; +export interface Result { text: string; addon: A; } -export interface Render { +export function userData(def: () => T) { + const k: unique symbol = Symbol(); + return { + key: k, + get: () => ((UNWRAP(current).addon[k] as T) ??= def() as T), + set: (value: T) => void (UNWRAP(current).addon[k] = value), + }; +} + +/** Inline HTML into a render without escaping it */ +export function raw(node: ResolvedNode): Raw { + return [kRaw, node]; +} +export function element(type: Element[1], props: Element[2] = {}): Element { + return [kElement, type, props]; +} +export function resolvedElement( + type: ResolvedElement[1], + props: ResolvedElement[2], + children: ResolvedElement[3], +): ResolvedElement { + return [kElement, type, props, children]; +} + +export interface State { /** * Set to '-1' if rendering synchronously * Number of async promises the render is waiting on. @@ -56,35 +79,29 @@ export interface Render { } export const kElement = Symbol("Element"); -export const kDirectHtml = Symbol("DirectHtml"); +export const kRaw = Symbol("Raw"); /** Node represents a webpage that can be 'rendered' into HTML. */ export type Node = | number | string // Escape HTML | Node[] // Concat - | Element // Render - | DirectHtml // Insert + | Element // Stringify + | Raw // Insert | Promise // Await // Ignore - | undefined - | null - | boolean; + | (undefined | null | boolean); export type Element = [ tag: typeof kElement, type: string | Component, props: Record, - _?: "", + _?: "", // children source?: SrcLoc, ]; -export type DirectHtml = [tag: typeof kDirectHtml, html: ResolvedNode]; -/** - * Components must return a value; 'undefined' is prohibited here - * to avoid functions that are missing a return statement. - */ -export type Component = ( - props: Record, -) => Exclude; +export type Raw = [tag: typeof kRaw, html: ResolvedNode]; +/** Components must return a value; 'undefined' is prohibited here + * to avoid functions that are missing a return statement. */ +export type Component = (props: Record) => Exclude; /** Emitted by JSX runtime */ export interface SrcLoc { fileName: string; @@ -94,10 +111,10 @@ export interface SrcLoc { /** * Resolution narrows the type 'Node' into 'ResolvedNode'. Async trees are - * marked in the 'Render'. This operation performs everything besides the final + * marked in 'State'. This operation performs everything besides the final * string concatenation. This function is agnostic across async/sync modes. */ -export function resolveNode(r: Render, node: unknown): ResolvedNode { +export function resolveNode(r: State, node: unknown): ResolvedNode { if (!node && node !== 0) return ""; // falsy, non numeric if (typeof node !== "object") { if (node === true) return ""; // booleans are ignored @@ -132,7 +149,7 @@ export function resolveNode(r: Render, node: unknown): ResolvedNode { if (type === kElement) { const { 1: tag, 2: props } = node; if (typeof tag === "function") { - currentRender = r; + current = r; try { return resolveNode(r, tag(props)); } catch (e) { @@ -140,7 +157,7 @@ export function resolveNode(r: Render, node: unknown): ResolvedNode { if (e && typeof e === "object") (e as { src?: string }).src = src; throw e; } finally { - currentRender = null; + current = null; } } if (typeof tag !== "string") throw new Error("Unexpected " + inspect(type)); @@ -148,14 +165,14 @@ export function resolveNode(r: Render, node: unknown): ResolvedNode { if (children) return [kElement, tag, props, resolveNode(r, children)]; return node; } - if (type === kDirectHtml) return node[1]; + if (type === kRaw) return node[1]; return node.map((elem) => resolveNode(r, elem)); } export type ResolvedNode = | ResolvedNode[] // Concat | ResolvedElement // Render - | string; // Direct HTML + | string; // Raw HTML export type ResolvedElement = [ tag: typeof kElement, type: string, @@ -174,23 +191,23 @@ export type InsertionPoint = [null | ResolvedNode]; * Convert 'ResolvedNode' into HTML text. This operation happens after all * async work is settled. The HTML is emitted as concisely as possible. */ -export function renderNode(node: ResolvedNode): string { +export function stringifyNode(node: ResolvedNode): string { if (typeof node === "string") return node; ASSERT(node, "Unresolved Render Node"); const type = node[0]; if (type === kElement) { - return renderElement(node as ResolvedElement); + return stringifyElement(node as ResolvedElement); } node = node as ResolvedNode[]; // TS cannot infer. - let out = type ? renderNode(type) : ""; + let out = type ? stringifyNode(type) : ""; let len = node.length; for (let i = 1; i < len; i++) { const elem = node[i]; - if (elem) out += renderNode(elem); + if (elem) out += stringifyNode(elem); } return out; } -function renderElement(element: ResolvedElement) { +function stringifyElement(element: ResolvedElement) { const { 1: tag, 2: props, 3: children } = element; let out = "<" + tag; let needSpace = true; @@ -216,26 +233,30 @@ function renderElement(element: ResolvedElement) { case "key": continue; } - if (needSpace) out += " ", needSpace = !attr.endsWith('"'); + if (needSpace) (out += " "), (needSpace = !attr.endsWith('"')); out += attr; } out += ">"; - if (children) out += renderNode(children); + if (children) out += stringifyNode(children); if ( - tag !== "br" && tag !== "img" && tag !== "input" && tag !== "meta" && - tag !== "link" && tag !== "hr" + tag !== "br" && + tag !== "img" && + tag !== "input" && + tag !== "meta" && + tag !== "link" && + tag !== "hr" ) { out += ``; } return out; } -export function renderStyleAttribute(style: Record) { +export function stringifyStyleAttribute(style: Record) { let out = ``; for (const styleName in style) { if (out) out += ";"; - out += `${styleName.replace(/[A-Z]/g, "-$&").toLowerCase()}:${ - escapeHtml(String(style[styleName])) - }`; + out += `${styleName.replace(/[A-Z]/g, "-$&").toLowerCase()}:${escapeHtml( + String(style[styleName]), + )}`; } return "style=" + quoteIfNeeded(out); } @@ -246,7 +267,7 @@ export function quoteIfNeeded(text: string) { // -- utility functions -- -export function initRender(allowAsync: boolean, addon: Addons): Render { +export function init(allowAsync: boolean, addon: Addons): State { return { async: allowAsync ? 0 : -1, rejections: null, @@ -255,29 +276,11 @@ export function initRender(allowAsync: boolean, addon: Addons): Render { }; } -let currentRender: Render | null = null; -export function getCurrentRender() { - if (!currentRender) throw new Error("No Render Active"); - return currentRender; -} -export function setCurrentRender(r?: Render | null) { - currentRender = r ?? null; -} -export function getUserData(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 | ClsxInput[]; export function clsx(mix: ClsxInput) { - var k, y, str = ""; + var k, + y, + str = ""; if (typeof mix === "string") { return mix; } else if (typeof mix === "object") { @@ -302,5 +305,17 @@ export function clsx(mix: ClsxInput) { export const escapeHtml = (unsafeText: string) => String(unsafeText) - .replace(/&/g, "&").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; + } +} diff --git a/framework/engine/suspense.ts b/framework/engine/suspense.ts index a04b384..d0c7625 100644 --- a/framework/engine/suspense.ts +++ b/framework/engine/suspense.ts @@ -7,46 +7,44 @@ // // I would link to an article from Next.js or React, but their examples // are too verbose and not informative to what they actually do. -const kState = Symbol("SuspenseState"); +const userData = render.userData(() => null); interface SuspenseProps { - children: ssr.Node; - fallback?: ssr.Node; + children: render.Node; + fallback?: render.Node; } interface State { nested: boolean; nextId: number; completed: number; - pushChunk(name: string, node: ssr.ResolvedNode): void; + pushChunk(name: string, node: render.ResolvedNode): void; } -export function Suspense({ children, fallback }: SuspenseProps): ssr.Node { - const state = ssr.getUserData(kState, () => { - throw new Error("Can only use with 'renderStreaming'"); - }); +export function Suspense({ children, fallback }: SuspenseProps): render.Node { + const state = userData.get(); + if (!state) return children; if (state.nested) throw new Error(" cannot be nested"); - const parent = ssr.getCurrentRender()!; - const r = ssr.initRender(true, { [kState]: { nested: true } }); - const resolved = ssr.resolveNode(r, children); - if (r.async == 0) return ssr.html(resolved); - const name = "suspended_" + (++state.nextId); + const parent = UNWRAP(render.current); + const r = render.initRender(true, { [userData.key]: { nested: true } }); + const resolved = render.resolveNode(r, children); + if (r.async == 0) return render.raw(resolved); + const name = "suspended_" + ++state.nextId; state.nested = true; - const ip: [ssr.ResolvedNode] = [ - [ - ssr.kElement, + const ip: [render.ResolvedNode] = [ + render.resolvedElement( "slot", { name }, - fallback ? ssr.resolveNode(parent, fallback) : "", - ], + fallback ? render.resolveNode(parent, fallback) : "", + ), ]; state.nested = false; r.asyncDone = () => { const rejections = r.rejections; if (rejections && rejections.length > 0) throw new Error("TODO"); - state.pushChunk?.(name, ip[0] = resolved); + state.pushChunk?.(name, (ip[0] = resolved)); }; - return ssr.html(ip); + return render.raw(ip); } // TODO: add a User-Agent parameter, which is used to determine if a @@ -54,17 +52,14 @@ export function Suspense({ children, fallback }: SuspenseProps): ssr.Node { // - Before ~2024 needs to use a JS implementation. // - IE should probably bail out entirely. export async function* renderStreaming< - T extends ssr.Addons = Record, ->( - node: ssr.Node, - addon: T = {} as T, -) { + T extends render.Addons = Record, +>(node: render.Node, addon: T = {} as T) { const { text: begin, - addon: { [kState]: state, ...addonOutput }, - } = await ssr.ssrAsync(node, { + addon: { [userData.key]: state, ...addonOutput }, + } = await render.async(node, { ...addon, - [kState]: { + [userData.key]: { nested: false, nextId: 0, completed: 0, @@ -79,24 +74,29 @@ export async function* renderStreaming< let chunks: string[] = []; state.pushChunk = (slot, node) => { while (node.length === 1 && Array.isArray(node)) node = node[0]; - if (node[0] === ssr.kElement) { - (node as ssr.ResolvedElement)[2].slot = slot; + if (node[0] === render.kElement) { + (node as render.ResolvedElement)[2].slot = slot; } else { - node = [ssr.kElement, "clover-suspense", { - style: "display:contents", - slot, - }, node]; + node = [ + render.kElement, + "clover-suspense", + { + style: "display:contents", + slot, + }, + node, + ]; } - chunks.push(ssr.renderNode(node)); + chunks.push(render.stringifyNode(node)); resolve?.(); }; yield ``; do { - await new Promise((done) => resolve = done); + await new Promise((done) => (resolve = done)); yield* chunks; chunks = []; } while (state.nextId < state.completed); return addonOutput as unknown as T; } -import * as ssr from "./ssr.ts"; +import * as render from "./render.ts"; diff --git a/framework/generate.ts b/framework/generate.ts index c88af61..4e620af 100644 --- a/framework/generate.ts +++ b/framework/generate.ts @@ -14,12 +14,8 @@ export async function main() { export async function generate() { // -- read config and discover files -- const siteConfig = await incr.work(readManifest); - const { - staticFiles, - scripts, - views, - pages, - } = await discoverAllFiles(siteConfig); + const { staticFiles, scripts, views, pages } = + await discoverAllFiles(siteConfig); // TODO: make sure that `static` and `pages` does not overlap @@ -28,12 +24,15 @@ export async function generate() { // -- perform build-time rendering -- const builtPages = pages.map((item) => incr.work(preparePage, item)); const builtViews = views.map((item) => incr.work(prepareView, item)); - const builtStaticFiles = Promise.all((staticFiles.map((item) => - incr.work( - async (io, { id, file }) => void await io.writeAsset(id, await io.readFile(file)), - item, - ) - ))); + const builtStaticFiles = Promise.all( + staticFiles.map((item) => + incr.work( + async (io, { id, file }) => + void (await io.writeAsset(id, await io.readFile(file))), + item, + ), + ), + ); const routes = await Promise.all([...builtViews, ...builtPages]); // -- page resources -- @@ -47,23 +46,19 @@ export async function generate() { // -- backend -- const builtBackend = bundle.bundleServerJavaScript({ entries: siteConfig.backends, - platform: 'node', + platform: "node", styleMap, scriptMap, viewItems: views, viewRefs: builtViews, - }) + }); // -- assemble page assets -- const pAssemblePages = builtPages.map((page) => - assembleAndWritePage(page, styleMap, scriptMap) + assembleAndWritePage(page, styleMap, scriptMap), ); - await Promise.all([ - builtBackend, - builtStaticFiles, - ...pAssemblePages, - ]); + await Promise.all([builtBackend, builtStaticFiles, ...pAssemblePages]); } export async function readManifest(io: Io) { @@ -82,7 +77,7 @@ export async function discoverAllFiles( return ( await Promise.all( siteConfig.siteSections.map(({ root: sectionRoot }) => - incr.work(scanSiteSection, toAbs(sectionRoot)) + incr.work(scanSiteSection, toAbs(sectionRoot)), ), ) ).reduce((acc, next) => ({ @@ -110,9 +105,10 @@ export async function scanSiteSection(io: Io, sectionRoot: string) { let scripts: FileItem[] = []; const sectionPath = (...sub: string[]) => path.join(sectionRoot, ...sub); - const rootPrefix = hot.projectSrc === sectionRoot - ? "" - : path.relative(hot.projectSrc, sectionRoot) + "/"; + const rootPrefix = + hot.projectSrc === sectionRoot + ? "" + : path.relative(hot.projectSrc, sectionRoot) + "/"; const kinds = [ { dir: sectionPath("pages"), @@ -186,12 +182,8 @@ export async function preparePage(io: Io, item: sg.FileItem) { theme: pageTheme, layout, } = await io.import(item.file); - if (!Page) { - throw new Error("Page is missing a 'default' export."); - } - if (!metadata) { - throw new Error("Page is missing 'meta' export with a title."); - } + if (!Page) throw new Error("Page is missing a 'default' export."); + if (!metadata) throw new Error("Page is missing 'meta' export with a title."); // -- css -- if (layout?.theme) pageTheme = layout.theme; @@ -210,12 +202,12 @@ export async function preparePage(io: Io, item: sg.FileItem) { ).then((m) => meta.renderMeta(m)); // -- html -- - let page = [engine.kElement, Page, {}]; + let page = render.element(Page); if (layout?.default) { - page = [engine.kElement, layout.default, { children: page }]; + page = render.element(layout.default, { children: page }); } - const bodyPromise = engine.ssrAsync(page, { - sitegen: sg.initRender(), + const bodyPromise = render.async(page, { + [sg.userData.key]: sg.initRender(), }); const [{ text, addon }, renderedMeta] = await Promise.all([ @@ -235,23 +227,16 @@ export async function preparePage(io: Io, item: sg.FileItem) { cssImports, theme: theme ?? null, styleKey, - clientRefs: Array.from(addon.sitegen.scripts), + clientRefs: Array.from(addon[sg.userData.key].scripts), }; } export async function prepareView(io: Io, item: sg.FileItem) { const module = await io.import(item.file); - if (!module.meta) { - throw new Error(`${item.file} is missing 'export const meta'`); - } - if (!module.default) { - throw new Error(`${item.file} is missing a default export.`); - } + if (!module.meta) throw new Error(`View is missing 'export const meta'`); + if (!module.default) throw new Error(`View is missing a default export.`); const pageTheme = module.layout?.theme ?? module.theme; - const theme: css.Theme = { - ...css.defaultTheme, - ...pageTheme, - }; + const theme: css.Theme = { ...css.defaultTheme, ...pageTheme }; const cssImports = Array.from( new Set([globalCssPath, ...hot.getCssImports(item.file)]), (file) => path.relative(hot.projectSrc, file), @@ -269,22 +254,14 @@ export async function prepareView(io: Io, item: sg.FileItem) { export type PreparedView = Awaited>; export function prepareInlineCss( - items: Array<{ - styleKey: string; - cssImports: string[]; - theme: css.Theme; - }>, + items: Array<{ styleKey: string; cssImports: string[]; theme: css.Theme }>, ) { const map = new Map>(); for (const { styleKey, cssImports, theme } of items) { if (map.has(styleKey)) continue; map.set( styleKey, - incr.work(css.bundleCssFiles, { - cssImports, - theme, - dev: false, - }), + incr.work(css.bundleCssFiles, { cssImports, theme, dev: false }), ); } return map; @@ -297,29 +274,26 @@ export async function assembleAndWritePage( scriptWork: incr.Ref>, ) { const page = await pageWork; - return incr.work( - async (io, { id, html, meta, styleKey, clientRefs }) => { - const inlineCss = await io.readWork(UNWRAP(styleMap.get(styleKey))); + return incr.work(async (io, { id, html, meta, styleKey, clientRefs }) => { + const inlineCss = await io.readWork(UNWRAP(styleMap.get(styleKey))); - const scriptIds = clientRefs.map(hot.getScriptId); - const scriptMap = await io.readWork(scriptWork); - const scripts = scriptIds.map((ref) => - UNWRAP(scriptMap[ref], `Missing script ${ref}`) - ) - .map((x) => `{${x}}`).join("\n"); + const scriptIds = clientRefs.map(hot.getScriptId); + const scriptMap = await io.readWork(scriptWork); + const scripts = scriptIds + .map((ref) => UNWRAP(scriptMap[ref], `Missing script ${ref}`)) + .map((x) => `{${x}}`) + .join("\n"); - const doc = wrapDocument({ - body: html, - head: meta, - inlineCss, - scripts, - }); - await io.writeAsset(id, doc, { - "Content-Type": "text/html", - }); - }, - page, - ); + const doc = wrapDocument({ + body: html, + head: meta, + inlineCss, + scripts, + }); + await io.writeAsset(id, doc, { + "Content-Type": "text/html", + }); + }, page); } import * as sg from "#sitegen"; @@ -327,11 +301,10 @@ import * as incr from "./incremental.ts"; import { Io } from "./incremental.ts"; import * as bundle from "./bundle.ts"; import * as css from "./css.ts"; -import * as engine from "./engine/ssr.ts"; +import * as render from "#engine/render"; import * as hot from "./hot.ts"; import * as fs from "#sitegen/fs"; import type { FileItem } from "#sitegen"; import * as path from "node:path"; import * as meta from "#sitegen/meta"; -import { Spinner, withSpinner } from "@paperclover/console/Spinner"; import { wrapDocument } from "./lib/view.ts"; diff --git a/framework/hot.ts b/framework/hot.ts index 234533a..aaf936c 100644 --- a/framework/hot.ts +++ b/framework/hot.ts @@ -19,7 +19,7 @@ export const load = createRequire( }; export const { cache } = load; -load("source-map-support").install({hookRequire: true}); +load("source-map-support").install({ hookRequire: true }); // Register extensions by overwriting `require.extensions` const require = load; @@ -75,15 +75,15 @@ Module.prototype._compile = function ( imports.push(file); (childModule.cloverImporters ??= []).push(this); if (cloverClientRefs && cloverClientRefs.length > 0) { - (this.cloverClientRefs ??= []) - .push(...cloverClientRefs); + (this.cloverClientRefs ??= []).push(...cloverClientRefs); } } } fileStats.set(filename, { - cssImportsRecursive: cssImportsMaybe.length > 0 - ? Array.from(new Set(cssImportsMaybe)) - : null, + cssImportsRecursive: + cssImportsMaybe.length > 0 + ? Array.from(new Set(cssImportsMaybe)) + : null, imports, lastModified: Math.floor(stat.mtimeMs), }); @@ -100,7 +100,8 @@ Module._resolveFilename = (...args) => { return require.resolve(replacedPath, { paths: [projectSrc] }); } catch (err: any) { if ( - err.code === "MODULE_NOT_FOUND" && (err?.requireStack?.length ?? 0) <= 1 + err.code === "MODULE_NOT_FOUND" && + (err?.requireStack?.length ?? 0) <= 1 ) { err.message.replace(replacedPath, args[0]); } @@ -138,45 +139,45 @@ export function loadEsbuildCode( src = code; } if (src.includes("import.meta")) { - src = ` - import.meta.url = ${JSON.stringify(pathToFileURL(filepath).toString())}; - import.meta.dirname = ${JSON.stringify(path.dirname(filepath))}; - import.meta.filename = ${JSON.stringify(filepath)}; - `.trim().replace(/[\n\s]/g, "") + src; + src = + ` + import.meta.url = ${JSON.stringify(pathToFileURL(filepath).toString())}; + import.meta.dirname = ${JSON.stringify(path.dirname(filepath))}; + import.meta.filename = ${JSON.stringify(filepath)}; + ` + .trim() + .replace(/[\n\s]/g, "") + src; } src = esbuild.transformSync(src, { loader, format: "cjs", target: "esnext", jsx: "automatic", - jsxImportSource: "#ssr", + jsxImportSource: "#engine", jsxDev: true, sourcefile: filepath, - sourcemap: 'inline', + sourcemap: "inline", }).code; return module._compile(src, filepath, "commonjs"); } export function resolveClientRef(sourcePath: string, ref: string) { const filePath = resolveFrom(sourcePath, ref); - if ( - !filePath.endsWith(".client.ts") && - !filePath.endsWith(".client.tsx") - ) { + if (!filePath.endsWith(".client.ts") && !filePath.endsWith(".client.tsx")) { throw new Error("addScript must be a .client.ts or .client.tsx"); } return path.relative(projectSrc, filePath); } -let lazyMarko: typeof import('./marko.ts') | null = null; +let lazyMarko: typeof import("./marko.ts") | null = null; function loadMarko(module: NodeJS.Module, filepath: string) { - lazyMarko ??= require("./framework/marko.ts"); + lazyMarko ??= require("./framework/marko.ts"); lazyMarko.loadMarko(module, filepath); } function loadMdx(module: NodeJS.Module, filepath: string) { const input = fs.readFileSync(filepath); - const out = mdx.compileSync(input, { jsxImportSource: "#ssr" }).value; + const out = mdx.compileSync(input, { jsxImportSource: "#engine" }).value; const src = typeof out === "string" ? out : Buffer.from(out).toString("utf8"); return loadEsbuildCode(module, filepath, src); } @@ -194,7 +195,7 @@ export function reloadRecursive(filepath: string) { } export function unload(filepath: string) { - lazyMarko?.markoCache.delete(filepath) + lazyMarko?.markoCache.delete(filepath); filepath = path.resolve(filepath); const module = cache[filepath]; if (!module) return; @@ -291,8 +292,9 @@ export function resolveClientRefs( } export function getScriptId(file: string) { - return (path.isAbsolute(file) ? path.relative(projectSrc, file) : file) - .replaceAll("\\", "/"); + return ( + path.isAbsolute(file) ? path.relative(projectSrc, file) : file + ).replaceAll("\\", "/"); } declare global { @@ -300,7 +302,7 @@ declare global { interface Module { cloverClientRefs?: string[]; cloverSourceCode?: string; - cloverImporters?: Module[], + cloverImporters?: Module[]; _compile( this: NodeJS.Module, @@ -312,10 +314,7 @@ declare global { } } declare module "node:module" { - export function _resolveFilename( - id: string, - parent: NodeJS.Module, - ): unknown; + export function _resolveFilename(id: string, parent: NodeJS.Module): unknown; } import * as fs from "#sitegen/fs"; diff --git a/framework/lib/assets.ts b/framework/lib/assets.ts index 090cf45..73e0d30 100644 --- a/framework/lib/assets.ts +++ b/framework/lib/assets.ts @@ -11,30 +11,22 @@ export async function reload() { fs.readFile(path.join(import.meta.dirname, "static.json"), "utf8"), fs.readFile(path.join(import.meta.dirname, "static.blob")), ]); - assets = { - map: JSON.parse(map), - buf, - }; + return (assets = { map: JSON.parse(map), buf }); } -export async function reloadSync() { +export function reloadSync() { const map = fs.readFileSync( path.join(import.meta.dirname, "static.json"), "utf8", ); const buf = fs.readFileSync(path.join(import.meta.dirname, "static.blob")); - assets = { - map: JSON.parse(map), - buf, - }; + return (assets = { map: JSON.parse(map), buf }); } export async function middleware(c: Context, next: Next) { if (!assets) await reload(); const asset = assets!.map[c.req.path]; - if (asset) { - return assetInner(c, asset, 200); - } + if (asset) return assetInner(c, asset, 200); return next(); } @@ -56,13 +48,11 @@ export async function serveAsset( id: StaticPageId, status: StatusCode, ) { - assets ?? await reload(); - return assetInner(c, assets!.map[id], status); + return assetInner(c, (assets ?? (await reload())).map[id], status); } export function hasAsset(id: string) { - if (!assets) reloadSync(); - return assets!.map[id] !== undefined; + return (assets ?? reloadSync()).map[id] !== undefined; } export function etagMatches(etag: string, ifNoneMatch: string) { @@ -78,13 +68,13 @@ function assetInner(c: Context, asset: BuiltAsset, status: StatusCode) { if (ifnonematch) { const etag = asset.headers.ETag; if (etagMatches(etag, ifnonematch)) { - return c.res = new Response(null, { + return (c.res = new Response(null, { status: 304, statusText: "Not Modified", headers: { ETag: etag, }, - }); + })); } } const acceptEncoding = c.req.header("Accept-Encoding") ?? ""; @@ -105,7 +95,7 @@ function assetInner(c: Context, asset: BuiltAsset, status: StatusCode) { } else { body = subarrayAsset(asset.raw); } - return c.res = new Response(body, { headers, status }); + return (c.res = new Response(body, { headers, status })); } process.on("message", (msg: any) => { diff --git a/framework/lib/markdown.tsx b/framework/lib/markdown.tsx index 7959603..333b95c 100644 --- a/framework/lib/markdown.tsx +++ b/framework/lib/markdown.tsx @@ -5,21 +5,21 @@ * via clover's SSR engine. This way, generation optimizations, async * components, and other features are gained for free here. */ -function parse(src: string, options: Partial = {}) { -} +function parse(src: string, options: Partial = {}) {} /* Render markdown content. Same function as 'parse', but JSX components * only take one argument and must start with a capital letter. */ -export function Markdown( - { src, ...options }: { src: string } & Partial, -) { +export function Markdown({ + src, + ...options +}: { src: string } & Partial) { return parse(src, options); } function parseInline(src: string, options: Partial = {}) { const { rules = inlineRules, links = new Map() } = options; const opts: InlineOpts = { rules, links }; - const parts: engine.Node[] = []; + const parts: render.Node[] = []; const ruleList = Object.values(rules); parse: while (true) { for (const rule of ruleList) { @@ -96,7 +96,9 @@ export const inlineRules: Record = { if (!splitText) return null; if (splitText.delim !== "]") return null; const { first: textSrc, rest: afterText } = splitText; - let href: string, title: string | null = null, rest: string; + let href: string, + title: string | null = null, + rest: string; if (afterText[0] === "(") { // Inline link const splitTarget = splitFirst(afterText.slice(1), /\)/); @@ -108,11 +110,12 @@ export const inlineRules: Record = { } else if (afterText[0] === "[") { const splitTarget = splitFirst(afterText.slice(1), /]/); if (!splitTarget) return null; - const name = splitTarget.first.trim().length === 0 - // Collapsed reference link - ? textSrc.trim() - // Full Reference Link - : splitTarget.first.trim(); + const name = + splitTarget.first.trim().length === 0 + ? // Collapsed reference link + textSrc.trim() + : // Full Reference Link + splitTarget.first.trim(); const target = opts.links.get(name); if (!target) return null; ({ href, title } = target); @@ -149,7 +152,6 @@ export const inlineRules: Record = { // 6.2 - emphasis and strong emphasis parse({ before, match, after, opts }) { // find out how long the delim sequence is - // look for 'ends' }, }, @@ -164,14 +166,17 @@ export const inlineRules: Record = { }; function parseLinkTarget(src: string) { - let href: string, title: string | null = null; + let href: string, + title: string | null = null; href = src; return { href, title }; } /* Find a delimiter while considering backslash escapes. */ function splitFirst(text: string, match: RegExp) { - let first = "", delim: string, escaped: boolean; + let first = "", + delim: string, + escaped: boolean; do { const find = text.match(match); if (!find) return null; @@ -179,14 +184,13 @@ function splitFirst(text: string, match: RegExp) { const index = UNWRAP(find.index); let i = index - 1; escaped = false; - while (i >= 0 && text[i] === "\\") escaped = !escaped, i -= 1; + while (i >= 0 && text[i] === "\\") (escaped = !escaped), (i -= 1); first += text.slice(0, index - +escaped); text = text.slice(index + find[0].length); } while (escaped); return { first, delim, rest: text }; } -console.log(engine.ssrSync(parseInline("meow `bwaa` `` ` `` `` `z``"))); +console.log(render.sync(parseInline("meow `bwaa` `` ` `` `` `z``"))); -import * as engine from "#ssr"; -import type { ParseOptions } from "node:querystring"; +import * as render from "#engine/render"; diff --git a/framework/lib/meta.ts b/framework/lib/meta.ts index 420b4b5..511b2a9 100644 --- a/framework/lib/meta.ts +++ b/framework/lib/meta.ts @@ -21,4 +21,4 @@ export interface AlternateType { export function renderMeta({ title }: Meta): string { return `${esc(title)}`; } -import { escapeHtml as esc } from "../engine/ssr.ts"; +import { escapeHtml as esc } from "#engine/render"; diff --git a/framework/lib/sitegen.ts b/framework/lib/sitegen.ts index bc7b3ab..74b69e5 100644 --- a/framework/lib/sitegen.ts +++ b/framework/lib/sitegen.ts @@ -9,36 +9,25 @@ export interface FileItem { id: string; file: string; } +export interface Section { + root: string; +} + +export const userData = render.userData(() => { + throw new Error("This function can only be used in a page (static or view)"); +}); export interface SitegenRender { scripts: Set; } export function initRender(): SitegenRender { - return { - scripts: new Set(), - }; -} - -export function getRender() { - return ssr.getUserData("sitegen", () => { - throw new Error( - "This function can only be used in a page (static or view)", - ); - }); -} - -export function inRender() { - return "sitegen" in ssr.getCurrentRender(); + return { scripts: new Set() }; } /** Add a client-side script to the page. */ export function addScript(id: ScriptId | { value: ScriptId }) { - getRender().scripts.add(typeof id === "string" ? id : id.value); + userData.get().scripts.add(typeof id === "string" ? id : id.value); } -export interface Section { - root: string; -} - -import * as ssr from "../engine/ssr.ts"; +import * as render from "#engine/render"; diff --git a/framework/lib/view.ts b/framework/lib/view.ts index 47e3f13..577cd21 100644 --- a/framework/lib/view.ts +++ b/framework/lib/view.ts @@ -1,10 +1,9 @@ -// This import is generated by code 'bundle.ts' export interface View { - component: engine.Component; + component: render.Component; meta: | meta.Meta | ((props: { context?: hono.Context }) => Promise | meta.Meta); - layout?: engine.Component; + layout?: render.Component; inlineCss: string; scripts: Record; } @@ -12,9 +11,6 @@ export interface View { let views: Record = null!; let scripts: Record = null!; -// An older version of the Clover Engine supported streaming suspense -// boundaries, but those were never used. Pages will wait until they -// are fully rendered before sending. export async function renderView( context: hono.Context, id: string, @@ -44,11 +40,12 @@ export async function renderViewToString( ).then((m) => meta.renderMeta(m)); // -- html -- - let page: engine.Element = [engine.kElement, component, props]; - if (layout) page = [engine.kElement, layout, { children: page }]; - const { text: body, addon: { sitegen } } = await engine.ssrAsync(page, { - sitegen: sg.initRender(), - }); + let page: render.Element = render.element(component, props); + if (layout) page = render.element(layout, { children: page }); + const { + text: body, + addon: { [sg.userData.key]: sitegen }, + } = await render.async(page, { [sg.userData.key]: sg.initRender() }); // -- join document and send -- return wrapDocument({ @@ -56,17 +53,15 @@ export async function renderViewToString( head: await renderedMetaPromise, inlineCss, scripts: joinScripts( - Array.from( - sitegen.scripts, - (id) => UNWRAP(scripts[id], `Missing script ${id}`), + Array.from(sitegen.scripts, (id) => + UNWRAP(scripts[id], `Missing script ${id}`), ), ), }); } export function provideViewData(v: typeof views, s: typeof scripts) { - views = v; - scripts = s; + (views = v), (scripts = s); } export function joinScripts(scriptSources: string[]) { @@ -96,5 +91,5 @@ export function wrapDocument({ import * as meta from "./meta.ts"; import type * as hono from "#hono"; -import * as engine from "../engine/ssr.ts"; +import * as render from "#engine/render"; import * as sg from "./sitegen.ts"; diff --git a/framework/marko.ts b/framework/marko.ts index c865656..f3c79a0 100644 --- a/framework/marko.ts +++ b/framework/marko.ts @@ -1,4 +1,3 @@ -console.log("MARKO"); export interface MarkoCacheEntry { src: string; scannedClientRefs: string[]; @@ -8,7 +7,6 @@ export const markoCache = new Map(); export function loadMarko(module: NodeJS.Module, filepath: string) { let cache = markoCache.get(filepath); - console.log({ filepath, has: !!cache }) if (!cache) { let src = fs.readFileSync(filepath, "utf8"); // A non-standard thing here is Clover Sitegen implements @@ -16,21 +14,22 @@ export function loadMarko(module: NodeJS.Module, filepath: string) { // bare client import statements to it's own usage. const scannedClientRefs = new Set(); if (src.match(/^\s*client\s+import\s+["']/m)) { - src = src.replace( - /^\s*client\s+import\s+("[^"]+"|'[^']+')[^\n]+/m, - (_, src) => { - const ref = JSON.parse(`"${src.slice(1, -1)}"`); - const resolved = hot.resolveClientRef(filepath, ref); - scannedClientRefs.add(resolved); - return ``; - }, - ) + '\nimport { addScript as CloverScriptInclude } from "#sitegen";\n'; + src = + src.replace( + /^\s*client\s+import\s+("[^"]+"|'[^']+')[^\n]+/m, + (_, src) => { + const ref = JSON.parse(`"${src.slice(1, -1)}"`); + const resolved = hot.resolveClientRef(filepath, ref); + scannedClientRefs.add(resolved); + return ``; + }, + ) + '\nimport { addScript as CloverScriptInclude } from "#sitegen";\n'; } src = marko.compileSync(src, filepath).code; - src = src.replace("marko/debug/html", "#ssr/marko"); + src = src.replace("marko/debug/html", "#engine/marko-runtime"); cache = { src, scannedClientRefs: Array.from(scannedClientRefs) }; markoCache.set(filepath, cache); } diff --git a/package.json b/package.json index 8701176..ae6219e 100644 --- a/package.json +++ b/package.json @@ -28,10 +28,8 @@ "#debug": "./framework/debug.safe.ts", "#sitegen": "./framework/lib/sitegen.ts", "#sitegen/*": "./framework/lib/*.ts", - "#ssr": "./framework/engine/ssr.ts", - "#ssr/jsx-dev-runtime": "./framework/engine/jsx-runtime.ts", - "#ssr/jsx-runtime": "./framework/engine/jsx-runtime.ts", - "#ssr/marko": "./framework/engine/marko-runtime.ts", + "#engine/jsx-dev-runtime": "./framework/engine/jsx-runtime.ts", + "#engine/*": "./framework/engine/*.ts", "#marko/html": { "types": "marko/html", "production": "marko/production", diff --git a/readme.md b/readme.md index e255c25..cecf111 100644 --- a/readme.md +++ b/readme.md @@ -3,7 +3,7 @@ this repository contains clover's "sitegen" framework, which is a set of tools that assist building websites. these tools power . -- **HTML "Server Side Rendering") engine written from scratch.** (~500 lines) +- **HTML ("Server Side Rendering") engine written from scratch.** (~500 lines) - A more practical JSX runtime (`class` instead of `className`, built-in `clsx`, `html()` helper over `dangerouslySetInnerHTML` prop, etc). - Integration with [Marko] for concisely written components. @@ -57,19 +57,11 @@ my development machine, for example, is Dell Inspiron 7348 with Core i7 # production generation node run generate - node .clover/out/server + node .clover/o/backend # "development" watch mode node run watch - - - - - - - - for unix systems, the provided `flake.nix` can be used with `nix develop` to open a shell with all needed system dependencies. diff --git a/src/file-viewer/backend.tsx b/src/file-viewer/backend.tsx index 9878945..1577997 100644 --- a/src/file-viewer/backend.tsx +++ b/src/file-viewer/backend.tsx @@ -125,10 +125,11 @@ app.get("/file/*", async (c, next) => { let encoding = decideEncoding(c.req.header("Accept-Encoding")); - let sizeHeader = encoding === "raw" - ? expectedSize - // Size cannot be known because of compression modes - : undefined; + let sizeHeader = + encoding === "raw" + ? expectedSize + : // Size cannot be known because of compression modes + undefined; // Etag { @@ -294,9 +295,10 @@ function handleRanges( ): Response { // TODO: multiple ranges const rangeSize = ranges.reduce((a, b) => a + (b[1] - b[0] + 1), 0); - const rangeBody = streamOrBuffer instanceof ReadableStream - ? applySingleRangeToStream(streamOrBuffer, ranges) - : applyRangesToBuffer(streamOrBuffer, ranges, rangeSize); + const rangeBody = + streamOrBuffer instanceof ReadableStream + ? applySingleRangeToStream(streamOrBuffer, ranges) + : applyRangesToBuffer(streamOrBuffer, ranges, rangeSize); return new Response(rangeBody, { status: 206, headers: { @@ -380,9 +382,9 @@ function getPartialPage(c: Context, rawFilePath: string) { if (!checkCotyledonCookie(c)) { let root = Speedbump(); // Remove the root element, it's created client side! - root = root[2].children as ssr.Element; + root = root[2].children as render.Element; - const html = ssr.ssrSync(root).text; + const html = render.sync(root).text; c.header("X-Cotyledon", "true"); return c.html(html); } @@ -408,15 +410,15 @@ function getPartialPage(c: Context, rawFilePath: string) { hasCotyledonCookie: rawFilePath === "" && checkCotyledonCookie(c), }); // Remove the root element, it's created client side! - root = root[2].children as ssr.Element; + root = root[2].children as render.Element; - const html = ssr.ssrSync(root).text; + const html = render.sync(root).text; return c.html(html); } import { type Context, Hono } from "hono"; -import * as ssr from "#ssr"; +import * as render from "#engine/render"; import { etagMatches, hasAsset, serveAsset } from "#sitegen/assets"; import { renderView } from "#sitegen/view"; import { contentTypeFor } from "#sitegen/mime"; diff --git a/src/file-viewer/format.ts b/src/file-viewer/format.ts index e28b214..fe3ab03 100644 --- a/src/file-viewer/format.ts +++ b/src/file-viewer/format.ts @@ -100,22 +100,21 @@ export function highlightLinksInTextView( // Case 1: https:// or http:// URLs if (match.startsWith("http")) { if (match.includes(findDomain)) { - return `${match}`; + return `${match}`; } - return `${match}`; + return `${match}`; } // Case 2: domain URLs without protocol if (match.startsWith(findDomain)) { - return `${match}`; + return `${match}`; } // Case 3: /file/ URLs @@ -146,7 +145,7 @@ export function highlightLinksInTextView( // Match sibling file names (only if they're not already part of a link) if (siblingFiles.length > 0) { const escapedBasenames = siblingFiles.map((f) => - f.basename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + f.basename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), ); const pattern = new RegExp(`\\b(${escapedBasenames.join("|")})\\b`, "g"); const parts = processedText.split(/(<[^>]*>)/); @@ -156,9 +155,9 @@ export function highlightLinksInTextView( parts[i] = parts[i].replace(pattern, (match: string) => { const file = siblingLookup[match]; if (file) { - return `${match}`; + return `${match}`; } return match; }); @@ -241,11 +240,9 @@ export function highlightConvo(text: string) { return paras .map(({ speaker, lines }) => { - return `
${ - lines - .map((line) => `
${line}
`) - .join("\n") - }
`; + return `
${lines + .map((line) => `
${line}
`) + .join("\n")}
`; }) .join("\n"); } @@ -267,22 +264,14 @@ const unknownDateWithKnownYear = new Date("1970-02-20"); export function formatDate(dateTime: Date) { return dateTime < unknownDateWithKnownYear - ? ( - dateTime < unknownDate - ? ( - "??.??.??" - ) - : `xx.xx.${21 + Math.floor(dateTime.getTime() / 86400000)}` - ) - : ( - `${(dateTime.getMonth() + 1).toString().padStart(2, "0")}.${ - dateTime - .getDate() - .toString() - .padStart(2, "0") - }.${dateTime.getFullYear().toString().slice(2)}` - ); + ? dateTime < unknownDate + ? "??.??.??" + : `xx.xx.${21 + Math.floor(dateTime.getTime() / 86400000)}` + : `${(dateTime.getMonth() + 1).toString().padStart(2, "0")}.${dateTime + .getDate() + .toString() + .padStart(2, "0")}.${dateTime.getFullYear().toString().slice(2)}`; } import type { MediaFile } from "@/file-viewer/models/MediaFile.ts"; -import { escapeHtml } from "#ssr"; +import { escapeHtml } from "#engine/render"; diff --git a/src/file-viewer/highlight.ts b/src/file-viewer/highlight.ts index 13f94c0..13ab7c7 100644 --- a/src/file-viewer/highlight.ts +++ b/src/file-viewer/highlight.ts @@ -113,7 +113,7 @@ function highlightLines({ const str = lines[i].slice(token.startIndex, token.endIndex); if (str.trim().length === 0) { // Emit but do not consider scope changes - html += ssr.escapeHtml(str); + html += render.escapeHtml(str); continue; } @@ -122,7 +122,7 @@ function highlightLines({ if (lastHtmlStyle) html += ""; if (style) html += ``; } - html += ssr.escapeHtml(str); + html += render.escapeHtml(str); lastHtmlStyle = style; } html += "\n"; @@ -197,4 +197,4 @@ import * as fs from "#sitegen/fs"; import * as path from "node:path"; import * as oniguruma from "vscode-oniguruma"; import * as textmate from "vscode-textmate"; -import * as ssr from "#ssr"; +import * as render from "#engine/render"; diff --git a/src/q+a/clover-markdown.tsx b/src/q+a/clover-markdown.tsx index 426051e..63f576d 100644 --- a/src/q+a/clover-markdown.tsx +++ b/src/q+a/clover-markdown.tsx @@ -104,13 +104,15 @@ questionRules.insertBefore("paragraph", { assert(x.startsWith("q: ")); return x.slice(3); }); - const content = lines.map((line, i) => { - const parsed = parseInline(parse, line, state); - if (i < lines.length - 1) { - parsed.push({ type: "br" }); - } - return parsed; - }).flat(); + const content = lines + .map((line, i) => { + const parsed = parseInline(parse, line, state); + if (i < lines.length - 1) { + parsed.push({ type: "br" }); + } + return parsed; + }) + .flat(); return { content, }; @@ -140,7 +142,13 @@ function BasicNode(node: ASTNode, children: any) { function ListRenderer(node: ASTNode, children: any[]) { const T = node.ordered ? "ol" : "ul"; - return {children.map((child) =>
  • {child}
  • )}
    ; + return ( + + {children.map((child) => ( +
  • {child}
  • + ))} +
    + ); } function MarkdownLink(node: ASTNode, children: any[]) { @@ -174,7 +182,11 @@ function ArtifactRef(node: ASTNode) { if (!url) { return {title}; } - return {title}; + return ( + + {title} + + ); } const br =
    ; @@ -202,7 +214,7 @@ const ssrFunctions: any = { // Custom Elements question: BasicNode, - html: (node: any) => ssr.html(node.data), + html: (node: any) => render.raw(node.data), mentionQuestion: QuestionRef, mentionArtifact: ArtifactRef, }; @@ -247,4 +259,4 @@ import { parseCaptureInline, parseInline, } from "./simple-markdown.ts"; -import * as ssr from "#ssr"; +import * as render from "#engine/render"; diff --git a/src/q+a/scripts/editor.client.tsx b/src/q+a/scripts/editor.client.tsx index a3c1456..3b4faac 100644 --- a/src/q+a/scripts/editor.client.tsx +++ b/src/q+a/scripts/editor.client.tsx @@ -1,6 +1,6 @@ import { EditorState } from "@codemirror/state"; import { basicSetup, EditorView } from "codemirror"; -import { ssrSync } from "#ssr"; +import * as render from "#engine/render"; import type { ScriptPayload } from "@/q+a/views/editor.marko"; import QuestionRender from "@/q+a/tags/question.marko"; @@ -11,7 +11,7 @@ const main = document.getElementById("edit-grid")! as HTMLDivElement; const preview = document.getElementById("preview")! as HTMLDivElement; function updatePreview(text: string) { - preview.innerHTML = ssrSync( + preview.innerHTML = render.sync( Promise) { return async () => { main.style.opacity = "0.5"; main.style.pointerEvents = "none"; - const inputs = main.querySelectorAll("button,select,input") as NodeListOf< - HTMLButtonElement - >; + const inputs = main.querySelectorAll( + "button,select,input", + ) as NodeListOf; inputs.forEach((b) => { b.disabled = true; }); diff --git a/tsconfig.json b/tsconfig.json index ae3139f..58918d0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,7 @@ "baseUrl": ".", "incremental": true, "jsx": "react-jsxdev", - "jsxImportSource": "#ssr", + "jsxImportSource": "#engine", "lib": ["dom", "esnext", "esnext.iterator"], "module": "nodenext", "noEmit": true,