| author | |
| committer | |
| log | a176037c3c37afa39eac946515d3d5a533076674 |
| tree | 34f54f82f86c0052cd9ec9822deff416d30593aa |
| parent | 73cb20561b7cc88cebc4d2be5bff899efa8d80d5 |
| signature | Commit is signed but in an unrecognized format. |
this is only verified functional on windows 78 files changed, 190 insertions(+), 47 deletions(-)
framework/bundle.ts+11-19| ... | ... | @@ -21,8 +21,8 @@ export async function bundleClientJavaScript( |
| 21 | 21 | if (invalidFiles.length > 0) { |
| 22 | 22 | const cwd = process.cwd(); |
| 23 | 23 | throw new Error( |
| 24 | "All client-side scripts should be named like '.client.ts'. Exceptions: " + | |
| 25 | invalidFiles.map((x) => path.join(cwd, x)).join(","), | |
| 24 | "All client-side scripts should be named like '.client.ts'. Exceptions: \n" + | |
| 25 | invalidFiles.map((x) => path.join(cwd, x)).join("\n"), | |
| 26 | 26 | ); |
| 27 | 27 | } |
| 28 | 28 | |
| ... | ... | @@ -36,9 +36,9 @@ export async function bundleClientJavaScript( |
| 36 | 36 | minify: !dev, |
| 37 | 37 | outdir: "/out!", |
| 38 | 38 | plugins: clientPlugins, |
| 39 | splitting: true, | |
| 40 | 39 | write: false, |
| 41 | 40 | metafile: true, |
| 41 | external: ["node_modules/"], | |
| 42 | 42 | }); |
| 43 | 43 | if (bundle.errors.length || bundle.warnings.length) { |
| 44 | 44 | throw new AggregateError( |
| ... | ... | @@ -61,11 +61,12 @@ export async function bundleClientJavaScript( |
| 61 | 61 | // Register non-chunks as script entries. |
| 62 | 62 | const chunk = route.startsWith("/js/c."); |
| 63 | 63 | if (!chunk) { |
| 64 | route = route.replace(".client.js", ".js"); | |
| 64 | const key = hot.getScriptId(sources[0]); | |
| 65 | route = "/js/" + key + ".js"; | |
| 65 | 66 | incr.put({ |
| 66 | 67 | sources, |
| 67 | 68 | type: "script", |
| 68 | key: route.slice("/js/".length, -".js".length), | |
| 69 | key, | |
| 69 | 70 | value: text, |
| 70 | 71 | }); |
| 71 | 72 | } |
| ... | ... | @@ -130,23 +131,14 @@ export async function bundleServerJavaScript( |
| 130 | 131 | virtualFiles({ |
| 131 | 132 | "$views": viewSource, |
| 132 | 133 | }), |
| 133 | // banFiles([ | |
| 134 | // "hot.ts", | |
| 135 | // "incremental.ts", | |
| 136 | // "bundle.ts", | |
| 137 | // "generate.ts", | |
| 138 | // "css.ts", | |
| 139 | // ].map((subPath) => path.join(hot.projectRoot, "framework/" + subPath))), | |
| 140 | 134 | projectRelativeResolution(), |
| 141 | 135 | { |
| 142 | name: "marko", | |
| 136 | name: "marko via build cache", | |
| 143 | 137 | setup(b) { |
| 144 | b.onLoad({ filter: /\.marko$/ }, async ({ path: file }) => { | |
| 145 | return { | |
| 146 | loader: "ts", | |
| 147 | contents: hot.getSourceCode(file), | |
| 148 | }; | |
| 149 | }); | |
| 138 | b.onLoad({ filter: /\.marko$/ }, async ({ path: file }) => ({ | |
| 139 | loader: "ts", | |
| 140 | contents: hot.getSourceCode(file), | |
| 141 | })); | |
| 150 | 142 | }, |
| 151 | 143 | }, |
| 152 | 144 | { |
framework/generate.ts+21-18| ... | ... | @@ -1,20 +1,29 @@ |
| 1 | export function main() { | |
| 2 | return withSpinner({ | |
| 1 | export function main(incremental?: Incremental) { | |
| 2 | return withSpinner<Record<string, unknown>, any>({ | |
| 3 | 3 | text: "Recovering State", |
| 4 | successText: ({ elapsed }) => | |
| 5 | "sitegen! update in " + elapsed.toFixed(1) + "s", | |
| 4 | successText, | |
| 6 | 5 | failureText: () => "sitegen FAIL", |
| 7 | }, sitegen); | |
| 6 | }, async (spinner) => { | |
| 7 | const incr = Incremental.fromDisk(); | |
| 8 | await incr.statAllFiles(); | |
| 9 | const result = await sitegen(spinner, incr); | |
| 10 | incr.toDisk(); // Allows picking up this state again | |
| 11 | return result; | |
| 12 | }) as ReturnType<typeof sitegen>; | |
| 8 | 13 | } |
| 9 | 14 | |
| 10 | async function sitegen(status: Spinner) { | |
| 15 | export function successText({ elapsed }: { elapsed: number }) { | |
| 16 | return "sitegen! update in " + elapsed.toFixed(1) + "s"; | |
| 17 | } | |
| 18 | ||
| 19 | export async function sitegen( | |
| 20 | status: Spinner, | |
| 21 | incr: Incremental, | |
| 22 | ) { | |
| 11 | 23 | const startTime = performance.now(); |
| 12 | 24 | |
| 13 | 25 | let root = path.resolve(import.meta.dirname, "../src"); |
| 14 | 26 | const join = (...sub: string[]) => path.join(root, ...sub); |
| 15 | const incr = new Incremental(); | |
| 16 | // const incr = Incremental.fromDisk(); | |
| 17 | await incr.statAllFiles(); | |
| 18 | 27 | |
| 19 | 28 | // Sitegen reviews every defined section for resources to process |
| 20 | 29 | const sections: sg.Section[] = |
| ... | ... | @@ -213,7 +222,7 @@ async function sitegen(status: Spinner) { |
| 213 | 222 | ...renderResults.flatMap((r) => r.clientRefs), |
| 214 | 223 | ...backend.views.flatMap((r) => r.clientRefs), |
| 215 | 224 | ]), |
| 216 | (script) => path.join(hot.projectSrc, script), | |
| 225 | (script) => path.resolve(hot.projectSrc, script), | |
| 217 | 226 | ); |
| 218 | 227 | const extraPublicScripts = scripts.map((entry) => entry.file); |
| 219 | 228 | const uniqueCount = new Set([ |
| ... | ... | @@ -264,12 +273,7 @@ async function sitegen(status: Spinner) { |
| 264 | 273 | head, |
| 265 | 274 | inlineCss: css.text, |
| 266 | 275 | scripts: scriptFiles.map( |
| 267 | (id) => | |
| 268 | UNWRAP( | |
| 269 | incr.out.script.get( | |
| 270 | path.basename(id).replace(/\.client\.[jt]sx?$/, ""), | |
| 271 | ), | |
| 272 | ), | |
| 276 | (file) => UNWRAP(incr.out.script.get(hot.getScriptId(file))), | |
| 273 | 277 | ).map((x) => `{${x}}`).join("\n"), |
| 274 | 278 | }); |
| 275 | 279 | await incr.putAsset({ |
| ... | ... | @@ -293,8 +297,7 @@ async function sitegen(status: Spinner) { |
| 293 | 297 | status.format = spinnerFormat; |
| 294 | 298 | status.text = `Incremental Flush`; |
| 295 | 299 | incr.flush(); // Write outputs |
| 296 | incr.toDisk(); // Allows picking up this state again | |
| 297 | return { elapsed: (performance.now() - startTime) / 1000 }; | |
| 300 | return { incr, elapsed: (performance.now() - startTime) / 1000 }; | |
| 298 | 301 | } |
| 299 | 302 | |
| 300 | 303 | function getItemText({ file }: FileItem) { |
framework/hot.ts+11-3| ... | ... | @@ -155,7 +155,7 @@ function resolveClientRef(sourcePath: string, ref: string) { |
| 155 | 155 | ) { |
| 156 | 156 | throw new Error("addScript must be a .client.ts or .client.tsx"); |
| 157 | 157 | } |
| 158 | return path.relative(projectSrc, filePath); | |
| 158 | return filePath; | |
| 159 | 159 | } |
| 160 | 160 | |
| 161 | 161 | function loadMarko(module: NodeJS.Module, filepath: string) { |
| ... | ... | @@ -171,7 +171,9 @@ function loadMarko(module: NodeJS.Module, filepath: string) { |
| 171 | 171 | const ref = JSON.parse(`"${src.slice(1, -1)}"`); |
| 172 | 172 | const resolved = resolveClientRef(filepath, ref); |
| 173 | 173 | scannedClientRefs.add(resolved); |
| 174 | return `<CloverScriptInclude=${JSON.stringify(resolved)} />`; | |
| 174 | return `<CloverScriptInclude=${ | |
| 175 | JSON.stringify(getScriptId(resolved)) | |
| 176 | } />`; | |
| 175 | 177 | }, |
| 176 | 178 | ) + '\nimport { addScript as CloverScriptInclude } from "#sitegen";\n'; |
| 177 | 179 | } |
| ... | ... | @@ -287,11 +289,17 @@ export function resolveClientRefs( |
| 287 | 289 | const ref = JSON.parse(`"${arg.slice(1, -1)}"`); |
| 288 | 290 | const resolved = resolveClientRef(filepath, ref); |
| 289 | 291 | scannedClientRefs.add(resolved); |
| 290 | return `${call}(${JSON.stringify(resolved)})`; | |
| 292 | return `${call}(${JSON.stringify(getScriptId(resolved))})`; | |
| 291 | 293 | }); |
| 292 | 294 | return { code, refs: Array.from(scannedClientRefs) }; |
| 293 | 295 | } |
| 294 | 296 | |
| 297 | export function getScriptId(file: string) { | |
| 298 | return (path.isAbsolute(file) ? path.relative(projectSrc, file) : file) | |
| 299 | .replace(/^\/?src\//, "") | |
| 300 | .replaceAll("\\", "/"); | |
| 301 | } | |
| 302 | ||
| 295 | 303 | declare global { |
| 296 | 304 | namespace NodeJS { |
| 297 | 305 | interface Module { |
framework/incremental.ts+9-6| ... | ... | @@ -136,13 +136,15 @@ export class Incremental { |
| 136 | 136 | } |
| 137 | 137 | } |
| 138 | 138 | |
| 139 | updateStat(fileKey: string, newLastModified: number) { | |
| 140 | const stat = this.invals.get(fileKey); | |
| 141 | ASSERT(stat, "Updated stat on untracked file " + fileKey); | |
| 142 | if (stat.lastModified < newLastModified) { | |
| 139 | updateStat(file: string, newLastModified: number) { | |
| 140 | file = path.relative(hot.projectRoot, file); | |
| 141 | const stat = this.invals.get(file); | |
| 142 | ASSERT(stat, "Updated stat on untracked file " + file); | |
| 143 | const hasUpdate = stat.lastModified < newLastModified; | |
| 144 | if (hasUpdate) { | |
| 143 | 145 | // Invalidate |
| 144 | console.info(fileKey + " updated"); | |
| 145 | const invalidQueue = [fileKey]; | |
| 146 | console.info(file + " updated"); | |
| 147 | const invalidQueue = [file]; | |
| 146 | 148 | let currentInvalid; |
| 147 | 149 | while (currentInvalid = invalidQueue.pop()) { |
| 148 | 150 | const invalidations = this.invals.get(currentInvalid); |
| ... | ... | @@ -160,6 +162,7 @@ export class Incremental { |
| 160 | 162 | } |
| 161 | 163 | } |
| 162 | 164 | stat.lastModified = newLastModified; |
| 165 | return hasUpdate; | |
| 163 | 166 | } |
| 164 | 167 | |
| 165 | 168 | async putAsset(info: PutAsset) { |
framework/lib/view.ts+4-1| ... | ... | @@ -9,6 +9,9 @@ export interface View { |
| 9 | 9 | scripts: Record<string, string>; |
| 10 | 10 | } |
| 11 | 11 | |
| 12 | let views: Record<string, View> = null!; | |
| 13 | let scripts: Record<string, string> = null!; | |
| 14 | ||
| 12 | 15 | // An older version of the Clover Engine supported streaming suspense |
| 13 | 16 | // boundaries, but those were never used. Pages will wait until they |
| 14 | 17 | // are fully rendered before sending. |
| ... | ... | @@ -17,7 +20,7 @@ export async function renderView( |
| 17 | 20 | id: string, |
| 18 | 21 | props: Record<string, unknown>, |
| 19 | 22 | ) { |
| 20 | const { views, scripts } = require("$views"); | |
| 23 | views ?? ({ views, scripts } = require("$views")); | |
| 21 | 24 | // The view contains pre-bundled CSS and scripts, but keeps the scripts |
| 22 | 25 | // separate for run-time dynamic scripts. For example, the file viewer |
| 23 | 26 | // includes the canvas for the current page, but only the current page. |
framework/watch.ts created+132| ... | ... | @@ -0,0 +1,132 @@ |
| 1 | // File watcher and live reloading site generator | |
| 2 | ||
| 3 | const debounceMilliseconds = 25; | |
| 4 | ||
| 5 | export async function main() { | |
| 6 | // Catch up state | |
| 7 | const { incr } = await generate.main(); | |
| 8 | ||
| 9 | // Initialize a watch | |
| 10 | const watch = new Watch(rebuild); | |
| 11 | statusLine(); | |
| 12 | ||
| 13 | function rebuild(files: string[]) { | |
| 14 | files = files.map((file) => path.relative(hot.projectRoot, file)); | |
| 15 | const changed: string[] = []; | |
| 16 | for (const file of files) { | |
| 17 | if (incr.updateStat(file, fs.statSync(file).mtimeMs)) changed.push(file); | |
| 18 | } | |
| 19 | if (changed.length === 0) { | |
| 20 | console.warn("Files were modified but the 'modify' time did not change."); | |
| 21 | return; | |
| 22 | } | |
| 23 | withSpinner<Record<string, unknown>, any>({ | |
| 24 | text: "Recovering State", | |
| 25 | successText: generate.successText, | |
| 26 | failureText: () => "sitegen FAIL", | |
| 27 | }, async (spinner) => { | |
| 28 | console.clear(); | |
| 29 | console.log( | |
| 30 | "Updated" + | |
| 31 | (changed.length === 1 | |
| 32 | ? " " + changed[0] | |
| 33 | : changed.map((file) => "\n- " + file)), | |
| 34 | ); | |
| 35 | const result = await generate.sitegen(spinner, incr); | |
| 36 | incr.toDisk(); // Allows picking up this state again | |
| 37 | return result; | |
| 38 | }).catch((err) => { | |
| 39 | console.error(util.inspect(err)); | |
| 40 | }).finally(statusLine); | |
| 41 | } | |
| 42 | ||
| 43 | function statusLine() { | |
| 44 | watch.add(...incr.invals.keys()); | |
| 45 | console.info( | |
| 46 | `Watching ${incr.invals.size} files \x1b[36m[last change: ${ | |
| 47 | new Date().toLocaleTimeString() | |
| 48 | }]\x1b[39m`, | |
| 49 | ); | |
| 50 | } | |
| 51 | } | |
| 52 | ||
| 53 | class Watch { | |
| 54 | files = new Set<string>(); | |
| 55 | stale = new Set<string>(); | |
| 56 | onChange: (files: string[]) => void; | |
| 57 | watchers: fs.FSWatcher[] = []; | |
| 58 | /** Has a trailing slash */ | |
| 59 | roots: string[] = []; | |
| 60 | debounce: ReturnType<typeof setTimeout> | null = null; | |
| 61 | ||
| 62 | constructor(onChange: Watch["onChange"]) { | |
| 63 | this.onChange = onChange; | |
| 64 | } | |
| 65 | ||
| 66 | add(...files: string[]) { | |
| 67 | const { roots, watchers } = this; | |
| 68 | let newRoots: string[] = []; | |
| 69 | for (let file of files) { | |
| 70 | file = path.resolve(file); | |
| 71 | if (this.files.has(file)) continue; | |
| 72 | this.files.add(file); | |
| 73 | // Find an existing watcher | |
| 74 | if (roots.some((root) => file.startsWith(root))) continue; | |
| 75 | if (newRoots.some((root) => file.startsWith(root))) continue; | |
| 76 | newRoots.push(path.dirname(file) + path.sep); | |
| 77 | } | |
| 78 | if (newRoots.length === 0) return; | |
| 79 | // Filter out directories that are already specified | |
| 80 | newRoots = newRoots | |
| 81 | .sort((a, b) => a.length - b.length) | |
| 82 | .filter((dir, i, a) => { | |
| 83 | for (let j = 0; j < i; j++) if (dir.startsWith(a[j])) return false; | |
| 84 | return true; | |
| 85 | }); | |
| 86 | // Append Watches | |
| 87 | let i = roots.length; | |
| 88 | for (const root of newRoots) { | |
| 89 | this.watchers.push(fs.watch( | |
| 90 | root, | |
| 91 | { recursive: true, encoding: "utf-8" }, | |
| 92 | this.#handleEvent.bind(this, root), | |
| 93 | )); | |
| 94 | this.roots.push(root); | |
| 95 | } | |
| 96 | // If any new roots shadow over and old one, delete it! | |
| 97 | while (i > 0) { | |
| 98 | i -= 1; | |
| 99 | const root = roots[i]; | |
| 100 | if (newRoots.some((newRoot) => root.startsWith(newRoot))) { | |
| 101 | watchers.splice(i, 1)[0].close(); | |
| 102 | roots.splice(i, 1); | |
| 103 | } | |
| 104 | } | |
| 105 | } | |
| 106 | ||
| 107 | stop() { | |
| 108 | for (const w of this.watchers) w.close(); | |
| 109 | } | |
| 110 | ||
| 111 | #handleEvent(root: string, event: fs.WatchEventType, subPath: string | null) { | |
| 112 | if (!subPath) return; | |
| 113 | const file = path.join(root, subPath); | |
| 114 | if (!this.files.has(file)) return; | |
| 115 | this.stale.add(file); | |
| 116 | const { debounce } = this; | |
| 117 | if (debounce !== null) clearTimeout(debounce); | |
| 118 | this.debounce = setTimeout(() => { | |
| 119 | this.debounce = null; | |
| 120 | this.onChange(Array.from(this.stale)); | |
| 121 | this.stale.clear(); | |
| 122 | }, debounceMilliseconds); | |
| 123 | } | |
| 124 | } | |
| 125 | ||
| 126 | import { Incremental } from "./incremental.ts"; | |
| 127 | import * as fs from "node:fs"; | |
| 128 | import { Spinner, withSpinner } from "@paperclover/console/Spinner"; | |
| 129 | import * as generate from "./generate.ts"; | |
| 130 | import * as path from "node:path"; | |
| 131 | import * as util from "node:util"; | |
| 132 | import * as hot from "./hot.ts"; |
src/global.css+1| ... | ... | @@ -118,3 +118,4 @@ code { |
| 118 | 118 | font-family: "rmo", monospace; |
| 119 | 119 | font-size: inherit; |
| 120 | 120 | } |
| 121 |
tsconfig.json+1| ... | ... | @@ -13,6 +13,7 @@ |
| 13 | 13 | "rootDir": ".", |
| 14 | 14 | "skipLibCheck": true, |
| 15 | 15 | "strict": true, |
| 16 | "verbaitimModuleSyntax": true, | |
| 16 | 17 | "target": "es2022" |
| 17 | 18 | } |
| 18 | 19 | } |