authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-06-10 20:06:32-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-14 02:40:44-07:00
loga176037c3c37afa39eac946515d3d5a533076674
tree34f54f82f86c0052cd9ec9822deff416d30593aa
parent73cb20561b7cc88cebc4d2be5bff899efa8d80d5
signature Commit is signed but in an unrecognized format.

add a file watcher, live rebuild.

this is only verified functional on windows 7

8 files changed, 190 insertions(+), 47 deletions(-)

framework/bundle.ts+11-19
......@@ -21,8 +21,8 @@ export async function bundleClientJavaScript(
2121 if (invalidFiles.length > 0) {
2222 const cwd = process.cwd();
2323 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"),
2626 );
2727 }
2828
......@@ -36,9 +36,9 @@ export async function bundleClientJavaScript(
3636 minify: !dev,
3737 outdir: "/out!",
3838 plugins: clientPlugins,
39 splitting: true,
4039 write: false,
4140 metafile: true,
41 external: ["node_modules/"],
4242 });
4343 if (bundle.errors.length || bundle.warnings.length) {
4444 throw new AggregateError(
......@@ -61,11 +61,12 @@ export async function bundleClientJavaScript(
6161 // Register non-chunks as script entries.
6262 const chunk = route.startsWith("/js/c.");
6363 if (!chunk) {
64 route = route.replace(".client.js", ".js");
64 const key = hot.getScriptId(sources[0]);
65 route = "/js/" + key + ".js";
6566 incr.put({
6667 sources,
6768 type: "script",
68 key: route.slice("/js/".length, -".js".length),
69 key,
6970 value: text,
7071 });
7172 }
......@@ -130,23 +131,14 @@ export async function bundleServerJavaScript(
130131 virtualFiles({
131132 "$views": viewSource,
132133 }),
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))),
140134 projectRelativeResolution(),
141135 {
142 name: "marko",
136 name: "marko via build cache",
143137 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 }));
150142 },
151143 },
152144 {
framework/generate.ts+21-18
......@@ -1,20 +1,29 @@
1export function main() {
2 return withSpinner({
1export function main(incremental?: Incremental) {
2 return withSpinner<Record<string, unknown>, any>({
33 text: "Recovering State",
4 successText: ({ elapsed }) =>
5 "sitegen! update in " + elapsed.toFixed(1) + "s",
4 successText,
65 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>;
813}
914
10async function sitegen(status: Spinner) {
15export function successText({ elapsed }: { elapsed: number }) {
16 return "sitegen! update in " + elapsed.toFixed(1) + "s";
17}
18
19export async function sitegen(
20 status: Spinner,
21 incr: Incremental,
22) {
1123 const startTime = performance.now();
1224
1325 let root = path.resolve(import.meta.dirname, "../src");
1426 const join = (...sub: string[]) => path.join(root, ...sub);
15 const incr = new Incremental();
16 // const incr = Incremental.fromDisk();
17 await incr.statAllFiles();
1827
1928 // Sitegen reviews every defined section for resources to process
2029 const sections: sg.Section[] =
......@@ -213,7 +222,7 @@ async function sitegen(status: Spinner) {
213222 ...renderResults.flatMap((r) => r.clientRefs),
214223 ...backend.views.flatMap((r) => r.clientRefs),
215224 ]),
216 (script) => path.join(hot.projectSrc, script),
225 (script) => path.resolve(hot.projectSrc, script),
217226 );
218227 const extraPublicScripts = scripts.map((entry) => entry.file);
219228 const uniqueCount = new Set([
......@@ -264,12 +273,7 @@ async function sitegen(status: Spinner) {
264273 head,
265274 inlineCss: css.text,
266275 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))),
273277 ).map((x) => `{${x}}`).join("\n"),
274278 });
275279 await incr.putAsset({
......@@ -293,8 +297,7 @@ async function sitegen(status: Spinner) {
293297 status.format = spinnerFormat;
294298 status.text = `Incremental Flush`;
295299 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 };
298301}
299302
300303function getItemText({ file }: FileItem) {
framework/hot.ts+11-3
......@@ -155,7 +155,7 @@ function resolveClientRef(sourcePath: string, ref: string) {
155155 ) {
156156 throw new Error("addScript must be a .client.ts or .client.tsx");
157157 }
158 return path.relative(projectSrc, filePath);
158 return filePath;
159159}
160160
161161function loadMarko(module: NodeJS.Module, filepath: string) {
......@@ -171,7 +171,9 @@ function loadMarko(module: NodeJS.Module, filepath: string) {
171171 const ref = JSON.parse(`"${src.slice(1, -1)}"`);
172172 const resolved = resolveClientRef(filepath, ref);
173173 scannedClientRefs.add(resolved);
174 return `<CloverScriptInclude=${JSON.stringify(resolved)} />`;
174 return `<CloverScriptInclude=${
175 JSON.stringify(getScriptId(resolved))
176 } />`;
175177 },
176178 ) + '\nimport { addScript as CloverScriptInclude } from "#sitegen";\n';
177179 }
......@@ -287,11 +289,17 @@ export function resolveClientRefs(
287289 const ref = JSON.parse(`"${arg.slice(1, -1)}"`);
288290 const resolved = resolveClientRef(filepath, ref);
289291 scannedClientRefs.add(resolved);
290 return `${call}(${JSON.stringify(resolved)})`;
292 return `${call}(${JSON.stringify(getScriptId(resolved))})`;
291293 });
292294 return { code, refs: Array.from(scannedClientRefs) };
293295}
294296
297export function getScriptId(file: string) {
298 return (path.isAbsolute(file) ? path.relative(projectSrc, file) : file)
299 .replace(/^\/?src\//, "")
300 .replaceAll("\\", "/");
301}
302
295303declare global {
296304 namespace NodeJS {
297305 interface Module {
framework/incremental.ts+9-6
......@@ -136,13 +136,15 @@ export class Incremental {
136136 }
137137 }
138138
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) {
143145 // Invalidate
144 console.info(fileKey + " updated");
145 const invalidQueue = [fileKey];
146 console.info(file + " updated");
147 const invalidQueue = [file];
146148 let currentInvalid;
147149 while (currentInvalid = invalidQueue.pop()) {
148150 const invalidations = this.invals.get(currentInvalid);
......@@ -160,6 +162,7 @@ export class Incremental {
160162 }
161163 }
162164 stat.lastModified = newLastModified;
165 return hasUpdate;
163166 }
164167
165168 async putAsset(info: PutAsset) {
framework/lib/view.ts+4-1
......@@ -9,6 +9,9 @@ export interface View {
99 scripts: Record<string, string>;
1010}
1111
12let views: Record<string, View> = null!;
13let scripts: Record<string, string> = null!;
14
1215// An older version of the Clover Engine supported streaming suspense
1316// boundaries, but those were never used. Pages will wait until they
1417// are fully rendered before sending.
......@@ -17,7 +20,7 @@ export async function renderView(
1720 id: string,
1821 props: Record<string, unknown>,
1922) {
20 const { views, scripts } = require("$views");
23 views ?? ({ views, scripts } = require("$views"));
2124 // The view contains pre-bundled CSS and scripts, but keeps the scripts
2225 // separate for run-time dynamic scripts. For example, the file viewer
2326 // 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
3const debounceMilliseconds = 25;
4
5export 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
53class 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
126import { Incremental } from "./incremental.ts";
127import * as fs from "node:fs";
128import { Spinner, withSpinner } from "@paperclover/console/Spinner";
129import * as generate from "./generate.ts";
130import * as path from "node:path";
131import * as util from "node:util";
132import * as hot from "./hot.ts";
src/global.css+1
......@@ -118,3 +118,4 @@ code {
118118 font-family: "rmo", monospace;
119119 font-size: inherit;
120120}
121
tsconfig.json+1
......@@ -13,6 +13,7 @@
1313 "rootDir": ".",
1414 "skipLibCheck": true,
1515 "strict": true,
16 "verbaitimModuleSyntax": true,
1617 "target": "es2022"
1718 }
1819}