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