| author | |
| committer | |
| log | 521edec76fecc043e04bedcf0c00c0368fa9e96e |
| tree | 696d510a0b649bb832b98862e784c8b5b56f74e9 |
| parent | 630def77dd3df902c021bd8cc05db17578d21eda |
| signature | Commit is signed but in an unrecognized format. |
"server side rendering" is a misleading term since it implies there is a
server. that isn't neccecarily the case here, since it supports running
in the browser. I think "clover engine" is cute, short for "clover html
rendering engine". Instead of "server side rendering", it's just rendering.
This commit makes things a lot more concise, such as `ssr.ssrAsync`
being renamed to `render.async` to play nicely with namespaced imports.
`getCurrentRender` and `setCurrentRender` are just `current` and
`setCurrent`, and the addon interface has been redesigned to force
symbols with a wrapping helper.24 files changed, 861 insertions(+), 879 deletions(-)
framework/bundle.ts+199-165| ... | ... | @@ -1,27 +1,33 @@ |
| 1 | 1 | async function trackEsbuild(io: Io, metafile: esbuild.Metafile) { |
| 2 | await Promise.all(Object.keys(metafile.inputs) | |
| 3 | .filter(file => !isIgnoredSource(file)) | |
| 4 | .map(file => io.trackFile(file))); | |
| 2 | await Promise.all( | |
| 3 | Object.keys(metafile.inputs) | |
| 4 | .filter((file) => !isIgnoredSource(file)) | |
| 5 | .map((file) => io.trackFile(file)), | |
| 6 | ); | |
| 5 | 7 | } |
| 6 | 8 | |
| 7 | 9 | // This file implements client-side bundling, mostly wrapping esbuild. |
| 8 | 10 | export async function bundleClientJavaScript( |
| 9 | 11 | io: Io, |
| 10 | { clientRefs, extraPublicScripts, dev = false }: { | |
| 12 | { | |
| 13 | clientRefs, | |
| 14 | extraPublicScripts, | |
| 15 | dev = false, | |
| 16 | }: { | |
| 11 | 17 | clientRefs: string[]; |
| 12 | 18 | extraPublicScripts: string[]; |
| 13 | 19 | dev: boolean; |
| 14 | 20 | }, |
| 15 | 21 | ) { |
| 16 | 22 | const entryPoints = [ |
| 17 | ...new Set([ | |
| 18 | ...clientRefs.map((x) => `src/${x}`), | |
| 19 | ...extraPublicScripts, | |
| 20 | ].map(toAbs)), | |
| 23 | ...new Set( | |
| 24 | [...clientRefs.map((x) => `src/${x}`), ...extraPublicScripts].map(toAbs), | |
| 25 | ), | |
| 21 | 26 | ]; |
| 22 | 27 | if (entryPoints.length === 0) return {}; |
| 23 | const invalidFiles = entryPoints | |
| 24 | .filter((file) => !file.match(/\.client\.[tj]sx?/)); | |
| 28 | const invalidFiles = entryPoints.filter( | |
| 29 | (file) => !file.match(/\.client\.[tj]sx?/), | |
| 30 | ); | |
| 25 | 31 | if (invalidFiles.length > 0) { |
| 26 | 32 | const cwd = process.cwd(); |
| 27 | 33 | throw new Error( |
| ... | ... | @@ -35,45 +41,48 @@ export async function bundleClientJavaScript( |
| 35 | 41 | markoViaBuildCache(), |
| 36 | 42 | ]; |
| 37 | 43 | |
| 38 | const bundle = await esbuild.build({ | |
| 39 | assetNames: "/asset/[hash]", | |
| 40 | bundle: true, | |
| 41 | chunkNames: "/js/c.[hash]", | |
| 42 | entryNames: "/js/[name]", | |
| 43 | entryPoints, | |
| 44 | format: "esm", | |
| 45 | jsx: "automatic", | |
| 46 | jsxDev: dev, | |
| 47 | jsxImportSource: "#ssr", | |
| 48 | logLevel: "silent", | |
| 49 | metafile: true, | |
| 50 | minify: !dev, | |
| 51 | outdir: "out!", | |
| 52 | plugins: clientPlugins, | |
| 53 | write: false, | |
| 54 | define: { | |
| 55 | "ASSERT": "console.assert", | |
| 56 | MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText), | |
| 57 | }, | |
| 58 | }).catch((err: any) => { | |
| 59 | err.message = `Client ${err.message}`; | |
| 60 | throw err; | |
| 61 | }); | |
| 44 | const bundle = await esbuild | |
| 45 | .build({ | |
| 46 | assetNames: "/asset/[hash]", | |
| 47 | bundle: true, | |
| 48 | chunkNames: "/js/c.[hash]", | |
| 49 | entryNames: "/js/[name]", | |
| 50 | entryPoints, | |
| 51 | format: "esm", | |
| 52 | jsx: "automatic", | |
| 53 | jsxDev: dev, | |
| 54 | jsxImportSource: "#engine", | |
| 55 | logLevel: "silent", | |
| 56 | metafile: true, | |
| 57 | minify: !dev, | |
| 58 | outdir: "out!", | |
| 59 | plugins: clientPlugins, | |
| 60 | write: false, | |
| 61 | define: { | |
| 62 | ASSERT: "console.assert", | |
| 63 | MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText), | |
| 64 | }, | |
| 65 | }) | |
| 66 | .catch((err: any) => { | |
| 67 | err.message = `Client ${err.message}`; | |
| 68 | throw err; | |
| 69 | }); | |
| 62 | 70 | if (bundle.errors.length || bundle.warnings.length) { |
| 63 | 71 | throw new AggregateError( |
| 64 | 72 | bundle.errors.concat(bundle.warnings), |
| 65 | 73 | "JS bundle failed", |
| 66 | 74 | ); |
| 67 | 75 | } |
| 68 | const publicScriptRoutes = extraPublicScripts.map((file) => | |
| 69 | "/js/" + | |
| 70 | path.relative(hot.projectSrc, file).replaceAll("\\", "/").replace( | |
| 71 | /\.client\.[tj]sx?/, | |
| 72 | ".js", | |
| 73 | ) | |
| 76 | const publicScriptRoutes = extraPublicScripts.map( | |
| 77 | (file) => | |
| 78 | "/js/" + | |
| 79 | path | |
| 80 | .relative(hot.projectSrc, file) | |
| 81 | .replaceAll("\\", "/") | |
| 82 | .replace(/\.client\.[tj]sx?/, ".js"), | |
| 74 | 83 | ); |
| 75 | 84 | const { metafile, outputFiles } = bundle; |
| 76 | const p = [] | |
| 85 | const p = []; | |
| 77 | 86 | p.push(trackEsbuild(io, metafile)); |
| 78 | 87 | const scripts: Record<string, string> = {}; |
| 79 | 88 | for (const file of outputFiles) { |
| ... | ... | @@ -100,156 +109,184 @@ export async function bundleClientJavaScript( |
| 100 | 109 | |
| 101 | 110 | export type ServerPlatform = "node" | "passthru"; |
| 102 | 111 | export interface ServerSideOptions { |
| 103 | entries: string[], | |
| 104 | viewItems: sg.FileItem[] | |
| 105 | viewRefs: incr.Ref<PreparedView>[], | |
| 112 | entries: string[]; | |
| 113 | viewItems: sg.FileItem[]; | |
| 114 | viewRefs: incr.Ref<PreparedView>[]; | |
| 106 | 115 | styleMap: Map<string, incr.Ref<string>>; |
| 107 | 116 | scriptMap: incr.Ref<Record<string, string>>; |
| 108 | platform: ServerPlatform, | |
| 117 | platform: ServerPlatform; | |
| 109 | 118 | } |
| 110 | export async function bundleServerJavaScript( | |
| 111 | { viewItems, viewRefs, styleMap, scriptMap: wScriptMap, entries, platform }: ServerSideOptions | |
| 112 | ) { | |
| 119 | export async function bundleServerJavaScript({ | |
| 120 | viewItems, | |
| 121 | viewRefs, | |
| 122 | styleMap, | |
| 123 | scriptMap: wScriptMap, | |
| 124 | entries, | |
| 125 | platform, | |
| 126 | }: ServerSideOptions) { | |
| 113 | 127 | const wViewSource = incr.work(async (_, viewItems: sg.FileItem[]) => { |
| 114 | 128 | const magicWord = "C_" + crypto.randomUUID().replaceAll("-", "_"); |
| 115 | 129 | return { |
| 116 | 130 | magicWord, |
| 117 | 131 | file: [ |
| 118 | ...viewItems.map((view, i) => `import * as view${i} from ${JSON.stringify(view.file)}`), | |
| 132 | ...viewItems.map( | |
| 133 | (view, i) => `import * as view${i} from ${JSON.stringify(view.file)}`, | |
| 134 | ), | |
| 119 | 135 | `const styles = ${magicWord}[-2]`, |
| 120 | 136 | `export const scripts = ${magicWord}[-1]`, |
| 121 | 137 | "export const views = {", |
| 122 | ...viewItems.map((view, i) => [ | |
| 123 | ` ${JSON.stringify(view.id)}: {`, | |
| 124 | ` component: view${i}.default,`, | |
| 125 | ` meta: view${i}.meta,`, | |
| 126 | ` layout: view${i}.layout?.default ?? null,`, | |
| 127 | ` inlineCss: styles[${magicWord}[${i}]]`, | |
| 128 | ` },`, | |
| 129 | ].join("\n")), | |
| 138 | ...viewItems.map((view, i) => | |
| 139 | [ | |
| 140 | ` ${JSON.stringify(view.id)}: {`, | |
| 141 | ` component: view${i}.default,`, | |
| 142 | ` meta: view${i}.meta,`, | |
| 143 | ` layout: view${i}.layout?.default ?? null,`, | |
| 144 | ` inlineCss: styles[${magicWord}[${i}]]`, | |
| 145 | ` },`, | |
| 146 | ].join("\n"), | |
| 147 | ), | |
| 130 | 148 | "}", |
| 131 | ].join("\n") | |
| 149 | ].join("\n"), | |
| 132 | 150 | }; |
| 133 | }, viewItems) | |
| 151 | }, viewItems); | |
| 134 | 152 | |
| 135 | const wBundles = entries.map(entry => [entry, incr.work(async (io, entry) => { | |
| 136 | const pkg = await io.readJson<{ dependencies: Record<string, string>; }>("package.json"); | |
| 153 | const wBundles = entries.map( | |
| 154 | (entry) => | |
| 155 | [ | |
| 156 | entry, | |
| 157 | incr.work(async (io, entry) => { | |
| 158 | const pkg = await io.readJson<{ | |
| 159 | dependencies: Record<string, string>; | |
| 160 | }>("package.json"); | |
| 137 | 161 | |
| 138 | let magicWord = null as string | null; | |
| 139 | // -- plugins -- | |
| 140 | const serverPlugins: esbuild.Plugin[] = [ | |
| 141 | virtualFiles({ | |
| 142 | // only add dependency when imported. | |
| 143 | "$views": async () => { | |
| 144 | const view = await io.readWork(wViewSource); | |
| 145 | ({ magicWord } = view); | |
| 146 | return view.file; | |
| 147 | }, | |
| 148 | }), | |
| 149 | projectRelativeResolution(), | |
| 150 | markoViaBuildCache(), | |
| 151 | { | |
| 152 | name: "replace client references", | |
| 153 | setup(b) { | |
| 154 | b.onLoad({ filter: /\.tsx?$/ }, async ({ path: file }) => ({ | |
| 155 | contents: | |
| 156 | hot.resolveClientRefs(await fs.readFile(file, "utf-8"), file).code, | |
| 157 | loader: path.extname(file).slice(1) as esbuild.Loader, | |
| 158 | })); | |
| 159 | }, | |
| 160 | }, | |
| 161 | { | |
| 162 | name: "mark css external", | |
| 163 | setup(b) { | |
| 164 | b.onResolve( | |
| 165 | { filter: /\.css$/ }, | |
| 166 | () => ({ path: ".", namespace: "dropped" }), | |
| 167 | ); | |
| 168 | b.onLoad( | |
| 169 | { filter: /./, namespace: "dropped" }, | |
| 170 | () => ({ contents: "" }), | |
| 171 | ); | |
| 172 | }, | |
| 173 | }, | |
| 174 | ]; | |
| 162 | let magicWord = null as string | null; | |
| 163 | // -- plugins -- | |
| 164 | const serverPlugins: esbuild.Plugin[] = [ | |
| 165 | virtualFiles({ | |
| 166 | // only add dependency when imported. | |
| 167 | $views: async () => { | |
| 168 | const view = await io.readWork(wViewSource); | |
| 169 | ({ magicWord } = view); | |
| 170 | return view.file; | |
| 171 | }, | |
| 172 | }), | |
| 173 | projectRelativeResolution(), | |
| 174 | markoViaBuildCache(), | |
| 175 | { | |
| 176 | name: "replace client references", | |
| 177 | setup(b) { | |
| 178 | b.onLoad({ filter: /\.tsx?$/ }, async ({ path: file }) => ({ | |
| 179 | contents: hot.resolveClientRefs( | |
| 180 | await fs.readFile(file, "utf-8"), | |
| 181 | file, | |
| 182 | ).code, | |
| 183 | loader: path.extname(file).slice(1) as esbuild.Loader, | |
| 184 | })); | |
| 185 | }, | |
| 186 | }, | |
| 187 | { | |
| 188 | name: "mark css external", | |
| 189 | setup(b) { | |
| 190 | b.onResolve({ filter: /\.css$/ }, () => ({ | |
| 191 | path: ".", | |
| 192 | namespace: "dropped", | |
| 193 | })); | |
| 194 | b.onLoad({ filter: /./, namespace: "dropped" }, () => ({ | |
| 195 | contents: "", | |
| 196 | })); | |
| 197 | }, | |
| 198 | }, | |
| 199 | ]; | |
| 175 | 200 | |
| 176 | const { metafile, outputFiles, errors, warnings } = await esbuild.build({ | |
| 177 | bundle: true, | |
| 178 | chunkNames: "c.[hash]", | |
| 179 | entryNames: path.basename(entry, path.extname(entry)), | |
| 180 | entryPoints: [ | |
| 181 | path.join(import.meta.dirname, "backend/entry-" + platform + ".ts"), | |
| 182 | ], | |
| 183 | platform: "node", | |
| 184 | format: "esm", | |
| 185 | minify: false, | |
| 186 | outdir: "out!", | |
| 187 | plugins: serverPlugins, | |
| 188 | splitting: true, | |
| 189 | logLevel: "silent", | |
| 190 | write: false, | |
| 191 | metafile: true, | |
| 192 | jsx: "automatic", | |
| 193 | jsxImportSource: "#ssr", | |
| 194 | jsxDev: false, | |
| 195 | define: { | |
| 196 | MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText), | |
| 197 | 'globalThis.CLOVER_SERVER_ENTRY': JSON.stringify(entry), | |
| 198 | }, | |
| 199 | external: Object.keys(pkg.dependencies) | |
| 200 | .filter((x) => !x.startsWith("@paperclover")), | |
| 201 | }); | |
| 202 | await trackEsbuild(io, metafile) | |
| 201 | const { metafile, outputFiles } = await esbuild.build({ | |
| 202 | bundle: true, | |
| 203 | chunkNames: "c.[hash]", | |
| 204 | entryNames: path.basename(entry, path.extname(entry)), | |
| 205 | entryPoints: [ | |
| 206 | path.join( | |
| 207 | import.meta.dirname, | |
| 208 | "backend/entry-" + platform + ".ts", | |
| 209 | ), | |
| 210 | ], | |
| 211 | platform: "node", | |
| 212 | format: "esm", | |
| 213 | minify: false, | |
| 214 | outdir: "out!", | |
| 215 | plugins: serverPlugins, | |
| 216 | splitting: true, | |
| 217 | logLevel: "silent", | |
| 218 | write: false, | |
| 219 | metafile: true, | |
| 220 | jsx: "automatic", | |
| 221 | jsxImportSource: "#engine", | |
| 222 | jsxDev: false, | |
| 223 | define: { | |
| 224 | MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText), | |
| 225 | "globalThis.CLOVER_SERVER_ENTRY": JSON.stringify(entry), | |
| 226 | }, | |
| 227 | external: Object.keys(pkg.dependencies).filter( | |
| 228 | (x) => !x.startsWith("@paperclover"), | |
| 229 | ), | |
| 230 | }); | |
| 231 | await trackEsbuild(io, metafile); | |
| 203 | 232 | |
| 204 | let fileWithMagicWord: { | |
| 205 | bytes: Buffer; | |
| 206 | basename: string; | |
| 207 | magicWord: string; | |
| 208 | } | null = null; | |
| 209 | for (const output of outputFiles) { | |
| 210 | const basename = output.path.replace(/^.*?!(?:\/|\\)/, ""); | |
| 211 | const key = "out!/" + basename.replaceAll("\\", "/"); | |
| 212 | // If this contains the generated "$views" file, then | |
| 213 | // mark this file as the one for replacement. Because | |
| 214 | // `splitting` is `true`, esbuild will not emit this | |
| 215 | // file in more than one chunk. | |
| 216 | if (magicWord && metafile.outputs[key].inputs["framework/lib/view.ts"]) { | |
| 217 | ASSERT(!fileWithMagicWord); | |
| 218 | fileWithMagicWord = { | |
| 219 | basename, | |
| 220 | bytes: Buffer.from(output.contents), | |
| 221 | magicWord, | |
| 222 | }; | |
| 223 | } else { | |
| 224 | io.writeFile(basename, Buffer.from(output.contents)) | |
| 225 | } | |
| 226 | } | |
| 227 | return fileWithMagicWord; | |
| 228 | }, entry)] as const); | |
| 233 | let fileWithMagicWord: { | |
| 234 | bytes: Buffer; | |
| 235 | basename: string; | |
| 236 | magicWord: string; | |
| 237 | } | null = null; | |
| 238 | for (const output of outputFiles) { | |
| 239 | const basename = output.path.replace(/^.*?!(?:\/|\\)/, ""); | |
| 240 | const key = "out!/" + basename.replaceAll("\\", "/"); | |
| 241 | // If this contains the generated "$views" file, then | |
| 242 | // mark this file as the one for replacement. Because | |
| 243 | // `splitting` is `true`, esbuild will not emit this | |
| 244 | // file in more than one chunk. | |
| 245 | if ( | |
| 246 | magicWord && | |
| 247 | metafile.outputs[key].inputs["framework/lib/view.ts"] | |
| 248 | ) { | |
| 249 | ASSERT(!fileWithMagicWord); | |
| 250 | fileWithMagicWord = { | |
| 251 | basename, | |
| 252 | bytes: Buffer.from(output.contents), | |
| 253 | magicWord, | |
| 254 | }; | |
| 255 | } else { | |
| 256 | io.writeFile(basename, Buffer.from(output.contents)); | |
| 257 | } | |
| 258 | } | |
| 259 | return fileWithMagicWord; | |
| 260 | }, entry), | |
| 261 | ] as const, | |
| 262 | ); | |
| 229 | 263 | |
| 230 | const wProcessed = wBundles.map(async([entry, wBundle]) => { | |
| 231 | if (!await wBundle) return; | |
| 264 | const wProcessed = wBundles.map(async ([entry, wBundle]) => { | |
| 265 | if (!(await wBundle)) return; | |
| 232 | 266 | await incr.work(async (io) => { |
| 233 | 267 | // Only the reachable resources need to be read and inserted into the bundle. |
| 234 | 268 | // This is what Map<string, incr.Ref> is for |
| 235 | 269 | const { basename, bytes, magicWord } = UNWRAP(await io.readWork(wBundle)); |
| 236 | const views = await Promise.all(viewRefs.map(ref => io.readWork(ref))); | |
| 270 | const views = await Promise.all(viewRefs.map((ref) => io.readWork(ref))); | |
| 237 | 271 | |
| 238 | 272 | // Client JS |
| 239 | 273 | const scriptList = Object.entries(await io.readWork(wScriptMap)); |
| 240 | const viewScriptsList = new Set(views.flatMap(view => view.clientRefs)); | |
| 274 | const viewScriptsList = new Set(views.flatMap((view) => view.clientRefs)); | |
| 241 | 275 | const neededScripts = scriptList.filter(([k]) => viewScriptsList.has(k)); |
| 242 | 276 | |
| 243 | 277 | // CSS |
| 244 | 278 | const viewStyleKeys = views.map((view) => view.styleKey); |
| 245 | 279 | const viewCssBundles = await Promise.all( |
| 246 | viewStyleKeys.map((key) => io.readWork(UNWRAP(styleMap.get(key), "Style key: " + key)))); | |
| 280 | viewStyleKeys.map((key) => | |
| 281 | io.readWork(UNWRAP(styleMap.get(key), "Style key: " + key)), | |
| 282 | ), | |
| 283 | ); | |
| 247 | 284 | const styleList = Array.from(new Set(viewCssBundles)); |
| 248 | 285 | |
| 249 | 286 | // Replace the magic word |
| 250 | const text = bytes.toString("utf-8").replace( | |
| 251 | new RegExp(magicWord + "\\[(-?\\d+)\\]", "gs"), | |
| 252 | (_, i) => { | |
| 287 | const text = bytes | |
| 288 | .toString("utf-8") | |
| 289 | .replace(new RegExp(magicWord + "\\[(-?\\d+)\\]", "gs"), (_, i) => { | |
| 253 | 290 | i = Number(i); |
| 254 | 291 | // Inline the styling data |
| 255 | 292 | if (i === -2) { |
| ... | ... | @@ -261,17 +298,15 @@ export async function bundleServerJavaScript( |
| 261 | 298 | } |
| 262 | 299 | // Reference an index into `styleList` |
| 263 | 300 | return `${styleList.indexOf(viewCssBundles[i])}`; |
| 264 | }, | |
| 265 | ); | |
| 301 | }); | |
| 266 | 302 | |
| 267 | 303 | io.writeFile(basename, text); |
| 268 | 304 | }); |
| 269 | }) | |
| 305 | }); | |
| 270 | 306 | |
| 271 | 307 | await Promise.all(wProcessed); |
| 272 | 308 | } |
| 273 | 309 | |
| 274 | ||
| 275 | 310 | import * as esbuild from "esbuild"; |
| 276 | 311 | import * as path from "node:path"; |
| 277 | 312 | import process from "node:process"; |
| ... | ... | @@ -282,9 +317,8 @@ import { |
| 282 | 317 | projectRelativeResolution, |
| 283 | 318 | virtualFiles, |
| 284 | 319 | } from "./esbuild-support.ts"; |
| 285 | import { Io, toAbs, toRel } from "./incremental.ts"; | |
| 286 | import * as css from "./css.ts"; | |
| 320 | import { Io, toAbs } from "./incremental.ts"; | |
| 287 | 321 | import * as fs from "#sitegen/fs"; |
| 288 | 322 | import * as mime from "#sitegen/mime"; |
| 289 | 323 | import * as incr from "./incremental.ts"; |
| 290 | import * as sg from "#sitegen";import type { PreparedView } from "./generate2.ts";import { meta } from "@/file-viewer/pages/file.cotyledon_speedbump.tsx"; | |
| 324 | import * as sg from "#sitegen"; |
framework/engine/jsx-runtime.ts+14-14| ... | ... | @@ -1,37 +1,37 @@ |
| 1 | export const Fragment = ({ children }: { children: engine.Node[] }) => children; | |
| 1 | export const Fragment = ({ children }: { children: render.Node[] }) => children; | |
| 2 | 2 | |
| 3 | 3 | export function jsx( |
| 4 | type: string | engine.Component, | |
| 4 | type: string | render.Component, | |
| 5 | 5 | props: Record<string, unknown>, |
| 6 | ): engine.Element { | |
| 6 | ): render.Element { | |
| 7 | 7 | if (typeof type !== "function" && typeof type !== "string") { |
| 8 | throw new Error("Invalid component type: " + engine.inspect(type)); | |
| 8 | throw new Error("Invalid component type: " + render.inspect(type)); | |
| 9 | 9 | } |
| 10 | return [engine.kElement, type, props]; | |
| 10 | return [render.kElement, type, props]; | |
| 11 | 11 | } |
| 12 | 12 | |
| 13 | 13 | export function jsxDEV( |
| 14 | type: string | engine.Component, | |
| 14 | type: string | render.Component, | |
| 15 | 15 | props: Record<string, unknown>, |
| 16 | 16 | // Unused with the clover engine |
| 17 | 17 | _key: string, |
| 18 | 18 | // Unused with the clover engine |
| 19 | 19 | _isStaticChildren: boolean, |
| 20 | source: engine.SrcLoc, | |
| 21 | ): engine.Element { | |
| 20 | source: render.SrcLoc, | |
| 21 | ): render.Element { | |
| 22 | 22 | const { fileName, lineNumber, columnNumber } = source; |
| 23 | 23 | |
| 24 | 24 | // Assert the component type is valid to render. |
| 25 | 25 | if (typeof type !== "function" && typeof type !== "string") { |
| 26 | 26 | throw new Error( |
| 27 | 27 | `Invalid component type at ${fileName}:${lineNumber}:${columnNumber}: ` + |
| 28 | engine.inspect(type) + | |
| 28 | render.inspect(type) + | |
| 29 | 29 | ". Clover SSR element must be a function or string", |
| 30 | 30 | ); |
| 31 | 31 | } |
| 32 | 32 | |
| 33 | 33 | // Construct an `ssr.Element` |
| 34 | return [engine.kElement, type, props, "", source]; | |
| 34 | return [render.kElement, type, props, "", source]; | |
| 35 | 35 | } |
| 36 | 36 | |
| 37 | 37 | // jsxs |
| ... | ... | @@ -45,10 +45,10 @@ declare global { |
| 45 | 45 | interface ElementChildrenAttribute { |
| 46 | 46 | children: Node; |
| 47 | 47 | } |
| 48 | type Element = engine.Element; | |
| 49 | type ElementType = keyof IntrinsicElements | engine.Component; | |
| 50 | type ElementClass = ReturnType<engine.Component>; | |
| 48 | type Element = render.Element; | |
| 49 | type ElementType = keyof IntrinsicElements | render.Component; | |
| 50 | type ElementClass = ReturnType<render.Component>; | |
| 51 | 51 | } |
| 52 | 52 | } |
| 53 | 53 | |
| 54 | import * as engine from "./ssr.ts"; | |
| 54 | import * as render from "./render.ts"; |
framework/engine/marko-runtime.ts+27-36| ... | ... | @@ -11,29 +11,22 @@ export const createTemplate = ( |
| 11 | 11 | templateId: string, |
| 12 | 12 | renderer: ServerRenderer, |
| 13 | 13 | ) => { |
| 14 | const { render } = marko.createTemplate(templateId, renderer); | |
| 14 | const { render: renderFn } = marko.createTemplate(templateId, renderer); | |
| 15 | 15 | function wrap(props: Record<string, unknown>, n: number) { |
| 16 | 16 | // Marko Custom Tags |
| 17 | 17 | const cloverAsyncMarker = { isAsync: false }; |
| 18 | let r: engine.Render | undefined = undefined; | |
| 19 | try { | |
| 20 | r = engine.getCurrentRender(); | |
| 21 | } catch {} | |
| 18 | const r = render.current; | |
| 22 | 19 | // Support using Marko outside of Clover SSR |
| 23 | if (r) { | |
| 24 | engine.setCurrentRender(null); | |
| 25 | const markoResult = render.call(renderer, { | |
| 26 | ...props, | |
| 27 | $global: { clover: r, cloverAsyncMarker }, | |
| 28 | }); | |
| 29 | if (cloverAsyncMarker.isAsync) { | |
| 30 | return markoResult.then(engine.html); | |
| 31 | } | |
| 32 | const rr = markoResult.toString(); | |
| 33 | return engine.html(rr); | |
| 34 | } else { | |
| 35 | return renderer(props, n); | |
| 20 | if (!r) return renderer(props, n); | |
| 21 | const markoResult = renderFn.call(renderer, { | |
| 22 | ...props, | |
| 23 | $global: { clover: r, cloverAsyncMarker }, | |
| 24 | }); | |
| 25 | if (cloverAsyncMarker.isAsync) { | |
| 26 | return markoResult.then(render.raw); | |
| 36 | 27 | } |
| 28 | const rr = markoResult.toString(); | |
| 29 | return render.raw(rr); | |
| 37 | 30 | } |
| 38 | 31 | wrap.render = render; |
| 39 | 32 | wrap.unwrapped = renderer; |
| ... | ... | @@ -56,20 +49,16 @@ export const dynamicTag = ( |
| 56 | 49 | tag = unwrapped; |
| 57 | 50 | break clover; |
| 58 | 51 | } |
| 59 | let r: engine.Render; | |
| 60 | try { | |
| 61 | r = engine.getCurrentRender(); | |
| 62 | if (!r) throw 0; | |
| 63 | } catch { | |
| 64 | r = marko.$global().clover as engine.Render; | |
| 65 | } | |
| 52 | const r = render.current ?? (marko.$global().clover as render.State); | |
| 66 | 53 | if (!r) throw new Error("No Clover Render Active"); |
| 67 | const subRender = engine.initRender(r.async !== -1, r.addon); | |
| 68 | const resolved = engine.resolveNode(subRender, [ | |
| 69 | engine.kElement, | |
| 70 | tag, | |
| 71 | inputOrArgs, | |
| 72 | ]); | |
| 54 | const subRender = render.init(r.async !== -1, r.addon); | |
| 55 | const resolved = render.resolveNode( | |
| 56 | subRender, | |
| 57 | render.element( | |
| 58 | tag as render.Component, | |
| 59 | inputOrArgs as Record<any, any>, | |
| 60 | ), | |
| 61 | ); | |
| 73 | 62 | |
| 74 | 63 | if (subRender.async > 0) { |
| 75 | 64 | const marker = marko.$global().cloverAsyncMarker as Async; |
| ... | ... | @@ -79,7 +68,7 @@ export const dynamicTag = ( |
| 79 | 68 | const { resolve, reject, promise } = Promise.withResolvers<string>(); |
| 80 | 69 | subRender.asyncDone = () => { |
| 81 | 70 | const rejections = subRender.rejections; |
| 82 | if (!rejections) return resolve(engine.renderNode(resolved)); | |
| 71 | if (!rejections) return resolve(render.stringifyNode(resolved)); | |
| 83 | 72 | (r.rejections ??= []).push(...rejections); |
| 84 | 73 | return reject(new Error("Render had errors")); |
| 85 | 74 | }; |
| ... | ... | @@ -91,7 +80,7 @@ export const dynamicTag = ( |
| 91 | 80 | 0, |
| 92 | 81 | ); |
| 93 | 82 | } else { |
| 94 | marko.write(engine.renderNode(resolved)); | |
| 83 | marko.write(render.stringifyNode(resolved)); | |
| 95 | 84 | } |
| 96 | 85 | return; |
| 97 | 86 | } |
| ... | ... | @@ -124,13 +113,15 @@ export function escapeXML(input: unknown) { |
| 124 | 113 | // creating `[object Object]` is universally useless to any end user. |
| 125 | 114 | if ( |
| 126 | 115 | input == null || |
| 127 | (typeof input === "object" && input && | |
| 116 | (typeof input === "object" && | |
| 117 | input && | |
| 128 | 118 | // only block this if it's the default `toString` |
| 129 | 119 | input.toString === Object.prototype.toString) |
| 130 | 120 | ) { |
| 131 | 121 | throw new Error( |
| 132 | 122 | `Unexpected value in template placeholder: '` + |
| 133 | engine.inspect(input) + "'. " + | |
| 123 | render.inspect(input) + | |
| 124 | "'. " + | |
| 134 | 125 | `To emit a literal '${input}', use \${String(value)}`, |
| 135 | 126 | ); |
| 136 | 127 | } |
| ... | ... | @@ -141,7 +132,7 @@ interface Async { |
| 141 | 132 | isAsync: boolean; |
| 142 | 133 | } |
| 143 | 134 | |
| 144 | import * as engine from "./ssr.ts"; | |
| 135 | import * as render from "#engine/render"; | |
| 145 | 136 | import type { ServerRenderer } from "marko/html/template"; |
| 146 | 137 | import { type Accessor } from "marko/common/types"; |
| 147 | 138 | import * as marko from "#marko/html"; |
framework/engine/render.test.tsx created+41| ... | ... | @@ -0,0 +1,41 @@ |
| 1 | import { test } from "node:test"; | |
| 2 | import * as render from "./render.ts"; | |
| 3 | ||
| 4 | test("sanity", (t) => t.assert.equal(render.sync("gm <3").text, "gm &lt;3")); | |
| 5 | test("simple tree", (t) => | |
| 6 | t.assert.equal( | |
| 7 | render.sync( | |
| 8 | <main class={["a", "b"]}> | |
| 9 | <h1 style="background-color:red">hello world</h1> | |
| 10 | <p>haha</p> | |
| 11 | {1}|{0}|{true}|{false}|{null}|{undefined}| | |
| 12 | </main>, | |
| 13 | ).text, | |
| 14 | '<main class="a b"><h1 style=background-color:red>hello world</h1><p>haha</p>1|0|||||</main>', | |
| 15 | )); | |
| 16 | test("unescaped/escaped html", (t) => | |
| 17 | t.assert.equal( | |
| 18 | render.sync( | |
| 19 | <div> | |
| 20 | {render.raw("<fuck>")} | |
| 21 | {"\"&'`<>"} | |
| 22 | </div>, | |
| 23 | ).text, | |
| 24 | "<div><fuck>&quot;&amp;&#x27;&#x60;&lt;&gt;</div>", | |
| 25 | )); | |
| 26 | test("clsx built-in", (t) => | |
| 27 | t.assert.equal( | |
| 28 | render.sync( | |
| 29 | <> | |
| 30 | <a class="a" /> | |
| 31 | <b class={null} /> | |
| 32 | <c class={undefined} /> | |
| 33 | <d class={["a", "b", null]} /> | |
| 34 | <e class={{ a: true, b: false }} /> | |
| 35 | <e | |
| 36 | class={[null, "x", { z: true }, [{ m: true }, null, { v: false }]]} | |
| 37 | /> | |
| 38 | </>, | |
| 39 | ).text, | |
| 40 | '<a class=a></a><b></b><c></c><d class="a b"></d><e class=a></e><e class="x z m"></e>', | |
| 41 | )); |
framework/engine/render.ts created+321| ... | ... | @@ -0,0 +1,321 @@ |
| 1 | // Clover's Rendering Engine is the backbone of her website generator. It | |
| 2 | // converts objects and components (functions returning 'Node') into HTML. The | |
| 3 | // engine is simple and self-contained, with integrations for JSX and Marko | |
| 4 | // (which can interop with each-other) are provided next to this file. | |
| 5 | // | |
| 6 | // Add-ons to the rendering engine can provide opaque data, And retrieve it | |
| 7 | // within component calls with 'getAddonData'. For example, 'sitegen' uses this | |
| 8 | // to track needed client scripts without introducing patches to the engine. | |
| 9 | export let current: State | null = null; | |
| 10 | export function setCurrent(r: State | null) { | |
| 11 | current = r ?? null; | |
| 12 | } | |
| 13 | ||
| 14 | /* Convert a UI description into a string synchronously. */ | |
| 15 | export function sync<A extends Addons>(node: Node, addon: A = {} as A) { | |
| 16 | const state = init(false, addon); | |
| 17 | const resolved = resolveNode(state, node); | |
| 18 | return { text: stringifyNode(resolved), addon }; | |
| 19 | } | |
| 20 | ||
| 21 | /* Convert a UI description into a string asynchronously. */ | |
| 22 | export function async<A extends Addons>(node: Node, addon: A = {} as A) { | |
| 23 | const state = init(true, addon); | |
| 24 | const resolved = resolveNode(state, node); | |
| 25 | if (state.async === 0) { | |
| 26 | return Promise.resolve({ text: stringifyNode(resolved), addon }); | |
| 27 | } | |
| 28 | const { resolve, reject, promise } = Promise.withResolvers<Result<A>>(); | |
| 29 | state.asyncDone = () => { | |
| 30 | const rejections = state.rejections; | |
| 31 | if (!rejections) return resolve({ text: stringifyNode(resolved), addon }); | |
| 32 | if (rejections.length === 1) return reject(rejections[0]); | |
| 33 | return reject(new AggregateError(rejections)); | |
| 34 | }; | |
| 35 | return promise; | |
| 36 | } | |
| 37 | ||
| 38 | export type Addons = Record<symbol, unknown>; | |
| 39 | export interface Result<A extends Addons = Addons> { | |
| 40 | text: string; | |
| 41 | addon: A; | |
| 42 | } | |
| 43 | ||
| 44 | export function userData<T>(def: () => T) { | |
| 45 | const k: unique symbol = Symbol(); | |
| 46 | return { | |
| 47 | key: k, | |
| 48 | get: () => ((UNWRAP(current).addon[k] as T) ??= def() as T), | |
| 49 | set: (value: T) => void (UNWRAP(current).addon[k] = value), | |
| 50 | }; | |
| 51 | } | |
| 52 | ||
| 53 | /** Inline HTML into a render without escaping it */ | |
| 54 | export function raw(node: ResolvedNode): Raw { | |
| 55 | return [kRaw, node]; | |
| 56 | } | |
| 57 | export function element(type: Element[1], props: Element[2] = {}): Element { | |
| 58 | return [kElement, type, props]; | |
| 59 | } | |
| 60 | export function resolvedElement( | |
| 61 | type: ResolvedElement[1], | |
| 62 | props: ResolvedElement[2], | |
| 63 | children: ResolvedElement[3], | |
| 64 | ): ResolvedElement { | |
| 65 | return [kElement, type, props, children]; | |
| 66 | } | |
| 67 | ||
| 68 | export interface State { | |
| 69 | /** | |
| 70 | * Set to '-1' if rendering synchronously | |
| 71 | * Number of async promises the render is waiting on. | |
| 72 | */ | |
| 73 | async: number | -1; | |
| 74 | asyncDone: null | (() => void); | |
| 75 | /** When components reject, those are logged here */ | |
| 76 | rejections: unknown[] | null; | |
| 77 | /** Add-ons to the rendering engine store state here */ | |
| 78 | addon: Addons; | |
| 79 | } | |
| 80 | ||
| 81 | export const kElement = Symbol("Element"); | |
| 82 | export const kRaw = Symbol("Raw"); | |
| 83 | ||
| 84 | /** Node represents a webpage that can be 'rendered' into HTML. */ | |
| 85 | export type Node = | |
| 86 | | number | |
| 87 | | string // Escape HTML | |
| 88 | | Node[] // Concat | |
| 89 | | Element // Stringify | |
| 90 | | Raw // Insert | |
| 91 | | Promise<Node> // Await | |
| 92 | // Ignore | |
| 93 | | (undefined | null | boolean); | |
| 94 | export type Element = [ | |
| 95 | tag: typeof kElement, | |
| 96 | type: string | Component, | |
| 97 | props: Record<string, unknown>, | |
| 98 | _?: "", // children | |
| 99 | source?: SrcLoc, | |
| 100 | ]; | |
| 101 | export type Raw = [tag: typeof kRaw, html: ResolvedNode]; | |
| 102 | /** Components must return a value; 'undefined' is prohibited here | |
| 103 | * to avoid functions that are missing a return statement. */ | |
| 104 | export type Component = (props: Record<any, any>) => Exclude<Node, undefined>; | |
| 105 | /** Emitted by JSX runtime */ | |
| 106 | export interface SrcLoc { | |
| 107 | fileName: string; | |
| 108 | lineNumber: number; | |
| 109 | columnNumber: number; | |
| 110 | } | |
| 111 | ||
| 112 | /** | |
| 113 | * Resolution narrows the type 'Node' into 'ResolvedNode'. Async trees are | |
| 114 | * marked in 'State'. This operation performs everything besides the final | |
| 115 | * string concatenation. This function is agnostic across async/sync modes. | |
| 116 | */ | |
| 117 | export function resolveNode(r: State, node: unknown): ResolvedNode { | |
| 118 | if (!node && node !== 0) return ""; // falsy, non numeric | |
| 119 | if (typeof node !== "object") { | |
| 120 | if (node === true) return ""; // booleans are ignored | |
| 121 | if (typeof node === "string") return escapeHtml(node); | |
| 122 | if (typeof node === "number") return String(node); // no escaping ever | |
| 123 | throw new Error(`Cannot render ${inspect(node)} to HTML`); | |
| 124 | } | |
| 125 | if (node instanceof Promise) { | |
| 126 | if (r.async === -1) { | |
| 127 | throw new Error(`Asynchronous rendering is not supported here.`); | |
| 128 | } | |
| 129 | const placeholder: InsertionPoint = [null]; | |
| 130 | r.async += 1; | |
| 131 | node | |
| 132 | .then((result) => void (placeholder[0] = resolveNode(r, result))) | |
| 133 | // Intentionally catching errors in `resolveNode` | |
| 134 | .catch((e) => (r.rejections ??= []).push(e)) | |
| 135 | .finally(() => { | |
| 136 | if (--r.async == 0) { | |
| 137 | if (r.asyncDone == null) throw new Error("r.asyncDone == null"); | |
| 138 | r.asyncDone(); | |
| 139 | r.asyncDone = null; | |
| 140 | } | |
| 141 | }); | |
| 142 | // This lie is checked with an assertion in `renderNode` | |
| 143 | return placeholder as [ResolvedNode]; | |
| 144 | } | |
| 145 | if (!Array.isArray(node)) { | |
| 146 | throw new Error(`Invalid node type: ${inspect(node)}`); | |
| 147 | } | |
| 148 | const type = node[0]; | |
| 149 | if (type === kElement) { | |
| 150 | const { 1: tag, 2: props } = node; | |
| 151 | if (typeof tag === "function") { | |
| 152 | current = r; | |
| 153 | try { | |
| 154 | return resolveNode(r, tag(props)); | |
| 155 | } catch (e) { | |
| 156 | const { 4: src } = node; | |
| 157 | if (e && typeof e === "object") (e as { src?: string }).src = src; | |
| 158 | throw e; | |
| 159 | } finally { | |
| 160 | current = null; | |
| 161 | } | |
| 162 | } | |
| 163 | if (typeof tag !== "string") throw new Error("Unexpected " + inspect(type)); | |
| 164 | const children = props?.children; | |
| 165 | if (children) return [kElement, tag, props, resolveNode(r, children)]; | |
| 166 | return node; | |
| 167 | } | |
| 168 | if (type === kRaw) return node[1]; | |
| 169 | return node.map((elem) => resolveNode(r, elem)); | |
| 170 | } | |
| 171 | ||
| 172 | export type ResolvedNode = | |
| 173 | | ResolvedNode[] // Concat | |
| 174 | | ResolvedElement // Render | |
| 175 | | string; // Raw HTML | |
| 176 | export type ResolvedElement = [ | |
| 177 | tag: typeof kElement, | |
| 178 | type: string, | |
| 179 | props: Record<string, unknown>, | |
| 180 | children: ResolvedNode, | |
| 181 | ]; | |
| 182 | /** | |
| 183 | * Async rendering is done by creating an array of one item, | |
| 184 | * which is already a valid 'Node', but the element is written | |
| 185 | * once the data is available. The 'Render' contains a count | |
| 186 | * of how many async jobs are left. | |
| 187 | */ | |
| 188 | export type InsertionPoint = [null | ResolvedNode]; | |
| 189 | ||
| 190 | /** | |
| 191 | * Convert 'ResolvedNode' into HTML text. This operation happens after all | |
| 192 | * async work is settled. The HTML is emitted as concisely as possible. | |
| 193 | */ | |
| 194 | export function stringifyNode(node: ResolvedNode): string { | |
| 195 | if (typeof node === "string") return node; | |
| 196 | ASSERT(node, "Unresolved Render Node"); | |
| 197 | const type = node[0]; | |
| 198 | if (type === kElement) { | |
| 199 | return stringifyElement(node as ResolvedElement); | |
| 200 | } | |
| 201 | node = node as ResolvedNode[]; // TS cannot infer. | |
| 202 | let out = type ? stringifyNode(type) : ""; | |
| 203 | let len = node.length; | |
| 204 | for (let i = 1; i < len; i++) { | |
| 205 | const elem = node[i]; | |
| 206 | if (elem) out += stringifyNode(elem); | |
| 207 | } | |
| 208 | return out; | |
| 209 | } | |
| 210 | function stringifyElement(element: ResolvedElement) { | |
| 211 | const { 1: tag, 2: props, 3: children } = element; | |
| 212 | let out = "<" + tag; | |
| 213 | let needSpace = true; | |
| 214 | for (const prop in props) { | |
| 215 | const value = props[prop]; | |
| 216 | if (!value || typeof value === "function") continue; | |
| 217 | let attr; | |
| 218 | switch (prop) { | |
| 219 | default: | |
| 220 | attr = `${prop}=${quoteIfNeeded(escapeHtml(String(value)))}`; | |
| 221 | break; | |
| 222 | case "className": | |
| 223 | // Legacy React Compat | |
| 224 | case "class": | |
| 225 | attr = `class=${quoteIfNeeded(escapeHtml(clsx(value as ClsxInput)))}`; | |
| 226 | break; | |
| 227 | case "htmlFor": | |
| 228 | throw new Error("Do not use the `htmlFor` attribute. Use `for`"); | |
| 229 | // Do not process these | |
| 230 | case "children": | |
| 231 | case "ref": | |
| 232 | case "dangerouslySetInnerHTML": | |
| 233 | case "key": | |
| 234 | continue; | |
| 235 | } | |
| 236 | if (needSpace) (out += " "), (needSpace = !attr.endsWith('"')); | |
| 237 | out += attr; | |
| 238 | } | |
| 239 | out += ">"; | |
| 240 | if (children) out += stringifyNode(children); | |
| 241 | if ( | |
| 242 | tag !== "br" && | |
| 243 | tag !== "img" && | |
| 244 | tag !== "input" && | |
| 245 | tag !== "meta" && | |
| 246 | tag !== "link" && | |
| 247 | tag !== "hr" | |
| 248 | ) { | |
| 249 | out += `</${tag}>`; | |
| 250 | } | |
| 251 | return out; | |
| 252 | } | |
| 253 | export function stringifyStyleAttribute(style: Record<string, string>) { | |
| 254 | let out = ``; | |
| 255 | for (const styleName in style) { | |
| 256 | if (out) out += ";"; | |
| 257 | out += `${styleName.replace(/[A-Z]/g, "-$&").toLowerCase()}:${escapeHtml( | |
| 258 | String(style[styleName]), | |
| 259 | )}`; | |
| 260 | } | |
| 261 | return "style=" + quoteIfNeeded(out); | |
| 262 | } | |
| 263 | export function quoteIfNeeded(text: string) { | |
| 264 | if (text.includes(" ")) return '"' + text + '"'; | |
| 265 | return text; | |
| 266 | } | |
| 267 | ||
| 268 | // -- utility functions -- | |
| 269 | ||
| 270 | export function init(allowAsync: boolean, addon: Addons): State { | |
| 271 | return { | |
| 272 | async: allowAsync ? 0 : -1, | |
| 273 | rejections: null, | |
| 274 | asyncDone: null, | |
| 275 | addon, | |
| 276 | }; | |
| 277 | } | |
| 278 | ||
| 279 | export type ClsxInput = string | Record<string, boolean | null> | ClsxInput[]; | |
| 280 | export function clsx(mix: ClsxInput) { | |
| 281 | var k, | |
| 282 | y, | |
| 283 | str = ""; | |
| 284 | if (typeof mix === "string") { | |
| 285 | return mix; | |
| 286 | } else if (typeof mix === "object") { | |
| 287 | if (Array.isArray(mix)) { | |
| 288 | for (k = 0; k < mix.length; k++) { | |
| 289 | if (mix[k] && (y = clsx(mix[k]))) { | |
| 290 | str && (str += " "); | |
| 291 | str += y; | |
| 292 | } | |
| 293 | } | |
| 294 | } else { | |
| 295 | for (k in mix) { | |
| 296 | if (mix[k]) { | |
| 297 | str && (str += " "); | |
| 298 | str += k; | |
| 299 | } | |
| 300 | } | |
| 301 | } | |
| 302 | } | |
| 303 | return str; | |
| 304 | } | |
| 305 | ||
| 306 | export const escapeHtml = (unsafeText: string) => | |
| 307 | String(unsafeText) | |
| 308 | .replace(/&/g, "&amp;") | |
| 309 | .replace(/</g, "&lt;") | |
| 310 | .replace(/>/g, "&gt;") | |
| 311 | .replace(/"/g, "&quot;") | |
| 312 | .replace(/'/g, "&#x27;") | |
| 313 | .replace(/`/g, "&#x60;"); | |
| 314 | ||
| 315 | export function inspect(object: unknown) { | |
| 316 | try { | |
| 317 | return require("node:util").inspect(object); | |
| 318 | } catch { | |
| 319 | return typeof object; | |
| 320 | } | |
| 321 | } |
framework/engine/ssr.test.tsx deleted-41| ... | ... | @@ -1,41 +0,0 @@ |
| 1 | import { test } from "node:test"; | |
| 2 | import * as engine from "./ssr.ts"; | |
| 3 | ||
| 4 | test("sanity", (t) => t.assert.equal(engine.ssrSync("gm <3").text, "gm &lt;3")); | |
| 5 | test("simple tree", (t) => | |
| 6 | t.assert.equal( | |
| 7 | engine.ssrSync( | |
| 8 | <main class={["a", "b"]}> | |
| 9 | <h1 style="background-color:red">hello world</h1> | |
| 10 | <p>haha</p> | |
| 11 | {1}| | |
| 12 | {0}| | |
| 13 | {true}| | |
| 14 | {false}| | |
| 15 | {null}| | |
| 16 | {undefined}| | |
| 17 | </main>, | |
| 18 | ).text, | |
| 19 | '<main class="a b"><h1 style=background-color:red>hello world</h1><p>haha</p>1|0|||||</main>', | |
| 20 | )); | |
| 21 | test("unescaped/escaped html", (t) => | |
| 22 | t.assert.equal( | |
| 23 | engine.ssrSync(<div>{engine.html("<fuck>")}{"\"&'`<>"}</div>).text, | |
| 24 | "<div><fuck>&quot;&amp;&#x27;&#x60;&lt;&gt;</div>", | |
| 25 | )); | |
| 26 | test("clsx built-in", (t) => | |
| 27 | t.assert.equal( | |
| 28 | engine.ssrSync( | |
| 29 | <> | |
| 30 | <a class="a" /> | |
| 31 | <b class={null} /> | |
| 32 | <c class={undefined} /> | |
| 33 | <d class={["a", "b", null]} /> | |
| 34 | <e class={{ a: true, b: false }} /> | |
| 35 | <e | |
| 36 | class={[null, "x", { z: true }, [{ m: true }, null, { v: false }]]} | |
| 37 | /> | |
| 38 | </>, | |
| 39 | ).text, | |
| 40 | '<a class=a></a><b></b><c></c><d class="a b"></d><e class=a></e><e class="x z m"></e>', | |
| 41 | )); |
framework/engine/ssr.ts deleted-306| ... | ... | @@ -1,306 +0,0 @@ |
| 1 | // Clover's Rendering Engine is the backbone of her website generator. It | |
| 2 | // converts objects and components (functions returning 'Node') into HTML. The | |
| 3 | // engine is simple and self-contained, with integrations for JSX and Marko | |
| 4 | // (which can interop with each-other) are provided next to this file. | |
| 5 | // | |
| 6 | // Add-ons to the rendering engine can provide opaque data, And retrieve it | |
| 7 | // within component calls with 'getAddonData'. For example, 'sitegen' uses this | |
| 8 | // to track needed client scripts without introducing patches to the engine. | |
| 9 | export type Addons = Record<string | symbol, unknown>; | |
| 10 | ||
| 11 | export function ssrSync<A extends Addons>(node: Node, addon: A = {} as A) { | |
| 12 | const r = initRender(false, addon); | |
| 13 | const resolved = resolveNode(r, node); | |
| 14 | return { text: renderNode(resolved), addon }; | |
| 15 | } | |
| 16 | export { ssrSync as sync }; | |
| 17 | ||
| 18 | export function ssrAsync<A extends Addons>(node: Node, addon: A = {} as A) { | |
| 19 | const r = initRender(true, addon); | |
| 20 | const resolved = resolveNode(r, node); | |
| 21 | if (r.async === 0) { | |
| 22 | return Promise.resolve({ text: renderNode(resolved), addon }); | |
| 23 | } | |
| 24 | const { resolve, reject, promise } = Promise.withResolvers<Result<A>>(); | |
| 25 | r.asyncDone = () => { | |
| 26 | const rejections = r.rejections; | |
| 27 | if (!rejections) return resolve({ text: renderNode(resolved), addon }); | |
| 28 | if (rejections.length === 1) return reject(rejections[0]); | |
| 29 | return reject(new AggregateError(rejections)); | |
| 30 | }; | |
| 31 | return promise; | |
| 32 | } | |
| 33 | export { ssrAsync as async }; | |
| 34 | ||
| 35 | /** Inline HTML into a render without escaping it */ | |
| 36 | export function html(rawText: ResolvedNode): DirectHtml { | |
| 37 | return [kDirectHtml, rawText]; | |
| 38 | } | |
| 39 | ||
| 40 | interface Result<A extends Addons = Addons> { | |
| 41 | text: string; | |
| 42 | addon: A; | |
| 43 | } | |
| 44 | ||
| 45 | export interface Render { | |
| 46 | /** | |
| 47 | * Set to '-1' if rendering synchronously | |
| 48 | * Number of async promises the render is waiting on. | |
| 49 | */ | |
| 50 | async: number | -1; | |
| 51 | asyncDone: null | (() => void); | |
| 52 | /** When components reject, those are logged here */ | |
| 53 | rejections: unknown[] | null; | |
| 54 | /** Add-ons to the rendering engine store state here */ | |
| 55 | addon: Addons; | |
| 56 | } | |
| 57 | ||
| 58 | export const kElement = Symbol("Element"); | |
| 59 | export const kDirectHtml = Symbol("DirectHtml"); | |
| 60 | ||
| 61 | /** Node represents a webpage that can be 'rendered' into HTML. */ | |
| 62 | export type Node = | |
| 63 | | number | |
| 64 | | string // Escape HTML | |
| 65 | | Node[] // Concat | |
| 66 | | Element // Render | |
| 67 | | DirectHtml // Insert | |
| 68 | | Promise<Node> // Await | |
| 69 | // Ignore | |
| 70 | | undefined | |
| 71 | | null | |
| 72 | | boolean; | |
| 73 | export type Element = [ | |
| 74 | tag: typeof kElement, | |
| 75 | type: string | Component, | |
| 76 | props: Record<string, unknown>, | |
| 77 | _?: "", | |
| 78 | source?: SrcLoc, | |
| 79 | ]; | |
| 80 | export type DirectHtml = [tag: typeof kDirectHtml, html: ResolvedNode]; | |
| 81 | /** | |
| 82 | * Components must return a value; 'undefined' is prohibited here | |
| 83 | * to avoid functions that are missing a return statement. | |
| 84 | */ | |
| 85 | export type Component = ( | |
| 86 | props: Record<any, any>, | |
| 87 | ) => Exclude<Node, undefined>; | |
| 88 | /** Emitted by JSX runtime */ | |
| 89 | export interface SrcLoc { | |
| 90 | fileName: string; | |
| 91 | lineNumber: number; | |
| 92 | columnNumber: number; | |
| 93 | } | |
| 94 | ||
| 95 | /** | |
| 96 | * Resolution narrows the type 'Node' into 'ResolvedNode'. Async trees are | |
| 97 | * marked in the 'Render'. This operation performs everything besides the final | |
| 98 | * string concatenation. This function is agnostic across async/sync modes. | |
| 99 | */ | |
| 100 | export function resolveNode(r: Render, node: unknown): ResolvedNode { | |
| 101 | if (!node && node !== 0) return ""; // falsy, non numeric | |
| 102 | if (typeof node !== "object") { | |
| 103 | if (node === true) return ""; // booleans are ignored | |
| 104 | if (typeof node === "string") return escapeHtml(node); | |
| 105 | if (typeof node === "number") return String(node); // no escaping ever | |
| 106 | throw new Error(`Cannot render ${inspect(node)} to HTML`); | |
| 107 | } | |
| 108 | if (node instanceof Promise) { | |
| 109 | if (r.async === -1) { | |
| 110 | throw new Error(`Asynchronous rendering is not supported here.`); | |
| 111 | } | |
| 112 | const placeholder: InsertionPoint = [null]; | |
| 113 | r.async += 1; | |
| 114 | node | |
| 115 | .then((result) => void (placeholder[0] = resolveNode(r, result))) | |
| 116 | // Intentionally catching errors in `resolveNode` | |
| 117 | .catch((e) => (r.rejections ??= []).push(e)) | |
| 118 | .finally(() => { | |
| 119 | if (--r.async == 0) { | |
| 120 | if (r.asyncDone == null) throw new Error("r.asyncDone == null"); | |
| 121 | r.asyncDone(); | |
| 122 | r.asyncDone = null; | |
| 123 | } | |
| 124 | }); | |
| 125 | // This lie is checked with an assertion in `renderNode` | |
| 126 | return placeholder as [ResolvedNode]; | |
| 127 | } | |
| 128 | if (!Array.isArray(node)) { | |
| 129 | throw new Error(`Invalid node type: ${inspect(node)}`); | |
| 130 | } | |
| 131 | const type = node[0]; | |
| 132 | if (type === kElement) { | |
| 133 | const { 1: tag, 2: props } = node; | |
| 134 | if (typeof tag === "function") { | |
| 135 | currentRender = r; | |
| 136 | try { | |
| 137 | return resolveNode(r, tag(props)); | |
| 138 | } catch (e) { | |
| 139 | const { 4: src } = node; | |
| 140 | if (e && typeof e === "object") (e as { src?: string }).src = src; | |
| 141 | throw e; | |
| 142 | } finally { | |
| 143 | currentRender = null; | |
| 144 | } | |
| 145 | } | |
| 146 | if (typeof tag !== "string") throw new Error("Unexpected " + inspect(type)); | |
| 147 | const children = props?.children; | |
| 148 | if (children) return [kElement, tag, props, resolveNode(r, children)]; | |
| 149 | return node; | |
| 150 | } | |
| 151 | if (type === kDirectHtml) return node[1]; | |
| 152 | return node.map((elem) => resolveNode(r, elem)); | |
| 153 | } | |
| 154 | ||
| 155 | export type ResolvedNode = | |
| 156 | | ResolvedNode[] // Concat | |
| 157 | | ResolvedElement // Render | |
| 158 | | string; // Direct HTML | |
| 159 | export type ResolvedElement = [ | |
| 160 | tag: typeof kElement, | |
| 161 | type: string, | |
| 162 | props: Record<string, unknown>, | |
| 163 | children: ResolvedNode, | |
| 164 | ]; | |
| 165 | /** | |
| 166 | * Async rendering is done by creating an array of one item, | |
| 167 | * which is already a valid 'Node', but the element is written | |
| 168 | * once the data is available. The 'Render' contains a count | |
| 169 | * of how many async jobs are left. | |
| 170 | */ | |
| 171 | export type InsertionPoint = [null | ResolvedNode]; | |
| 172 | ||
| 173 | /** | |
| 174 | * Convert 'ResolvedNode' into HTML text. This operation happens after all | |
| 175 | * async work is settled. The HTML is emitted as concisely as possible. | |
| 176 | */ | |
| 177 | export function renderNode(node: ResolvedNode): string { | |
| 178 | if (typeof node === "string") return node; | |
| 179 | ASSERT(node, "Unresolved Render Node"); | |
| 180 | const type = node[0]; | |
| 181 | if (type === kElement) { | |
| 182 | return renderElement(node as ResolvedElement); | |
| 183 | } | |
| 184 | node = node as ResolvedNode[]; // TS cannot infer. | |
| 185 | let out = type ? renderNode(type) : ""; | |
| 186 | let len = node.length; | |
| 187 | for (let i = 1; i < len; i++) { | |
| 188 | const elem = node[i]; | |
| 189 | if (elem) out += renderNode(elem); | |
| 190 | } | |
| 191 | return out; | |
| 192 | } | |
| 193 | function renderElement(element: ResolvedElement) { | |
| 194 | const { 1: tag, 2: props, 3: children } = element; | |
| 195 | let out = "<" + tag; | |
| 196 | let needSpace = true; | |
| 197 | for (const prop in props) { | |
| 198 | const value = props[prop]; | |
| 199 | if (!value || typeof value === "function") continue; | |
| 200 | let attr; | |
| 201 | switch (prop) { | |
| 202 | default: | |
| 203 | attr = `${prop}=${quoteIfNeeded(escapeHtml(String(value)))}`; | |
| 204 | break; | |
| 205 | case "className": | |
| 206 | // Legacy React Compat | |
| 207 | case "class": | |
| 208 | attr = `class=${quoteIfNeeded(escapeHtml(clsx(value as ClsxInput)))}`; | |
| 209 | break; | |
| 210 | case "htmlFor": | |
| 211 | throw new Error("Do not use the `htmlFor` attribute. Use `for`"); | |
| 212 | // Do not process these | |
| 213 | case "children": | |
| 214 | case "ref": | |
| 215 | case "dangerouslySetInnerHTML": | |
| 216 | case "key": | |
| 217 | continue; | |
| 218 | } | |
| 219 | if (needSpace) out += " ", needSpace = !attr.endsWith('"'); | |
| 220 | out += attr; | |
| 221 | } | |
| 222 | out += ">"; | |
| 223 | if (children) out += renderNode(children); | |
| 224 | if ( | |
| 225 | tag !== "br" && tag !== "img" && tag !== "input" && tag !== "meta" && | |
| 226 | tag !== "link" && tag !== "hr" | |
| 227 | ) { | |
| 228 | out += `</${tag}>`; | |
| 229 | } | |
| 230 | return out; | |
| 231 | } | |
| 232 | export function renderStyleAttribute(style: Record<string, string>) { | |
| 233 | let out = ``; | |
| 234 | for (const styleName in style) { | |
| 235 | if (out) out += ";"; | |
| 236 | out += `${styleName.replace(/[A-Z]/g, "-$&").toLowerCase()}:${ | |
| 237 | escapeHtml(String(style[styleName])) | |
| 238 | }`; | |
| 239 | } | |
| 240 | return "style=" + quoteIfNeeded(out); | |
| 241 | } | |
| 242 | export function quoteIfNeeded(text: string) { | |
| 243 | if (text.includes(" ")) return '"' + text + '"'; | |
| 244 | return text; | |
| 245 | } | |
| 246 | ||
| 247 | // -- utility functions -- | |
| 248 | ||
| 249 | export function initRender(allowAsync: boolean, addon: Addons): Render { | |
| 250 | return { | |
| 251 | async: allowAsync ? 0 : -1, | |
| 252 | rejections: null, | |
| 253 | asyncDone: null, | |
| 254 | addon, | |
| 255 | }; | |
| 256 | } | |
| 257 | ||
| 258 | let currentRender: Render | null = null; | |
| 259 | export function getCurrentRender() { | |
| 260 | if (!currentRender) throw new Error("No Render Active"); | |
| 261 | return currentRender; | |
| 262 | } | |
| 263 | export function setCurrentRender(r?: Render | null) { | |
| 264 | currentRender = r ?? null; | |
| 265 | } | |
| 266 | export function getUserData<T>(namespace: PropertyKey, def: () => T): T { | |
| 267 | return (getCurrentRender().addon[namespace] ??= def()) as T; | |
| 268 | } | |
| 269 | ||
| 270 | export function inspect(object: unknown) { | |
| 271 | try { | |
| 272 | return require("node:util").inspect(object); | |
| 273 | } catch { | |
| 274 | return typeof object; | |
| 275 | } | |
| 276 | } | |
| 277 | ||
| 278 | export type ClsxInput = string | Record<string, boolean | null> | ClsxInput[]; | |
| 279 | export function clsx(mix: ClsxInput) { | |
| 280 | var k, y, str = ""; | |
| 281 | if (typeof mix === "string") { | |
| 282 | return mix; | |
| 283 | } else if (typeof mix === "object") { | |
| 284 | if (Array.isArray(mix)) { | |
| 285 | for (k = 0; k < mix.length; k++) { | |
| 286 | if (mix[k] && (y = clsx(mix[k]))) { | |
| 287 | str && (str += " "); | |
| 288 | str += y; | |
| 289 | } | |
| 290 | } | |
| 291 | } else { | |
| 292 | for (k in mix) { | |
| 293 | if (mix[k]) { | |
| 294 | str && (str += " "); | |
| 295 | str += k; | |
| 296 | } | |
| 297 | } | |
| 298 | } | |
| 299 | } | |
| 300 | return str; | |
| 301 | } | |
| 302 | ||
| 303 | export const escapeHtml = (unsafeText: string) => | |
| 304 | String(unsafeText) | |
| 305 | .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;") | |
| 306 | .replace(/"/g, "&quot;").replace(/'/g, "&#x27;").replace(/`/g, "&#x60;"); |
framework/engine/suspense.ts+37-37| ... | ... | @@ -7,46 +7,44 @@ |
| 7 | 7 | // |
| 8 | 8 | // I would link to an article from Next.js or React, but their examples |
| 9 | 9 | // are too verbose and not informative to what they actually do. |
| 10 | const kState = Symbol("SuspenseState"); | |
| 10 | const userData = render.userData<null | State>(() => null); | |
| 11 | 11 | |
| 12 | 12 | interface SuspenseProps { |
| 13 | children: ssr.Node; | |
| 14 | fallback?: ssr.Node; | |
| 13 | children: render.Node; | |
| 14 | fallback?: render.Node; | |
| 15 | 15 | } |
| 16 | 16 | |
| 17 | 17 | interface State { |
| 18 | 18 | nested: boolean; |
| 19 | 19 | nextId: number; |
| 20 | 20 | completed: number; |
| 21 | pushChunk(name: string, node: ssr.ResolvedNode): void; | |
| 21 | pushChunk(name: string, node: render.ResolvedNode): void; | |
| 22 | 22 | } |
| 23 | 23 | |
| 24 | export function Suspense({ children, fallback }: SuspenseProps): ssr.Node { | |
| 25 | const state = ssr.getUserData<State>(kState, () => { | |
| 26 | throw new Error("Can only use <Suspense> with 'renderStreaming'"); | |
| 27 | }); | |
| 24 | export function Suspense({ children, fallback }: SuspenseProps): render.Node { | |
| 25 | const state = userData.get(); | |
| 26 | if (!state) return children; | |
| 28 | 27 | if (state.nested) throw new Error("<Suspense> cannot be nested"); |
| 29 | const parent = ssr.getCurrentRender()!; | |
| 30 | const r = ssr.initRender(true, { [kState]: { nested: true } }); | |
| 31 | const resolved = ssr.resolveNode(r, children); | |
| 32 | if (r.async == 0) return ssr.html(resolved); | |
| 33 | const name = "suspended_" + (++state.nextId); | |
| 28 | const parent = UNWRAP(render.current); | |
| 29 | const r = render.initRender(true, { [userData.key]: { nested: true } }); | |
| 30 | const resolved = render.resolveNode(r, children); | |
| 31 | if (r.async == 0) return render.raw(resolved); | |
| 32 | const name = "suspended_" + ++state.nextId; | |
| 34 | 33 | state.nested = true; |
| 35 | const ip: [ssr.ResolvedNode] = [ | |
| 36 | [ | |
| 37 | ssr.kElement, | |
| 34 | const ip: [render.ResolvedNode] = [ | |
| 35 | render.resolvedElement( | |
| 38 | 36 | "slot", |
| 39 | 37 | { name }, |
| 40 | fallback ? ssr.resolveNode(parent, fallback) : "", | |
| 41 | ], | |
| 38 | fallback ? render.resolveNode(parent, fallback) : "", | |
| 39 | ), | |
| 42 | 40 | ]; |
| 43 | 41 | state.nested = false; |
| 44 | 42 | r.asyncDone = () => { |
| 45 | 43 | const rejections = r.rejections; |
| 46 | 44 | if (rejections && rejections.length > 0) throw new Error("TODO"); |
| 47 | state.pushChunk?.(name, ip[0] = resolved); | |
| 45 | state.pushChunk?.(name, (ip[0] = resolved)); | |
| 48 | 46 | }; |
| 49 | return ssr.html(ip); | |
| 47 | return render.raw(ip); | |
| 50 | 48 | } |
| 51 | 49 | |
| 52 | 50 | // TODO: add a User-Agent parameter, which is used to determine if a |
| ... | ... | @@ -54,17 +52,14 @@ export function Suspense({ children, fallback }: SuspenseProps): ssr.Node { |
| 54 | 52 | // - Before ~2024 needs to use a JS implementation. |
| 55 | 53 | // - IE should probably bail out entirely. |
| 56 | 54 | export async function* renderStreaming< |
| 57 | T extends ssr.Addons = Record<never, unknown>, | |
| 58 | >( | |
| 59 | node: ssr.Node, | |
| 60 | addon: T = {} as T, | |
| 61 | ) { | |
| 55 | T extends render.Addons = Record<never, unknown>, | |
| 56 | >(node: render.Node, addon: T = {} as T) { | |
| 62 | 57 | const { |
| 63 | 58 | text: begin, |
| 64 | addon: { [kState]: state, ...addonOutput }, | |
| 65 | } = await ssr.ssrAsync(node, { | |
| 59 | addon: { [userData.key]: state, ...addonOutput }, | |
| 60 | } = await render.async(node, { | |
| 66 | 61 | ...addon, |
| 67 | [kState]: { | |
| 62 | [userData.key]: { | |
| 68 | 63 | nested: false, |
| 69 | 64 | nextId: 0, |
| 70 | 65 | completed: 0, |
| ... | ... | @@ -79,24 +74,29 @@ export async function* renderStreaming< |
| 79 | 74 | let chunks: string[] = []; |
| 80 | 75 | state.pushChunk = (slot, node) => { |
| 81 | 76 | while (node.length === 1 && Array.isArray(node)) node = node[0]; |
| 82 | if (node[0] === ssr.kElement) { | |
| 83 | (node as ssr.ResolvedElement)[2].slot = slot; | |
| 77 | if (node[0] === render.kElement) { | |
| 78 | (node as render.ResolvedElement)[2].slot = slot; | |
| 84 | 79 | } else { |
| 85 | node = [ssr.kElement, "clover-suspense", { | |
| 86 | style: "display:contents", | |
| 87 | slot, | |
| 88 | }, node]; | |
| 80 | node = [ | |
| 81 | render.kElement, | |
| 82 | "clover-suspense", | |
| 83 | { | |
| 84 | style: "display:contents", | |
| 85 | slot, | |
| 86 | }, | |
| 87 | node, | |
| 88 | ]; | |
| 89 | 89 | } |
| 90 | chunks.push(ssr.renderNode(node)); | |
| 90 | chunks.push(render.stringifyNode(node)); | |
| 91 | 91 | resolve?.(); |
| 92 | 92 | }; |
| 93 | 93 | yield `<template shadowrootmode=open>${begin}</template>`; |
| 94 | 94 | do { |
| 95 | await new Promise<void>((done) => resolve = done); | |
| 95 | await new Promise<void>((done) => (resolve = done)); | |
| 96 | 96 | yield* chunks; |
| 97 | 97 | chunks = []; |
| 98 | 98 | } while (state.nextId < state.completed); |
| 99 | 99 | return addonOutput as unknown as T; |
| 100 | 100 | } |
| 101 | 101 | |
| 102 | import * as ssr from "./ssr.ts"; | |
| 102 | import * as render from "./render.ts"; |
framework/generate.ts+51-78| ... | ... | @@ -14,12 +14,8 @@ export async function main() { |
| 14 | 14 | export async function generate() { |
| 15 | 15 | // -- read config and discover files -- |
| 16 | 16 | const siteConfig = await incr.work(readManifest); |
| 17 | const { | |
| 18 | staticFiles, | |
| 19 | scripts, | |
| 20 | views, | |
| 21 | pages, | |
| 22 | } = await discoverAllFiles(siteConfig); | |
| 17 | const { staticFiles, scripts, views, pages } = | |
| 18 | await discoverAllFiles(siteConfig); | |
| 23 | 19 | |
| 24 | 20 | // TODO: make sure that `static` and `pages` does not overlap |
| 25 | 21 | |
| ... | ... | @@ -28,12 +24,15 @@ export async function generate() { |
| 28 | 24 | // -- perform build-time rendering -- |
| 29 | 25 | const builtPages = pages.map((item) => incr.work(preparePage, item)); |
| 30 | 26 | const builtViews = views.map((item) => incr.work(prepareView, item)); |
| 31 | const builtStaticFiles = Promise.all((staticFiles.map((item) => | |
| 32 | incr.work( | |
| 33 | async (io, { id, file }) => void await io.writeAsset(id, await io.readFile(file)), | |
| 34 | item, | |
| 35 | ) | |
| 36 | ))); | |
| 27 | const builtStaticFiles = Promise.all( | |
| 28 | staticFiles.map((item) => | |
| 29 | incr.work( | |
| 30 | async (io, { id, file }) => | |
| 31 | void (await io.writeAsset(id, await io.readFile(file))), | |
| 32 | item, | |
| 33 | ), | |
| 34 | ), | |
| 35 | ); | |
| 37 | 36 | const routes = await Promise.all([...builtViews, ...builtPages]); |
| 38 | 37 | |
| 39 | 38 | // -- page resources -- |
| ... | ... | @@ -47,23 +46,19 @@ export async function generate() { |
| 47 | 46 | // -- backend -- |
| 48 | 47 | const builtBackend = bundle.bundleServerJavaScript({ |
| 49 | 48 | entries: siteConfig.backends, |
| 50 | platform: 'node', | |
| 49 | platform: "node", | |
| 51 | 50 | styleMap, |
| 52 | 51 | scriptMap, |
| 53 | 52 | viewItems: views, |
| 54 | 53 | viewRefs: builtViews, |
| 55 | }) | |
| 54 | }); | |
| 56 | 55 | |
| 57 | 56 | // -- assemble page assets -- |
| 58 | 57 | const pAssemblePages = builtPages.map((page) => |
| 59 | assembleAndWritePage(page, styleMap, scriptMap) | |
| 58 | assembleAndWritePage(page, styleMap, scriptMap), | |
| 60 | 59 | ); |
| 61 | 60 | |
| 62 | await Promise.all([ | |
| 63 | builtBackend, | |
| 64 | builtStaticFiles, | |
| 65 | ...pAssemblePages, | |
| 66 | ]); | |
| 61 | await Promise.all([builtBackend, builtStaticFiles, ...pAssemblePages]); | |
| 67 | 62 | } |
| 68 | 63 | |
| 69 | 64 | export async function readManifest(io: Io) { |
| ... | ... | @@ -82,7 +77,7 @@ export async function discoverAllFiles( |
| 82 | 77 | return ( |
| 83 | 78 | await Promise.all( |
| 84 | 79 | siteConfig.siteSections.map(({ root: sectionRoot }) => |
| 85 | incr.work(scanSiteSection, toAbs(sectionRoot)) | |
| 80 | incr.work(scanSiteSection, toAbs(sectionRoot)), | |
| 86 | 81 | ), |
| 87 | 82 | ) |
| 88 | 83 | ).reduce((acc, next) => ({ |
| ... | ... | @@ -110,9 +105,10 @@ export async function scanSiteSection(io: Io, sectionRoot: string) { |
| 110 | 105 | let scripts: FileItem[] = []; |
| 111 | 106 | |
| 112 | 107 | const sectionPath = (...sub: string[]) => path.join(sectionRoot, ...sub); |
| 113 | const rootPrefix = hot.projectSrc === sectionRoot | |
| 114 | ? "" | |
| 115 | : path.relative(hot.projectSrc, sectionRoot) + "/"; | |
| 108 | const rootPrefix = | |
| 109 | hot.projectSrc === sectionRoot | |
| 110 | ? "" | |
| 111 | : path.relative(hot.projectSrc, sectionRoot) + "/"; | |
| 116 | 112 | const kinds = [ |
| 117 | 113 | { |
| 118 | 114 | dir: sectionPath("pages"), |
| ... | ... | @@ -186,12 +182,8 @@ export async function preparePage(io: Io, item: sg.FileItem) { |
| 186 | 182 | theme: pageTheme, |
| 187 | 183 | layout, |
| 188 | 184 | } = await io.import<any>(item.file); |
| 189 | if (!Page) { | |
| 190 | throw new Error("Page is missing a 'default' export."); | |
| 191 | } | |
| 192 | if (!metadata) { | |
| 193 | throw new Error("Page is missing 'meta' export with a title."); | |
| 194 | } | |
| 185 | if (!Page) throw new Error("Page is missing a 'default' export."); | |
| 186 | if (!metadata) throw new Error("Page is missing 'meta' export with a title."); | |
| 195 | 187 | |
| 196 | 188 | // -- css -- |
| 197 | 189 | if (layout?.theme) pageTheme = layout.theme; |
| ... | ... | @@ -210,12 +202,12 @@ export async function preparePage(io: Io, item: sg.FileItem) { |
| 210 | 202 | ).then((m) => meta.renderMeta(m)); |
| 211 | 203 | |
| 212 | 204 | // -- html -- |
| 213 | let page = [engine.kElement, Page, {}]; | |
| 205 | let page = render.element(Page); | |
| 214 | 206 | if (layout?.default) { |
| 215 | page = [engine.kElement, layout.default, { children: page }]; | |
| 207 | page = render.element(layout.default, { children: page }); | |
| 216 | 208 | } |
| 217 | const bodyPromise = engine.ssrAsync(page, { | |
| 218 | sitegen: sg.initRender(), | |
| 209 | const bodyPromise = render.async(page, { | |
| 210 | [sg.userData.key]: sg.initRender(), | |
| 219 | 211 | }); |
| 220 | 212 | |
| 221 | 213 | const [{ text, addon }, renderedMeta] = await Promise.all([ |
| ... | ... | @@ -235,23 +227,16 @@ export async function preparePage(io: Io, item: sg.FileItem) { |
| 235 | 227 | cssImports, |
| 236 | 228 | theme: theme ?? null, |
| 237 | 229 | styleKey, |
| 238 | clientRefs: Array.from(addon.sitegen.scripts), | |
| 230 | clientRefs: Array.from(addon[sg.userData.key].scripts), | |
| 239 | 231 | }; |
| 240 | 232 | } |
| 241 | 233 | |
| 242 | 234 | export async function prepareView(io: Io, item: sg.FileItem) { |
| 243 | 235 | const module = await io.import<any>(item.file); |
| 244 | if (!module.meta) { | |
| 245 | throw new Error(`${item.file} is missing 'export const meta'`); | |
| 246 | } | |
| 247 | if (!module.default) { | |
| 248 | throw new Error(`${item.file} is missing a default export.`); | |
| 249 | } | |
| 236 | if (!module.meta) throw new Error(`View is missing 'export const meta'`); | |
| 237 | if (!module.default) throw new Error(`View is missing a default export.`); | |
| 250 | 238 | const pageTheme = module.layout?.theme ?? module.theme; |
| 251 | const theme: css.Theme = { | |
| 252 | ...css.defaultTheme, | |
| 253 | ...pageTheme, | |
| 254 | }; | |
| 239 | const theme: css.Theme = { ...css.defaultTheme, ...pageTheme }; | |
| 255 | 240 | const cssImports = Array.from( |
| 256 | 241 | new Set([globalCssPath, ...hot.getCssImports(item.file)]), |
| 257 | 242 | (file) => path.relative(hot.projectSrc, file), |
| ... | ... | @@ -269,22 +254,14 @@ export async function prepareView(io: Io, item: sg.FileItem) { |
| 269 | 254 | export type PreparedView = Awaited<ReturnType<typeof prepareView>>; |
| 270 | 255 | |
| 271 | 256 | export function prepareInlineCss( |
| 272 | items: Array<{ | |
| 273 | styleKey: string; | |
| 274 | cssImports: string[]; | |
| 275 | theme: css.Theme; | |
| 276 | }>, | |
| 257 | items: Array<{ styleKey: string; cssImports: string[]; theme: css.Theme }>, | |
| 277 | 258 | ) { |
| 278 | 259 | const map = new Map<string, incr.Ref<string>>(); |
| 279 | 260 | for (const { styleKey, cssImports, theme } of items) { |
| 280 | 261 | if (map.has(styleKey)) continue; |
| 281 | 262 | map.set( |
| 282 | 263 | styleKey, |
| 283 | incr.work(css.bundleCssFiles, { | |
| 284 | cssImports, | |
| 285 | theme, | |
| 286 | dev: false, | |
| 287 | }), | |
| 264 | incr.work(css.bundleCssFiles, { cssImports, theme, dev: false }), | |
| 288 | 265 | ); |
| 289 | 266 | } |
| 290 | 267 | return map; |
| ... | ... | @@ -297,29 +274,26 @@ export async function assembleAndWritePage( |
| 297 | 274 | scriptWork: incr.Ref<Record<string, string>>, |
| 298 | 275 | ) { |
| 299 | 276 | const page = await pageWork; |
| 300 | return incr.work( | |
| 301 | async (io, { id, html, meta, styleKey, clientRefs }) => { | |
| 302 | const inlineCss = await io.readWork(UNWRAP(styleMap.get(styleKey))); | |
| 277 | return incr.work(async (io, { id, html, meta, styleKey, clientRefs }) => { | |
| 278 | const inlineCss = await io.readWork(UNWRAP(styleMap.get(styleKey))); | |
| 303 | 279 | |
| 304 | const scriptIds = clientRefs.map(hot.getScriptId); | |
| 305 | const scriptMap = await io.readWork(scriptWork); | |
| 306 | const scripts = scriptIds.map((ref) => | |
| 307 | UNWRAP(scriptMap[ref], `Missing script ${ref}`) | |
| 308 | ) | |
| 309 | .map((x) => `{${x}}`).join("\n"); | |
| 280 | const scriptIds = clientRefs.map(hot.getScriptId); | |
| 281 | const scriptMap = await io.readWork(scriptWork); | |
| 282 | const scripts = scriptIds | |
| 283 | .map((ref) => UNWRAP(scriptMap[ref], `Missing script ${ref}`)) | |
| 284 | .map((x) => `{${x}}`) | |
| 285 | .join("\n"); | |
| 310 | 286 | |
| 311 | const doc = wrapDocument({ | |
| 312 | body: html, | |
| 313 | head: meta, | |
| 314 | inlineCss, | |
| 315 | scripts, | |
| 316 | }); | |
| 317 | await io.writeAsset(id, doc, { | |
| 318 | "Content-Type": "text/html", | |
| 319 | }); | |
| 320 | }, | |
| 321 | page, | |
| 322 | ); | |
| 287 | const doc = wrapDocument({ | |
| 288 | body: html, | |
| 289 | head: meta, | |
| 290 | inlineCss, | |
| 291 | scripts, | |
| 292 | }); | |
| 293 | await io.writeAsset(id, doc, { | |
| 294 | "Content-Type": "text/html", | |
| 295 | }); | |
| 296 | }, page); | |
| 323 | 297 | } |
| 324 | 298 | |
| 325 | 299 | import * as sg from "#sitegen"; |
| ... | ... | @@ -327,11 +301,10 @@ import * as incr from "./incremental.ts"; |
| 327 | 301 | import { Io } from "./incremental.ts"; |
| 328 | 302 | import * as bundle from "./bundle.ts"; |
| 329 | 303 | import * as css from "./css.ts"; |
| 330 | import * as engine from "./engine/ssr.ts"; | |
| 304 | import * as render from "#engine/render"; | |
| 331 | 305 | import * as hot from "./hot.ts"; |
| 332 | 306 | import * as fs from "#sitegen/fs"; |
| 333 | 307 | import type { FileItem } from "#sitegen"; |
| 334 | 308 | import * as path from "node:path"; |
| 335 | 309 | import * as meta from "#sitegen/meta"; |
| 336 | import { Spinner, withSpinner } from "@paperclover/console/Spinner"; | |
| 337 | 310 | import { wrapDocument } from "./lib/view.ts"; |
framework/hot.ts+28-29| ... | ... | @@ -19,7 +19,7 @@ export const load = createRequire( |
| 19 | 19 | }; |
| 20 | 20 | export const { cache } = load; |
| 21 | 21 | |
| 22 | load<any>("source-map-support").install({hookRequire: true}); | |
| 22 | load<any>("source-map-support").install({ hookRequire: true }); | |
| 23 | 23 | |
| 24 | 24 | // Register extensions by overwriting `require.extensions` |
| 25 | 25 | const require = load; |
| ... | ... | @@ -75,15 +75,15 @@ Module.prototype._compile = function ( |
| 75 | 75 | imports.push(file); |
| 76 | 76 | (childModule.cloverImporters ??= []).push(this); |
| 77 | 77 | if (cloverClientRefs && cloverClientRefs.length > 0) { |
| 78 | (this.cloverClientRefs ??= []) | |
| 79 | .push(...cloverClientRefs); | |
| 78 | (this.cloverClientRefs ??= []).push(...cloverClientRefs); | |
| 80 | 79 | } |
| 81 | 80 | } |
| 82 | 81 | } |
| 83 | 82 | fileStats.set(filename, { |
| 84 | cssImportsRecursive: cssImportsMaybe.length > 0 | |
| 85 | ? Array.from(new Set(cssImportsMaybe)) | |
| 86 | : null, | |
| 83 | cssImportsRecursive: | |
| 84 | cssImportsMaybe.length > 0 | |
| 85 | ? Array.from(new Set(cssImportsMaybe)) | |
| 86 | : null, | |
| 87 | 87 | imports, |
| 88 | 88 | lastModified: Math.floor(stat.mtimeMs), |
| 89 | 89 | }); |
| ... | ... | @@ -100,7 +100,8 @@ Module._resolveFilename = (...args) => { |
| 100 | 100 | return require.resolve(replacedPath, { paths: [projectSrc] }); |
| 101 | 101 | } catch (err: any) { |
| 102 | 102 | if ( |
| 103 | err.code === "MODULE_NOT_FOUND" && (err?.requireStack?.length ?? 0) <= 1 | |
| 103 | err.code === "MODULE_NOT_FOUND" && | |
| 104 | (err?.requireStack?.length ?? 0) <= 1 | |
| 104 | 105 | ) { |
| 105 | 106 | err.message.replace(replacedPath, args[0]); |
| 106 | 107 | } |
| ... | ... | @@ -138,45 +139,45 @@ export function loadEsbuildCode( |
| 138 | 139 | src = code; |
| 139 | 140 | } |
| 140 | 141 | if (src.includes("import.meta")) { |
| 141 | src = ` | |
| 142 | import.meta.url = ${JSON.stringify(pathToFileURL(filepath).toString())}; | |
| 143 | import.meta.dirname = ${JSON.stringify(path.dirname(filepath))}; | |
| 144 | import.meta.filename = ${JSON.stringify(filepath)}; | |
| 145 | `.trim().replace(/[\n\s]/g, "") + src; | |
| 142 | src = | |
| 143 | ` | |
| 144 | import.meta.url = ${JSON.stringify(pathToFileURL(filepath).toString())}; | |
| 145 | import.meta.dirname = ${JSON.stringify(path.dirname(filepath))}; | |
| 146 | import.meta.filename = ${JSON.stringify(filepath)}; | |
| 147 | ` | |
| 148 | .trim() | |
| 149 | .replace(/[\n\s]/g, "") + src; | |
| 146 | 150 | } |
| 147 | 151 | src = esbuild.transformSync(src, { |
| 148 | 152 | loader, |
| 149 | 153 | format: "cjs", |
| 150 | 154 | target: "esnext", |
| 151 | 155 | jsx: "automatic", |
| 152 | jsxImportSource: "#ssr", | |
| 156 | jsxImportSource: "#engine", | |
| 153 | 157 | jsxDev: true, |
| 154 | 158 | sourcefile: filepath, |
| 155 | sourcemap: 'inline', | |
| 159 | sourcemap: "inline", | |
| 156 | 160 | }).code; |
| 157 | 161 | return module._compile(src, filepath, "commonjs"); |
| 158 | 162 | } |
| 159 | 163 | |
| 160 | 164 | export function resolveClientRef(sourcePath: string, ref: string) { |
| 161 | 165 | const filePath = resolveFrom(sourcePath, ref); |
| 162 | if ( | |
| 163 | !filePath.endsWith(".client.ts") && | |
| 164 | !filePath.endsWith(".client.tsx") | |
| 165 | ) { | |
| 166 | if (!filePath.endsWith(".client.ts") && !filePath.endsWith(".client.tsx")) { | |
| 166 | 167 | throw new Error("addScript must be a .client.ts or .client.tsx"); |
| 167 | 168 | } |
| 168 | 169 | return path.relative(projectSrc, filePath); |
| 169 | 170 | } |
| 170 | 171 | |
| 171 | let lazyMarko: typeof import('./marko.ts') | null = null; | |
| 172 | let lazyMarko: typeof import("./marko.ts") | null = null; | |
| 172 | 173 | function loadMarko(module: NodeJS.Module, filepath: string) { |
| 173 | lazyMarko ??= require<typeof import('./marko.ts')>("./framework/marko.ts"); | |
| 174 | lazyMarko ??= require<typeof import("./marko.ts")>("./framework/marko.ts"); | |
| 174 | 175 | lazyMarko.loadMarko(module, filepath); |
| 175 | 176 | } |
| 176 | 177 | |
| 177 | 178 | function loadMdx(module: NodeJS.Module, filepath: string) { |
| 178 | 179 | const input = fs.readFileSync(filepath); |
| 179 | const out = mdx.compileSync(input, { jsxImportSource: "#ssr" }).value; | |
| 180 | const out = mdx.compileSync(input, { jsxImportSource: "#engine" }).value; | |
| 180 | 181 | const src = typeof out === "string" ? out : Buffer.from(out).toString("utf8"); |
| 181 | 182 | return loadEsbuildCode(module, filepath, src); |
| 182 | 183 | } |
| ... | ... | @@ -194,7 +195,7 @@ export function reloadRecursive(filepath: string) { |
| 194 | 195 | } |
| 195 | 196 | |
| 196 | 197 | export function unload(filepath: string) { |
| 197 | lazyMarko?.markoCache.delete(filepath) | |
| 198 | lazyMarko?.markoCache.delete(filepath); | |
| 198 | 199 | filepath = path.resolve(filepath); |
| 199 | 200 | const module = cache[filepath]; |
| 200 | 201 | if (!module) return; |
| ... | ... | @@ -291,8 +292,9 @@ export function resolveClientRefs( |
| 291 | 292 | } |
| 292 | 293 | |
| 293 | 294 | export function getScriptId(file: string) { |
| 294 | return (path.isAbsolute(file) ? path.relative(projectSrc, file) : file) | |
| 295 | .replaceAll("\\", "/"); | |
| 295 | return ( | |
| 296 | path.isAbsolute(file) ? path.relative(projectSrc, file) : file | |
| 297 | ).replaceAll("\\", "/"); | |
| 296 | 298 | } |
| 297 | 299 | |
| 298 | 300 | declare global { |
| ... | ... | @@ -300,7 +302,7 @@ declare global { |
| 300 | 302 | interface Module { |
| 301 | 303 | cloverClientRefs?: string[]; |
| 302 | 304 | cloverSourceCode?: string; |
| 303 | cloverImporters?: Module[], | |
| 305 | cloverImporters?: Module[]; | |
| 304 | 306 | |
| 305 | 307 | _compile( |
| 306 | 308 | this: NodeJS.Module, |
| ... | ... | @@ -312,10 +314,7 @@ declare global { |
| 312 | 314 | } |
| 313 | 315 | } |
| 314 | 316 | declare module "node:module" { |
| 315 | export function _resolveFilename( | |
| 316 | id: string, | |
| 317 | parent: NodeJS.Module, | |
| 318 | ): unknown; | |
| 317 | export function _resolveFilename(id: string, parent: NodeJS.Module): unknown; | |
| 319 | 318 | } |
| 320 | 319 | |
| 321 | 320 | import * as fs from "#sitegen/fs"; |
framework/lib/assets.ts+9-19| ... | ... | @@ -11,30 +11,22 @@ export async function reload() { |
| 11 | 11 | fs.readFile(path.join(import.meta.dirname, "static.json"), "utf8"), |
| 12 | 12 | fs.readFile(path.join(import.meta.dirname, "static.blob")), |
| 13 | 13 | ]); |
| 14 | assets = { | |
| 15 | map: JSON.parse(map), | |
| 16 | buf, | |
| 17 | }; | |
| 14 | return (assets = { map: JSON.parse(map), buf }); | |
| 18 | 15 | } |
| 19 | 16 | |
| 20 | export async function reloadSync() { | |
| 17 | export function reloadSync() { | |
| 21 | 18 | const map = fs.readFileSync( |
| 22 | 19 | path.join(import.meta.dirname, "static.json"), |
| 23 | 20 | "utf8", |
| 24 | 21 | ); |
| 25 | 22 | const buf = fs.readFileSync(path.join(import.meta.dirname, "static.blob")); |
| 26 | assets = { | |
| 27 | map: JSON.parse(map), | |
| 28 | buf, | |
| 29 | }; | |
| 23 | return (assets = { map: JSON.parse(map), buf }); | |
| 30 | 24 | } |
| 31 | 25 | |
| 32 | 26 | export async function middleware(c: Context, next: Next) { |
| 33 | 27 | if (!assets) await reload(); |
| 34 | 28 | const asset = assets!.map[c.req.path]; |
| 35 | if (asset) { | |
| 36 | return assetInner(c, asset, 200); | |
| 37 | } | |
| 29 | if (asset) return assetInner(c, asset, 200); | |
| 38 | 30 | return next(); |
| 39 | 31 | } |
| 40 | 32 | |
| ... | ... | @@ -56,13 +48,11 @@ export async function serveAsset( |
| 56 | 48 | id: StaticPageId, |
| 57 | 49 | status: StatusCode, |
| 58 | 50 | ) { |
| 59 | assets ?? await reload(); | |
| 60 | return assetInner(c, assets!.map[id], status); | |
| 51 | return assetInner(c, (assets ?? (await reload())).map[id], status); | |
| 61 | 52 | } |
| 62 | 53 | |
| 63 | 54 | export function hasAsset(id: string) { |
| 64 | if (!assets) reloadSync(); | |
| 65 | return assets!.map[id] !== undefined; | |
| 55 | return (assets ?? reloadSync()).map[id] !== undefined; | |
| 66 | 56 | } |
| 67 | 57 | |
| 68 | 58 | export function etagMatches(etag: string, ifNoneMatch: string) { |
| ... | ... | @@ -78,13 +68,13 @@ function assetInner(c: Context, asset: BuiltAsset, status: StatusCode) { |
| 78 | 68 | if (ifnonematch) { |
| 79 | 69 | const etag = asset.headers.ETag; |
| 80 | 70 | if (etagMatches(etag, ifnonematch)) { |
| 81 | return c.res = new Response(null, { | |
| 71 | return (c.res = new Response(null, { | |
| 82 | 72 | status: 304, |
| 83 | 73 | statusText: "Not Modified", |
| 84 | 74 | headers: { |
| 85 | 75 | ETag: etag, |
| 86 | 76 | }, |
| 87 | }); | |
| 77 | })); | |
| 88 | 78 | } |
| 89 | 79 | } |
| 90 | 80 | const acceptEncoding = c.req.header("Accept-Encoding") ?? ""; |
| ... | ... | @@ -105,7 +95,7 @@ function assetInner(c: Context, asset: BuiltAsset, status: StatusCode) { |
| 105 | 95 | } else { |
| 106 | 96 | body = subarrayAsset(asset.raw); |
| 107 | 97 | } |
| 108 | return c.res = new Response(body, { headers, status }); | |
| 98 | return (c.res = new Response(body, { headers, status })); | |
| 109 | 99 | } |
| 110 | 100 | |
| 111 | 101 | process.on("message", (msg: any) => { |
framework/lib/markdown.tsx+23-19| ... | ... | @@ -5,21 +5,21 @@ |
| 5 | 5 | * via clover's SSR engine. This way, generation optimizations, async |
| 6 | 6 | * components, and other features are gained for free here. |
| 7 | 7 | */ |
| 8 | function parse(src: string, options: Partial<ParseOpts> = {}) { | |
| 9 | } | |
| 8 | function parse(src: string, options: Partial<ParseOpts> = {}) {} | |
| 10 | 9 | |
| 11 | 10 | /* Render markdown content. Same function as 'parse', but JSX components |
| 12 | 11 | * only take one argument and must start with a capital letter. */ |
| 13 | export function Markdown( | |
| 14 | { src, ...options }: { src: string } & Partial<ParseOpts>, | |
| 15 | ) { | |
| 12 | export function Markdown({ | |
| 13 | src, | |
| 14 | ...options | |
| 15 | }: { src: string } & Partial<ParseOpts>) { | |
| 16 | 16 | return parse(src, options); |
| 17 | 17 | } |
| 18 | 18 | |
| 19 | 19 | function parseInline(src: string, options: Partial<InlineOpts> = {}) { |
| 20 | 20 | const { rules = inlineRules, links = new Map() } = options; |
| 21 | 21 | const opts: InlineOpts = { rules, links }; |
| 22 | const parts: engine.Node[] = []; | |
| 22 | const parts: render.Node[] = []; | |
| 23 | 23 | const ruleList = Object.values(rules); |
| 24 | 24 | parse: while (true) { |
| 25 | 25 | for (const rule of ruleList) { |
| ... | ... | @@ -96,7 +96,9 @@ export const inlineRules: Record<string, InlineRule> = { |
| 96 | 96 | if (!splitText) return null; |
| 97 | 97 | if (splitText.delim !== "]") return null; |
| 98 | 98 | const { first: textSrc, rest: afterText } = splitText; |
| 99 | let href: string, title: string | null = null, rest: string; | |
| 99 | let href: string, | |
| 100 | title: string | null = null, | |
| 101 | rest: string; | |
| 100 | 102 | if (afterText[0] === "(") { |
| 101 | 103 | // Inline link |
| 102 | 104 | const splitTarget = splitFirst(afterText.slice(1), /\)/); |
| ... | ... | @@ -108,11 +110,12 @@ export const inlineRules: Record<string, InlineRule> = { |
| 108 | 110 | } else if (afterText[0] === "[") { |
| 109 | 111 | const splitTarget = splitFirst(afterText.slice(1), /]/); |
| 110 | 112 | if (!splitTarget) return null; |
| 111 | const name = splitTarget.first.trim().length === 0 | |
| 112 | // Collapsed reference link | |
| 113 | ? textSrc.trim() | |
| 114 | // Full Reference Link | |
| 115 | : splitTarget.first.trim(); | |
| 113 | const name = | |
| 114 | splitTarget.first.trim().length === 0 | |
| 115 | ? // Collapsed reference link | |
| 116 | textSrc.trim() | |
| 117 | : // Full Reference Link | |
| 118 | splitTarget.first.trim(); | |
| 116 | 119 | const target = opts.links.get(name); |
| 117 | 120 | if (!target) return null; |
| 118 | 121 | ({ href, title } = target); |
| ... | ... | @@ -149,7 +152,6 @@ export const inlineRules: Record<string, InlineRule> = { |
| 149 | 152 | // 6.2 - emphasis and strong emphasis |
| 150 | 153 | parse({ before, match, after, opts }) { |
| 151 | 154 | // find out how long the delim sequence is |
| 152 | ||
| 153 | 155 | // look for 'ends' |
| 154 | 156 | }, |
| 155 | 157 | }, |
| ... | ... | @@ -164,14 +166,17 @@ export const inlineRules: Record<string, InlineRule> = { |
| 164 | 166 | }; |
| 165 | 167 | |
| 166 | 168 | function parseLinkTarget(src: string) { |
| 167 | let href: string, title: string | null = null; | |
| 169 | let href: string, | |
| 170 | title: string | null = null; | |
| 168 | 171 | href = src; |
| 169 | 172 | return { href, title }; |
| 170 | 173 | } |
| 171 | 174 | |
| 172 | 175 | /* Find a delimiter while considering backslash escapes. */ |
| 173 | 176 | function splitFirst(text: string, match: RegExp) { |
| 174 | let first = "", delim: string, escaped: boolean; | |
| 177 | let first = "", | |
| 178 | delim: string, | |
| 179 | escaped: boolean; | |
| 175 | 180 | do { |
| 176 | 181 | const find = text.match(match); |
| 177 | 182 | if (!find) return null; |
| ... | ... | @@ -179,14 +184,13 @@ function splitFirst(text: string, match: RegExp) { |
| 179 | 184 | const index = UNWRAP(find.index); |
| 180 | 185 | let i = index - 1; |
| 181 | 186 | escaped = false; |
| 182 | while (i >= 0 && text[i] === "\\") escaped = !escaped, i -= 1; | |
| 187 | while (i >= 0 && text[i] === "\\") (escaped = !escaped), (i -= 1); | |
| 183 | 188 | first += text.slice(0, index - +escaped); |
| 184 | 189 | text = text.slice(index + find[0].length); |
| 185 | 190 | } while (escaped); |
| 186 | 191 | return { first, delim, rest: text }; |
| 187 | 192 | } |
| 188 | 193 | |
| 189 | console.log(engine.ssrSync(parseInline("meow `bwaa` `` ` `` `` `z``"))); | |
| 194 | console.log(render.sync(parseInline("meow `bwaa` `` ` `` `` `z``"))); | |
| 190 | 195 | |
| 191 | import * as engine from "#ssr"; | |
| 192 | import type { ParseOptions } from "node:querystring"; | |
| 196 | import * as render from "#engine/render"; |
framework/lib/meta.ts+1-1| ... | ... | @@ -21,4 +21,4 @@ export interface AlternateType { |
| 21 | 21 | export function renderMeta({ title }: Meta): string { |
| 22 | 22 | return `<title>${esc(title)}</title>`; |
| 23 | 23 | } |
| 24 | import { escapeHtml as esc } from "../engine/ssr.ts"; | |
| 24 | import { escapeHtml as esc } from "#engine/render"; |
framework/lib/sitegen.ts+10-21| ... | ... | @@ -9,36 +9,25 @@ export interface FileItem { |
| 9 | 9 | id: string; |
| 10 | 10 | file: string; |
| 11 | 11 | } |
| 12 | export interface Section { | |
| 13 | root: string; | |
| 14 | } | |
| 15 | ||
| 16 | export const userData = render.userData<SitegenRender>(() => { | |
| 17 | throw new Error("This function can only be used in a page (static or view)"); | |
| 18 | }); | |
| 12 | 19 | |
| 13 | 20 | export interface SitegenRender { |
| 14 | 21 | scripts: Set<string>; |
| 15 | 22 | } |
| 16 | 23 | |
| 17 | 24 | export function initRender(): SitegenRender { |
| 18 | return { | |
| 19 | scripts: new Set(), | |
| 20 | }; | |
| 21 | } | |
| 22 | ||
| 23 | export function getRender() { | |
| 24 | return ssr.getUserData<SitegenRender>("sitegen", () => { | |
| 25 | throw new Error( | |
| 26 | "This function can only be used in a page (static or view)", | |
| 27 | ); | |
| 28 | }); | |
| 29 | } | |
| 30 | ||
| 31 | export function inRender() { | |
| 32 | return "sitegen" in ssr.getCurrentRender(); | |
| 25 | return { scripts: new Set() }; | |
| 33 | 26 | } |
| 34 | 27 | |
| 35 | 28 | /** Add a client-side script to the page. */ |
| 36 | 29 | export function addScript(id: ScriptId | { value: ScriptId }) { |
| 37 | getRender().scripts.add(typeof id === "string" ? id : id.value); | |
| 38 | } | |
| 39 | ||
| 40 | export interface Section { | |
| 41 | root: string; | |
| 30 | userData.get().scripts.add(typeof id === "string" ? id : id.value); | |
| 42 | 31 | } |
| 43 | 32 | |
| 44 | import * as ssr from "../engine/ssr.ts"; | |
| 33 | import * as render from "#engine/render"; |
framework/lib/view.ts+12-17| ... | ... | @@ -1,10 +1,9 @@ |
| 1 | // This import is generated by code 'bundle.ts' | |
| 2 | 1 | export interface View { |
| 3 | component: engine.Component; | |
| 2 | component: render.Component; | |
| 4 | 3 | meta: |
| 5 | 4 | | meta.Meta |
| 6 | 5 | | ((props: { context?: hono.Context }) => Promise<meta.Meta> | meta.Meta); |
| 7 | layout?: engine.Component; | |
| 6 | layout?: render.Component; | |
| 8 | 7 | inlineCss: string; |
| 9 | 8 | scripts: Record<string, string>; |
| 10 | 9 | } |
| ... | ... | @@ -12,9 +11,6 @@ export interface View { |
| 12 | 11 | let views: Record<string, View> = null!; |
| 13 | 12 | let scripts: Record<string, string> = null!; |
| 14 | 13 | |
| 15 | // An older version of the Clover Engine supported streaming suspense | |
| 16 | // boundaries, but those were never used. Pages will wait until they | |
| 17 | // are fully rendered before sending. | |
| 18 | 14 | export async function renderView( |
| 19 | 15 | context: hono.Context, |
| 20 | 16 | id: string, |
| ... | ... | @@ -44,11 +40,12 @@ export async function renderViewToString( |
| 44 | 40 | ).then((m) => meta.renderMeta(m)); |
| 45 | 41 | |
| 46 | 42 | // -- html -- |
| 47 | let page: engine.Element = [engine.kElement, component, props]; | |
| 48 | if (layout) page = [engine.kElement, layout, { children: page }]; | |
| 49 | const { text: body, addon: { sitegen } } = await engine.ssrAsync(page, { | |
| 50 | sitegen: sg.initRender(), | |
| 51 | }); | |
| 43 | let page: render.Element = render.element(component, props); | |
| 44 | if (layout) page = render.element(layout, { children: page }); | |
| 45 | const { | |
| 46 | text: body, | |
| 47 | addon: { [sg.userData.key]: sitegen }, | |
| 48 | } = await render.async(page, { [sg.userData.key]: sg.initRender() }); | |
| 52 | 49 | |
| 53 | 50 | // -- join document and send -- |
| 54 | 51 | return wrapDocument({ |
| ... | ... | @@ -56,17 +53,15 @@ export async function renderViewToString( |
| 56 | 53 | head: await renderedMetaPromise, |
| 57 | 54 | inlineCss, |
| 58 | 55 | scripts: joinScripts( |
| 59 | Array.from( | |
| 60 | sitegen.scripts, | |
| 61 | (id) => UNWRAP(scripts[id], `Missing script ${id}`), | |
| 56 | Array.from(sitegen.scripts, (id) => | |
| 57 | UNWRAP(scripts[id], `Missing script ${id}`), | |
| 62 | 58 | ), |
| 63 | 59 | ), |
| 64 | 60 | }); |
| 65 | 61 | } |
| 66 | 62 | |
| 67 | 63 | export function provideViewData(v: typeof views, s: typeof scripts) { |
| 68 | views = v; | |
| 69 | scripts = s; | |
| 64 | (views = v), (scripts = s); | |
| 70 | 65 | } |
| 71 | 66 | |
| 72 | 67 | export function joinScripts(scriptSources: string[]) { |
| ... | ... | @@ -96,5 +91,5 @@ export function wrapDocument({ |
| 96 | 91 | |
| 97 | 92 | import * as meta from "./meta.ts"; |
| 98 | 93 | import type * as hono from "#hono"; |
| 99 | import * as engine from "../engine/ssr.ts"; | |
| 94 | import * as render from "#engine/render"; | |
| 100 | 95 | import * as sg from "./sitegen.ts"; |
framework/marko.ts+13-14| ... | ... | @@ -1,4 +1,3 @@ |
| 1 | console.log("MARKO"); | |
| 2 | 1 | export interface MarkoCacheEntry { |
| 3 | 2 | src: string; |
| 4 | 3 | scannedClientRefs: string[]; |
| ... | ... | @@ -8,7 +7,6 @@ export const markoCache = new Map<string, MarkoCacheEntry>(); |
| 8 | 7 | |
| 9 | 8 | export function loadMarko(module: NodeJS.Module, filepath: string) { |
| 10 | 9 | let cache = markoCache.get(filepath); |
| 11 | console.log({ filepath, has: !!cache }) | |
| 12 | 10 | if (!cache) { |
| 13 | 11 | let src = fs.readFileSync(filepath, "utf8"); |
| 14 | 12 | // A non-standard thing here is Clover Sitegen implements |
| ... | ... | @@ -16,21 +14,22 @@ export function loadMarko(module: NodeJS.Module, filepath: string) { |
| 16 | 14 | // bare client import statements to it's own usage. |
| 17 | 15 | const scannedClientRefs = new Set<string>(); |
| 18 | 16 | if (src.match(/^\s*client\s+import\s+["']/m)) { |
| 19 | src = src.replace( | |
| 20 | /^\s*client\s+import\s+("[^"]+"|'[^']+')[^\n]+/m, | |
| 21 | (_, src) => { | |
| 22 | const ref = JSON.parse(`"${src.slice(1, -1)}"`); | |
| 23 | const resolved = hot.resolveClientRef(filepath, ref); | |
| 24 | scannedClientRefs.add(resolved); | |
| 25 | return `<CloverScriptInclude=${ | |
| 26 | JSON.stringify(hot.getScriptId(resolved)) | |
| 27 | } />`; | |
| 28 | }, | |
| 29 | ) + '\nimport { addScript as CloverScriptInclude } from "#sitegen";\n'; | |
| 17 | src = | |
| 18 | src.replace( | |
| 19 | /^\s*client\s+import\s+("[^"]+"|'[^']+')[^\n]+/m, | |
| 20 | (_, src) => { | |
| 21 | const ref = JSON.parse(`"${src.slice(1, -1)}"`); | |
| 22 | const resolved = hot.resolveClientRef(filepath, ref); | |
| 23 | scannedClientRefs.add(resolved); | |
| 24 | return `<CloverScriptInclude=${JSON.stringify( | |
| 25 | hot.getScriptId(resolved), | |
| 26 | )} />`; | |
| 27 | }, | |
| 28 | ) + '\nimport { addScript as CloverScriptInclude } from "#sitegen";\n'; | |
| 30 | 29 | } |
| 31 | 30 | |
| 32 | 31 | src = marko.compileSync(src, filepath).code; |
| 33 | src = src.replace("marko/debug/html", "#ssr/marko"); | |
| 32 | src = src.replace("marko/debug/html", "#engine/marko-runtime"); | |
| 34 | 33 | cache = { src, scannedClientRefs: Array.from(scannedClientRefs) }; |
| 35 | 34 | markoCache.set(filepath, cache); |
| 36 | 35 | } |
package.json+2-4| ... | ... | @@ -28,10 +28,8 @@ |
| 28 | 28 | "#debug": "./framework/debug.safe.ts", |
| 29 | 29 | "#sitegen": "./framework/lib/sitegen.ts", |
| 30 | 30 | "#sitegen/*": "./framework/lib/*.ts", |
| 31 | "#ssr": "./framework/engine/ssr.ts", | |
| 32 | "#ssr/jsx-dev-runtime": "./framework/engine/jsx-runtime.ts", | |
| 33 | "#ssr/jsx-runtime": "./framework/engine/jsx-runtime.ts", | |
| 34 | "#ssr/marko": "./framework/engine/marko-runtime.ts", | |
| 31 | "#engine/jsx-dev-runtime": "./framework/engine/jsx-runtime.ts", | |
| 32 | "#engine/*": "./framework/engine/*.ts", | |
| 35 | 33 | "#marko/html": { |
| 36 | 34 | "types": "marko/html", |
| 37 | 35 | "production": "marko/production", |
readme.md+2-10| ... | ... | @@ -3,7 +3,7 @@ |
| 3 | 3 | this repository contains clover's "sitegen" framework, which is a set of tools |
| 4 | 4 | that assist building websites. these tools power <https://paperclover.net>. |
| 5 | 5 | |
| 6 | - **HTML "Server Side Rendering") engine written from scratch.** (~500 lines) | |
| 6 | - **HTML ("Server Side Rendering") engine written from scratch.** (~500 lines) | |
| 7 | 7 | - A more practical JSX runtime (`class` instead of `className`, built-in |
| 8 | 8 | `clsx`, `html()` helper over `dangerouslySetInnerHTML` prop, etc). |
| 9 | 9 | - Integration with [Marko] for concisely written components. |
| ... | ... | @@ -57,19 +57,11 @@ my development machine, for example, is Dell Inspiron 7348 with Core i7 |
| 57 | 57 | |
| 58 | 58 | # production generation |
| 59 | 59 | node run generate |
| 60 | node .clover/out/server | |
| 60 | node .clover/o/backend | |
| 61 | 61 | |
| 62 | 62 | # "development" watch mode |
| 63 | 63 | node run watch |
| 64 | 64 | |
| 65 | <!-- `repl.js` will open a read-eval-print-loop where plugin state is cached (on my --> | |
| 66 | <!-- 2014 dev laptop, startup time is 600-1000ms). every file in `framework` and --> | |
| 67 | <!-- `src` besides `hot.ts` can be edited and quickly re-run. for example, to run --> | |
| 68 | <!-- `framework/generate.ts`, you can type "generate" into the shell. since --> | |
| 69 | <!-- top-level await is not supported (plugins are built on `require` as Node has --> | |
| 70 | <!-- poor module support), CLIs can include a `main` function, which is executed --> | |
| 71 | <!-- when the REPL runs it. --> | |
| 72 | ||
| 73 | 65 | for unix systems, the provided `flake.nix` can be used with `nix develop` to |
| 74 | 66 | open a shell with all needed system dependencies. |
| 75 | 67 |
src/file-viewer/backend.tsx+14-12| ... | ... | @@ -125,10 +125,11 @@ app.get("/file/*", async (c, next) => { |
| 125 | 125 | |
| 126 | 126 | let encoding = decideEncoding(c.req.header("Accept-Encoding")); |
| 127 | 127 | |
| 128 | let sizeHeader = encoding === "raw" | |
| 129 | ? expectedSize | |
| 130 | // Size cannot be known because of compression modes | |
| 131 | : undefined; | |
| 128 | let sizeHeader = | |
| 129 | encoding === "raw" | |
| 130 | ? expectedSize | |
| 131 | : // Size cannot be known because of compression modes | |
| 132 | undefined; | |
| 132 | 133 | |
| 133 | 134 | // Etag |
| 134 | 135 | { |
| ... | ... | @@ -294,9 +295,10 @@ function handleRanges( |
| 294 | 295 | ): Response { |
| 295 | 296 | // TODO: multiple ranges |
| 296 | 297 | const rangeSize = ranges.reduce((a, b) => a + (b[1] - b[0] + 1), 0); |
| 297 | const rangeBody = streamOrBuffer instanceof ReadableStream | |
| 298 | ? applySingleRangeToStream(streamOrBuffer, ranges) | |
| 299 | : applyRangesToBuffer(streamOrBuffer, ranges, rangeSize); | |
| 298 | const rangeBody = | |
| 299 | streamOrBuffer instanceof ReadableStream | |
| 300 | ? applySingleRangeToStream(streamOrBuffer, ranges) | |
| 301 | : applyRangesToBuffer(streamOrBuffer, ranges, rangeSize); | |
| 300 | 302 | return new Response(rangeBody, { |
| 301 | 303 | status: 206, |
| 302 | 304 | headers: { |
| ... | ... | @@ -380,9 +382,9 @@ function getPartialPage(c: Context, rawFilePath: string) { |
| 380 | 382 | if (!checkCotyledonCookie(c)) { |
| 381 | 383 | let root = Speedbump(); |
| 382 | 384 | // Remove the root element, it's created client side! |
| 383 | root = root[2].children as ssr.Element; | |
| 385 | root = root[2].children as render.Element; | |
| 384 | 386 | |
| 385 | const html = ssr.ssrSync(root).text; | |
| 387 | const html = render.sync(root).text; | |
| 386 | 388 | c.header("X-Cotyledon", "true"); |
| 387 | 389 | return c.html(html); |
| 388 | 390 | } |
| ... | ... | @@ -408,15 +410,15 @@ function getPartialPage(c: Context, rawFilePath: string) { |
| 408 | 410 | hasCotyledonCookie: rawFilePath === "" && checkCotyledonCookie(c), |
| 409 | 411 | }); |
| 410 | 412 | // Remove the root element, it's created client side! |
| 411 | root = root[2].children as ssr.Element; | |
| 413 | root = root[2].children as render.Element; | |
| 412 | 414 | |
| 413 | const html = ssr.ssrSync(root).text; | |
| 415 | const html = render.sync(root).text; | |
| 414 | 416 | return c.html(html); |
| 415 | 417 | } |
| 416 | 418 | |
| 417 | 419 | import { type Context, Hono } from "hono"; |
| 418 | 420 | |
| 419 | import * as ssr from "#ssr"; | |
| 421 | import * as render from "#engine/render"; | |
| 420 | 422 | import { etagMatches, hasAsset, serveAsset } from "#sitegen/assets"; |
| 421 | 423 | import { renderView } from "#sitegen/view"; |
| 422 | 424 | import { contentTypeFor } from "#sitegen/mime"; |
src/file-viewer/format.ts+25-36| ... | ... | @@ -100,22 +100,21 @@ export function highlightLinksInTextView( |
| 100 | 100 | // Case 1: https:// or http:// URLs |
| 101 | 101 | if (match.startsWith("http")) { |
| 102 | 102 | if (match.includes(findDomain)) { |
| 103 | return `<a href="${ | |
| 104 | match | |
| 105 | .replace(/https?:\/\/paperclover\.net\/+/, "/") | |
| 106 | .replace(/\/\/+/g, "/") | |
| 107 | }">${match}</a>`; | |
| 103 | return `<a href="${match | |
| 104 | .replace(/https?:\/\/paperclover\.net\/+/, "/") | |
| 105 | .replace(/\/\/+/g, "/")}">${match}</a>`; | |
| 108 | 106 | } |
| 109 | return `<a href="${ | |
| 110 | match.replace(/\/\/+/g, "/") | |
| 111 | }" target="_blank" rel="noopener noreferrer">${match}</a>`; | |
| 107 | return `<a href="${match.replace( | |
| 108 | /\/\/+/g, | |
| 109 | "/", | |
| 110 | )}" target="_blank" rel="noopener noreferrer">${match}</a>`; | |
| 112 | 111 | } |
| 113 | 112 | |
| 114 | 113 | // Case 2: domain URLs without protocol |
| 115 | 114 | if (match.startsWith(findDomain)) { |
| 116 | return `<a href="${ | |
| 117 | match.replace(findDomain + "/", "/").replace(/\/\/+/g, "/") | |
| 118 | }">${match}</a>`; | |
| 115 | return `<a href="${match | |
| 116 | .replace(findDomain + "/", "/") | |
| 117 | .replace(/\/\/+/g, "/")}">${match}</a>`; | |
| 119 | 118 | } |
| 120 | 119 | |
| 121 | 120 | // Case 3: /file/ URLs |
| ... | ... | @@ -146,7 +145,7 @@ export function highlightLinksInTextView( |
| 146 | 145 | // Match sibling file names (only if they're not already part of a link) |
| 147 | 146 | if (siblingFiles.length > 0) { |
| 148 | 147 | const escapedBasenames = siblingFiles.map((f) => |
| 149 | f.basename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") | |
| 148 | f.basename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), | |
| 150 | 149 | ); |
| 151 | 150 | const pattern = new RegExp(`\\b(${escapedBasenames.join("|")})\\b`, "g"); |
| 152 | 151 | const parts = processedText.split(/(<[^>]*>)/); |
| ... | ... | @@ -156,9 +155,9 @@ export function highlightLinksInTextView( |
| 156 | 155 | parts[i] = parts[i].replace(pattern, (match: string) => { |
| 157 | 156 | const file = siblingLookup[match]; |
| 158 | 157 | if (file) { |
| 159 | return `<a href="/file/${ | |
| 160 | file.path.replace(/^\//, "").replace(/\/\/+/g, "/") | |
| 161 | }">${match}</a>`; | |
| 158 | return `<a href="/file/${file.path | |
| 159 | .replace(/^\//, "") | |
| 160 | .replace(/\/\/+/g, "/")}">${match}</a>`; | |
| 162 | 161 | } |
| 163 | 162 | return match; |
| 164 | 163 | }); |
| ... | ... | @@ -241,11 +240,9 @@ export function highlightConvo(text: string) { |
| 241 | 240 | |
| 242 | 241 | return paras |
| 243 | 242 | .map(({ speaker, lines }) => { |
| 244 | return `<div class="s-${speaker}">${ | |
| 245 | lines | |
| 246 | .map((line) => `<div class="line">${line}</div>`) | |
| 247 | .join("\n") | |
| 248 | }</div>`; | |
| 243 | return `<div class="s-${speaker}">${lines | |
| 244 | .map((line) => `<div class="line">${line}</div>`) | |
| 245 | .join("\n")}</div>`; | |
| 249 | 246 | }) |
| 250 | 247 | .join("\n"); |
| 251 | 248 | } |
| ... | ... | @@ -267,22 +264,14 @@ const unknownDateWithKnownYear = new Date("1970-02-20"); |
| 267 | 264 | |
| 268 | 265 | export function formatDate(dateTime: Date) { |
| 269 | 266 | return dateTime < unknownDateWithKnownYear |
| 270 | ? ( | |
| 271 | dateTime < unknownDate | |
| 272 | ? ( | |
| 273 | "??.??.??" | |
| 274 | ) | |
| 275 | : `xx.xx.${21 + Math.floor(dateTime.getTime() / 86400000)}` | |
| 276 | ) | |
| 277 | : ( | |
| 278 | `${(dateTime.getMonth() + 1).toString().padStart(2, "0")}.${ | |
| 279 | dateTime | |
| 280 | .getDate() | |
| 281 | .toString() | |
| 282 | .padStart(2, "0") | |
| 283 | }.${dateTime.getFullYear().toString().slice(2)}` | |
| 284 | ); | |
| 267 | ? dateTime < unknownDate | |
| 268 | ? "??.??.??" | |
| 269 | : `xx.xx.${21 + Math.floor(dateTime.getTime() / 86400000)}` | |
| 270 | : `${(dateTime.getMonth() + 1).toString().padStart(2, "0")}.${dateTime | |
| 271 | .getDate() | |
| 272 | .toString() | |
| 273 | .padStart(2, "0")}.${dateTime.getFullYear().toString().slice(2)}`; | |
| 285 | 274 | } |
| 286 | 275 | |
| 287 | 276 | import type { MediaFile } from "@/file-viewer/models/MediaFile.ts"; |
| 288 | import { escapeHtml } from "#ssr"; | |
| 277 | import { escapeHtml } from "#engine/render"; |
src/file-viewer/highlight.ts+3-3| ... | ... | @@ -113,7 +113,7 @@ function highlightLines({ |
| 113 | 113 | const str = lines[i].slice(token.startIndex, token.endIndex); |
| 114 | 114 | if (str.trim().length === 0) { |
| 115 | 115 | // Emit but do not consider scope changes |
| 116 | html += ssr.escapeHtml(str); | |
| 116 | html += render.escapeHtml(str); | |
| 117 | 117 | continue; |
| 118 | 118 | } |
| 119 | 119 | |
| ... | ... | @@ -122,7 +122,7 @@ function highlightLines({ |
| 122 | 122 | if (lastHtmlStyle) html += "</span>"; |
| 123 | 123 | if (style) html += `<span class='${style}'>`; |
| 124 | 124 | } |
| 125 | html += ssr.escapeHtml(str); | |
| 125 | html += render.escapeHtml(str); | |
| 126 | 126 | lastHtmlStyle = style; |
| 127 | 127 | } |
| 128 | 128 | html += "\n"; |
| ... | ... | @@ -197,4 +197,4 @@ import * as fs from "#sitegen/fs"; |
| 197 | 197 | import * as path from "node:path"; |
| 198 | 198 | import * as oniguruma from "vscode-oniguruma"; |
| 199 | 199 | import * as textmate from "vscode-textmate"; |
| 200 | import * as ssr from "#ssr"; | |
| 200 | import * as render from "#engine/render"; |
src/q+a/clover-markdown.tsx+23-11| ... | ... | @@ -104,13 +104,15 @@ questionRules.insertBefore("paragraph", { |
| 104 | 104 | assert(x.startsWith("q: ")); |
| 105 | 105 | return x.slice(3); |
| 106 | 106 | }); |
| 107 | const content = lines.map((line, i) => { | |
| 108 | const parsed = parseInline(parse, line, state); | |
| 109 | if (i < lines.length - 1) { | |
| 110 | parsed.push({ type: "br" }); | |
| 111 | } | |
| 112 | return parsed; | |
| 113 | }).flat(); | |
| 107 | const content = lines | |
| 108 | .map((line, i) => { | |
| 109 | const parsed = parseInline(parse, line, state); | |
| 110 | if (i < lines.length - 1) { | |
| 111 | parsed.push({ type: "br" }); | |
| 112 | } | |
| 113 | return parsed; | |
| 114 | }) | |
| 115 | .flat(); | |
| 114 | 116 | return { |
| 115 | 117 | content, |
| 116 | 118 | }; |
| ... | ... | @@ -140,7 +142,13 @@ function BasicNode(node: ASTNode, children: any) { |
| 140 | 142 | |
| 141 | 143 | function ListRenderer(node: ASTNode, children: any[]) { |
| 142 | 144 | const T = node.ordered ? "ol" : "ul"; |
| 143 | return <T>{children.map((child) => <li>{child}</li>)}</T>; | |
| 145 | return ( | |
| 146 | <T> | |
| 147 | {children.map((child) => ( | |
| 148 | <li>{child}</li> | |
| 149 | ))} | |
| 150 | </T> | |
| 151 | ); | |
| 144 | 152 | } |
| 145 | 153 | |
| 146 | 154 | function MarkdownLink(node: ASTNode, children: any[]) { |
| ... | ... | @@ -174,7 +182,11 @@ function ArtifactRef(node: ASTNode) { |
| 174 | 182 | if (!url) { |
| 175 | 183 | return <span>{title}</span>; |
| 176 | 184 | } |
| 177 | return <a class={`custom artifact artifact-${type}`} href={url}>{title}</a>; | |
| 185 | return ( | |
| 186 | <a class={`custom artifact artifact-${type}`} href={url}> | |
| 187 | {title} | |
| 188 | </a> | |
| 189 | ); | |
| 178 | 190 | } |
| 179 | 191 | |
| 180 | 192 | const br = <br />; |
| ... | ... | @@ -202,7 +214,7 @@ const ssrFunctions: any = { |
| 202 | 214 | |
| 203 | 215 | // Custom Elements |
| 204 | 216 | question: BasicNode, |
| 205 | html: (node: any) => ssr.html(node.data), | |
| 217 | html: (node: any) => render.raw(node.data), | |
| 206 | 218 | mentionQuestion: QuestionRef, |
| 207 | 219 | mentionArtifact: ArtifactRef, |
| 208 | 220 | }; |
| ... | ... | @@ -247,4 +259,4 @@ import { |
| 247 | 259 | parseCaptureInline, |
| 248 | 260 | parseInline, |
| 249 | 261 | } from "./simple-markdown.ts"; |
| 250 | import * as ssr from "#ssr"; | |
| 262 | import * as render from "#engine/render"; |
src/q+a/scripts/editor.client.tsx+5-5| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { EditorState } from "@codemirror/state"; |
| 2 | 2 | import { basicSetup, EditorView } from "codemirror"; |
| 3 | import { ssrSync } from "#ssr"; | |
| 3 | import * as render from "#engine/render"; | |
| 4 | 4 | import type { ScriptPayload } from "@/q+a/views/editor.marko"; |
| 5 | 5 | import QuestionRender from "@/q+a/tags/question.marko"; |
| 6 | 6 | |
| ... | ... | @@ -11,7 +11,7 @@ const main = document.getElementById("edit-grid")! as HTMLDivElement; |
| 11 | 11 | const preview = document.getElementById("preview")! as HTMLDivElement; |
| 12 | 12 | |
| 13 | 13 | function updatePreview(text: string) { |
| 14 | preview.innerHTML = ssrSync( | |
| 14 | preview.innerHTML = render.sync( | |
| 15 | 15 | <QuestionRender |
| 16 | 16 | question={{ |
| 17 | 17 | id: payload.id, |
| ... | ... | @@ -96,9 +96,9 @@ function wrapAction(cb: () => Promise<void>) { |
| 96 | 96 | return async () => { |
| 97 | 97 | main.style.opacity = "0.5"; |
| 98 | 98 | main.style.pointerEvents = "none"; |
| 99 | const inputs = main.querySelectorAll("button,select,input") as NodeListOf< | |
| 100 | HTMLButtonElement | |
| 101 | >; | |
| 99 | const inputs = main.querySelectorAll( | |
| 100 | "button,select,input", | |
| 101 | ) as NodeListOf<HTMLButtonElement>; | |
| 102 | 102 | inputs.forEach((b) => { |
| 103 | 103 | b.disabled = true; |
| 104 | 104 | }); |
tsconfig.json+1-1| ... | ... | @@ -4,7 +4,7 @@ |
| 4 | 4 | "baseUrl": ".", |
| 5 | 5 | "incremental": true, |
| 6 | 6 | "jsx": "react-jsxdev", |
| 7 | "jsxImportSource": "#ssr", | |
| 7 | "jsxImportSource": "#engine", | |
| 8 | 8 | "lib": ["dom", "esnext", "esnext.iterator"], |
| 9 | 9 | "module": "nodenext", |
| 10 | 10 | "noEmit": true, |