| author | |
| committer | |
| log | 93498bf84ee5dd9ad221152af00a7da852ed406c |
| tree | 644e661f13812cc584b3ad7f6ff422be5a4c6204 |
| parent | 2fa9b4eb6ee7d21505a0def86bd0c36801869e9a |
| signature | Commit is signed but in an unrecognized format. |
16 files changed, 662 insertions(+), 370 deletions(-)
framework/bundle.ts+70-8| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | // This file implements client-side bundling, mostly wrapping esbuild. | 1 | // This file implements client-side bundling, mostly wrapping esbuild. |
| 2 | const plugins: esbuild.Plugin[] = [ | 2 | const clientPlugins: esbuild.Plugin[] = [ |
| 3 | // There are currently no plugins needed by 'paperclover.net' | 3 | // There are currently no plugins needed by 'paperclover.net' |
| 4 | ]; | 4 | ]; |
| 5 | 5 | ||
| ... | @@ -35,7 +35,7 @@ export async function bundleClientJavaScript( | ... | @@ -35,7 +35,7 @@ export async function bundleClientJavaScript( |
| 35 | format: "esm", | 35 | format: "esm", |
| 36 | minify: !dev, | 36 | minify: !dev, |
| 37 | outdir: "/out!", | 37 | outdir: "/out!", |
| 38 | plugins, | 38 | plugins: clientPlugins, |
| 39 | splitting: true, | 39 | splitting: true, |
| 40 | write: false, | 40 | write: false, |
| 41 | metafile: true, | 41 | metafile: true, |
| ... | @@ -82,23 +82,85 @@ export async function bundleClientJavaScript( | ... | @@ -82,23 +82,85 @@ export async function bundleClientJavaScript( |
| 82 | await Promise.all(promises); | 82 | await Promise.all(promises); |
| 83 | } | 83 | } |
| 84 | 84 | ||
| 85 | export async function bundleServerJavaScript(entryPoint: string) { | 85 | type ServerPlatform = "node" | "passthru"; |
| 86 | export async function bundleServerJavaScript( | ||
| 87 | /** Has 'export default app;' */ | ||
| 88 | backendEntryPoint: string, | ||
| 89 | /** Views for dynamic loading */ | ||
| 90 | viewEntryPoints: FileItem[], | ||
| 91 | platform: ServerPlatform = "node", | ||
| 92 | ) { | ||
| 93 | const scriptMagic = "CLOVER_CLIENT_SCRIPTS_DEFINITION"; | ||
| 94 | const viewSource = [ | ||
| 95 | ...viewEntryPoints.map((view, i) => | ||
| 96 | `import * as view${i} from ${JSON.stringify(view.file)}` | ||
| 97 | ), | ||
| 98 | `const scripts = ${scriptMagic}[-1]`, | ||
| 99 | "export const views = {", | ||
| 100 | ...viewEntryPoints.flatMap((view, i) => [ | ||
| 101 | ` ${JSON.stringify(view.id)}: {`, | ||
| 102 | ` component: view${i}.default,`, | ||
| 103 | ` meta: view${i}.meta,`, | ||
| 104 | ` layout: view${i}.layout?.default,`, | ||
| 105 | ` theme: view${i}.layout?.theme ?? view${i}.theme,`, | ||
| 106 | ` scripts: ${scriptMagic}[${i}]`, | ||
| 107 | ` },`, | ||
| 108 | ]), | ||
| 109 | "}", | ||
| 110 | ].join("\n"); | ||
| 111 | const serverPlugins: esbuild.Plugin[] = [ | ||
| 112 | virtualFiles({ | ||
| 113 | "$views": viewSource, | ||
| 114 | }), | ||
| 115 | banFiles([ | ||
| 116 | "hot.ts", | ||
| 117 | "incremental.ts", | ||
| 118 | "bundle.ts", | ||
| 119 | "generate.ts", | ||
| 120 | "css.ts", | ||
| 121 | ].map((subPath) => path.join(hot.projectRoot, "framework/" + subPath))), | ||
| 122 | { | ||
| 123 | name: "marko", | ||
| 124 | setup(b) { | ||
| 125 | b.onLoad({ filter: /\.marko$/ }, async ({ path }) => { | ||
| 126 | const src = await fs.readFile(path); | ||
| 127 | const result = await marko.compile(src, path, { | ||
| 128 | output: "html", | ||
| 129 | }); | ||
| 130 | return { | ||
| 131 | loader: "ts", | ||
| 132 | contents: result.code, | ||
| 133 | }; | ||
| 134 | }); | ||
| 135 | }, | ||
| 136 | }, | ||
| 137 | ]; | ||
| 86 | const bundle = await esbuild.build({ | 138 | const bundle = await esbuild.build({ |
| 87 | bundle: true, | 139 | bundle: true, |
| 88 | chunkNames: "/js/c.[hash]", | 140 | chunkNames: "/js/c.[hash]", |
| 89 | entryNames: "/js/[name]", | 141 | entryNames: "/js/[name]", |
| 90 | assetNames: "/asset/[hash]", | 142 | assetNames: "/asset/[hash]", |
| 91 | entryPoints: [entryPoint], | 143 | entryPoints: [backendEntryPoint], |
| 144 | platform: "node", | ||
| 92 | format: "esm", | 145 | format: "esm", |
| 93 | minify: true, | 146 | minify: false, |
| 94 | outdir: "/out!", | 147 | // outdir: "/out!", |
| 95 | plugins, | 148 | outdir: ".clover/wah", |
| 149 | plugins: serverPlugins, | ||
| 96 | splitting: true, | 150 | splitting: true, |
| 97 | write: false, | 151 | write: true, |
| 152 | external: ["@babel/preset-typescript"], | ||
| 98 | }); | 153 | }); |
| 154 | console.log(bundle); | ||
| 155 | throw new Error("wahhh"); | ||
| 99 | } | 156 | } |
| 100 | 157 | ||
| 101 | import * as esbuild from "esbuild"; | 158 | import * as esbuild from "esbuild"; |
| 102 | import * as path from "node:path"; | 159 | import * as path from "node:path"; |
| 103 | import process from "node:process"; | 160 | import process from "node:process"; |
| 161 | import * as hot from "./hot.ts"; | ||
| 162 | import { banFiles, virtualFiles } from "./esbuild-support.ts"; | ||
| 104 | import { Incremental } from "./incremental.ts"; | 163 | import { Incremental } from "./incremental.ts"; |
| 164 | import type { FileItem } from "#sitegen"; | ||
| 165 | import * as marko from "@marko/compiler"; | ||
| 166 | import * as fs from "./lib/fs.ts"; |
framework/css.ts+14-16| ... | @@ -48,22 +48,8 @@ export async function bundleCssFiles( | ... | @@ -48,22 +48,8 @@ export async function bundleCssFiles( |
| 48 | path.isAbsolute(file) ? path.relative(hot.projectRoot, file) : file | 48 | path.isAbsolute(file) ? path.relative(hot.projectRoot, file) : file |
| 49 | ); | 49 | ); |
| 50 | const plugin = { | 50 | const plugin = { |
| 51 | name: "clover", | 51 | name: "clover css", |
| 52 | setup(b) { | 52 | setup(b) { |
| 53 | b.onResolve( | ||
| 54 | { filter: /^\$input\$$/ }, | ||
| 55 | () => ({ path: ".", namespace: "input" }), | ||
| 56 | ); | ||
| 57 | b.onLoad( | ||
| 58 | { filter: /./, namespace: "input" }, | ||
| 59 | () => ({ | ||
| 60 | loader: "css", | ||
| 61 | contents: | ||
| 62 | cssImports.map((path) => `@import url(${JSON.stringify(path)});`) | ||
| 63 | .join("\n") + stringifyTheme(theme), | ||
| 64 | resolveDir: ".", | ||
| 65 | }), | ||
| 66 | ); | ||
| 67 | b.onLoad( | 53 | b.onLoad( |
| 68 | { filter: /\.css$/ }, | 54 | { filter: /\.css$/ }, |
| 69 | async ({ path: file }) => ({ | 55 | async ({ path: file }) => ({ |
| ... | @@ -79,7 +65,18 @@ export async function bundleCssFiles( | ... | @@ -79,7 +65,18 @@ export async function bundleCssFiles( |
| 79 | external: ["*.woff2", "*.ttf", "*.png", "*.jpeg"], | 65 | external: ["*.woff2", "*.ttf", "*.png", "*.jpeg"], |
| 80 | metafile: true, | 66 | metafile: true, |
| 81 | minify: !dev, | 67 | minify: !dev, |
| 82 | plugins: [plugin], | 68 | plugins: [ |
| 69 | virtualFiles({ | ||
| 70 | "$input$": { | ||
| 71 | contents: cssImports.map((path) => | ||
| 72 | `@import url(${JSON.stringify(path)});` | ||
| 73 | ) | ||
| 74 | .join("\n") + stringifyTheme(theme), | ||
| 75 | loader: "css", | ||
| 76 | }, | ||
| 77 | }), | ||
| 78 | plugin, | ||
| 79 | ], | ||
| 83 | target: ["ie11"], | 80 | target: ["ie11"], |
| 84 | write: false, | 81 | write: false, |
| 85 | }); | 82 | }); |
| ... | @@ -102,4 +99,5 @@ import * as esbuild from "esbuild"; | ... | @@ -102,4 +99,5 @@ import * as esbuild from "esbuild"; |
| 102 | import * as fs from "#sitegen/fs"; | 99 | import * as fs from "#sitegen/fs"; |
| 103 | import * as hot from "./hot.ts"; | 100 | import * as hot from "./hot.ts"; |
| 104 | import * as path from "node:path"; | 101 | import * as path from "node:path"; |
| 102 | import { virtualFiles } from "./esbuild-support.ts"; | ||
| 105 | import { Incremental } from "./incremental.ts"; | 103 | import { Incremental } from "./incremental.ts"; |
framework/esbuild-support.ts created+58| ... | @@ -0,0 +1,58 @@ | ||
| 1 | export function virtualFiles( | ||
| 2 | map: Record<string, string | esbuild.OnLoadResult>, | ||
| 3 | ) { | ||
| 4 | return { | ||
| 5 | name: "clover vfs", | ||
| 6 | setup(b) { | ||
| 7 | b.onResolve( | ||
| 8 | { | ||
| 9 | filter: new RegExp( | ||
| 10 | // TODO: Proper Escape | ||
| 11 | `\\$`, | ||
| 12 | ), | ||
| 13 | }, | ||
| 14 | ({ path }) => { | ||
| 15 | console.log({ path }); | ||
| 16 | return ({ path, namespace: "vfs" }); | ||
| 17 | }, | ||
| 18 | ); | ||
| 19 | b.onLoad( | ||
| 20 | { filter: /./, namespace: "vfs" }, | ||
| 21 | ({ path }) => { | ||
| 22 | const entry = map[path]; | ||
| 23 | return ({ | ||
| 24 | resolveDir: ".", | ||
| 25 | loader: "ts", | ||
| 26 | ...typeof entry === "string" ? { contents: entry } : entry, | ||
| 27 | }); | ||
| 28 | }, | ||
| 29 | ); | ||
| 30 | }, | ||
| 31 | } satisfies esbuild.Plugin; | ||
| 32 | } | ||
| 33 | |||
| 34 | export function banFiles( | ||
| 35 | files: string[], | ||
| 36 | ) { | ||
| 37 | return { | ||
| 38 | name: "clover vfs", | ||
| 39 | setup(b) { | ||
| 40 | b.onResolve( | ||
| 41 | { | ||
| 42 | filter: new RegExp( | ||
| 43 | "^(?:" + files.map((file) => string.escapeRegExp(file)).join("|") + | ||
| 44 | ")$", | ||
| 45 | ), | ||
| 46 | }, | ||
| 47 | ({ path, importer }) => { | ||
| 48 | throw new Error( | ||
| 49 | `Loading ${path} (from ${importer}) is banned!`, | ||
| 50 | ); | ||
| 51 | }, | ||
| 52 | ); | ||
| 53 | }, | ||
| 54 | } satisfies esbuild.Plugin; | ||
| 55 | } | ||
| 56 | |||
| 57 | import * as esbuild from "esbuild"; | ||
| 58 | import * as string from "#sitegen/string"; | ||
framework/generate.ts created+300| ... | @@ -0,0 +1,300 @@ | ||
| 1 | export function main() { | ||
| 2 | return withSpinner({ | ||
| 3 | text: "Recovering State", | ||
| 4 | successText: ({ elapsed }) => | ||
| 5 | "sitegen! update in " + elapsed.toFixed(1) + "s", | ||
| 6 | failureText: () => "sitegen FAIL", | ||
| 7 | }, sitegen); | ||
| 8 | } | ||
| 9 | |||
| 10 | async function sitegen(status: Spinner) { | ||
| 11 | const startTime = performance.now(); | ||
| 12 | |||
| 13 | let root = path.resolve(import.meta.dirname, "../src"); | ||
| 14 | const join = (...sub: string[]) => path.join(root, ...sub); | ||
| 15 | const incr = Incremental.fromDisk(); | ||
| 16 | await incr.statAllFiles(); | ||
| 17 | |||
| 18 | // Sitegen reviews every defined section for resources to process | ||
| 19 | const sections: sg.Section[] = | ||
| 20 | require(path.join(root, "site.ts")).siteSections; | ||
| 21 | |||
| 22 | // Static files are compressed and served as-is. | ||
| 23 | // - "{section}/static/*.png" | ||
| 24 | let staticFiles: FileItem[] = []; | ||
| 25 | // Pages are rendered then served as static files. | ||
| 26 | // - "{section}/pages/*.marko" | ||
| 27 | let pages: FileItem[] = []; | ||
| 28 | // Views are dynamically rendered pages called via backend code. | ||
| 29 | // - "{section}/views/*.tsx" | ||
| 30 | let views: FileItem[] = []; | ||
| 31 | // Public scripts are bundled for the client as static assets under "/js/[...]" | ||
| 32 | // This is used for the file viewer's canvases. | ||
| 33 | // Note that '.client.ts' can be placed anywhere in the file structure. | ||
| 34 | // - "{section}/scripts/*.client.ts" | ||
| 35 | let scripts: FileItem[] = []; | ||
| 36 | |||
| 37 | // -- Scan for files -- | ||
| 38 | status.text = "Scanning Project"; | ||
| 39 | for (const section of sections) { | ||
| 40 | const { root: sectionRoot } = section; | ||
| 41 | const sectionPath = (...sub: string[]) => path.join(sectionRoot, ...sub); | ||
| 42 | const rootPrefix = root === sectionRoot | ||
| 43 | ? "" | ||
| 44 | : path.relative(root, sectionRoot) + "/"; | ||
| 45 | const kinds = [ | ||
| 46 | { | ||
| 47 | dir: sectionPath("pages"), | ||
| 48 | list: pages, | ||
| 49 | prefix: "/", | ||
| 50 | exclude: [".css", ".client.ts", ".client.tsx"], | ||
| 51 | }, | ||
| 52 | { dir: sectionPath("static"), list: staticFiles, prefix: "/", ext: true }, | ||
| 53 | { dir: sectionPath("scripts"), list: scripts, prefix: rootPrefix }, | ||
| 54 | { | ||
| 55 | dir: sectionPath("views"), | ||
| 56 | list: views, | ||
| 57 | prefix: rootPrefix, | ||
| 58 | exclude: [".css", ".client.ts", ".client.tsx"], | ||
| 59 | }, | ||
| 60 | ]; | ||
| 61 | for (const { dir, list, prefix, exclude = [], ext = false } of kinds) { | ||
| 62 | const items = fs.readDirRecOptionalSync(dir); | ||
| 63 | item: for (const subPath of items) { | ||
| 64 | const file = path.join(dir, subPath); | ||
| 65 | const stat = fs.statSync(file); | ||
| 66 | if (stat.isDirectory()) continue; | ||
| 67 | for (const e of exclude) { | ||
| 68 | if (subPath.endsWith(e)) continue item; | ||
| 69 | } | ||
| 70 | const trim = ext | ||
| 71 | ? subPath | ||
| 72 | : subPath.slice(0, -path.extname(subPath).length).replaceAll( | ||
| 73 | ".", | ||
| 74 | "/", | ||
| 75 | ); | ||
| 76 | let id = prefix + trim.replaceAll("\\", "/"); | ||
| 77 | if (prefix === "/" && id.endsWith("/index")) { | ||
| 78 | id = id.slice(0, -"/index".length) || "/"; | ||
| 79 | } | ||
| 80 | list.push({ id, file: file }); | ||
| 81 | } | ||
| 82 | } | ||
| 83 | } | ||
| 84 | scripts = scripts.filter(({ file }) => !file.match(/\.client\.[tj]sx?/)); | ||
| 85 | const globalCssPath = join("global.css"); | ||
| 86 | |||
| 87 | // TODO: invalidate incremental resources | ||
| 88 | |||
| 89 | // -- server side render -- | ||
| 90 | status.text = "Building"; | ||
| 91 | const cssOnce = new OnceMap<css.Output>(); | ||
| 92 | const cssQueue = new Queue<[string, string[], css.Theme], css.Output>({ | ||
| 93 | name: "Bundle", | ||
| 94 | fn: ([, files, theme]) => css.bundleCssFiles(files, theme), | ||
| 95 | passive: true, | ||
| 96 | getItemText: ([id]) => id, | ||
| 97 | maxJobs: 2, | ||
| 98 | }); | ||
| 99 | interface RenderResult { | ||
| 100 | body: string; | ||
| 101 | head: string; | ||
| 102 | css: css.Output; | ||
| 103 | scriptFiles: string[]; | ||
| 104 | item: FileItem; | ||
| 105 | } | ||
| 106 | const renderResults: RenderResult[] = []; | ||
| 107 | async function loadPageModule({ file }: FileItem) { | ||
| 108 | require(file); | ||
| 109 | } | ||
| 110 | async function renderPage(item: FileItem) { | ||
| 111 | // -- load and validate module -- | ||
| 112 | let { | ||
| 113 | default: Page, | ||
| 114 | meta: metadata, | ||
| 115 | theme: pageTheme, | ||
| 116 | layout, | ||
| 117 | } = require(item.file); | ||
| 118 | if (!Page) throw new Error("Page is missing a 'default' export."); | ||
| 119 | if (!metadata) { | ||
| 120 | throw new Error("Page is missing 'meta' export with a title."); | ||
| 121 | } | ||
| 122 | if (layout?.theme) pageTheme = layout.theme; | ||
| 123 | const theme = { | ||
| 124 | bg: "#fff", | ||
| 125 | fg: "#050505", | ||
| 126 | primary: "#2e7dab", | ||
| 127 | ...pageTheme, | ||
| 128 | }; | ||
| 129 | |||
| 130 | // -- metadata -- | ||
| 131 | const renderedMetaPromise = Promise.resolve( | ||
| 132 | typeof metadata === "function" ? metadata({ ssr: true }) : metadata, | ||
| 133 | ).then((m) => meta.renderMeta(m)); | ||
| 134 | // -- css -- | ||
| 135 | const cssImports = [globalCssPath, ...hot.getCssImports(item.file)]; | ||
| 136 | const cssPromise = cssOnce.get( | ||
| 137 | cssImports.join(":") + JSON.stringify(theme), | ||
| 138 | () => cssQueue.add([item.id, cssImports, theme]), | ||
| 139 | ); | ||
| 140 | // -- html -- | ||
| 141 | let page = [engine.kElement, Page, {}]; | ||
| 142 | if (layout?.default) { | ||
| 143 | page = [engine.kElement, layout.default, { children: page }]; | ||
| 144 | } | ||
| 145 | const bodyPromise = engine.ssrAsync(page, { | ||
| 146 | sitegen: sg.initRender(), | ||
| 147 | }); | ||
| 148 | |||
| 149 | const [{ text, addon }, cssBundle, renderedMeta] = await Promise.all([ | ||
| 150 | bodyPromise, | ||
| 151 | cssPromise, | ||
| 152 | renderedMetaPromise, | ||
| 153 | ]); | ||
| 154 | if (!renderedMeta.includes("<title>")) { | ||
| 155 | throw new Error( | ||
| 156 | "Page is missing 'meta.title'. " + | ||
| 157 | "All pages need a title tag.", | ||
| 158 | ); | ||
| 159 | } | ||
| 160 | // The script content is not ready, allow another page to Render. The page | ||
| 161 | // contents will be rebuilt at the end. This is more convenient anyways | ||
| 162 | // because it means client scripts don't re-render the page. | ||
| 163 | renderResults.push({ | ||
| 164 | body: text, | ||
| 165 | head: renderedMeta, | ||
| 166 | css: cssBundle, | ||
| 167 | scriptFiles: Array.from(addon.sitegen.scripts), | ||
| 168 | item: item, | ||
| 169 | }); | ||
| 170 | } | ||
| 171 | // This is done in two passes so that a page that throws during evaluation | ||
| 172 | // will report "Load Render Module" instead of "Render Static Page". | ||
| 173 | const neededPages = pages.filter((page) => incr.needsBuild("asset", page.id)); | ||
| 174 | const spinnerFormat = status.format; | ||
| 175 | status.format = () => ""; | ||
| 176 | const moduleLoadQueue = new Queue({ | ||
| 177 | name: "Load Render Module", | ||
| 178 | fn: loadPageModule, | ||
| 179 | getItemText, | ||
| 180 | maxJobs: 1, | ||
| 181 | }); | ||
| 182 | moduleLoadQueue.addMany(neededPages); | ||
| 183 | await moduleLoadQueue.done({ method: "stop" }); | ||
| 184 | const pageQueue = new Queue({ | ||
| 185 | name: "Render Static Page", | ||
| 186 | fn: renderPage, | ||
| 187 | getItemText, | ||
| 188 | maxJobs: 2, | ||
| 189 | }); | ||
| 190 | pageQueue.addMany(neededPages); | ||
| 191 | await pageQueue.done({ method: "stop" }); | ||
| 192 | status.format = spinnerFormat; | ||
| 193 | |||
| 194 | // -- bundle backend and views -- | ||
| 195 | status.text = "Bundle backend code"; | ||
| 196 | const {} = await bundle.bundleServerJavaScript( | ||
| 197 | join("backend.ts"), | ||
| 198 | views, | ||
| 199 | ); | ||
| 200 | |||
| 201 | // -- bundle scripts -- | ||
| 202 | const referencedScripts = Array.from( | ||
| 203 | new Set(renderResults.flatMap((r) => r.scriptFiles)), | ||
| 204 | ); | ||
| 205 | const extraPublicScripts = scripts.map((entry) => entry.file); | ||
| 206 | const uniqueCount = new Set([ | ||
| 207 | ...referencedScripts, | ||
| 208 | ...extraPublicScripts, | ||
| 209 | ]).size; | ||
| 210 | status.text = `Bundle ${uniqueCount} Scripts`; | ||
| 211 | await bundle.bundleClientJavaScript( | ||
| 212 | referencedScripts, | ||
| 213 | extraPublicScripts, | ||
| 214 | incr, | ||
| 215 | ); | ||
| 216 | |||
| 217 | // -- copy/compress static files -- | ||
| 218 | async function doStaticFile(item: FileItem) { | ||
| 219 | const body = await fs.readFile(item.file); | ||
| 220 | await incr.putAsset({ | ||
| 221 | sources: [item.file], | ||
| 222 | key: item.id, | ||
| 223 | body, | ||
| 224 | }); | ||
| 225 | } | ||
| 226 | const staticQueue = new Queue({ | ||
| 227 | name: "Load Static", | ||
| 228 | fn: doStaticFile, | ||
| 229 | getItemText, | ||
| 230 | maxJobs: 16, | ||
| 231 | }); | ||
| 232 | status.format = () => ""; | ||
| 233 | staticQueue.addMany( | ||
| 234 | staticFiles.filter((file) => incr.needsBuild("asset", file.id)), | ||
| 235 | ); | ||
| 236 | await staticQueue.done({ method: "stop" }); | ||
| 237 | status.format = spinnerFormat; | ||
| 238 | |||
| 239 | // -- concatenate static rendered pages -- | ||
| 240 | status.text = `Concat ${renderResults.length} Pages`; | ||
| 241 | await Promise.all( | ||
| 242 | renderResults.map( | ||
| 243 | async ( | ||
| 244 | { item: page, body, head, css, scriptFiles }, | ||
| 245 | ) => { | ||
| 246 | const doc = wrapDocument({ | ||
| 247 | body, | ||
| 248 | head, | ||
| 249 | inlineCss: css.text, | ||
| 250 | scripts: scriptFiles.map( | ||
| 251 | (id) => | ||
| 252 | UNWRAP( | ||
| 253 | incr.out.script.get( | ||
| 254 | path.basename(id).replace(/\.client\.[jt]sx?$/, ""), | ||
| 255 | ), | ||
| 256 | ), | ||
| 257 | ).map((x) => `{${x}}`).join("\n"), | ||
| 258 | }); | ||
| 259 | await incr.putAsset({ | ||
| 260 | sources: [page.file, ...css.sources], | ||
| 261 | key: page.id, | ||
| 262 | body: doc, | ||
| 263 | headers: { | ||
| 264 | "Content-Type": "text/html", | ||
| 265 | }, | ||
| 266 | }); | ||
| 267 | }, | ||
| 268 | ), | ||
| 269 | ); | ||
| 270 | status.format = () => ""; | ||
| 271 | status.text = ``; | ||
| 272 | // This will wait for all compression jobs to finish, which up | ||
| 273 | // to this point have been left as dangling promises. | ||
| 274 | await incr.wait(); | ||
| 275 | |||
| 276 | // Flush the site to disk. | ||
| 277 | status.format = spinnerFormat; | ||
| 278 | status.text = `Incremental Flush`; | ||
| 279 | incr.flush(); // Write outputs | ||
| 280 | incr.toDisk(); // Allows picking up this state again | ||
| 281 | return { elapsed: (performance.now() - startTime) / 1000 }; | ||
| 282 | } | ||
| 283 | |||
| 284 | function getItemText({ file }: FileItem) { | ||
| 285 | return path.relative(hot.projectSrc, file).replaceAll("\\", "/"); | ||
| 286 | } | ||
| 287 | |||
| 288 | import { OnceMap, Queue } from "./queue.ts"; | ||
| 289 | import { Incremental } from "./incremental.ts"; | ||
| 290 | import * as bundle from "./bundle.ts"; | ||
| 291 | import * as css from "./css.ts"; | ||
| 292 | import * as engine from "./engine/ssr.ts"; | ||
| 293 | import * as hot from "./hot.ts"; | ||
| 294 | import * as fs from "#sitegen/fs"; | ||
| 295 | import * as sg from "#sitegen"; | ||
| 296 | import type { FileItem } from "#sitegen"; | ||
| 297 | import * as path from "node:path"; | ||
| 298 | import * as meta from "#sitegen/meta"; | ||
| 299 | import { Spinner, withSpinner } from "@paperclover/console/Spinner"; | ||
| 300 | import { wrapDocument } from "./lib/view.ts"; | ||
framework/generate.tsx deleted-316| ... | @@ -1,316 +0,0 @@ | ||
| 1 | export function main() { | ||
| 2 | return withSpinner({ | ||
| 3 | text: "Recovering State", | ||
| 4 | successText: ({ elapsed }) => | ||
| 5 | "sitegen! update in " + elapsed.toFixed(1) + "s", | ||
| 6 | failureText: () => "sitegen FAIL", | ||
| 7 | }, sitegen); | ||
| 8 | } | ||
| 9 | |||
| 10 | /** | ||
| 11 | * A filesystem object associated with some ID, | ||
| 12 | * such as a page's route to it's source file. | ||
| 13 | */ | ||
| 14 | interface FileItem { | ||
| 15 | id: string; | ||
| 16 | file: string; | ||
| 17 | } | ||
| 18 | |||
| 19 | async function sitegen(status: Spinner) { | ||
| 20 | const startTime = performance.now(); | ||
| 21 | |||
| 22 | let root = path.resolve(import.meta.dirname, "../src"); | ||
| 23 | const join = (...sub: string[]) => path.join(root, ...sub); | ||
| 24 | const incr = Incremental.fromDisk(); | ||
| 25 | await incr.statAllFiles(); | ||
| 26 | |||
| 27 | // Sitegen reviews every defined section for resources to process | ||
| 28 | const sections: sg.Section[] = | ||
| 29 | require(path.join(root, "site.ts")).siteSections; | ||
| 30 | |||
| 31 | // Static files are compressed and served as-is. | ||
| 32 | // - "{section}/static/*.png" | ||
| 33 | let staticFiles: FileItem[] = []; | ||
| 34 | // Pages are rendered then served as static files. | ||
| 35 | // - "{section}/pages/*.marko" | ||
| 36 | let pages: FileItem[] = []; | ||
| 37 | // Views are dynamically rendered pages called via backend code. | ||
| 38 | // - "{section}/views/*.tsx" | ||
| 39 | let views: FileItem[] = []; | ||
| 40 | // Public scripts are bundled for the client as static assets under "/js/[...]" | ||
| 41 | // This is used for the file viewer's canvases. | ||
| 42 | // Note that '.client.ts' can be placed anywhere in the file structure. | ||
| 43 | // - "{section}/scripts/*.client.ts" | ||
| 44 | let scripts: FileItem[] = []; | ||
| 45 | |||
| 46 | // -- Scan for files -- | ||
| 47 | status.text = "Scanning Project"; | ||
| 48 | for (const section of sections) { | ||
| 49 | const { root: sectionRoot } = section; | ||
| 50 | const sectionPath = (...sub: string[]) => path.join(sectionRoot, ...sub); | ||
| 51 | const rootPrefix = root === sectionRoot | ||
| 52 | ? "" | ||
| 53 | : path.relative(root, sectionRoot) + "/"; | ||
| 54 | const kinds = [ | ||
| 55 | { | ||
| 56 | dir: sectionPath("pages"), | ||
| 57 | list: pages, | ||
| 58 | prefix: "/", | ||
| 59 | exclude: [".css", ".client.ts", ".client.tsx"], | ||
| 60 | }, | ||
| 61 | { dir: sectionPath("static"), list: staticFiles, prefix: "/", ext: true }, | ||
| 62 | { dir: sectionPath("scripts"), list: scripts, prefix: rootPrefix }, | ||
| 63 | { | ||
| 64 | dir: sectionPath("views"), | ||
| 65 | list: views, | ||
| 66 | prefix: rootPrefix, | ||
| 67 | exclude: [".css", ".client.ts", ".client.tsx"], | ||
| 68 | }, | ||
| 69 | ]; | ||
| 70 | for (const { dir, list, prefix, exclude = [], ext = false } of kinds) { | ||
| 71 | const items = fs.readDirRecOptionalSync(dir); | ||
| 72 | item: for (const subPath of items) { | ||
| 73 | const file = path.join(dir, subPath); | ||
| 74 | const stat = fs.statSync(file); | ||
| 75 | if (stat.isDirectory()) continue; | ||
| 76 | for (const e of exclude) { | ||
| 77 | if (subPath.endsWith(e)) continue item; | ||
| 78 | } | ||
| 79 | const trim = ext | ||
| 80 | ? subPath | ||
| 81 | : subPath.slice(0, -path.extname(subPath).length).replaceAll( | ||
| 82 | ".", | ||
| 83 | "/", | ||
| 84 | ); | ||
| 85 | let id = prefix + trim.replaceAll("\\", "/"); | ||
| 86 | if (prefix === "/" && id.endsWith("/index")) { | ||
| 87 | id = id.slice(0, -"/index".length) || "/"; | ||
| 88 | } | ||
| 89 | list.push({ id, file: file }); | ||
| 90 | } | ||
| 91 | } | ||
| 92 | } | ||
| 93 | scripts = scripts.filter(({ file }) => !file.match(/\.client\.[tj]sx?/)); | ||
| 94 | const globalCssPath = join("global.css"); | ||
| 95 | |||
| 96 | // TODO: invalidate incremental resources | ||
| 97 | |||
| 98 | // -- server side render -- | ||
| 99 | status.text = "Building"; | ||
| 100 | const cssOnce = new OnceMap<css.Output>(); | ||
| 101 | const cssQueue = new Queue<[string, string[], css.Theme], css.Output>({ | ||
| 102 | name: "Bundle", | ||
| 103 | fn: ([, files, theme]) => css.bundleCssFiles(files, theme), | ||
| 104 | passive: true, | ||
| 105 | getItemText: ([id]) => id, | ||
| 106 | maxJobs: 2, | ||
| 107 | }); | ||
| 108 | interface RenderResult { | ||
| 109 | body: string; | ||
| 110 | head: string; | ||
| 111 | css: css.Output; | ||
| 112 | scriptFiles: string[]; | ||
| 113 | item: FileItem; | ||
| 114 | } | ||
| 115 | const renderResults: RenderResult[] = []; | ||
| 116 | async function loadPageModule({ file }: FileItem) { | ||
| 117 | require(file); | ||
| 118 | } | ||
| 119 | async function renderPage(item: FileItem) { | ||
| 120 | // -- load and validate module -- | ||
| 121 | let { default: Page, meta: metadata, theme: pageTheme, layout } = require( | ||
| 122 | item.file, | ||
| 123 | ); | ||
| 124 | if (!Page) throw new Error("Page is missing a 'default' export."); | ||
| 125 | if (!metadata) { | ||
| 126 | throw new Error("Page is missing 'meta' export with a title."); | ||
| 127 | } | ||
| 128 | if (layout?.theme) pageTheme = layout.theme; | ||
| 129 | const theme = { | ||
| 130 | bg: "#fff", | ||
| 131 | fg: "#050505", | ||
| 132 | primary: "#2e7dab", | ||
| 133 | ...pageTheme, | ||
| 134 | }; | ||
| 135 | |||
| 136 | // -- metadata -- | ||
| 137 | const renderedMetaPromise = Promise.resolve( | ||
| 138 | typeof metadata === "function" ? metadata({ ssr: true }) : metadata, | ||
| 139 | ).then((m) => meta.renderMeta(m)); | ||
| 140 | // -- css -- | ||
| 141 | const cssImports = [globalCssPath, ...hot.getCssImports(item.file)]; | ||
| 142 | const cssPromise = cssOnce.get( | ||
| 143 | cssImports.join(":") + JSON.stringify(theme), | ||
| 144 | () => cssQueue.add([item.id, cssImports, theme]), | ||
| 145 | ); | ||
| 146 | // -- html -- | ||
| 147 | let page = <Page />; | ||
| 148 | if (layout?.default) { | ||
| 149 | const Layout = layout.default; | ||
| 150 | page = <Layout>{page}</Layout>; | ||
| 151 | } | ||
| 152 | const bodyPromise = await ssr.ssrAsync(page, { | ||
| 153 | sitegen: sg.initRender(), | ||
| 154 | }); | ||
| 155 | |||
| 156 | const [{ text, addon }, cssBundle, renderedMeta] = await Promise.all([ | ||
| 157 | bodyPromise, | ||
| 158 | cssPromise, | ||
| 159 | renderedMetaPromise, | ||
| 160 | ]); | ||
| 161 | if (!renderedMeta.includes("<title>")) { | ||
| 162 | throw new Error( | ||
| 163 | "Page is missing 'meta.title'. " + | ||
| 164 | "All pages need a title tag.", | ||
| 165 | ); | ||
| 166 | } | ||
| 167 | // The script content is not ready, allow another page to Render. The page | ||
| 168 | // contents will be rebuilt at the end. This is more convenient anyways | ||
| 169 | // because it means client scripts don't re-render the page. | ||
| 170 | renderResults.push({ | ||
| 171 | body: text, | ||
| 172 | head: renderedMeta, | ||
| 173 | css: cssBundle, | ||
| 174 | scriptFiles: Array.from(addon.sitegen.scripts), | ||
| 175 | item: item, | ||
| 176 | }); | ||
| 177 | } | ||
| 178 | // This is done in two passes so that a page that throws during evaluation | ||
| 179 | // will report "Load Render Module" instead of "Render Static Page". | ||
| 180 | const neededPages = pages.filter((page) => incr.needsBuild("asset", page.id)); | ||
| 181 | const spinnerFormat = status.format; | ||
| 182 | status.format = () => ""; | ||
| 183 | const moduleLoadQueue = new Queue({ | ||
| 184 | name: "Load Render Module", | ||
| 185 | fn: loadPageModule, | ||
| 186 | getItemText, | ||
| 187 | maxJobs: 1, | ||
| 188 | }); | ||
| 189 | moduleLoadQueue.addMany(neededPages); | ||
| 190 | await moduleLoadQueue.done({ method: "stop" }); | ||
| 191 | const pageQueue = new Queue({ | ||
| 192 | name: "Render Static Page", | ||
| 193 | fn: renderPage, | ||
| 194 | getItemText, | ||
| 195 | maxJobs: 2, | ||
| 196 | }); | ||
| 197 | pageQueue.addMany(neededPages); | ||
| 198 | await pageQueue.done({ method: "stop" }); | ||
| 199 | status.format = spinnerFormat; | ||
| 200 | |||
| 201 | // -- bundle scripts -- | ||
| 202 | const referencedScripts = Array.from( | ||
| 203 | new Set(renderResults.flatMap((r) => r.scriptFiles)), | ||
| 204 | ); | ||
| 205 | const extraPublicScripts = scripts.map((entry) => entry.file); | ||
| 206 | const uniqueCount = new Set([ | ||
| 207 | ...referencedScripts, | ||
| 208 | ...extraPublicScripts, | ||
| 209 | ]).size; | ||
| 210 | status.text = `Bundle ${uniqueCount} Scripts`; | ||
| 211 | await bundle.bundleClientJavaScript( | ||
| 212 | referencedScripts, | ||
| 213 | extraPublicScripts, | ||
| 214 | incr, | ||
| 215 | ); | ||
| 216 | |||
| 217 | // -- copy/compress static files -- | ||
| 218 | async function doStaticFile(item: FileItem) { | ||
| 219 | const body = await fs.readFile(item.file); | ||
| 220 | await incr.putAsset({ | ||
| 221 | sources: [item.file], | ||
| 222 | key: item.id, | ||
| 223 | body, | ||
| 224 | }); | ||
| 225 | } | ||
| 226 | const staticQueue = new Queue({ | ||
| 227 | name: "Load Static", | ||
| 228 | fn: doStaticFile, | ||
| 229 | getItemText, | ||
| 230 | maxJobs: 16, | ||
| 231 | }); | ||
| 232 | status.format = () => ""; | ||
| 233 | staticQueue.addMany( | ||
| 234 | staticFiles.filter((file) => incr.needsBuild("asset", file.id)), | ||
| 235 | ); | ||
| 236 | await staticQueue.done({ method: "stop" }); | ||
| 237 | status.format = spinnerFormat; | ||
| 238 | |||
| 239 | // -- concatenate static rendered pages -- | ||
| 240 | status.text = `Concat ${renderResults.length} Pages`; | ||
| 241 | await Promise.all( | ||
| 242 | renderResults.map( | ||
| 243 | async ( | ||
| 244 | { item: page, body, head, css, scriptFiles }, | ||
| 245 | ) => { | ||
| 246 | const doc = wrapDocument({ | ||
| 247 | body, | ||
| 248 | head, | ||
| 249 | inlineCss: css.text, | ||
| 250 | scripts: scriptFiles.map( | ||
| 251 | (id) => | ||
| 252 | UNWRAP( | ||
| 253 | incr.out.script.get( | ||
| 254 | path.basename(id).replace(/\.client\.[jt]sx?$/, ""), | ||
| 255 | ), | ||
| 256 | ), | ||
| 257 | ).map((x) => `{${x}}`).join("\n"), | ||
| 258 | }); | ||
| 259 | await incr.putAsset({ | ||
| 260 | sources: [page.file, ...css.sources], | ||
| 261 | key: page.id, | ||
| 262 | body: doc, | ||
| 263 | headers: { | ||
| 264 | "Content-Type": "text/html", | ||
| 265 | }, | ||
| 266 | }); | ||
| 267 | }, | ||
| 268 | ), | ||
| 269 | ); | ||
| 270 | status.format = () => ""; | ||
| 271 | status.text = ``; | ||
| 272 | // This will wait for all compression jobs to finish, which up | ||
| 273 | // to this point have been left as dangling promises. | ||
| 274 | await incr.wait(); | ||
| 275 | |||
| 276 | // Flush the site to disk. | ||
| 277 | status.format = spinnerFormat; | ||
| 278 | status.text = `Incremental Flush`; | ||
| 279 | incr.flush(); | ||
| 280 | incr.toDisk(); // Allows picking up this state again | ||
| 281 | return { elapsed: (performance.now() - startTime) / 1000 }; | ||
| 282 | } | ||
| 283 | |||
| 284 | function getItemText({ file }: FileItem) { | ||
| 285 | return path.relative(hot.projectSrc, file).replaceAll("\\", "/"); | ||
| 286 | } | ||
| 287 | |||
| 288 | function wrapDocument({ | ||
| 289 | body, | ||
| 290 | head, | ||
| 291 | inlineCss, | ||
| 292 | scripts, | ||
| 293 | }: { | ||
| 294 | head: string; | ||
| 295 | body: string; | ||
| 296 | inlineCss: string; | ||
| 297 | scripts: string; | ||
| 298 | }) { | ||
| 299 | return `<!doctype html><html lang=en><head>${head}${ | ||
| 300 | inlineCss ? `<style>${inlineCss}</style>` : "" | ||
| 301 | }</head><body>${body}${ | ||
| 302 | scripts ? `<script>${scripts}</script>` : "" | ||
| 303 | }</body></html>`; | ||
| 304 | } | ||
| 305 | |||
| 306 | import { OnceMap, Queue } from "./queue.ts"; | ||
| 307 | import { Incremental } from "./incremental.ts"; | ||
| 308 | import * as bundle from "./bundle.ts"; | ||
| 309 | import * as css from "./css.ts"; | ||
| 310 | import * as ssr from "./engine/ssr.ts"; | ||
| 311 | import * as hot from "./hot.ts"; | ||
| 312 | import * as fs from "#sitegen/fs"; | ||
| 313 | import * as sg from "#sitegen"; | ||
| 314 | import * as path from "node:path"; | ||
| 315 | import * as meta from "#sitegen/meta"; | ||
| 316 | import { Spinner, withSpinner } from "@paperclover/console/Spinner"; | ||
framework/hot.ts+44-11| ... | @@ -36,12 +36,6 @@ export interface FileStat { | ... | @@ -36,12 +36,6 @@ export interface FileStat { |
| 36 | imports: string[]; | 36 | imports: string[]; |
| 37 | } | 37 | } |
| 38 | let fsGraph = new Map<string, FileStat>(); | 38 | let fsGraph = new Map<string, FileStat>(); |
| 39 | export function setFsGraph(g: Map<string, FileStat>) { | ||
| 40 | if (fsGraph.size > 0) { | ||
| 41 | throw new Error("Cannot restore fsGraph when it has been written into"); | ||
| 42 | } | ||
| 43 | fsGraph = g; | ||
| 44 | } | ||
| 45 | export function getFsGraph() { | 39 | export function getFsGraph() { |
| 46 | return fsGraph; | 40 | return fsGraph; |
| 47 | } | 41 | } |
| ... | @@ -108,11 +102,18 @@ Module._resolveFilename = (...args) => { | ... | @@ -108,11 +102,18 @@ Module._resolveFilename = (...args) => { |
| 108 | }; | 102 | }; |
| 109 | 103 | ||
| 110 | function loadEsbuild(module: NodeJS.Module, filepath: string) { | 104 | function loadEsbuild(module: NodeJS.Module, filepath: string) { |
| 111 | let src = fs.readFileSync(filepath, "utf8"); | 105 | return loadEsbuildCode(module, filepath, fs.readFileSync(filepath, "utf8")); |
| 112 | return loadEsbuildCode(module, filepath, src); | ||
| 113 | } | 106 | } |
| 114 | 107 | ||
| 115 | function loadEsbuildCode(module: NodeJS.Module, filepath: string, src: string) { | 108 | interface LoadOptions { |
| 109 | scannedClientRefs?: string[]; | ||
| 110 | } | ||
| 111 | function loadEsbuildCode( | ||
| 112 | module: NodeJS.Module, | ||
| 113 | filepath: string, | ||
| 114 | src: string, | ||
| 115 | opt: LoadOptions = {}, | ||
| 116 | ) { | ||
| 116 | if (filepath === import.meta.filename) { | 117 | if (filepath === import.meta.filename) { |
| 117 | module.exports = self; | 118 | module.exports = self; |
| 118 | return; | 119 | return; |
| ... | @@ -122,12 +123,13 @@ function loadEsbuildCode(module: NodeJS.Module, filepath: string, src: string) { | ... | @@ -122,12 +123,13 @@ function loadEsbuildCode(module: NodeJS.Module, filepath: string, src: string) { |
| 122 | if (filepath.endsWith(".ts")) loader = "ts"; | 123 | if (filepath.endsWith(".ts")) loader = "ts"; |
| 123 | else if (filepath.endsWith(".jsx")) loader = "jsx"; | 124 | else if (filepath.endsWith(".jsx")) loader = "jsx"; |
| 124 | else if (filepath.endsWith(".js")) loader = "js"; | 125 | else if (filepath.endsWith(".js")) loader = "js"; |
| 126 | module.cloverClientRefs = opt.scannedClientRefs ?? extractClientScripts(src); | ||
| 125 | if (src.includes("import.meta")) { | 127 | if (src.includes("import.meta")) { |
| 126 | src = ` | 128 | src = ` |
| 127 | import.meta.url = ${JSON.stringify(pathToFileURL(filepath).toString())}; | 129 | import.meta.url = ${JSON.stringify(pathToFileURL(filepath).toString())}; |
| 128 | import.meta.dirname = ${JSON.stringify(path.dirname(filepath))}; | 130 | import.meta.dirname = ${JSON.stringify(path.dirname(filepath))}; |
| 129 | import.meta.filename = ${JSON.stringify(filepath)}; | 131 | import.meta.filename = ${JSON.stringify(filepath)}; |
| 130 | ` + src; | 132 | `.trim().replace(/\n/g, "") + src; |
| 131 | } | 133 | } |
| 132 | src = esbuild.transformSync(src, { | 134 | src = esbuild.transformSync(src, { |
| 133 | loader, | 135 | loader, |
| ... | @@ -148,7 +150,7 @@ function loadMarko(module: NodeJS.Module, filepath: string) { | ... | @@ -148,7 +150,7 @@ function loadMarko(module: NodeJS.Module, filepath: string) { |
| 148 | if (src.match(/^\s*client\s+import\s+["']/m)) { | 150 | if (src.match(/^\s*client\s+import\s+["']/m)) { |
| 149 | src = src.replace( | 151 | src = src.replace( |
| 150 | /^\s*client\s+import\s+("[^"]+"|'[^']+')[^\n]+/m, | 152 | /^\s*client\s+import\s+("[^"]+"|'[^']+')[^\n]+/m, |
| 151 | "<CloverScriptInclude src=$1 />", | 153 | (_, src) => `<CloverScriptInclude src=${src} />`, |
| 152 | ) + '\nimport { Script as CloverScriptInclude } from "#sitegen";\n'; | 154 | ) + '\nimport { Script as CloverScriptInclude } from "#sitegen";\n'; |
| 153 | } | 155 | } |
| 154 | 156 | ||
| ... | @@ -206,9 +208,40 @@ export function resolveFrom(src: string, dest: string) { | ... | @@ -206,9 +208,40 @@ export function resolveFrom(src: string, dest: string) { |
| 206 | } | 208 | } |
| 207 | } | 209 | } |
| 208 | 210 | ||
| 211 | const importRegExp = | ||
| 212 | /import\s+(\*\sas\s([a-zA-Z0-9$_]+)|{[^}]+})\s+from\s+(?:"#sitegen"|'#sitegen')/s; | ||
| 213 | const getSitegenAddScriptRegExp = /addScript(?:\s+as\s+([a-zA-Z0-9$_]+))/; | ||
| 214 | export function extractClientScripts(source: string): string[] { | ||
| 215 | // This match finds a call to 'import ... from "#sitegen"' | ||
| 216 | const importMatch = source.match(importRegExp); | ||
| 217 | if (!importMatch) return []; | ||
| 218 | const items = importMatch[1]; | ||
| 219 | let identifier = ""; | ||
| 220 | if (items.startsWith("{")) { | ||
| 221 | const clauseMatch = items.match(getSitegenAddScriptRegExp); | ||
| 222 | if (!clauseMatch) return []; // did not import | ||
| 223 | identifier = clauseMatch[1] || "addScript"; | ||
| 224 | } else if (items.startsWith("*")) { | ||
| 225 | identifier = importMatch[2] + "\\s*\\.\\s*addScript"; | ||
| 226 | } else { | ||
| 227 | throw new Error("Impossible"); | ||
| 228 | } | ||
| 229 | identifier = identifier.replaceAll("$", "\\$"); // only needed escape | ||
| 230 | const findCallsRegExp = new RegExp( | ||
| 231 | `\\b${identifier}\\s*\\(("[^"]+"|'[^']+')\\)`, | ||
| 232 | "gs", | ||
| 233 | ); | ||
| 234 | const calls = source.matchAll(findCallsRegExp); | ||
| 235 | return [...calls].map((call) => { | ||
| 236 | return JSON.parse(`"${call[1].slice(1, -1)}"`) as string; | ||
| 237 | }); | ||
| 238 | } | ||
| 239 | |||
| 209 | declare global { | 240 | declare global { |
| 210 | namespace NodeJS { | 241 | namespace NodeJS { |
| 211 | interface Module { | 242 | interface Module { |
| 243 | cloverClientRefs?: string[]; | ||
| 244 | |||
| 212 | _compile( | 245 | _compile( |
| 213 | this: NodeJS.Module, | 246 | this: NodeJS.Module, |
| 214 | content: string, | 247 | content: string, |
framework/lib/sitegen.ts+9| ... | @@ -1,6 +1,15 @@ | ... | @@ -1,6 +1,15 @@ |
| 1 | // Import this file with 'import * as sg from "#sitegen";' | 1 | // Import this file with 'import * as sg from "#sitegen";' |
| 2 | export type ScriptId = string; | 2 | export type ScriptId = string; |
| 3 | 3 | ||
| 4 | /** | ||
| 5 | * A filesystem object associated with some ID, | ||
| 6 | * such as a page's route to it's source file. | ||
| 7 | */ | ||
| 8 | export interface FileItem { | ||
| 9 | id: string; | ||
| 10 | file: string; | ||
| 11 | } | ||
| 12 | |||
| 4 | const frameworkDir = path.dirname(import.meta.dirname); | 13 | const frameworkDir = path.dirname(import.meta.dirname); |
| 5 | 14 | ||
| 6 | export interface SitegenRender { | 15 | export interface SitegenRender { |
framework/lib/string.ts created+3| ... | @@ -0,0 +1,3 @@ | ||
| 1 | export function escapeRegExp(source: string) { | ||
| 2 | return source.replace(/[\$\\]/g, "\\$&"); | ||
| 3 | } | ||
framework/lib/view.ts created+87| ... | @@ -0,0 +1,87 @@ | ||
| 1 | // This import is generated by code 'bundle.ts' | ||
| 2 | export interface View { | ||
| 3 | component: engine.Component; | ||
| 4 | meta: | ||
| 5 | | meta.Meta | ||
| 6 | | ((props: { context?: hono.Context }) => Promise<meta.Meta> | meta.Meta); | ||
| 7 | layout?: engine.Component; | ||
| 8 | theme?: css.Theme; | ||
| 9 | inlineCss: string; | ||
| 10 | scripts: Record<string, string>; | ||
| 11 | } | ||
| 12 | let views: Record<string, View> = {}; | ||
| 13 | |||
| 14 | // An older version of the Clover Engine supported streaming suspense | ||
| 15 | // boundaries, but those were never used. Pages will wait until they | ||
| 16 | // are fully rendered before sending. | ||
| 17 | async function renderView( | ||
| 18 | c: hono.Context, | ||
| 19 | id: string, | ||
| 20 | props: Record<string, unknown>, | ||
| 21 | ) { | ||
| 22 | views = require("$views").views; | ||
| 23 | // The view contains pre-bundled CSS and scripts, but keeps the scripts | ||
| 24 | // separate for run-time dynamic scripts. For example, the file viewer | ||
| 25 | // includes the canvas for the current page, but only the current page. | ||
| 26 | const { | ||
| 27 | component, | ||
| 28 | inlineCss, | ||
| 29 | layout, | ||
| 30 | meta: metadata, | ||
| 31 | scripts, | ||
| 32 | theme, | ||
| 33 | }: View = UNWRAP(views[id]); | ||
| 34 | |||
| 35 | // -- metadata -- | ||
| 36 | const renderedMetaPromise = Promise.resolve( | ||
| 37 | typeof metadata === "function" ? metadata({ context: c }) : metadata, | ||
| 38 | ).then((m) => meta.renderMeta(m)); | ||
| 39 | |||
| 40 | // -- html -- | ||
| 41 | let page: engine.Element = [engine.kElement, component, props]; | ||
| 42 | if (layout) page = [engine.kElement, layout, { children: page }]; | ||
| 43 | const { text: body, addon: { sitegen } } = await engine.ssrAsync(page, { | ||
| 44 | sitegen: sg.initRender(), | ||
| 45 | }); | ||
| 46 | |||
| 47 | // -- join document and send -- | ||
| 48 | return c.html(wrapDocument({ | ||
| 49 | body, | ||
| 50 | head: await renderedMetaPromise, | ||
| 51 | inlineCss, | ||
| 52 | scripts: joinScripts( | ||
| 53 | Array.from(sitegen.scripts, (script) => scripts[script]), | ||
| 54 | ), | ||
| 55 | })); | ||
| 56 | } | ||
| 57 | |||
| 58 | export function joinScripts(scriptSources: string[]) { | ||
| 59 | const { length } = scriptSources; | ||
| 60 | if (length === 0) return ""; | ||
| 61 | if (length === 1) return scriptSources[0]; | ||
| 62 | return scriptSources.map((source) => `{${source}}`).join(";"); | ||
| 63 | } | ||
| 64 | |||
| 65 | export function wrapDocument({ | ||
| 66 | body, | ||
| 67 | head, | ||
| 68 | inlineCss, | ||
| 69 | scripts, | ||
| 70 | }: { | ||
| 71 | head: string; | ||
| 72 | body: string; | ||
| 73 | inlineCss: string; | ||
| 74 | scripts: string; | ||
| 75 | }) { | ||
| 76 | return `<!doctype html><html lang=en><head>${head}${ | ||
| 77 | inlineCss ? `<style>${inlineCss}</style>` : "" | ||
| 78 | }</head><body>${body}${ | ||
| 79 | scripts ? `<script>${scripts}</script>` : "" | ||
| 80 | }</body></html>`; | ||
| 81 | } | ||
| 82 | |||
| 83 | import * as meta from "./meta.ts"; | ||
| 84 | import type * as hono from "#hono"; | ||
| 85 | import * as engine from "../engine/ssr.ts"; | ||
| 86 | import type * as css from "../css.ts"; | ||
| 87 | import * as sg from "./sitegen.ts"; | ||
readme.md+3-1| ... | @@ -36,7 +36,9 @@ Included is `src`, which contains `paperclover.net`. Website highlights: | ... | @@ -36,7 +36,9 @@ Included is `src`, which contains `paperclover.net`. Website highlights: |
| 36 | minimum system requirements: | 36 | minimum system requirements: |
| 37 | - a cpu with at least 1 core. | 37 | - a cpu with at least 1 core. |
| 38 | - random access memory. | 38 | - random access memory. |
| 39 | - windows 7 or later, macos, or linux operating system. | 39 | - windows 7 or later, macos, or other operating system. |
| 40 | |||
| 41 | my development machine, for example, is Dell Inspiron 7348 with Core i7 | ||
| 40 | 42 | ||
| 41 | ``` | 43 | ``` |
| 42 | npm install | 44 | npm install |
repl.js+1-1| ... | @@ -27,7 +27,7 @@ hot.load("node:repl").start({ | ... | @@ -27,7 +27,7 @@ hot.load("node:repl").start({ |
| 27 | }); | 27 | }); |
| 28 | 28 | ||
| 29 | setTimeout(() => { | 29 | setTimeout(() => { |
| 30 | hot.reloadRecursive("./framework/generate.tsx"); | 30 | hot.reloadRecursive("./framework/generate.ts"); |
| 31 | }, 100); | 31 | }, 100); |
| 32 | 32 | ||
| 33 | async function evaluate(code) { | 33 | async function evaluate(code) { |
src/backend.ts+2| ... | @@ -1,3 +1,5 @@ | ... | @@ -1,3 +1,5 @@ |
| 1 | import * as v from "#sitegen/view"; | ||
| 2 | console.log(v); | ||
| 1 | const logHttp = scoped("http", { color: "magenta" }); | 3 | const logHttp = scoped("http", { color: "magenta" }); |
| 2 | 4 | ||
| 3 | const app = new Hono(); | 5 | const app = new Hono(); |
src/components/Video.tsx+57| ... | @@ -0,0 +1,57 @@ | ... | @@ -0,0 +1,57 @@ |
| 1 | import * as path from "node:path"; | ||
| 2 | import { addScript } from "#sitegen"; | ||
| 3 | import { PrecomputedBlurhash } from "./blurhash.tsx"; | ||
| 4 | import "./Video.css"; | ||
| 5 | export namespace Video { | ||
| 6 | export interface Props { | ||
| 7 | title: string; | ||
| 8 | width: number; | ||
| 9 | height: number; | ||
| 10 | sources: string[]; | ||
| 11 | downloads: string[]; | ||
| 12 | poster?: string; | ||
| 13 | posterHash?: string; | ||
| 14 | borderless?: boolean; | ||
| 15 | } | ||
| 16 | } | ||
| 17 | function PrecomputedBlurhash({ hash }: { hash: string }) { | ||
| 18 | export function Video( | ||
| 19 | { title, sources, height, poster, posterHash, width, borderless }: | ||
| 20 | Video.Props, | ||
| 21 | ) { | ||
| 22 | addScript("./video.client.ts"); | ||
| 23 | return ( | ||
| 24 | <figure class={`video ${borderless ? "borderless" : ""}`}> | ||
| 25 | <figcaption>{title}</figcaption> | ||
| 26 | {/* posterHash && <PrecomputedBlurhash hash={posterHash} /> */} | ||
| 27 | {poster && <img src={poster} alt="waterfalls" />} | ||
| 28 | <video | ||
| 29 | controls | ||
| 30 | preload="none" | ||
| 31 | style={`width:100%;background:transparent;aspect-ratio:${ | ||
| 32 | simplifyFraction(width, height) | ||
| 33 | }`} | ||
| 34 | poster="data:null" | ||
| 35 | > | ||
| 36 | {sources.map((src) => ( | ||
| 37 | <source | ||
| 38 | src={src} | ||
| 39 | type={contentTypeFromExt(src)} | ||
| 40 | /> | ||
| 41 | ))} | ||
| 42 | </video> | ||
| 43 | </figure> | ||
| 44 | ); | ||
| 45 | } | ||
| 46 | export function contentTypeFromExt(src: string) { | ||
| 47 | if (src.endsWith(".m3u8")) return "application/x-mpegURL"; | ||
| 48 | if (src.endsWith(".webm")) return "video/webm"; | ||
| 49 | if (src.endsWith(".mp4")) return "video/mp4"; | ||
| 50 | if (src.endsWith(".ogg")) return "video/ogg"; | ||
| 51 | throw new Error("Unknown video extension: " + path.extname(src)); | ||
| 52 | } | ||
| 53 | const gcd = (a: number, b: number): number => b ? gcd(b, a % b) : a; | ||
| 54 | function simplifyFraction(n: number, d: number) { | ||
| 55 | const divisor = gcd(n, d); | ||
| 56 | return `${n / divisor}/${d / divisor}`; | ||
| 57 | } |
src/q+a/views/backend-inbox.marko+4-3| ... | @@ -8,7 +8,6 @@ import { | ... | @@ -8,7 +8,6 @@ import { |
| 8 | } from "../q+a/QuestionRender"; | 8 | } from "../q+a/QuestionRender"; |
| 9 | 9 | ||
| 10 | <const/questions = PendingQuestion.getAll() /> | 10 | <const/questions = PendingQuestion.getAll() /> |
| 11 | <Script src="backend-inbox.client.ts" /> | ||
| 12 | 11 | ||
| 13 | <h1>inbox</h1> | 12 | <h1>inbox</h1> |
| 14 | <for|q| of=questions> | 13 | <for|q| of=questions> |
| ... | @@ -16,7 +15,7 @@ import { | ... | @@ -16,7 +15,7 @@ import { |
| 16 | data-q=q.id | 15 | data-q=q.id |
| 17 | style="border-bottom: 2px solid #fff7; margin-bottom: 1rem" | 16 | style="border-bottom: 2px solid #fff7; margin-bottom: 1rem" |
| 18 | > | 17 | > |
| 19 | <time datetime={formatQuestionISOTimestamp(q.date)}> | 18 | <time datetime=formatQuestionISOTimestamp(q.date)> |
| 20 | ${formatQuestionTimestamp(q.date)} ${q.id} | 19 | ${formatQuestionTimestamp(q.date)} ${q.id} |
| 21 | </time> | 20 | </time> |
| 22 | <div style="color: dodgerblue; margin-bottom: 0.25rem"> | 21 | <div style="color: dodgerblue; margin-bottom: 0.25rem"> |
| ... | @@ -26,7 +25,7 @@ import { | ... | @@ -26,7 +25,7 @@ import { |
| 26 | </div> | 25 | </div> |
| 27 | <p style="white-space: pre-wrap">${q.prompt}</p> | 26 | <p style="white-space: pre-wrap">${q.prompt}</p> |
| 28 | <p> | 27 | <p> |
| 29 | <button onclick=`onReply("${q.id}") style="color: lime"> | 28 | <button onclick=`onReply("${q.id}")` style="color: lime"> |
| 30 | reply | 29 | reply |
| 31 | </button> | 30 | </button> |
| 32 | <button onclick=`onDelete("${q.id}")` style="color: red"> | 31 | <button onclick=`onDelete("${q.id}")` style="color: red"> |
| ... | @@ -38,3 +37,5 @@ import { | ... | @@ -38,3 +37,5 @@ import { |
| 38 | </p> | 37 | </p> |
| 39 | </div> | 38 | </div> |
| 40 | </> | 39 | </> |
| 40 | |||
| 41 | client import "backend-inbox.client.ts"; |
src/q+a/views/fail.marko+9-13| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import * as layout from "../layouts/questions.tsx"; | 1 | import * as layout from "../layout.tsx"; |
| 2 | export { layout }; | 2 | export { layout }; |
| 3 | export const theme = { | 3 | export const theme = { |
| 4 | ...layout.theme, | 4 | ...layout.theme, |
| ... | @@ -10,18 +10,14 @@ export const theme = { | ... | @@ -10,18 +10,14 @@ export const theme = { |
| 10 | <p style="color: red"> | 10 | <p style="color: red"> |
| 11 | ${error} | 11 | ${error} |
| 12 | </p> | 12 | </p> |
| 13 | { | 13 | <if=content> |
| 14 | content && ( | 14 | <br /> |
| 15 | <> | 15 | <br /> |
| 16 | <br /> | 16 | <p> |
| 17 | <br /> | 17 | here is a copy of what you wrote, if you want to try again: |
| 18 | <p> | 18 | </p> |
| 19 | here is a copy of what you wrote, if you want to try again: | 19 | <pre style="white-space: pre-wrap"><code>${content}</code></pre> |
| 20 | </p> | 20 | </> |
| 21 | <pre style="white-space: pre-wrap"><code>${content}</code></pre> | ||
| 22 | </> | ||
| 23 | ) | ||
| 24 | } | ||
| 25 | <br /> | 21 | <br /> |
| 26 | <br /> | 22 | <br /> |
| 27 | <p> | 23 | <p> |
src/q+a/views/success.marko+1-1| ... | @@ -3,7 +3,7 @@ export interface Input { | ... | @@ -3,7 +3,7 @@ export interface Input { |
| 3 | } | 3 | } |
| 4 | <const/{ permalink }=input/> | 4 | <const/{ permalink }=input/> |
| 5 | 5 | ||
| 6 | import * as layout from "../layouts/questions.tsx"; | 6 | import * as layout from "../layout.tsx"; |
| 7 | export { layout }; | 7 | export { layout }; |
| 8 | export const theme = { | 8 | export const theme = { |
| 9 | ...layout.theme, | 9 | ...layout.theme, |