authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-08-02 17:31:58-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-14 02:40:46-07:00
log630def77dd3df902c021bd8cc05db17578d21eda
tree509334b5abfc6cd8074d920107423e0baa921fa1
parentf723b6b33082666d0db8475cc3171333b5326275
signature Commit is signed but in an unrecognized format.

rewrite `incremental.ts` (#21)

the problems with the original implementation was mostly around error handling. sources had to be tracked manually and provided to each incremental output. the `hasArtifact` check was frequently forgotten. this has been re-abstracted through `incr.work()`, which is given an `io` object. all fs reads and module loads go through this interface, which allows the sources to be properly tracked, even if it throws. closes #12

19 files changed, 1596 insertions(+), 1503 deletions(-)

framework/bundle.ts+189-219
......@@ -1,17 +1,25 @@
1async function trackEsbuild(io: Io, metafile: esbuild.Metafile) {
2 await Promise.all(Object.keys(metafile.inputs)
3 .filter(file => !isIgnoredSource(file))
4 .map(file => io.trackFile(file)));
5}
6
17// This file implements client-side bundling, mostly wrapping esbuild.
28export async function bundleClientJavaScript(
3 referencedScripts: string[],
4 extraPublicScripts: string[],
5 incr: Incremental,
6 dev: boolean = false,
9 io: Io,
10 { clientRefs, extraPublicScripts, dev = false }: {
11 clientRefs: string[];
12 extraPublicScripts: string[];
13 dev: boolean;
14 },
715) {
816 const entryPoints = [
917 ...new Set([
10 ...referencedScripts.map((file) => path.resolve(hot.projectSrc, file)),
18 ...clientRefs.map((x) => `src/${x}`),
1119 ...extraPublicScripts,
12 ]),
20 ].map(toAbs)),
1321 ];
14 if (entryPoints.length === 0) return;
22 if (entryPoints.length === 0) return {};
1523 const invalidFiles = entryPoints
1624 .filter((file) => !file.match(/\.client\.[tj]sx?/));
1725 if (invalidFiles.length > 0) {
......@@ -24,7 +32,7 @@ export async function bundleClientJavaScript(
2432
2533 const clientPlugins: esbuild.Plugin[] = [
2634 projectRelativeResolution(),
27 markoViaBuildCache(incr),
35 markoViaBuildCache(),
2836 ];
2937
3038 const bundle = await esbuild.build({
......@@ -65,256 +73,218 @@ export async function bundleClientJavaScript(
6573 )
6674 );
6775 const { metafile, outputFiles } = bundle;
68 const promises: Promise<void>[] = [];
76 const p = []
77 p.push(trackEsbuild(io, metafile));
78 const scripts: Record<string, string> = {};
6979 for (const file of outputFiles) {
7080 const { text } = file;
7181 let route = file.path.replace(/^.*!/, "").replaceAll("\\", "/");
7282 const { inputs } = UNWRAP(metafile.outputs["out!" + route]);
73 const sources = Object.keys(inputs)
74 .filter((x) => !x.startsWith("<define:"));
83 const sources = Object.keys(inputs).filter((x) => !isIgnoredSource(x));
7584
7685 // Register non-chunks as script entries.
7786 const chunk = route.startsWith("/js/c.");
7887 if (!chunk) {
7988 const key = hot.getScriptId(path.resolve(sources[sources.length - 1]));
8089 route = "/js/" + key.replace(/\.client\.tsx?/, ".js");
81 incr.put({
82 sources,
83 kind: "script",
84 key,
85 value: text,
86 });
90 scripts[key] = text;
8791 }
8892 // Register chunks and public scripts as assets.
8993 if (chunk || publicScriptRoutes.includes(route)) {
90 promises.push(incr.putAsset({
91 sources,
92 key: route,
93 body: text,
94 }));
94 p.push(io.writeAsset(route, text));
9595 }
9696 }
97 await Promise.all(promises);
97 await Promise.all(p);
98 return scripts;
9899}
99100
100101export type ServerPlatform = "node" | "passthru";
102export interface ServerSideOptions {
103 entries: string[],
104 viewItems: sg.FileItem[]
105 viewRefs: incr.Ref<PreparedView>[],
106 styleMap: Map<string, incr.Ref<string>>;
107 scriptMap: incr.Ref<Record<string, string>>;
108 platform: ServerPlatform,
109}
101110export async function bundleServerJavaScript(
102 incr: Incremental,
103 platform: ServerPlatform = "node",
111 { viewItems, viewRefs, styleMap, scriptMap: wScriptMap, entries, platform }: ServerSideOptions
104112) {
105 if (incr.hasArtifact("backendBundle", platform)) return;
106
107 // Comment
108 const magicWord = "C_" + crypto.randomUUID().replaceAll("-", "_");
113 const wViewSource = incr.work(async (_, viewItems: sg.FileItem[]) => {
114 const magicWord = "C_" + crypto.randomUUID().replaceAll("-", "_");
115 return {
116 magicWord,
117 file: [
118 ...viewItems.map((view, i) => `import * as view${i} from ${JSON.stringify(view.file)}`),
119 `const styles = ${magicWord}[-2]`,
120 `export const scripts = ${magicWord}[-1]`,
121 "export const views = {",
122 ...viewItems.map((view, i) => [
123 ` ${JSON.stringify(view.id)}: {`,
124 ` component: view${i}.default,`,
125 ` meta: view${i}.meta,`,
126 ` layout: view${i}.layout?.default ?? null,`,
127 ` inlineCss: styles[${magicWord}[${i}]]`,
128 ` },`,
129 ].join("\n")),
130 "}",
131 ].join("\n")
132 };
133 }, viewItems)
109134
110 const viewSource = [
111 ...Array.from(
112 incr.out.viewMetadata,
113 ([, view], i) => `import * as view${i} from ${JSON.stringify(view.file)}`,
114 ),
115 `const styles = ${magicWord}[-2]`,
116 `export const scripts = ${magicWord}[-1]`,
117 "export const views = {",
118 ...Array.from(incr.out.viewMetadata, ([key, view], i) =>
119 [
120 ` ${JSON.stringify(key)}: {`,
121 ` component: view${i}.default,`,
122 // ` meta: ${
123 // view.staticMeta ? JSON.stringify(view.staticMeta) : `view${i}.meta`
124 // },`,
125 ` meta: view${i}.meta,`,
126 ` layout: ${view.hasLayout ? `view${i}.layout?.default` : "null"},`,
127 ` inlineCss: styles[${magicWord}[${i}]]`,
128 ` },`,
129 ].join("\n")),
130 "}",
131 ].join("\n");
135 const wBundles = entries.map(entry => [entry, incr.work(async (io, entry) => {
136 const pkg = await io.readJson<{ dependencies: Record<string, string>; }>("package.json");
132137
133 // -- plugins --
134 const serverPlugins: esbuild.Plugin[] = [
135 virtualFiles({
136 "$views": viewSource,
137 }),
138 projectRelativeResolution(),
139 markoViaBuildCache(incr),
140 {
141 name: "replace client references",
142 setup(b) {
143 b.onLoad({ filter: /\.tsx?$/ }, async ({ path: file }) => ({
144 contents:
145 hot.resolveClientRefs(await fs.readFile(file, "utf-8"), file).code,
146 loader: path.extname(file).slice(1) as esbuild.Loader,
147 }));
138 let magicWord = null as string | null;
139 // -- plugins --
140 const serverPlugins: esbuild.Plugin[] = [
141 virtualFiles({
142 // only add dependency when imported.
143 "$views": async () => {
144 const view = await io.readWork(wViewSource);
145 ({ magicWord } = view);
146 return view.file;
147 },
148 }),
149 projectRelativeResolution(),
150 markoViaBuildCache(),
151 {
152 name: "replace client references",
153 setup(b) {
154 b.onLoad({ filter: /\.tsx?$/ }, async ({ path: file }) => ({
155 contents:
156 hot.resolveClientRefs(await fs.readFile(file, "utf-8"), file).code,
157 loader: path.extname(file).slice(1) as esbuild.Loader,
158 }));
159 },
148160 },
149 },
150 {
151 name: "mark css external",
152 setup(b) {
153 b.onResolve(
154 { filter: /\.css$/ },
155 () => ({ path: ".", namespace: "dropped" }),
156 );
157 b.onLoad(
158 { filter: /./, namespace: "dropped" },
159 () => ({ contents: "" }),
160 );
161 {
162 name: "mark css external",
163 setup(b) {
164 b.onResolve(
165 { filter: /\.css$/ },
166 () => ({ path: ".", namespace: "dropped" }),
167 );
168 b.onLoad(
169 { filter: /./, namespace: "dropped" },
170 () => ({ contents: "" }),
171 );
172 },
161173 },
162 },
163 ];
164 const pkg = await fs.readJson("package.json") as {
165 dependencies: Record<string, string>;
166 };
167 const { metafile, outputFiles } = await esbuild.build({
168 bundle: true,
169 chunkNames: "c.[hash]",
170 entryNames: "server",
171 entryPoints: [
172 path.join(import.meta.dirname, "backend/entry-" + platform + ".ts"),
173 ],
174 platform: "node",
175 format: "esm",
176 minify: false,
177 outdir: "out!",
178 plugins: serverPlugins,
179 splitting: true,
180 logLevel: "silent",
181 write: false,
182 metafile: true,
183 jsx: "automatic",
184 jsxImportSource: "#ssr",
185 jsxDev: false,
186 define: {
187 MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText),
188 },
189 external: Object.keys(pkg.dependencies)
190 .filter((x) => !x.startsWith("@paperclover")),
191 });
174 ];
192175
193 const files: Record<string, Buffer> = {};
194 let fileWithMagicWord: string | null = null;
195 for (const output of outputFiles) {
196 const basename = output.path.replace(/^.*?!/, "");
197 const key = "out!" + basename.replaceAll("\\", "/");
198 // If this contains the generated "$views" file, then
199 // mark this file as the one for replacement. Because
200 // `splitting` is `true`, esbuild will not emit this
201 // file in more than one chunk.
202 if (metafile.outputs[key].inputs["framework/lib/view.ts"]) {
203 fileWithMagicWord = basename;
204 }
205 files[basename] = Buffer.from(output.contents);
206 }
207 incr.put({
208 kind: "backendBundle",
209 key: platform,
210 value: {
211 magicWord,
212 files,
213 fileWithMagicWord,
214 },
215 sources: Object.keys(metafile.inputs).filter((x) =>
216 !x.includes("<define:") &&
217 !x.startsWith("vfs:") &&
218 !x.startsWith("dropped:") &&
219 !x.includes("node_modules")
220 ),
221 });
222}
223
224export async function finalizeServerJavaScript(
225 incr: Incremental,
226 platform: ServerPlatform,
227) {
228 if (incr.hasArtifact("backendReplace", platform)) return;
229 const {
230 files,
231 fileWithMagicWord,
232 magicWord,
233 } = UNWRAP(incr.getArtifact("backendBundle", platform));
234
235 if (!fileWithMagicWord) return;
176 const { metafile, outputFiles, errors, warnings } = await esbuild.build({
177 bundle: true,
178 chunkNames: "c.[hash]",
179 entryNames: path.basename(entry, path.extname(entry)),
180 entryPoints: [
181 path.join(import.meta.dirname, "backend/entry-" + platform + ".ts"),
182 ],
183 platform: "node",
184 format: "esm",
185 minify: false,
186 outdir: "out!",
187 plugins: serverPlugins,
188 splitting: true,
189 logLevel: "silent",
190 write: false,
191 metafile: true,
192 jsx: "automatic",
193 jsxImportSource: "#ssr",
194 jsxDev: false,
195 define: {
196 MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText),
197 'globalThis.CLOVER_SERVER_ENTRY': JSON.stringify(entry),
198 },
199 external: Object.keys(pkg.dependencies)
200 .filter((x) => !x.startsWith("@paperclover")),
201 });
202 await trackEsbuild(io, metafile)
236203
237 // Only the reachable resources need to be inserted into the bundle.
238 const viewScriptsList = new Set(
239 Array.from(incr.out.viewMetadata.values())
240 .flatMap((view) => view.clientRefs),
241 );
242 const viewStyleKeys = Array.from(incr.out.viewMetadata.values())
243 .map((view) => css.styleKey(view.cssImports, view.theme));
244 const viewCssBundles = viewStyleKeys
245 .map((key) => UNWRAP(incr.out.style.get(key), "Style key: " + key));
204 let fileWithMagicWord: {
205 bytes: Buffer;
206 basename: string;
207 magicWord: string;
208 } | null = null;
209 for (const output of outputFiles) {
210 const basename = output.path.replace(/^.*?!(?:\/|\\)/, "");
211 const key = "out!/" + basename.replaceAll("\\", "/");
212 // If this contains the generated "$views" file, then
213 // mark this file as the one for replacement. Because
214 // `splitting` is `true`, esbuild will not emit this
215 // file in more than one chunk.
216 if (magicWord && metafile.outputs[key].inputs["framework/lib/view.ts"]) {
217 ASSERT(!fileWithMagicWord);
218 fileWithMagicWord = {
219 basename,
220 bytes: Buffer.from(output.contents),
221 magicWord,
222 };
223 } else {
224 io.writeFile(basename, Buffer.from(output.contents))
225 }
226 }
227 return fileWithMagicWord;
228 }, entry)] as const);
246229
247 // Deduplicate styles
248 const styleList = Array.from(new Set(viewCssBundles));
230 const wProcessed = wBundles.map(async([entry, wBundle]) => {
231 if (!await wBundle) return;
232 await incr.work(async (io) => {
233 // Only the reachable resources need to be read and inserted into the bundle.
234 // This is what Map<string, incr.Ref> is for
235 const { basename, bytes, magicWord } = UNWRAP(await io.readWork(wBundle));
236 const views = await Promise.all(viewRefs.map(ref => io.readWork(ref)));
249237
250 // Replace the magic word
251 let text = files[fileWithMagicWord].toString("utf-8");
252 text = text.replace(
253 new RegExp(magicWord + "\\[(-?\\d+)\\]", "gs"),
254 (_, i) => {
255 i = Number(i);
256 // Inline the styling data
257 if (i === -2) {
258 return JSON.stringify(styleList.map((cssText) => cssText));
259 }
260 // Inline the script data
261 if (i === -1) {
262 return JSON.stringify(Object.fromEntries(incr.out.script));
263 }
264 // Reference an index into `styleList`
265 return `${styleList.indexOf(viewCssBundles[i])}`;
266 },
267 );
238 // Client JS
239 const scriptList = Object.entries(await io.readWork(wScriptMap));
240 const viewScriptsList = new Set(views.flatMap(view => view.clientRefs));
241 const neededScripts = scriptList.filter(([k]) => viewScriptsList.has(k));
268242
269 incr.put({
270 kind: "backendReplace",
271 key: platform,
272 sources: [
273 // Backend input code (includes view code)
274 ...incr.sourcesFor("backendBundle", platform),
275 // Script
276 ...Array.from(viewScriptsList)
277 .flatMap((key) => incr.sourcesFor("script", hot.getScriptId(key))),
278 // Style
279 ...viewStyleKeys.flatMap((key) => incr.sourcesFor("style", key)),
280 ],
281 value: Buffer.from(text),
282 });
283}
243 // CSS
244 const viewStyleKeys = views.map((view) => view.styleKey);
245 const viewCssBundles = await Promise.all(
246 viewStyleKeys.map((key) => io.readWork(UNWRAP(styleMap.get(key), "Style key: " + key))));
247 const styleList = Array.from(new Set(viewCssBundles));
284248
285function markoViaBuildCache(incr: Incremental): esbuild.Plugin {
286 return {
287 name: "marko via build cache",
288 setup(b) {
289 b.onLoad(
290 { filter: /\.marko$/ },
291 async ({ path: file }) => {
292 const key = path.relative(hot.projectRoot, file)
293 .replaceAll("\\", "/");
294 const cacheEntry = incr.out.serverMarko.get(key);
295 if (!cacheEntry) {
296 if (!fs.existsSync(file)) {
297 console.log(`File does not exist: ${file}`);
298 }
299 throw new Error("Marko file not in cache: " + file);
249 // Replace the magic word
250 const text = bytes.toString("utf-8").replace(
251 new RegExp(magicWord + "\\[(-?\\d+)\\]", "gs"),
252 (_, i) => {
253 i = Number(i);
254 // Inline the styling data
255 if (i === -2) {
256 return JSON.stringify(styleList.map((cssText) => cssText));
257 }
258 // Inline the script data
259 if (i === -1) {
260 return JSON.stringify(Object.fromEntries(neededScripts));
300261 }
301 return ({
302 loader: "ts",
303 contents: cacheEntry.src,
304 resolveDir: path.dirname(file),
305 });
262 // Reference an index into `styleList`
263 return `${styleList.indexOf(viewCssBundles[i])}`;
306264 },
307265 );
308 },
309 };
266
267 io.writeFile(basename, text);
268 });
269 })
270
271 await Promise.all(wProcessed);
310272}
311273
274
312275import * as esbuild from "esbuild";
313276import * as path from "node:path";
314277import process from "node:process";
315278import * as hot from "./hot.ts";
316import { projectRelativeResolution, virtualFiles } from "./esbuild-support.ts";
317import { Incremental } from "./incremental.ts";
279import {
280 isIgnoredSource,
281 markoViaBuildCache,
282 projectRelativeResolution,
283 virtualFiles,
284} from "./esbuild-support.ts";
285import { Io, toAbs, toRel } from "./incremental.ts";
318286import * as css from "./css.ts";
319287import * as fs from "#sitegen/fs";
320288import * as mime from "#sitegen/mime";
289import * as incr from "./incremental.ts";
290import * as sg from "#sitegen";import type { PreparedView } from "./generate2.ts";import { meta } from "@/file-viewer/pages/file.cotyledon_speedbump.tsx";
framework/css.ts+10-16
......@@ -40,11 +40,6 @@ export function preprocess(css: string, theme: Theme): string {
4040 );
4141}
4242
43export interface Output {
44 text: string;
45 sources: string[];
46}
47
4843export function styleKey(
4944 cssImports: string[],
5045 theme: Theme,
......@@ -60,11 +55,14 @@ export function styleKey(
6055}
6156
6257export async function bundleCssFiles(
63 cssImports: string[],
64 theme: Theme,
65 dev: boolean = false,
66): Promise<Output> {
67 cssImports = cssImports.map((file) => path.resolve(hot.projectSrc, file));
58 io: Io,
59 { cssImports, theme, dev }: {
60 cssImports: string[],
61 theme: Theme,
62 dev: boolean,
63 }
64) {
65 cssImports = await Promise.all(cssImports.map((file) => io.trackFile('src/' + file)));
6866 const plugin = {
6967 name: "clover css",
7068 setup(b) {
......@@ -106,15 +104,11 @@ export async function bundleCssFiles(
106104 throw new AggregateError(warnings, "CSS Build Failed");
107105 }
108106 if (outputFiles.length > 1) throw new Error("Too many output files");
109 return {
110 text: outputFiles[0].text,
111 sources: Object.keys(metafile.outputs["$input$.css"].inputs)
112 .filter((x) => !x.startsWith("vfs:")),
113 };
107 return outputFiles[0].text;
114108}
115109
116110import * as esbuild from "esbuild";
117111import * as fs from "#sitegen/fs";
118112import * as hot from "./hot.ts";
119113import * as path from "node:path";
120import { virtualFiles } from "./esbuild-support.ts";
114import { virtualFiles } from "./esbuild-support.ts";import type { Io } from "./incremental.ts";
framework/debug.safe.ts+2-7
......@@ -6,12 +6,7 @@ globalThis.UNWRAP = (t, ...args) => {
66 }
77 return t;
88};
9globalThis.ASSERT = (t, ...args) => {
10 if (!t) {
11 throw new Error(
12 args.length > 0 ? util.format(...args) : "Assertion Failed",
13 );
14 }
15};
9globalThis.ASSERT = assert.ok;
1610
1711import * as util from "node:util";
12import * as assert from 'node:assert'
framework/definitions.d.ts+1-1
......@@ -1,4 +1,4 @@
11declare function UNWRAP<T>(value: T | null | undefined, ...log: unknown[]): T;
2declare function ASSERT(value: unknown, ...log: unknown[]): asserts value;
2declare function ASSERT(value: unknown, message?: string): asserts value;
33
44type Timer = ReturnType<typeof setTimeout>;
framework/engine/ssr.ts+3-1
......@@ -13,6 +13,7 @@ export function ssrSync<A extends Addons>(node: Node, addon: A = {} as A) {
1313 const resolved = resolveNode(r, node);
1414 return { text: renderNode(resolved), addon };
1515}
16export { ssrSync as sync };
1617
1718export function ssrAsync<A extends Addons>(node: Node, addon: A = {} as A) {
1819 const r = initRender(true, addon);
......@@ -20,7 +21,7 @@ export function ssrAsync<A extends Addons>(node: Node, addon: A = {} as A) {
2021 if (r.async === 0) {
2122 return Promise.resolve({ text: renderNode(resolved), addon });
2223 }
23 const { resolve, reject, promise } = Promise.withResolvers<Result>();
24 const { resolve, reject, promise } = Promise.withResolvers<Result<A>>();
2425 r.asyncDone = () => {
2526 const rejections = r.rejections;
2627 if (!rejections) return resolve({ text: renderNode(resolved), addon });
......@@ -29,6 +30,7 @@ export function ssrAsync<A extends Addons>(node: Node, addon: A = {} as A) {
2930 };
3031 return promise;
3132}
33export { ssrAsync as async };
3234
3335/** Inline HTML into a render without escaping it */
3436export function html(rawText: ResolvedNode): DirectHtml {
framework/esbuild-support.ts+42-4
......@@ -1,5 +1,7 @@
1type Awaitable<T> = T | Promise<T>;
2
13export function virtualFiles(
2 map: Record<string, string | esbuild.OnLoadResult>,
4 map: Record<string, string | esbuild.OnLoadResult | (() => Awaitable<string | esbuild.OnLoadResult>)>,
35) {
46 return {
57 name: "clover vfs",
......@@ -18,8 +20,9 @@ export function virtualFiles(
1820 );
1921 b.onLoad(
2022 { filter: /./, namespace: "vfs" },
21 ({ path }) => {
22 const entry = map[path];
23 async ({ path }) => {
24 let entry = map[path];
25 if (typeof entry === 'function') entry = await entry();
2326 return ({
2427 resolveDir: ".",
2528 loader: "ts",
......@@ -73,7 +76,42 @@ export function projectRelativeResolution(root = process.cwd() + "/src") {
7376 } satisfies esbuild.Plugin;
7477}
7578
79export function markoViaBuildCache(): esbuild.Plugin {
80 return {
81 name: "marko via build cache",
82 setup(b) {
83 b.onLoad(
84 { filter: /\.marko$/ },
85 async ({ path: file }) => {
86 const cacheEntry = markoCache.get(file);
87 if (!cacheEntry) {
88 if (!fs.existsSync(file)) {
89 console.warn(`File does not exist: ${file}`);
90 }
91 console.log(markoCache.keys());
92 throw new Error("Marko file not in cache: " + file);
93 }
94 return ({
95 loader: "ts",
96 contents: cacheEntry.src,
97 resolveDir: path.dirname(file),
98 });
99 },
100 );
101 },
102 };
103}
104
105export function isIgnoredSource(source: string) {
106 return source.includes("<define:") ||
107 source.startsWith("vfs:") ||
108 source.startsWith("dropped:") ||
109 source.includes("node_modules")
110}
111
76112import * as esbuild from "esbuild";
77113import * as string from "#sitegen/string";
78114import * as path from "node:path";
79import * as hot from "./hot.ts";
115import * as fs from "#sitegen/fs";
116import * as incr from "./incremental.ts";
117import * as hot from "./hot.ts";import { markoCache } from "./marko.ts";
framework/generate.ts+280-399
......@@ -1,51 +1,99 @@
11// This file contains the main site generator build process.
2// By using `Incremental`'s ability to automatically purge stale
3// assets, the `sitegen` function performs partial rebuilds.
2// By using `incr.work`'s ability to cache work between runs,
3// the site generator is very fast to re-run.
4//
5// See `watch.ts` for a live development environment.
6const { toRel, toAbs } = incr;
7const globalCssPath = toAbs("src/global.css");
48
5export function main() {
6 return withSpinner<Record<string, unknown>, any>({
7 text: "Recovering State",
8 successText,
9 failureText: () => "sitegen FAIL",
10 }, async (spinner) => {
11 // const incr = Incremental.fromDisk();
12 // await incr.statAllFiles();
13 const incr = new Incremental();
14 const result = await sitegen(spinner, incr);
15 incr.toDisk(); // Allows picking up this state again
16 return result;
17 }) as ReturnType<typeof sitegen>;
9export async function main() {
10 await incr.restore();
11 await incr.compile(generate);
1812}
1913
20export function successText({
21 elapsed,
22 inserted,
23 referenced,
24 unreferenced,
25}: Awaited<ReturnType<typeof sitegen>>) {
26 const s = (array: unknown[]) => array.length === 1 ? "" : "s";
27 const kind = inserted.length === referenced.length ? "build" : "update";
28 const status = inserted.length > 0
29 ? `${kind} ${inserted.length} key${s(inserted)}`
30 : unreferenced.length > 0
31 ? `pruned ${unreferenced.length} key${s(unreferenced)}`
32 : `checked ${referenced.length} key${s(referenced)}`;
33 return `sitegen! ${status} in ${elapsed.toFixed(1)}s`;
34}
14export async function generate() {
15 // -- read config and discover files --
16 const siteConfig = await incr.work(readManifest);
17 const {
18 staticFiles,
19 scripts,
20 views,
21 pages,
22 } = await discoverAllFiles(siteConfig);
3523
36export async function sitegen(
37 status: Spinner,
38 incr: Incremental,
39) {
40 const startTime = performance.now();
24 // TODO: make sure that `static` and `pages` does not overlap
25
26 // TODO: loadMarkoCache
27
28 // -- perform build-time rendering --
29 const builtPages = pages.map((item) => incr.work(preparePage, item));
30 const builtViews = views.map((item) => incr.work(prepareView, item));
31 const builtStaticFiles = Promise.all((staticFiles.map((item) =>
32 incr.work(
33 async (io, { id, file }) => void await io.writeAsset(id, await io.readFile(file)),
34 item,
35 )
36 )));
37 const routes = await Promise.all([...builtViews, ...builtPages]);
38
39 // -- page resources --
40 const scriptMap = incr.work(bundle.bundleClientJavaScript, {
41 clientRefs: routes.flatMap((x) => x.clientRefs),
42 extraPublicScripts: scripts.map((entry) => entry.file),
43 dev: false,
44 });
45 const styleMap = prepareInlineCss(routes);
46
47 // -- backend --
48 const builtBackend = bundle.bundleServerJavaScript({
49 entries: siteConfig.backends,
50 platform: 'node',
51 styleMap,
52 scriptMap,
53 viewItems: views,
54 viewRefs: builtViews,
55 })
56
57 // -- assemble page assets --
58 const pAssemblePages = builtPages.map((page) =>
59 assembleAndWritePage(page, styleMap, scriptMap)
60 );
61
62 await Promise.all([
63 builtBackend,
64 builtStaticFiles,
65 ...pAssemblePages,
66 ]);
67}
4168
42 let root = path.resolve(import.meta.dirname, "../src");
43 const join = (...sub: string[]) => path.join(root, ...sub);
69export async function readManifest(io: Io) {
70 const cfg = await io.import<typeof import("../src/site.ts")>("src/site.ts");
71 return {
72 siteSections: cfg.siteSections.map((section) => ({
73 root: toRel(section.root),
74 })),
75 backends: cfg.backends.map(toRel),
76 };
77}
4478
45 // Sitegen reviews every defined section for resources to process
46 const sections: sg.Section[] =
47 require(path.join(root, "site.ts")).siteSections;
79export async function discoverAllFiles(
80 siteConfig: Awaited<ReturnType<typeof readManifest>>,
81) {
82 return (
83 await Promise.all(
84 siteConfig.siteSections.map(({ root: sectionRoot }) =>
85 incr.work(scanSiteSection, toAbs(sectionRoot))
86 ),
87 )
88 ).reduce((acc, next) => ({
89 staticFiles: acc.staticFiles.concat(next.staticFiles),
90 pages: acc.pages.concat(next.pages),
91 views: acc.views.concat(next.views),
92 scripts: acc.scripts.concat(next.scripts),
93 }));
94}
4895
96export async function scanSiteSection(io: Io, sectionRoot: string) {
4997 // Static files are compressed and served as-is.
5098 // - "{section}/static/*.png"
5199 let staticFiles: FileItem[] = [];
......@@ -61,394 +109,227 @@ export async function sitegen(
61109 // - "{section}/scripts/*.client.ts"
62110 let scripts: FileItem[] = [];
63111
64 // -- Scan for files --
65 status.text = "Scanning Project";
66 for (const section of sections) {
67 const { root: sectionRoot } = section;
68 const sectionPath = (...sub: string[]) => path.join(sectionRoot, ...sub);
69 const rootPrefix = root === sectionRoot
70 ? ""
71 : path.relative(root, sectionRoot) + "/";
72 const kinds = [
73 {
74 dir: sectionPath("pages"),
75 list: pages,
76 prefix: "/",
77 include: [".tsx", ".mdx", ".marko"],
78 exclude: [".client.ts", ".client.tsx"],
79 },
80 {
81 dir: sectionPath("static"),
82 list: staticFiles,
83 prefix: "/",
84 ext: true,
85 },
86 {
87 dir: sectionPath("scripts"),
88 list: scripts,
89 prefix: rootPrefix,
90 include: [".client.ts", ".client.tsx"],
91 },
92 {
93 dir: sectionPath("views"),
94 list: views,
95 prefix: rootPrefix,
96 include: [".tsx", ".mdx", ".marko"],
97 exclude: [".client.ts", ".client.tsx"],
98 },
99 ];
100 for (
101 const { dir, list, prefix, include = [""], exclude = [], ext = false }
102 of kinds
103 ) {
104 const items = fs.readDirRecOptionalSync(dir);
105 for (const subPath of items) {
106 const file = path.join(dir, subPath);
107 const stat = fs.statSync(file);
108 if (stat.isDirectory()) continue;
109 if (!include.some((e) => subPath.endsWith(e))) continue;
110 if (exclude.some((e) => subPath.endsWith(e))) continue;
111 const trim = ext
112 ? subPath
113 : subPath.slice(0, -path.extname(subPath).length).replaceAll(
114 ".",
115 "/",
116 );
117 let id = prefix + trim.replaceAll("\\", "/");
118 if (prefix === "/" && id.endsWith("/index")) {
119 id = id.slice(0, -"/index".length) || "/";
120 }
121 list.push({ id, file: file });
122 }
123 }
124 }
125 const globalCssPath = join("global.css");
126
127 // TODO: make sure that `static` and `pages` does not overlap
128
129 // -- inline style sheets, used and shared by pages and views --
130 status.text = "Building";
131 const cssOnce = new OnceMap();
132 const cssQueue = new Queue({
133 name: "Bundle",
134 async fn([, key, files, theme]: [string, string, string[], css.Theme]) {
135 const { text, sources } = await css.bundleCssFiles(files, theme);
136 incr.put({
137 kind: "style",
138 key,
139 sources,
140 value: text,
141 });
112 const sectionPath = (...sub: string[]) => path.join(sectionRoot, ...sub);
113 const rootPrefix = hot.projectSrc === sectionRoot
114 ? ""
115 : path.relative(hot.projectSrc, sectionRoot) + "/";
116 const kinds = [
117 {
118 dir: sectionPath("pages"),
119 list: pages,
120 prefix: "/",
121 include: [".tsx", ".mdx", ".marko"],
122 exclude: [".client.ts", ".client.tsx"],
142123 },
143 passive: true,
144 getItemText: ([id]) => id,
145 maxJobs: 2,
146 });
147 function ensureCssGetsBuilt(
148 cssImports: string[],
149 theme: css.Theme,
150 referrer: string,
151 ) {
152 const key = css.styleKey(cssImports, theme);
153 cssOnce.get(
154 key,
155 async () => {
156 incr.getArtifact("style", key) ??
157 await cssQueue.add([referrer, key, cssImports, theme]);
158 },
159 );
160 }
124 {
125 dir: sectionPath("static"),
126 list: staticFiles,
127 prefix: "/",
128 ext: true,
129 },
130 {
131 dir: sectionPath("scripts"),
132 list: scripts,
133 prefix: rootPrefix,
134 include: [".client.ts", ".client.tsx"],
135 },
136 {
137 dir: sectionPath("views"),
138 list: views,
139 prefix: rootPrefix,
140 include: [".tsx", ".mdx", ".marko"],
141 exclude: [".client.ts", ".client.tsx"],
142 },
143 ];
144 for (const kind of kinds) {
145 const {
146 dir,
147 list,
148 prefix,
149 include = [""],
150 exclude = [],
151 ext = false,
152 } = kind;
161153
162 // -- server side render pages --
163 async function loadPageModule({ file }: FileItem) {
164 require(file);
165 }
166 async function renderPage(item: FileItem) {
167 // -- load and validate module --
168 let {
169 default: Page,
170 meta: metadata,
171 theme: pageTheme,
172 layout,
173 } = require(item.file);
174 if (!Page) {
175 throw new Error("Page is missing a 'default' export.");
154 let items;
155 try {
156 items = await io.readDirRecursive(dir);
157 } catch (err: any) {
158 if (err.code === "ENOENT") continue;
159 throw err;
176160 }
177 if (!metadata) {
178 throw new Error("Page is missing 'meta' export with a title.");
161 for (const subPath of items) {
162 const file = path.join(dir, subPath);
163 const stat = fs.statSync(file);
164 if (stat.isDirectory()) continue;
165 if (!include.some((e) => subPath.endsWith(e))) continue;
166 if (exclude.some((e) => subPath.endsWith(e))) continue;
167 const trim = ext
168 ? subPath
169 : subPath.slice(0, -path.extname(subPath).length).replaceAll(".", "/");
170 let id = prefix + trim.replaceAll("\\", "/");
171 if (prefix === "/" && id.endsWith("/index")) {
172 id = id.slice(0, -"/index".length) || "/";
173 }
174 list.push({ id, file: path.relative(hot.projectRoot, file) });
179175 }
176 }
180177
181 // -- css --
182 if (layout?.theme) pageTheme = layout.theme;
183 const theme: css.Theme = {
184 ...css.defaultTheme,
185 ...pageTheme,
186 };
187 const cssImports = Array.from(
188 new Set([globalCssPath, ...hot.getCssImports(item.file)]),
189 (file) => path.relative(hot.projectSrc, file),
190 );
191 ensureCssGetsBuilt(cssImports, theme, item.id);
192
193 // -- metadata --
194 const renderedMetaPromise = Promise.resolve(
195 typeof metadata === "function" ? metadata({ ssr: true }) : metadata,
196 ).then((m) => meta.renderMeta(m));
197
198 // -- html --
199 let page = [engine.kElement, Page, {}];
200 if (layout?.default) {
201 page = [engine.kElement, layout.default, { children: page }];
202 }
203 const bodyPromise = engine.ssrAsync(page, {
204 sitegen: sg.initRender(),
205 });
178 return { staticFiles, pages, views, scripts };
179}
206180
207 const [{ text, addon }, renderedMeta] = await Promise.all([
208 bodyPromise,
209 renderedMetaPromise,
210 ]);
211 if (!renderedMeta.includes("<title>")) {
212 throw new Error(
213 "Page is missing 'meta.title'. " +
214 "All pages need a title tag.",
215 );
216 }
217 incr.put({
218 kind: "pageMetadata",
219 key: item.id,
220 // Incremental integrates with `hot.ts` + `require`
221 // to trace all the needed source files here.
222 sources: [item.file],
223 value: {
224 html: text,
225 meta: renderedMeta,
226 cssImports,
227 theme: theme ?? null,
228 clientRefs: Array.from(addon.sitegen.scripts),
229 },
230 });
181export async function preparePage(io: Io, item: sg.FileItem) {
182 // -- load and validate module --
183 let {
184 default: Page,
185 meta: metadata,
186 theme: pageTheme,
187 layout,
188 } = await io.import<any>(item.file);
189 if (!Page) {
190 throw new Error("Page is missing a 'default' export.");
231191 }
232 async function prepareView(item: FileItem) {
233 const module = require(item.file);
234 if (!module.meta) {
235 throw new Error(`${item.file} is missing 'export const meta'`);
236 }
237 if (!module.default) {
238 throw new Error(`${item.file} is missing a default export.`);
239 }
240 const pageTheme = module.layout?.theme ?? module.theme;
241 const theme: css.Theme = {
242 ...css.defaultTheme,
243 ...pageTheme,
244 };
245 const cssImports = Array.from(
246 new Set([globalCssPath, ...hot.getCssImports(item.file)]),
247 (file) => path.relative(hot.projectSrc, file),
248 );
249 ensureCssGetsBuilt(cssImports, theme, item.id);
250 incr.put({
251 kind: "viewMetadata",
252 key: item.id,
253 sources: [item.file],
254 value: {
255 file: path.relative(hot.projectRoot, item.file),
256 cssImports,
257 theme,
258 clientRefs: hot.getClientScriptRefs(item.file),
259 hasLayout: !!module.layout?.default,
260 },
261 });
192 if (!metadata) {
193 throw new Error("Page is missing 'meta' export with a title.");
262194 }
263195
264 // Of the pages that are already built, a call to 'ensureCssGetsBuilt' is
265 // required so that it's (1) re-built if needed, (2) not pruned from build.
266 const neededPages = pages.filter((page) => {
267 const existing = incr.getArtifact("pageMetadata", page.id);
268 if (existing) {
269 const { cssImports, theme } = existing;
270 ensureCssGetsBuilt(cssImports, theme, page.id);
271 }
272 return !existing;
273 });
274 const neededViews = views.filter((view) => {
275 const existing = incr.getArtifact("viewMetadata", view.id);
276 if (existing) {
277 const { cssImports, theme } = existing;
278 ensureCssGetsBuilt(cssImports, theme, view.id);
279 }
280 return !existing;
281 });
196 // -- css --
197 if (layout?.theme) pageTheme = layout.theme;
198 const theme: css.Theme = {
199 ...css.defaultTheme,
200 ...pageTheme,
201 };
202 const cssImports = Array.from(
203 new Set([globalCssPath, ...hot.getCssImports(item.file)]),
204 (file) => path.relative(hot.projectSrc, file),
205 );
282206
283 // Load the marko cache before render modules are loaded
284 incr.loadMarkoCache();
207 // -- metadata --
208 const renderedMetaPromise = Promise.resolve(
209 typeof metadata === "function" ? metadata({ ssr: true }) : metadata,
210 ).then((m) => meta.renderMeta(m));
285211
286 // This is done in two passes so that a page that throws during evaluation
287 // will report "Load Render Module" instead of "Render Static Page".
288 const spinnerFormat = status.format;
289 status.format = () => "";
290 const moduleLoadQueue = new Queue({
291 name: "Load Render Module",
292 fn: loadPageModule,
293 getItemText,
294 maxJobs: 1,
295 });
296 moduleLoadQueue.addMany(neededPages);
297 moduleLoadQueue.addMany(neededViews);
298 await moduleLoadQueue.done({ method: "stop" });
299 const pageQueue = new Queue({
300 name: "Render Static Page",
301 fn: renderPage,
302 getItemText,
303 maxJobs: 2,
304 });
305 pageQueue.addMany(neededPages);
306 const viewQueue = new Queue({
307 name: "Build Dynamic View",
308 fn: prepareView,
309 getItemText,
310 maxJobs: 2,
212 // -- html --
213 let page = [engine.kElement, Page, {}];
214 if (layout?.default) {
215 page = [engine.kElement, layout.default, { children: page }];
216 }
217 const bodyPromise = engine.ssrAsync(page, {
218 sitegen: sg.initRender(),
311219 });
312 viewQueue.addMany(neededViews);
313 const pageAndViews = [
314 pageQueue.done({ method: "stop" }),
315 viewQueue.done({ method: "stop" }),
316 ];
317 await Promise.allSettled(pageAndViews);
318 await Promise.all(pageAndViews);
319 status.format = spinnerFormat;
320
321 // -- bundle server javascript (backend and views) --
322 status.text = "Bundle JavaScript";
323 incr.snapshotMarkoCache();
324 const serverJavaScriptPromise = bundle.bundleServerJavaScript(incr, "node");
325220
326 // -- bundle client javascript --
327 const referencedScripts = Array.from(
328 new Set(
329 [
330 ...pages.map((item) =>
331 UNWRAP(
332 incr.getArtifact("pageMetadata", item.id),
333 `Missing pageMetadata ${item.id}`,
334 )
335 ),
336 ...views.map((item) =>
337 UNWRAP(
338 incr.getArtifact("viewMetadata", item.id),
339 `Missing viewMetadata ${item.id}`,
340 )
341 ),
342 ].flatMap((item) => item.clientRefs),
343 ),
344 (script) => path.resolve(hot.projectSrc, script),
345 ).filter((file) => !incr.hasArtifact("script", hot.getScriptId(file)));
346 const extraPublicScripts = scripts.map((entry) => entry.file);
347 const clientJavaScriptPromise = bundle.bundleClientJavaScript(
348 referencedScripts,
349 extraPublicScripts,
350 incr,
351 );
352 await Promise.all([
353 serverJavaScriptPromise,
354 clientJavaScriptPromise,
355 cssQueue.done({ method: "stop" }),
221 const [{ text, addon }, renderedMeta] = await Promise.all([
222 bodyPromise,
223 renderedMetaPromise,
356224 ]);
357 await bundle.finalizeServerJavaScript(incr, "node");
225 if (!renderedMeta.includes("<title>")) {
226 throw new Error(
227 "Page is missing 'meta.title'. " + "All pages need a title tag.",
228 );
229 }
230 const styleKey = css.styleKey(cssImports, theme);
231 return {
232 id: item.id,
233 html: text,
234 meta: renderedMeta,
235 cssImports,
236 theme: theme ?? null,
237 styleKey,
238 clientRefs: Array.from(addon.sitegen.scripts),
239 };
240}
358241
359 // -- copy/compress static files --
360 async function doStaticFile(item: FileItem) {
361 const body = await fs.readFile(item.file);
362 await incr.putAsset({
363 sources: [item.file],
364 key: item.id,
365 body,
366 });
242export async function prepareView(io: Io, item: sg.FileItem) {
243 const module = await io.import<any>(item.file);
244 if (!module.meta) {
245 throw new Error(`${item.file} is missing 'export const meta'`);
367246 }
368 const staticQueue = new Queue({
369 name: "Load Static",
370 fn: doStaticFile,
371 getItemText,
372 maxJobs: 16,
373 });
374 status.format = () => "";
375 staticQueue.addMany(
376 staticFiles.filter((file) => !incr.hasArtifact("asset", file.id)),
247 if (!module.default) {
248 throw new Error(`${item.file} is missing a default export.`);
249 }
250 const pageTheme = module.layout?.theme ?? module.theme;
251 const theme: css.Theme = {
252 ...css.defaultTheme,
253 ...pageTheme,
254 };
255 const cssImports = Array.from(
256 new Set([globalCssPath, ...hot.getCssImports(item.file)]),
257 (file) => path.relative(hot.projectSrc, file),
377258 );
378 await staticQueue.done({ method: "stop" });
379 status.format = spinnerFormat;
259 const styleKey = css.styleKey(cssImports, theme);
260 return {
261 file: path.relative(hot.projectRoot, item.file),
262 cssImports,
263 theme,
264 clientRefs: hot.getClientScriptRefs(item.file),
265 hasLayout: !!module.layout?.default,
266 styleKey,
267 };
268}
269export type PreparedView = Awaited<ReturnType<typeof prepareView>>;
380270
381 // -- concatenate static rendered pages --
382 status.text = `Concat Pages`;
383 await Promise.all(pages.map(async (page) => {
384 if (incr.hasArtifact("asset", page.id)) return;
385 const {
386 html,
387 meta,
388 cssImports,
389 theme,
390 clientRefs,
391 } = UNWRAP(incr.out.pageMetadata.get(page.id));
392 const scriptIds = clientRefs.map(hot.getScriptId);
393 const styleKey = css.styleKey(cssImports, theme);
394 const style = UNWRAP(
395 incr.out.style.get(styleKey),
396 `Missing style ${styleKey}`,
271export function prepareInlineCss(
272 items: Array<{
273 styleKey: string;
274 cssImports: string[];
275 theme: css.Theme;
276 }>,
277) {
278 const map = new Map<string, incr.Ref<string>>();
279 for (const { styleKey, cssImports, theme } of items) {
280 if (map.has(styleKey)) continue;
281 map.set(
282 styleKey,
283 incr.work(css.bundleCssFiles, {
284 cssImports,
285 theme,
286 dev: false,
287 }),
397288 );
398 const doc = wrapDocument({
399 body: html,
400 head: meta,
401 inlineCss: style,
402 scripts: scriptIds.map(
403 (ref) => UNWRAP(incr.out.script.get(ref), `Missing script ${ref}`),
404 ).map((x) => `{${x}}`).join("\n"),
405 });
406 await incr.putAsset({
407 sources: [
408 page.file,
409 ...incr.sourcesFor("style", styleKey),
410 ...scriptIds.flatMap((ref) => incr.sourcesFor("script", ref)),
411 ],
412 key: page.id,
413 body: doc,
414 headers: {
415 "Content-Type": "text/html",
416 },
417 });
418 }));
419 status.format = () => "";
420 status.text = ``;
421 // This will wait for all compression jobs to finish, which up
422 // to this point have been left as dangling promises.
423 await incr.wait();
289 }
290 return map;
291}
424292
425 const { inserted, referenced, unreferenced } = incr.shake();
293export type PreparedPage = Awaited<ReturnType<typeof preparePage>>;
294export async function assembleAndWritePage(
295 pageWork: incr.Ref<PreparedPage>,
296 styleMap: Map<string, incr.Ref<string>>,
297 scriptWork: incr.Ref<Record<string, string>>,
298) {
299 const page = await pageWork;
300 return incr.work(
301 async (io, { id, html, meta, styleKey, clientRefs }) => {
302 const inlineCss = await io.readWork(UNWRAP(styleMap.get(styleKey)));
426303
427 // Flush the site to disk.
428 status.format = spinnerFormat;
429 status.text = `Incremental Flush`;
430 incr.flush("node"); // Write outputs
431 return {
432 incr,
433 inserted,
434 referenced,
435 unreferenced,
436 elapsed: (performance.now() - startTime) / 1000,
437 };
438}
304 const scriptIds = clientRefs.map(hot.getScriptId);
305 const scriptMap = await io.readWork(scriptWork);
306 const scripts = scriptIds.map((ref) =>
307 UNWRAP(scriptMap[ref], `Missing script ${ref}`)
308 )
309 .map((x) => `{${x}}`).join("\n");
439310
440function getItemText({ file }: FileItem) {
441 return path.relative(hot.projectSrc, file).replaceAll("\\", "/");
311 const doc = wrapDocument({
312 body: html,
313 head: meta,
314 inlineCss,
315 scripts,
316 });
317 await io.writeAsset(id, doc, {
318 "Content-Type": "text/html",
319 });
320 },
321 page,
322 );
442323}
443324
444import { OnceMap, Queue } from "#sitegen/async";
445import { Incremental } from "./incremental.ts";
325import * as sg from "#sitegen";
326import * as incr from "./incremental.ts";
327import { Io } from "./incremental.ts";
446328import * as bundle from "./bundle.ts";
447329import * as css from "./css.ts";
448330import * as engine from "./engine/ssr.ts";
449331import * as hot from "./hot.ts";
450332import * as fs from "#sitegen/fs";
451import * as sg from "#sitegen";
452333import type { FileItem } from "#sitegen";
453334import * as path from "node:path";
454335import * as meta from "#sitegen/meta";
framework/hot.ts+24-50
......@@ -19,6 +19,8 @@ export const load = createRequire(
1919};
2020export const { cache } = load;
2121
22load<any>("source-map-support").install({hookRequire: true});
23
2224// Register extensions by overwriting `require.extensions`
2325const require = load;
2426const exts = require.extensions;
......@@ -42,8 +44,7 @@ export function getFileStat(filepath: string) {
4244}
4345
4446function shouldTrackPath(filename: string) {
45 return !filename.includes("node_modules") &&
46 !filename.includes(import.meta.dirname);
47 return !filename.includes("node_modules");
4748}
4849
4950const Module = load<typeof import("node:module")>("node:module");
......@@ -59,11 +60,12 @@ Module.prototype._compile = function (
5960 filename,
6061 format,
6162 );
62 const stat = fs.statSync(filename);
6363 if (shouldTrackPath(filename)) {
64 const stat = fs.statSync(filename);
6465 const cssImportsMaybe: string[] = [];
6566 const imports: string[] = [];
66 for (const { filename: file, cloverClientRefs } of this.children) {
67 for (const childModule of this.children) {
68 const { filename: file, cloverClientRefs } = childModule;
6769 if (file.endsWith(".css")) cssImportsMaybe.push(file);
6870 else {
6971 const child = fileStats.get(file);
......@@ -71,6 +73,7 @@ Module.prototype._compile = function (
7173 const { cssImportsRecursive } = child;
7274 if (cssImportsRecursive) cssImportsMaybe.push(...cssImportsRecursive);
7375 imports.push(file);
76 (childModule.cloverImporters ??= []).push(this);
7477 if (cloverClientRefs && cloverClientRefs.length > 0) {
7578 (this.cloverClientRefs ??= [])
7679 .push(...cloverClientRefs);
......@@ -82,7 +85,7 @@ Module.prototype._compile = function (
8285 ? Array.from(new Set(cssImportsMaybe))
8386 : null,
8487 imports,
85 lastModified: stat.mtimeMs,
88 lastModified: Math.floor(stat.mtimeMs),
8689 });
8790 }
8891 return result;
......@@ -113,7 +116,7 @@ function loadEsbuild(module: NodeJS.Module, filepath: string) {
113116interface LoadOptions {
114117 scannedClientRefs?: string[];
115118}
116function loadEsbuildCode(
119export function loadEsbuildCode(
117120 module: NodeJS.Module,
118121 filepath: string,
119122 src: string,
......@@ -139,7 +142,7 @@ function loadEsbuildCode(
139142 import.meta.url = ${JSON.stringify(pathToFileURL(filepath).toString())};
140143 import.meta.dirname = ${JSON.stringify(path.dirname(filepath))};
141144 import.meta.filename = ${JSON.stringify(filepath)};
142 `.trim().replace(/\n/g, "") + src;
145 `.trim().replace(/[\n\s]/g, "") + src;
143146 }
144147 src = esbuild.transformSync(src, {
145148 loader,
......@@ -149,11 +152,12 @@ function loadEsbuildCode(
149152 jsxImportSource: "#ssr",
150153 jsxDev: true,
151154 sourcefile: filepath,
155 sourcemap: 'inline',
152156 }).code;
153157 return module._compile(src, filepath, "commonjs");
154158}
155159
156function resolveClientRef(sourcePath: string, ref: string) {
160export function resolveClientRef(sourcePath: string, ref: string) {
157161 const filePath = resolveFrom(sourcePath, ref);
158162 if (
159163 !filePath.endsWith(".client.ts") &&
......@@ -164,44 +168,10 @@ function resolveClientRef(sourcePath: string, ref: string) {
164168 return path.relative(projectSrc, filePath);
165169}
166170
167// TODO: extract the marko compilation tools out, lazy load them
168export interface MarkoCacheEntry {
169 src: string;
170 scannedClientRefs: string[];
171}
172export const markoCache = new Map<string, MarkoCacheEntry>();
171let lazyMarko: typeof import('./marko.ts') | null = null;
173172function loadMarko(module: NodeJS.Module, filepath: string) {
174 let cache = markoCache.get(filepath);
175 if (!cache) {
176 let src = fs.readFileSync(filepath, "utf8");
177 // A non-standard thing here is Clover Sitegen implements
178 // its own client side scripting stuff, so it overrides
179 // bare client import statements to it's own usage.
180 const scannedClientRefs = new Set<string>();
181 if (src.match(/^\s*client\s+import\s+["']/m)) {
182 src = src.replace(
183 /^\s*client\s+import\s+("[^"]+"|'[^']+')[^\n]+/m,
184 (_, src) => {
185 const ref = JSON.parse(`"${src.slice(1, -1)}"`);
186 const resolved = resolveClientRef(filepath, ref);
187 scannedClientRefs.add(resolved);
188 return `<CloverScriptInclude=${
189 JSON.stringify(getScriptId(resolved))
190 } />`;
191 },
192 ) + '\nimport { addScript as CloverScriptInclude } from "#sitegen";\n';
193 }
194
195 src = marko.compileSync(src, filepath).code;
196 src = src.replace("marko/debug/html", "#ssr/marko");
197 cache = { src, scannedClientRefs: Array.from(scannedClientRefs) };
198 markoCache.set(filepath, cache);
199 }
200
201 const { src, scannedClientRefs } = cache;
202 return loadEsbuildCode(module, filepath, src, {
203 scannedClientRefs,
204 });
173 lazyMarko ??= require<typeof import('./marko.ts')>("./framework/marko.ts");
174 lazyMarko.loadMarko(module, filepath);
205175}
206176
207177function loadMdx(module: NodeJS.Module, filepath: string) {
......@@ -224,10 +194,14 @@ export function reloadRecursive(filepath: string) {
224194}
225195
226196export function unload(filepath: string) {
197 lazyMarko?.markoCache.delete(filepath)
227198 filepath = path.resolve(filepath);
228 const existing = cache[filepath];
229 if (existing) delete cache[filepath];
230 fileStats.delete(filepath);
199 const module = cache[filepath];
200 if (!module) return;
201 delete cache[filepath];
202 for (const importer of module.cloverImporters ?? []) {
203 unload(importer.filename);
204 }
231205}
232206
233207function deleteRecursiveInner(id: string, module: any) {
......@@ -326,6 +300,7 @@ declare global {
326300 interface Module {
327301 cloverClientRefs?: string[];
328302 cloverSourceCode?: string;
303 cloverImporters?: Module[],
329304
330305 _compile(
331306 this: NodeJS.Module,
......@@ -343,11 +318,10 @@ declare module "node:module" {
343318 ): unknown;
344319}
345320
346import * as fs from "./lib/fs.ts";
321import * as fs from "#sitegen/fs";
347322import * as path from "node:path";
348323import { pathToFileURL } from "node:url";
349324import * as esbuild from "esbuild";
350import * as marko from "@marko/compiler";
351325import { createRequire } from "node:module";
352326import * as mdx from "@mdx-js/mdx";
353327import * as self from "./hot.ts";
framework/incremental.test.ts created+56
......@@ -0,0 +1,56 @@
1test("trivial case", async () => {
2 incr.reset();
3
4 const file1 = tmpFile("example.txt");
5 file1.write("one");
6
7 async function compilation() {
8 const first = incr.work({
9 label: "first compute",
10 async run (io) {
11 await setTimeout(1000);
12 const contents = await io.readFile(file1.path);
13 return [contents, Math.random()] as const;
14 }
15 });
16 const second = incr.work({
17 label: "second compute",
18 wait: first,
19 async run (io) {
20 await setTimeout(1000);
21 return io.readWork(first)[0].toUpperCase();
22 }
23 });
24 const third = incr.work({
25 label: "third compute",
26 wait: first,
27 async run (io) {
28 await setTimeout(1000);
29 return io.readWork(first)[1] * 1000;
30 }
31 });
32 return incr.work({
33 label: "last compute",
34 wait: [second, third],
35 async run (io) {
36 await setTimeout(1000);
37 return {
38 second: io.readWork(second),
39 third: io.readWork(third),
40 }
41 }
42 });
43 }
44 const { value: first } = await incr.compile(compilation);
45 const { value: second } = await incr.compile(compilation);
46 ASSERT(first === second);
47 incr.forceInvalidate(file1.path);
48 const { value: third } = await incr.compile(compilation);
49 ASSERT(first !== third);
50 ASSERT(first[0] === third[0]);
51});
52
53import * as incr from "./incremental2.ts";
54import { beforeEach, test } from "node:test";
55import { tmpFile } from "#sitegen/testing";import { setTimeout } from "node:timers/promises";
56
framework/incremental.ts+603-577
......@@ -1,592 +1,575 @@
1// Incremental contains multiple maps for the different kinds
2// of Artifact, which contain a list of source files which
3// were used to produce it. When files change, Incremental sees
4// that the `mtime` is newer, and purges the referenced artifacts.
5
6type SourceId = string; // relative to project root, e.g. 'src/global.css'
7type ArtifactId = string; // `${ArtifactType}\0${string}`
8type Sha1Id = string; // Sha1 hex string
9
10// -- artifact types --
11interface ArtifactMap {
12 /* An asset (serve with "#sitegen/asset" */
13 asset: Asset;
14 /* The bundled text of a '.client.ts' script */
15 // TODO: track imports this has into `asset`
16 script: string;
17 /* The bundled style tag contents. Keyed by 'css.styleKey' */
18 style: string;
19 /* Metadata about a static page */
20 pageMetadata: PageMetadata;
21 /* Metadata about a dynamic view */
22 viewMetadata: ViewMetadata;
23 /* Cached '.marko' server compilation */
24 serverMarko: hot.MarkoCacheEntry;
25 /* Backend source code, pre-replacement. Keyed by platform type. */
26 backendBundle: BackendBundle;
27 /* One file in the backend receives post-processing. */
28 backendReplace: Buffer;
29}
30type ArtifactKind = keyof ArtifactMap;
31/* Automatic path tracing is performed to make it so that
32 * specifying 'sources: [file]' refers to it and everything it imports.
33 * These kinds do not have that behavior
34 */
35const exactDependencyKinds = ["serverMarko"];
36export interface Asset {
37 buffer: Buffer;
38 headers: Record<string, string | undefined>;
39 hash: string;
40}
41/**
42 * This interface intentionally omits the *contents*
43 * of its scripts and styles for fine-grained rebuilds.
44 */
45export interface PageMetadata {
46 html: string;
47 meta: string;
48 cssImports: string[];
49 theme: css.Theme;
50 clientRefs: string[];
1// Incremental build system using singleton state.
2// See `work()`, `compile()`, and `invalidate()` for details.
3//
4// All state is serializable to allow recovering state across sessions.
5// This framework special-cases the asset map, but is otherwise
6// agnostic of what it is a compiler for.
7let running = false;
8let jobs = 0;
9let newKeys = 0;
10let seenWorks = new Set<string>(); // for detecting conflict vs overwrite
11let seenWrites = new Set<string>(); // for detecting conflict vs overwrite
12let works = new Map<string, Work>();
13let files = new Map<string, TrackedFile>(); // keyed by `toRel` path
14let writes = new Map<string, FileWrite>();
15let assets = new Map<string, Asset>(); // keyed by hash
16
17export interface Ref<T> {
18 /** This method is compatible with `await` syntax */
19 then(
20 onFulfilled: (value: T) => void,
21 onRejected: (error: unknown) => void,
22 ): void;
23 key: string;
5124}
25type Job<I = any, O = any> = (io: Io, input: I) => Promise<O>;
26
5227/**
53 * Like a page, this intentionally omits resources,
54 * but additionally omits the bundled server code.
28 * Declare and a unit of work. Return value is memoized and
29 * only rebuilt when inputs (declared via `Io`) change. Outputs
30 * are written at the end of a compilation (see `compile`).
31 *
32 * If the returned `Ref` is not awaited or read
33 * via io.readWork, the job is never started.
5534 */
56export interface ViewMetadata {
57 file: string;
58 // staticMeta: string | null; TODO
59 cssImports: string[];
60 theme: css.Theme;
61 clientRefs: string[];
62 hasLayout: boolean;
63}
64export interface BackendBundle {
65 magicWord: string;
66 fileWithMagicWord: string | null;
67 files: Record<string, Buffer>;
68}
35export function work<O>(job: Job<void, O>): Ref<O>;
36export function work<I, O>(job: Job<I, O>, input: I): Ref<O>;
37export function work<I, O>(job: Job<I, O>, input: I = null as I): Ref<O> {
38 const source = JSON.stringify(util.getCallSites(2)[1]);
39 const keySource = [source, util.inspect(input)].join(":");
40 const key = crypto.createHash("sha1").update(keySource).digest("base64url");
41 ASSERT(running);
42 ASSERT(
43 !seenWorks.has(key),
44 `Key '${key}' must be unique during the build. ` +
45 `To fix this, provide a manual 'key' argument.`,
46 );
47 seenWorks.add(key);
48
49 const prev = works.get(key) as Work<O> | null;
50 if (prev) {
51 return { key, then: (done) => done(prev.value) };
52 }
6953
70// -- incremental support types --
71export interface PutBase {
72 sources: SourceId[];
73 key: string;
74}
75export interface Put<T extends ArtifactKind> extends PutBase {
76 kind: T;
77 value: ArtifactMap[T];
78}
79export interface Invalidations {
80 lastModified: number;
81 outputs: Set<ArtifactId>;
82 files: Set<SourceId>;
83}
54 async function perform() {
55 const io = new Io(key);
56 jobs += 1;
57 newKeys += 1;
58 try {
59 const value = await job(io, input);
60 validateSerializable(value, "");
61 const { reads, writes } = io;
62 works.set(key, {
63 value,
64 affects: [],
65 reads,
66 writes,
67 debug: source,
68 });
69 for (const add of reads.files) {
70 const { affects } = UNWRAP(files.get(add));
71 ASSERT(!affects.includes(key));
72 affects.push(key);
73 }
74 for (const add of reads.works) {
75 const { affects } = UNWRAP(works.get(add));
76 ASSERT(!affects.includes(key));
77 affects.push(key);
78 }
79 return value;
80 } finally {
81 jobs -= 1;
82 }
83 }
8484
85export class Incremental {
86 /** The generated artifacts */
87 out: {
88 [K in keyof ArtifactMap]: Map<string, ArtifactMap[K]>;
89 } = {
90 asset: new Map(),
91 script: new Map(),
92 style: new Map(),
93 pageMetadata: new Map(),
94 viewMetadata: new Map(),
95 serverMarko: new Map(),
96 backendBundle: new Map(),
97 backendReplace: new Map(),
98 };
99 /** Tracking filesystem entries to `srcId` */
100 invals = new Map<SourceId, Invalidations>();
101 /** Tracking output keys to files */
102 sources = new Map<ArtifactId, SourceId[]>();
103
104 /** Compressed resources */
105 compress = new Map<Sha1Id, Compressed>();
106 compressQueue = new Queue<CompressJob, void>({
107 name: "Compress",
108 maxJobs: 5,
109 fn: this.compressImpl.bind(this),
110 passive: true,
111 getItemText: (job) => `${job.algo.toUpperCase()} ${job.label}`,
112 });
113
114 /** Reset at the end of each update */
115 round = {
116 inserted: new Set<ArtifactId>(),
117 referenced: new Set<ArtifactId>(),
85 let cached: Promise<O>;
86 return {
87 key,
88 then: (fufill, reject) => void (cached ??= perform()).then(fufill, reject),
11889 };
90}
11991
120 getArtifact<T extends ArtifactKind>(kind: T, key: string) {
121 this.round.referenced.add(`${kind}\0${key}`);
122 return this.out[kind].get(key);
92export async function compile<T>(compiler: () => Promise<T>) {
93 ASSERT(!running, `Cannot run twice at the same time.`);
94 try {
95 running = true;
96 ASSERT(jobs === 0);
97 const start = performance.now();
98 const timerSpinner = new Spinner({
99 text: () =>
100 `sitegen! [${
101 ((performance.now() - start) / 1000).toFixed(
102 1,
103 )
104 }s]`,
105 fps: 10,
106 });
107 using _endTimerSpinner = { [Symbol.dispose]: () => timerSpinner.stop() };
108
109 const value = await compiler();
110 ASSERT(jobs === 0);
111 timerSpinner.text = "incremental flush";
112 await flush(start);
113 timerSpinner.stop();
114 return {
115 value,
116 watchFiles: new Set(files.keys()),
117 newOutputs: Array.from(seenWrites).filter(x => x.startsWith('f:')).map(x => x.slice(2)),
118 newAssets: !Array.from(seenWrites).some(x => x.startsWith('a:')),
119 };
120 } finally {
121 running = false;
122 newKeys = 0;
123 seenWrites.clear();
124 seenWorks.clear();
123125 }
126}
124127
125 hasArtifact(kind: ArtifactKind, key: string) {
126 return this.getArtifact(kind, key) != null;
128export async function flush(start: number) {
129 // Trim
130 const detachedFiles = new Set<string>;
131 const referencedAssets = new Set<string>;
132 for (const [k, { writes: { assets } }] of works) {
133 if (seenWorks.has(k)) {
134 for (const asset of assets.values()) referencedAssets.add(asset.hash);
135 continue;
136 }
137 deleteWork(k);
127138 }
128
129 sourcesFor(kind: ArtifactKind, key: string) {
130 return UNWRAP(
131 this.sources.get(kind + "\0" + key),
132 `No artifact '${kind}' '${key}'`,
133 );
139 for (const [k, file] of files) {
140 if (file.affects.length > 0) continue;
141 files.delete(k);
142 detachedFiles.add(k);
143 }
144 for (const k of assets.keys()) {
145 if (!referencedAssets.has(k))
146 assets.delete(k);
134147 }
135148
136 shake() {
137 const toPublic = (str: string) => {
138 const [kind, key] = str.split("\0");
139 return { kind: kind as ArtifactKind, key };
140 };
141 const inserted = Array.from(this.round.inserted, toPublic);
142 const referenced = Array.from(this.round.referenced, toPublic);
143 const unreferenced: { kind: ArtifactKind; key: string }[] = [];
144
145 for (const kind in this.out) {
146 const map = this.out[kind as keyof typeof this.out];
147 if (!map) continue;
148 for (const key of map.keys()) {
149 if (!this.round.referenced.has(`${kind}\0${key}`)) {
150 unreferenced.push({ kind: kind as ArtifactKind, key });
151 // this.out[kind as ArtifactKind].delete(key);
152 }
153 }
154 }
149 const p = [];
150 // File writes
151 let dist = 0;
152 for (const [key, { buffer, size }] of writes) {
153 if (buffer) p.push(fs.writeMkdir(path.join(`.clover/o/${key}`), buffer));
154 dist += size;
155 }
156 // Asset map
157 {
158 const { json, blob } = getAssetManifest();
159 const jsonString = Buffer.from(JSON.stringify(json));
160 p.push(fs.writeMkdir(".clover/o/static.json", jsonString));
161 p.push(fs.writeMkdir(".clover/o/static.blob", blob));
162 dist += blob.byteLength + jsonString.byteLength;
163 }
164 await Promise.all(p);
165
166 // Incremental state
167 const serialized = msgpackr.pack(serialize());
168 await fs.writeMkdir(".clover/incr.state", serialized);
169 const time = (performance.now() - start).toFixed(0);
170 console.success(`sitegen! in ${time} ms`);
171 console.writeLine(` - ${works.size} keys (${works.size - newKeys} cached)`);
172 console.writeLine(` - ${assets.size} static assets`);
173 console.writeLine(
174 ` - dist: ${formatSize(dist)}, incremental: ${
175 formatSize(serialized.byteLength)
176 }`,
177 );
178}
155179
156 this.round.inserted.clear();
157 this.round.referenced.clear();
180export async function restore() {
181 let buffer;
182 try {
183 buffer = await fs.readFile(".clover/incr.state");
184 } catch (err: any) {
185 if (err.code !== "ENOENT") throw err;
186 }
187 if (!buffer) return;
188 await deserialize(buffer);
189}
158190
159 return { inserted, referenced, unreferenced };
191function forceInvalidate(entry: { affects: string[] }) {
192 const queue = [...entry.affects];
193 let key;
194 while ((key = queue.shift())) {
195 const affects = deleteWork(key);
196 queue.push(...affects);
160197 }
198}
161199
162 /*
163 * Put built artifacts into the incremental cache. The entry points
164 * used to build this must be provided. 'Incremental' will trace JS
165 * imports and file modification times tracked by 'hot.ts'.
166 */
167 put<T extends ArtifactKind>({
168 sources,
169 kind,
170 key,
171 value,
172 }: Put<T>) {
173 // These three invariants affect incremental accuracy.
174 if (this.round.inserted.has(`${kind}\0${key}`)) {
175 console.error(
176 `Artifact ${kind}:${key} was inserted multiple times in the same round!`,
177 );
178 } else if (!this.round.referenced.has(`${kind}\0${key}`)) {
179 console.error(
180 `Artifact ${kind}:${key} was inserted without checking if (!hasArtifact())`,
181 );
182 } else if (this.out[kind].has(key)) {
183 console.error(
184 `Artifact ${kind}:${key} is not stale, but overwritten.`,
185 );
186 }
200function deleteWork(key: string) {
201 const work = works.get(key);
202 if (!work) return [];
203 const { reads, affects, writes: w } = work;
204 for (const remove of reads.files) {
205 const { affects } = UNWRAP(files.get(remove));
206 ASSERT(affects.includes(key));
207 affects.splice(affects.indexOf(key), 1);
208 }
209 for (const remove of reads.works) {
210 const { affects } = UNWRAP(works.get(remove), remove);
211 ASSERT(affects.includes(key));
212 affects.splice(affects.indexOf(key), 1);
213 }
214 for (const remove of affects) {
215 const { reads: { works: list } } = UNWRAP(works.get(remove), remove);
216 ASSERT(list.has(key));
217 list.delete(key);
218 }
219 for (const file of w.files) {
220 if (UNWRAP(writes.get(file)).work === key)
221 writes.delete(file);
222 }
223 // Assets are temporarily kept, trimmed via manual GC after compilation.
187224
188 this.out[kind].set(key, value);
225 works.delete(key);
226 return affects;
227}
189228
190 this.round.inserted.add(`${kind}\0${key}`);
229export function reset() {
230 ASSERT(!running);
231 works.clear();
232 files.clear();
233 assets.clear();
234}
191235
192 // Update sources information
193 ASSERT(sources.length > 0, "Missing sources for " + kind + " " + key);
194 sources = sources.map((src) => path.normalize(src));
195 const fullKey = `${kind}\0${key}`;
196 const prevSources = this.sources.get(fullKey);
197 const newSources = new Set(
198 sources.map((file) =>
199 path.isAbsolute(file) ? path.relative(hot.projectRoot, file) : file
200 ),
201 );
202 this.sources.set(fullKey, [...newSources]);
203 for (const source of prevSources ?? []) {
204 if (sources.includes(source)) {
205 newSources.delete(source);
206 continue;
207 }
208 const invals = UNWRAP(this.invals.get(source));
209 ASSERT(invals.outputs.has(fullKey));
210 invals.outputs.delete(fullKey);
211 }
212 // Use reflection from the plugin system to get imports.
213 for (const source of newSources) {
214 const invals = this.#getOrInitInvals(source);
215 invals.outputs.add(fullKey);
216 this.#followImports(source);
236export function serialize() {
237 const fileEntries = Array.from(files, ([k, v]) =>
238 [
239 k,
240 v.type,
241 v.type === 'f' ? v.lastModified : v.type === 'd' ? v.contentHash : null,
242 ...v.affects,
243 ] as const);
244 const workEntries = Array.from(works, ([k, v]) =>
245 [
246 k,
247 v.value,
248 Array.from(v.reads.files),
249 Array.from(v.reads.works),
250 Array.from(v.writes.files),
251 Array.from(v.writes.assets, ([k, { headers }]) => [k, headers] as const),
252 v.affects,
253 ] as const);
254 const expectedFilesOnDisk = Array.from(
255 writes,
256 ([k, { size, work }]) => [k, size, work] as const,
257 );
258 const assetEntries = Array.from(
259 assets,
260 ([k, asset]) => [k, asset.raw, asset.gzip, asset.zstd] as const,
261 );
262 return [
263 1,
264 fileEntries,
265 workEntries,
266 expectedFilesOnDisk,
267 assetEntries,
268 ] as const;
269}
270type SerializedState = ReturnType<typeof serialize>;
271
272/* No-op on failure */
273async function deserialize(buffer: Buffer) {
274 const decoded = msgpackr.decode(buffer) as SerializedState;
275 if (!Array.isArray(decoded)) return false;
276 if (decoded[0] !== 1) return false;
277 const [, fileEntries, workEntries, expectedFilesOnDisk, assetEntries] =
278 decoded;
279 for (const [k, type, content, ...affects] of fileEntries) {
280 if (type === "f") {
281 ASSERT(typeof content === "number");
282 files.set(k, { type, affects, lastModified: content });
283 } else if (type === 'd') {
284 ASSERT(typeof content === "string");
285 files.set(k, { type, affects, contentHash: content, contents: [] });
286 } else {
287 files.set(k, { type, affects });
217288 }
218289 }
219
220 // TODO: this doesnt remove stuff when it disappeary
221 #getOrInitInvals(source: string) {
222 let invals = this.invals.get(source);
223 if (!invals) {
224 const lastModified = hot.getFileStat(source)?.lastModified ??
225 fs.statSync(path.resolve(hot.projectRoot, source)).mtimeMs;
226 this.invals.set(
227 source,
228 invals = {
229 lastModified,
230 files: new Set(),
231 outputs: new Set(),
232 },
233 );
234 }
235 return invals;
290 for (const entry of workEntries) {
291 const [
292 k,
293 value,
294 readFiles,
295 readWorks,
296 writeFiles,
297 writeAssets,
298 affects,
299 ] = entry;
300 works.set(k, {
301 value,
302 reads: {
303 files: new Set(readFiles),
304 works: new Set(readWorks),
305 },
306 writes: {
307 files: new Set(writeFiles),
308 assets: new Map(Array.from(writeAssets, ([k, headers]) => [k, {
309 hash: JSON.parse(UNWRAP(headers.etag)),
310 headers,
311 }])),
312 },
313 affects,
314 });
236315 }
237
238 #followImports(file: string) {
239 const stat = hot.getFileStat(file);
240 if (!stat) return;
241 for (const i of stat.imports) {
242 const invals = this.#getOrInitInvals(i);
243 invals.files.add(file);
244 this.#followImports(i);
316 const statFiles = await Promise.all(expectedFilesOnDisk
317 .map(([k, size, work]) =>
318 fs.stat(path.join(".clover/o", k))
319 .catch((err) => {
320 if (err.code === "ENOENT") return null;
321 throw err;
322 })
323 .then((stat) => ({ k, size, work, stat }))
324 ));
325 for (const { k, stat, work, size } of statFiles) {
326 if (stat?.size === size) {
327 writes.set(k, {
328 size: size,
329 buffer: null,
330 work,
331 });
332 } else {
333 forceInvalidate({ affects: [work] });
245334 }
246335 }
247
248 async statAllFiles() {
249 for (const file of this.invals.keys()) {
250 try {
251 const mtime = fs.statSync(file).mtimeMs;
252 this.updateStat(file, mtime);
253 } catch (err) {
254 }
255 }
336 for (const [hash, raw, gzip, zstd] of assetEntries) {
337 assets.set(hash, { raw, gzip, zstd });
256338 }
257339
258 updateStat(file: string, newLastModified: number | null) {
259 file = path.relative(hot.projectRoot, file);
260 const stat = this.invals.get(file);
261 ASSERT(stat, "Updated stat on untracked file " + file);
262 const hasUpdate = !newLastModified || stat.lastModified < newLastModified;
263 if (hasUpdate) {
264 // Invalidate
265 console.info(file + " " + (newLastModified ? "updated" : "deleted"));
266 hot.unload(file);
267 const invalidQueue = [file];
268 let currentInvalid;
269 while (currentInvalid = invalidQueue.pop()) {
270 const invalidations = this.invals.get(currentInvalid);
271 ASSERT(
272 invalidations,
273 "No reason to track file '" + currentInvalid +
274 "' if it has no invalidations",
275 );
276 const { files, outputs } = invalidations;
277 for (const out of outputs) {
278 const [kind, artifactKey] = out.split("\0");
279 this.out[kind as ArtifactKind].delete(artifactKey);
280 }
281 invalidQueue.push(...files);
340 await Promise.all(Array.from(files, ([key, file]) => invalidateEntry(key, file)));
341}
342
343export async function invalidate(filePath: string): Promise<boolean> {
344 const key = toRel(toAbs(filePath));
345 const file = UNWRAP(files.get(key), `Untracked file '${key}'`)
346 return invalidateEntry(key, file)
347}
348
349export async function invalidateEntry(key: string, file: TrackedFile): Promise<boolean> {
350 try {
351 if (file.type === "d") {
352 const contents = file.contents = await fs.readdir(key);
353 contents.sort();
354 const contentHash = crypto
355 .createHash("sha1")
356 .update(contents.join("\0"))
357 .digest("base64url");
358 if (file.contentHash !== contentHash) {
359 file.contentHash = contentHash;
360 throw new Error();
361 }
362 } else if (file.type === 'f') {
363 const lastModified = await fs.stat(key)
364 .then(x => Math.floor(x.mtimeMs), () => 0);
365 if (file.lastModified !== lastModified) {
366 file.lastModified = lastModified;
367 throw new Error();
282368 }
283 }
284 if (newLastModified) {
285 stat.lastModified = newLastModified;
286369 } else {
287 this.invals.delete(file);
370 file.type satisfies 'null';
371 const stat = await fs.stat(key).catch(() => null);
372 if (stat) throw new Error();
288373 }
289 return hasUpdate;
374 return false;
375 } catch (e) {
376 forceInvalidate(file);
377 hot.unload(toAbs(key));
378 if (file.type === 'null') files.delete(key);
379 return true;
290380 }
381}
291382
292 async putAsset(info: PutAsset) {
293 const { body, headers, key } = info;
294 const buffer = Buffer.isBuffer(body) ? body : Buffer.from(body);
295 const hash = Buffer.from(await crypto.subtle.digest("sha-1", buffer))
296 .toString("hex");
297 const value: Asset = {
298 buffer,
299 headers: {
300 "Content-Type": headers?.["Content-Type"] ?? mime.contentTypeFor(key),
301 "ETag": JSON.stringify(hash),
302 ...headers,
303 },
304 hash,
305 };
306 const a = this.put({ ...info, kind: "asset", value });
307 if (!this.compress.has(hash)) {
308 const label = info.key;
309 this.compress.set(hash, {
310 zstd: undefined,
311 gzip: undefined,
312 });
313 this.compressQueue.add({ label, buffer, algo: "zstd", hash });
314 this.compressQueue.add({ label, buffer, algo: "gzip", hash });
315 }
316 return a;
317 }
318
319 async compressImpl({ algo, buffer, hash }: CompressJob) {
320 let out;
321 if (algo === "zstd") out = await zstd(buffer);
322 else if (algo === "gzip") out = await gzip(buffer, { level: 9 });
323 else algo satisfies never;
324
325 let entry = this.compress.get(hash);
326 if (!entry) {
327 this.compress.set(
328 hash,
329 entry = {
330 zstd: undefined,
331 gzip: undefined,
332 },
333 );
334 }
335 entry![algo] = out;
336 }
337
338 serialize() {
339 const writer = new BufferWriter();
340
341 // -- artifact --
342 const asset = Array.from(
343 this.out.asset,
344 ([key, { buffer, hash, headers }]) => {
345 const raw = writer.write(buffer, hash);
346 const { gzip: gzipBuf, zstd: zstdBuf } = this.compress.get(hash) ?? {};
347 const gzip = gzipBuf ? writer.write(gzipBuf, hash + ".gz") : null;
348 const zstd = zstdBuf ? writer.write(zstdBuf, hash + ".zstd") : null;
349 return [key, {
350 raw,
351 gzip,
352 zstd,
353 hash,
354 headers: headers as Record<string, string>,
355 }] satisfies SerializedMeta["asset"][0];
356 },
357 );
358 const script = Array.from(this.out.script);
359 const style = Array.from(this.out.style);
360 const pageMetadata = Array.from(this.out.pageMetadata);
361 const viewMetadata = Array.from(this.out.viewMetadata);
362 const serverMarko = Array.from(this.out.serverMarko);
363 const backendBundle = Array.from(this.out.backendBundle, ([k, v]) => {
364 return [k, {
365 magicWord: v.magicWord,
366 fileWithMagicWord: v.fileWithMagicWord,
367 files: Object.entries(v.files).map(
368 ([file, contents]) => [
369 file,
370 writer.write(contents, "backendBundle" + k + ":" + file),
371 ],
372 ),
373 }] satisfies SerializedMeta["backendBundle"][0];
374 });
375 const backendReplace = Array.from(
376 this.out.backendReplace,
377 ([k, v]) =>
378 [
379 k,
380 writer.write(v, "backendReplace" + k),
381 ] satisfies SerializedMeta["backendReplace"][0],
382 );
383 // -- incremental metadata --
384 const invals = Array.from(this.invals, ([key, value]) => {
385 const { lastModified, files, outputs } = value;
386 return [key, {
387 m: lastModified,
388 f: [...files],
389 o: [...outputs],
390 }] satisfies SerializedMeta["invals"][0];
391 });
392 const sources = Array.from(this.sources, ([key, value]) => {
393 return [key, ...value] as [string, ...string[]];
394 });
395 const json = {
396 asset,
397 script,
398 invals,
399 sources,
400 style,
401 pageMetadata,
402 viewMetadata,
403 serverMarko,
404 backendBundle,
405 backendReplace,
406 } satisfies SerializedMeta;
407 const meta = Buffer.from(JSON.stringify(json), "utf-8");
408
409 const lengthBuffer = Buffer.alloc(4);
410 lengthBuffer.writeUInt32LE(meta.byteLength, 0);
411
412 return Buffer.concat([lengthBuffer, meta, ...writer.buffers]);
413 }
414
415 static fromSerialized(buffer: Buffer): Incremental {
416 const metaLength = buffer.readUint32LE(0);
417 const meta: SerializedMeta = JSON.parse(
418 buffer.subarray(4, 4 + metaLength).toString("utf8"),
419 );
420 const view = ([start, end]: View) =>
421 buffer.subarray(4 + metaLength + start, 4 + metaLength + end);
422
423 const incr = new Incremental();
424 incr.out = {
425 asset: new Map(meta.asset.map(([key, value]) => {
426 const { hash, raw, gzip, zstd, headers } = value;
427 if ((gzip || zstd) && !incr.compress.has(hash)) {
428 incr.compress.set(hash, {
429 gzip: gzip ? view(gzip) : undefined,
430 zstd: zstd ? view(zstd) : undefined,
431 });
432 }
433 return [key, {
434 buffer: view(raw),
435 headers: headers,
436 hash: hash,
437 }];
438 })),
439 script: new Map(meta.script),
440 style: new Map(meta.style),
441 pageMetadata: new Map(meta.pageMetadata),
442 viewMetadata: new Map(meta.viewMetadata),
443 serverMarko: new Map(meta.serverMarko),
444 backendBundle: new Map(meta.backendBundle.map(([key, value]) => {
445 return [key, {
446 magicWord: value.magicWord,
447 fileWithMagicWord: value.fileWithMagicWord,
448 files: Object.fromEntries(
449 value.files.map(([file, contents]) => [file, view(contents)]),
450 ),
451 }];
452 })),
453 backendReplace: new Map(
454 meta.backendReplace.map(([key, contents]) => [key, view(contents)]),
383export function getAssetManifest() {
384 const writer = new BufferWriter();
385 const asset = Object.fromEntries(
386 Array.from(works, (work) => work[1].writes.assets)
387 .filter((map) => map.size > 0)
388 .flatMap((map) =>
389 Array.from(map, ([key, { hash, headers }]) => {
390 const { raw, gzip, zstd } = UNWRAP(
391 assets.get(hash),
392 `Asset ${key} (${hash})`,
393 );
394 return [key, {
395 raw: writer.write(raw, "raw:" + hash),
396 gzip: writer.write(gzip, "gzip:" + hash),
397 zstd: writer.write(zstd, "zstd:" + hash),
398 headers,
399 }] as const;
400 })
455401 ),
456 };
457 incr.invals = new Map(meta.invals.map(([key, { m, f, o }]) => {
458 return [key, {
459 lastModified: m,
460 files: new Set(f),
461 outputs: new Set(o),
462 }];
463 }));
464 incr.sources = new Map(meta.sources.map(([key, ...value]) => [key, value]));
465 return incr;
466 }
467
468 /*
469 * Move the cached (server) marko transpilations from this incremental
470 * into the running process.
471 */
472 loadMarkoCache() {
473 hot.markoCache.clear();
474 for (const [key, value] of this.out.serverMarko) {
475 hot.markoCache.set(path.resolve(hot.projectRoot, key), value);
476 }
477 }
402 ) satisfies BuiltAssetMap;
403 return { json: asset, blob: writer.get() };
404}
478405
479 /*
480 * Move the cached (server) marko transpilations from this incremental
481 * into the running process.
482 */
483 snapshotMarkoCache() {
484 for (const [file, value] of hot.markoCache) {
485 const key = path.relative(hot.projectRoot, file).replaceAll("\\", "/");
486 // Only insert if it doesn't exist. Calling 'put' when it
487 // already exists would inform the user of extra calls to put.
488 if (!this.hasArtifact("serverMarko", key)) {
489 this.put({
490 kind: "serverMarko",
491 sources: [file],
492 key,
493 value,
494 });
406/* Input/Output with automatic tracking.
407 * - Inputs read with Io are tracked to know when to rebuild
408 * - Outputs written with Io are deleted when abandoned.
409 */
410export class Io {
411 constructor(public key: string) {}
412 reads: Reads = { files: new Set(), works: new Set() };
413 writes: Writes = { files: new Set(), assets: new Map() };
414
415 #trackFs(file: string) {
416 const resolved = toAbs(file);
417 const key = toRel(resolved);
418 this.reads.files.add(key);
419 return { resolved, key };
420 }
421 async readWork<T>(ref: Ref<T>): Promise<T> {
422 this.reads.works.add(ref.key);
423 return await ref;
424 }
425 /** Track a file in the compilation without reading it. */
426 async trackFile(file: string) {
427 const { key, resolved } = this.#trackFs(file);
428 if (!files.get(key)) {
429 let lastModified: number = 0;
430 try {
431 lastModified = Math.floor((await fs.stat(file)).mtimeMs);
432 files.set(key, { type: "f", lastModified, affects: [] });
433 } catch {
434 files.set(key, { type: "null", affects: [] });
495435 }
496436 }
437 return resolved;
497438 }
498
499 toDisk(file = ".clover/incr.state") {
500 const buffer = this.serialize();
501 fs.writeFileSync(file, buffer);
439 async readFile(file: string) {
440 return fs.readFile(await this.trackFile(file), "utf-8");
502441 }
503
504 static fromDisk(file = ".clover/incr.state"): Incremental {
442 async readJson<T>(file: string) {
443 return JSON.parse(await this.readFile(file)) as T;
444 }
445 async readDir(dir: string) {
446 const { key, resolved } = this.#trackFs(dir);
447 const existing = files.get(key);
505448 try {
506 const buffer = fs.readFileSync(file);
507 return Incremental.fromSerialized(buffer);
508 } catch (err: any) {
509 if (err.code === "ENOENT") return new Incremental();
449 if (existing?.type === 'd') return existing.contents;
450 const contents = await fs.readdir(resolved);
451 contents.sort();
452 const contentHash = crypto
453 .createHash("sha1")
454 .update(contents.join("\0"))
455 .digest("base64url");
456 files.set(key, {
457 type: "d",
458 affects: [],
459 contentHash,
460 contents,
461 });
462 return contents;
463 } catch (err) {
464 if (!existing) files.set(key, { type: "null", affects: [] });
510465 throw err;
511466 }
512467 }
513
514 async wait() {
515 await this.compressQueue.done({ method: "success" });
468 async readDirRecursive(dir: string): Promise<string[]> {
469 const dirs = await this.readDir(dir);
470 return (
471 await Promise.all(
472 dirs.map(async (child) => {
473 const abs = path.join(dir, child);
474 const stat = await fs.stat(abs);
475 if (stat.isDirectory()) {
476 return (await this.readDirRecursive(abs)).map((grand) =>
477 path.join(child, grand)
478 );
479 } else {
480 return child;
481 }
482 }),
483 )
484 ).flat();
516485 }
517
518 async flush(
519 platform: bundle.ServerPlatform,
520 dir = path.resolve(".clover/out"),
486 /* Track all dependencies of a module. */
487 async import<T>(file: string): Promise<T> {
488 const { resolved } = this.#trackFs(file);
489 try {
490 return require(resolved) as T;
491 } finally {
492 const queue = [resolved];
493 const seen = new Set<string>();
494 let current;
495 while ((current = queue.shift())) {
496 const stat = hot.getFileStat(current);
497 if (!stat) continue;
498 const { key } = this.#trackFs(current);
499 if (!files.get(key)) {
500 files.set(key, {
501 type: "f",
502 affects: [],
503 lastModified: stat?.lastModified ?? 0,
504 });
505 }
506 for (const imp of stat.imports) {
507 if (!seen.has(imp)) {
508 seen.add(imp);
509 queue.push(imp);
510 }
511 }
512 }
513 }
514 }
515 async writeAsset(
516 pathname: string,
517 blob: string | Buffer,
518 headersOption?: HeadersInit,
521519 ) {
522 ASSERT(!this.compressQueue.active);
523 const join = (...args: string[]) => path.join(dir, ...args);
524 const writer = new BufferWriter();
525
526 // TODO: ensure all compressed got compressed
527
528 const asset = Object.fromEntries(
529 Array.from(this.out.asset, ([key, { buffer, hash, headers }]) => {
530 const raw = writer.write(buffer, hash);
531 const { gzip: gzipBuf, zstd: zstdBuf } = this.compress.get(hash) ?? {};
532 const gzip = writer.write(UNWRAP(gzipBuf), hash + ".gz");
533 const zstd = writer.write(UNWRAP(zstdBuf), hash + ".zstd");
534 return [key, { raw, gzip, zstd, headers }];
535 }),
536 );
537 const backendBundle = UNWRAP(this.out.backendBundle.get(platform));
538
539 // Arrange output files
540 const outFiles: Array<[file: string, contents: string | Buffer]> = [
541 // Asset manifest
542 ["static.json", JSON.stringify(asset)],
543 ["static.blob", writer.get()],
544
545 // Backend
546 ...Object.entries(backendBundle.files).map(([subPath, contents]) =>
547 [
548 subPath,
549 subPath === backendBundle.fileWithMagicWord
550 ? UNWRAP(this.out.backendReplace.get(platform))
551 : contents,
552 ] as [string, Buffer]
553 ),
554 ];
520 ASSERT(pathname.startsWith("/"));
521 ASSERT(!seenWrites.has("a:" + pathname));
555522
556 // TODO: check duplicates
523 const buffer = typeof blob === "string" ? Buffer.from(blob) : blob;
557524
558 // Perform all i/o
559 await Promise.all(
560 outFiles.map(([subPath, contents]) =>
561 fs.writeMkdir(join(subPath), contents, { flush: true })
562 ),
525 const headers = new Headers(headersOption ?? {});
526 const hash = crypto.createHash("sha1").update(buffer).digest("hex");
527 if (!headers.has("Content-Type")) {
528 headers.set("Content-Type", mime.contentTypeFor(pathname));
529 }
530 headers.set("ETag", JSON.stringify(hash));
531 this.writes.assets.set(pathname, {
532 hash,
533 // @ts-expect-error TODO
534 headers: Object.fromEntries(headers),
535 });
536 if (!assets.has(hash)) {
537 jobs += 1;
538 assets.set(hash, undefined!);
539 const [gzipBuffer, zstdBuffer] = await Promise.all([
540 gzip(buffer),
541 zstdCompress(buffer),
542 ]);
543 assets.set(hash, {
544 raw: buffer,
545 gzip: gzipBuffer,
546 zstd: zstdBuffer,
547 });
548 jobs -= 1;
549 }
550 }
551 writeFile(subPath: string, blob: string | Buffer) {
552 ASSERT(!subPath.startsWith("/"));
553 ASSERT(
554 !seenWrites.has("f:" + subPath),
555 `File overwritten: ${JSON.stringify(subPath)}`,
563556 );
557 seenWrites.add("f:" + subPath);
558 const buffer = Buffer.isBuffer(blob) ? blob : Buffer.from(blob);
559 writes.set(subPath, {
560 buffer,
561 size: buffer.byteLength,
562 work: this.key,
563 });
564564 }
565565}
566566
567export interface PutAsset extends PutBase {
568 body: string | Buffer;
569 headers?: Record<string, string | undefined>;
570}
571
572export interface Compressed {
573 gzip?: Buffer;
574 zstd?: Buffer;
575}
576
577export interface CompressJob {
578 algo: "zstd" | "gzip";
579 buffer: Buffer;
580 label: string;
581 hash: string;
582}
583
584567class BufferWriter {
585568 size = 0;
586 seen = new Map<string, View>();
569 seen = new Map<string, BufferView>();
587570 buffers: Buffer[] = [];
588571
589 write(buffer: Buffer, hash: string): View {
572 write(buffer: Buffer, hash: string): BufferView {
590573 let view = this.seen.get(hash);
591574 if (view) return view;
592575 view = [this.size, this.size += buffer.byteLength];
......@@ -600,58 +583,101 @@ class BufferWriter {
600583 }
601584}
602585
603export type View = [start: number, end: number];
586export function validateSerializable(value: unknown, key: string) {
587 if (typeof value === "string") {
588 if (value.includes(hot.projectRoot)) {
589 throw new Error(
590 `Return value must not contain the CWD for portability, found at ${key}`,
591 );
592 }
593 } else if (value && typeof value === "object") {
594 if (Array.isArray(value)) {
595 value.forEach((item, i) => validateSerializable(item, `${key}[${i}]`));
596 } else if (Object.getPrototypeOf(value) === Object.prototype || Buffer.isBuffer(value)) {
597 Object.entries(value).forEach(([k, v]) =>
598 validateSerializable(v, `${key}.${k}`)
599 );
600 } else {
601 throw new Error(
602 `Return value must be a plain JS object, found ${
603 Object.getPrototypeOf(value).constructor.name
604 } at ${key}`,
605 );
606 }
607 } else if (["bigint", "function", "symbol"].includes(typeof value)) {
608 throw new Error(
609 `Return value must be a plain JS object, found ${typeof value} at ${key}`,
610 );
611 }
612}
604613
605export interface BuiltAssetMap {
606 [route: string]: BuiltAsset;
614export function toAbs(absPath: string) {
615 return path.resolve(hot.projectRoot, absPath);
607616}
608617
609export interface BuiltAsset {
610 raw: View;
611 gzip: View;
612 zstd: View;
613 headers: Record<string, string>;
618export function toRel(absPath: string) {
619 return path.relative(hot.projectRoot, absPath).replaceAll("\\", "/");
614620}
615621
616export interface SerializedMeta {
617 asset: Array<[route: string, data: {
618 raw: View;
619 gzip: View | null;
620 zstd: View | null;
622export type BufferView = [start: number, end: number];
623interface Reads {
624 files: Set<string>;
625 works: Set<string>;
626}
627interface FileWrite {
628 buffer: Buffer | null;
629 size: number;
630 work: string;
631}
632interface Writes {
633 files: Set<string>;
634 assets: Map<string, {
621635 hash: string;
622636 headers: Record<string, string>;
623 }]>;
624 script: Array<[key: string, value: string]>;
625 style: Array<[key: string, value: string]>;
626 pageMetadata: Array<[key: string, PageMetadata]>;
627 viewMetadata: Array<[key: string, ViewMetadata]>;
628 serverMarko: Array<[key: string, hot.MarkoCacheEntry]>;
629 backendBundle: Array<[platform: string, {
630 magicWord: string;
631 fileWithMagicWord: string | null;
632 files: Array<[string, View]>;
633 }]>;
634 backendReplace: Array<[key: string, View]>;
635
636 invals: Array<[key: string, {
637 /** Modified */
638 m: number;
639 f: SourceId[];
640 o: ArtifactId[];
641 }]>;
642 sources: Array<[string, ...string[]]>;
637 }>;
638}
639interface Asset {
640 raw: Buffer;
641 gzip: Buffer;
642 zstd: Buffer;
643}
644interface Work<T = unknown> {
645 debug?: string;
646 value: T;
647 reads: Reads;
648 writes: Writes;
649 affects: string[];
650}
651type TrackedFile =
652 & {
653 affects: string[];
654 }
655 & (
656 | { type: "f"; lastModified: number }
657 | { type: "d"; contentHash: string; contents: string[] }
658 | { type: "null"; }
659 );
660export interface BuiltAssetMap {
661 [route: string]: BuiltAsset;
662}
663export interface BuiltAsset {
664 raw: BufferView;
665 gzip: BufferView;
666 zstd: BufferView;
667 headers: Record<string, string>;
643668}
644669
645670const gzip = util.promisify(zlib.gzip);
646const zstd = util.promisify(zlib.zstdCompress);
671const zstdCompress = util.promisify(zlib.zstdCompress);
647672
648673import * as fs from "#sitegen/fs";
649import * as zlib from "node:zlib";
650import * as util from "node:util";
651import { Queue } from "#sitegen/async";
674import * as path from "node:path";
652675import * as hot from "./hot.ts";
676import * as util from "node:util";
677import * as crypto from "node:crypto";
653678import * as mime from "#sitegen/mime";
654import * as path from "node:path";
655import { Buffer } from "node:buffer";
656import * as css from "./css.ts";
657import type * as bundle from "./bundle.ts";
679import * as zlib from "node:zlib";
680import * as console from "@paperclover/console";
681import { Spinner } from "@paperclover/console/Spinner";
682import { formatSize } from "@/file-viewer/format.ts";
683import * as msgpackr from "msgpackr";
framework/lib/assets.ts+2-2
......@@ -69,7 +69,7 @@ export function etagMatches(etag: string, ifNoneMatch: string) {
6969 return ifNoneMatch === etag || ifNoneMatch.split(/,\s*/).indexOf(etag) > -1;
7070}
7171
72function subarrayAsset([start, end]: View) {
72function subarrayAsset([start, end]: BufferView) {
7373 return assets!.buf.subarray(start, end);
7474}
7575
......@@ -115,6 +115,6 @@ process.on("message", (msg: any) => {
115115import * as fs from "#sitegen/fs";
116116import type { Context, Next } from "hono";
117117import type { StatusCode } from "hono/utils/http-status";
118import type { BuiltAsset, BuiltAssetMap, View } from "../incremental.ts";
118import type { BuiltAsset, BuiltAssetMap, BufferView } from "../incremental.ts";
119119import { Buffer } from "node:buffer";
120120import * as path from "node:path";
framework/lib/testing.ts created+11
......@@ -0,0 +1,11 @@
1export function tmpFile(basename: string) {
2 const file = path.join(import.meta.dirname, '../../.clover/testing', basename);
3 return {
4 path: file,
5 read: fs.readFile.bind(fs, file),
6 write: fs.writeMkdir.bind(fs, file),
7 };
8}
9
10import * as path from 'node:path';
11import * as fs from './fs.ts';
framework/marko.ts created+46
......@@ -0,0 +1,46 @@
1console.log("MARKO");
2export interface MarkoCacheEntry {
3 src: string;
4 scannedClientRefs: string[];
5}
6
7export const markoCache = new Map<string, MarkoCacheEntry>();
8
9export function loadMarko(module: NodeJS.Module, filepath: string) {
10 let cache = markoCache.get(filepath);
11 console.log({ filepath, has: !!cache })
12 if (!cache) {
13 let src = fs.readFileSync(filepath, "utf8");
14 // A non-standard thing here is Clover Sitegen implements
15 // its own client side scripting stuff, so it overrides
16 // bare client import statements to it's own usage.
17 const scannedClientRefs = new Set<string>();
18 if (src.match(/^\s*client\s+import\s+["']/m)) {
19 src = src.replace(
20 /^\s*client\s+import\s+("[^"]+"|'[^']+')[^\n]+/m,
21 (_, src) => {
22 const ref = JSON.parse(`"${src.slice(1, -1)}"`);
23 const resolved = hot.resolveClientRef(filepath, ref);
24 scannedClientRefs.add(resolved);
25 return `<CloverScriptInclude=${
26 JSON.stringify(hot.getScriptId(resolved))
27 } />`;
28 },
29 ) + '\nimport { addScript as CloverScriptInclude } from "#sitegen";\n';
30 }
31
32 src = marko.compileSync(src, filepath).code;
33 src = src.replace("marko/debug/html", "#ssr/marko");
34 cache = { src, scannedClientRefs: Array.from(scannedClientRefs) };
35 markoCache.set(filepath, cache);
36 }
37
38 const { src, scannedClientRefs } = cache;
39 return hot.loadEsbuildCode(module, filepath, src, {
40 scannedClientRefs,
41 });
42}
43
44import * as marko from "@marko/compiler";
45import * as hot from "./hot.ts";
46import * as fs from "#sitegen/fs";
framework/watch.ts+67-91
......@@ -2,102 +2,67 @@
22
33const debounceMilliseconds = 25;
44
5export async function main() {
6 let subprocess: child_process.ChildProcess | null = null;
5let subprocess: child_process.ChildProcess | null = null;
6process.on("beforeExit", () => {
7 subprocess?.removeListener("close", onSubprocessClose);
8});
9
10let watch: Watch;
711
12export async function main() {
813 // Catch up state by running a main build.
9 const { incr } = await generate.main();
10 // ...and watch the files that cause invals.
11 const watch = new Watch(rebuild);
12 watch.add(...incr.invals.keys());
13 statusLine();
14 // ... and then serve it!
15 serve();
16
17 function serve() {
18 if (subprocess) {
19 subprocess.removeListener("close", onSubprocessClose);
20 subprocess.kill();
21 }
22 subprocess = child_process.fork(".clover/out/server.js", [
23 "--development",
24 ], {
25 stdio: "inherit",
26 });
27 subprocess.on("close", onSubprocessClose);
28 }
14 await incr.restore();
15 watch = new Watch(rebuild);
16 rebuild([]);
17}
2918
30 function onSubprocessClose(code: number | null, signal: string | null) {
31 subprocess = null;
32 const status = code != null ? `code ${code}` : `signal ${signal}`;
33 console.error(`Backend process exited with ${status}`);
19function serve() {
20 if (subprocess) {
21 subprocess.removeListener("close", onSubprocessClose);
22 subprocess.kill();
3423 }
35
36 process.on("beforeExit", () => {
37 subprocess?.removeListener("close", onSubprocessClose);
24 subprocess = child_process.fork(".clover/o/backend.js", [
25 "--development",
26 ], {
27 stdio: "inherit",
3828 });
29 subprocess.on("close", onSubprocessClose);
30}
3931
40 function rebuild(files: string[]) {
41 files = files.map((file) => path.relative(hot.projectRoot, file));
42 const changed: string[] = [];
43 for (const file of files) {
44 let mtimeMs: number | null = null;
45 try {
46 mtimeMs = fs.statSync(file).mtimeMs;
47 } catch (err: any) {
48 if (err?.code !== "ENOENT") throw err;
49 }
50 if (incr.updateStat(file, mtimeMs)) changed.push(file);
51 }
52 if (changed.length === 0) {
53 console.warn("Files were modified but the 'modify' time did not change.");
54 return;
32function onSubprocessClose(code: number | null, signal: string | null) {
33 subprocess = null;
34 const status = code != null ? `code ${code}` : `signal ${signal}`;
35 console.error(`Backend process exited with ${status}`);
36}
37
38async function rebuild(files: string[]) {
39 const hasInvalidated = files.length === 0
40 || (await Promise.all(files.map(incr.invalidate))).some(Boolean);
41 if (!hasInvalidated) return;
42 incr.compile(generate.generate).then(({
43 watchFiles,
44 newOutputs,
45 newAssets
46 }) => {
47 const removeWatch = [...watch.files].filter(x => !watchFiles.has(x))
48 for (const file of removeWatch) watch.remove(file);
49 watch.add(...watchFiles);
50 // Restart the server if it was changed or not running.
51 if (!subprocess || newOutputs.includes("backend.js")) {
52 serve();
53 } else if (subprocess && newAssets) {
54 subprocess.send({ type: "clover.assets.reload" });
5555 }
56 withSpinner<any, Awaited<ReturnType<typeof generate.sitegen>>>({
57 text: "Rebuilding",
58 successText: generate.successText,
59 failureText: () => "sitegen FAIL",
60 }, async (spinner) => {
61 console.info("---");
62 console.info(
63 "Updated" +
64 (changed.length === 1
65 ? " " + changed[0]
66 : changed.map((file) => "\n- " + file)),
67 );
68 const result = await generate.sitegen(spinner, incr);
69 incr.toDisk(); // Allows picking up this state again
70 for (const file of watch.files) {
71 const relative = path.relative(hot.projectRoot, file);
72 if (!incr.invals.has(relative)) watch.remove(file);
73 }
74 return result;
75 }).then((result) => {
76 // Restart the server if it was changed or not running.
77 if (
78 !subprocess ||
79 result.inserted.some(({ kind }) => kind === "backendReplace")
80 ) {
81 serve();
82 } else if (
83 subprocess &&
84 result.inserted.some(({ kind }) => kind === "asset")
85 ) {
86 subprocess.send({ type: "clover.assets.reload" });
87 }
88 return result;
89 }).catch((err) => {
90 console.error(util.inspect(err));
91 }).finally(statusLine);
92 }
56 }).catch((err) => {
57 console.error(util.inspect(err));
58 }).finally(statusLine);
59}
9360
94 function statusLine() {
95 console.info(
96 `Watching ${incr.invals.size} files \x1b[36m[last change: ${
97 new Date().toLocaleTimeString()
98 }]\x1b[39m`,
99 );
100 }
61function statusLine() {
62 console.info(
63 `Watching ${watch.files.size} files `
64 + `\x1b[36m[last change: ${new Date().toLocaleTimeString()}]\x1b[39m`,
65 );
10166}
10267
10368class Watch {
......@@ -174,11 +139,21 @@ class Watch {
174139 for (const w of this.watchers) w.close();
175140 }
176141
142 #getFiles(absPath: string, event: fs.WatchEventType) {
143 const files = [];
144 if (this.files.has(absPath)) files.push(absPath);
145 if (event === 'rename') {
146 const dir = path.dirname(absPath);
147 if (this.files.has(dir)) files.push(dir);
148 }
149 return files;
150 }
151
177152 #handleEvent(root: string, event: fs.WatchEventType, subPath: string | null) {
178153 if (!subPath) return;
179 const file = path.join(root, subPath);
180 if (!this.files.has(file)) return;
181 this.stale.add(file);
154 const files = this.#getFiles(path.join(root, subPath), event);
155 if (files.length === 0) return;
156 for(const file of files) this.stale.add(file);
182157 const { debounce } = this;
183158 if (debounce !== null) clearTimeout(debounce);
184159 this.debounce = setTimeout(() => {
......@@ -192,6 +167,7 @@ class Watch {
192167import * as fs from "node:fs";
193168import { withSpinner } from "@paperclover/console/Spinner";
194169import * as generate from "./generate.ts";
170import * as incr from "./incremental.ts";
195171import * as path from "node:path";
196172import * as util from "node:util";
197173import * as hot from "./hot.ts";
package-lock.json+126
......@@ -15,8 +15,10 @@
1515 "hls.js": "^1.6.5",
1616 "hono": "^4.7.11",
1717 "marko": "^6.0.20",
18 "msgpackr": "^1.11.5",
1819 "puppeteer": "^24.10.1",
1920 "sharp": "^0.34.2",
21 "source-map-support": "^0.5.21",
2022 "unique-names-generator": "^4.7.1",
2123 "vscode-oniguruma": "^2.0.1",
2224 "vscode-textmate": "^9.2.0"
......@@ -1478,6 +1480,84 @@
14781480 "url": "https://opencollective.com/unified"
14791481 }
14801482 },
1483 "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
1484 "version": "3.0.3",
1485 "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
1486 "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
1487 "cpu": [
1488 "arm64"
1489 ],
1490 "license": "MIT",
1491 "optional": true,
1492 "os": [
1493 "darwin"
1494 ]
1495 },
1496 "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
1497 "version": "3.0.3",
1498 "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
1499 "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
1500 "cpu": [
1501 "x64"
1502 ],
1503 "license": "MIT",
1504 "optional": true,
1505 "os": [
1506 "darwin"
1507 ]
1508 },
1509 "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
1510 "version": "3.0.3",
1511 "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
1512 "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
1513 "cpu": [
1514 "arm"
1515 ],
1516 "license": "MIT",
1517 "optional": true,
1518 "os": [
1519 "linux"
1520 ]
1521 },
1522 "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
1523 "version": "3.0.3",
1524 "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
1525 "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
1526 "cpu": [
1527 "arm64"
1528 ],
1529 "license": "MIT",
1530 "optional": true,
1531 "os": [
1532 "linux"
1533 ]
1534 },
1535 "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
1536 "version": "3.0.3",
1537 "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
1538 "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
1539 "cpu": [
1540 "x64"
1541 ],
1542 "license": "MIT",
1543 "optional": true,
1544 "os": [
1545 "linux"
1546 ]
1547 },
1548 "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
1549 "version": "3.0.3",
1550 "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
1551 "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
1552 "cpu": [
1553 "x64"
1554 ],
1555 "license": "MIT",
1556 "optional": true,
1557 "os": [
1558 "win32"
1559 ]
1560 },
14811561 "node_modules/@paperclover/console": {
14821562 "resolved": "git+https://git.paperclover.net/clo/console.git#1a6ac2b79fdd8a21a1c57d25723975872bc07e3e",
14831563 "dependencies": {
......@@ -3765,6 +3845,37 @@
37653845 "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
37663846 "license": "MIT"
37673847 },
3848 "node_modules/msgpackr": {
3849 "version": "1.11.5",
3850 "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz",
3851 "integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==",
3852 "license": "MIT",
3853 "optionalDependencies": {
3854 "msgpackr-extract": "^3.0.2"
3855 }
3856 },
3857 "node_modules/msgpackr-extract": {
3858 "version": "3.0.3",
3859 "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
3860 "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
3861 "hasInstallScript": true,
3862 "license": "MIT",
3863 "optional": true,
3864 "dependencies": {
3865 "node-gyp-build-optional-packages": "5.2.2"
3866 },
3867 "bin": {
3868 "download-msgpackr-prebuilds": "bin/download-prebuilds.js"
3869 },
3870 "optionalDependencies": {
3871 "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
3872 "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
3873 "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
3874 "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
3875 "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
3876 "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
3877 }
3878 },
37683879 "node_modules/netmask": {
37693880 "version": "2.0.2",
37703881 "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz",
......@@ -3774,6 +3885,21 @@
37743885 "node": ">= 0.4.0"
37753886 }
37763887 },
3888 "node_modules/node-gyp-build-optional-packages": {
3889 "version": "5.2.2",
3890 "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
3891 "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
3892 "license": "MIT",
3893 "optional": true,
3894 "dependencies": {
3895 "detect-libc": "^2.0.1"
3896 },
3897 "bin": {
3898 "node-gyp-build-optional-packages": "bin.js",
3899 "node-gyp-build-optional-packages-optional": "optional.js",
3900 "node-gyp-build-optional-packages-test": "build-test.js"
3901 }
3902 },
37773903 "node_modules/node-releases": {
37783904 "version": "2.0.19",
37793905 "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
package.json+2
......@@ -11,8 +11,10 @@
1111 "hls.js": "^1.6.5",
1212 "hono": "^4.7.11",
1313 "marko": "^6.0.20",
14 "msgpackr": "^1.11.5",
1415 "puppeteer": "^24.10.1",
1516 "sharp": "^0.34.2",
17 "source-map-support": "^0.5.21",
1618 "unique-names-generator": "^4.7.1",
1719 "vscode-oniguruma": "^2.0.1",
1820 "vscode-textmate": "^9.2.0"
src/file-viewer/bin/scan3.ts+120-120
......@@ -17,9 +17,9 @@ export async function main() {
1717 const start = performance.now();
1818 const timerSpinner = new Spinner({
1919 text: () =>
20 `paper clover's scan3 [${
21 ((performance.now() - start) / 1000).toFixed(1)
22 }s]`,
20 `paper clover's scan3 [${((performance.now() - start) / 1000).toFixed(
21 1,
22 )}s]`,
2323 fps: 10,
2424 });
2525 using _endTimerSpinner = { [Symbol.dispose]: () => timerSpinner.stop() };
......@@ -38,20 +38,23 @@ export async function main() {
3838 qList.addMany(items.map((subPath) => path.join(absPath, subPath)));
3939
4040 if (mediaFile) {
41 const deleted = mediaFile.getChildren()
41 const deleted = mediaFile
42 .getChildren()
4243 .filter((child) => !items.includes(child.basename))
4344 .flatMap((child) =>
4445 child.kind === MediaFileKind.directory
4546 ? child.getRecursiveFileChildren()
46 : child
47 : child,
4748 );
4849
49 qMeta.addMany(deleted.map((mediaFile) => ({
50 absPath: path.join(root, mediaFile.path),
51 publicPath: mediaFile.path,
52 stat: null,
53 mediaFile,
54 })));
50 qMeta.addMany(
51 deleted.map((mediaFile) => ({
52 absPath: path.join(root, mediaFile.path),
53 publicPath: mediaFile.path,
54 stat: null,
55 mediaFile,
56 })),
57 );
5558 }
5659
5760 return;
......@@ -96,13 +99,13 @@ export async function main() {
9699 if (
97100 mediaFile &&
98101 mediaFile.date.getTime() < stat.mtime.getTime() &&
99 (Date.now() - stat.mtime.getTime()) < monthMilliseconds
102 Date.now() - stat.mtime.getTime() < monthMilliseconds
100103 ) {
101104 date = mediaFile.date;
102105 console.warn(
103 `M-time on ${publicPath} was likely corrupted. ${
104 formatDate(mediaFile.date)
105 } -> ${formatDate(stat.mtime)}`,
106 `M-time on ${publicPath} was likely corrupted. ${formatDate(
107 mediaFile.date,
108 )} -> ${formatDate(stat.mtime)}`,
106109 );
107110 }
108111 mediaFile = MediaFile.createFile({
......@@ -129,7 +132,10 @@ export async function main() {
129132 await processor.run({ absPath, stat, mediaFile, spin });
130133 mediaFile.setProcessed(mediaFile.processed | (1 << (16 + index)));
131134 for (const dependantJob of after) {
132 ASSERT(dependantJob.needs > 0, `dependantJob.needs > 0, ${dependantJob.needs}`);
135 ASSERT(
136 dependantJob.needs > 0,
137 `dependantJob.needs > 0, ${dependantJob.needs}`,
138 );
133139 dependantJob.needs -= 1;
134140 if (dependantJob.needs == 0) qProcess.add(dependantJob);
135141 }
......@@ -149,25 +155,27 @@ export async function main() {
149155 }));
150156 }
151157
152 async function queueProcessors(
153 { absPath, stat, mediaFile }: Omit<ProcessFileArgs, "spin">,
154 ) {
158 async function queueProcessors({
159 absPath,
160 stat,
161 mediaFile,
162 }: Omit<ProcessFileArgs, "spin">) {
155163 const ext = mediaFile.extensionNonEmpty.toLowerCase();
156164 let possible = processors.filter((p) =>
157 p.include ? p.include.has(ext) : !p.exclude?.has(ext)
165 p.include ? p.include.has(ext) : !p.exclude?.has(ext),
158166 );
159167 if (possible.length === 0) return;
160168
161169 const hash = possible.reduce((a, b) => a ^ b.hash, 0) | 1;
162 ASSERT(hash <= 0xFFFF, `${hash.toString(16)} has no bits above 16 set`);
170 ASSERT(hash <= 0xffff, `${hash.toString(16)} has no bits above 16 set`);
163171 let processed = mediaFile.processed;
164172
165173 // If the hash has changed, migrate the bitfield over.
166174 // This also runs when the processor hash is in it's initial 0 state.
167175 const order = decodeProcessors(mediaFile.processors);
168 if ((processed & 0xFFFF) !== hash) {
169 const previous = order.filter((_, i) =>
170 (processed & (1 << (16 + i))) !== 0
176 if ((processed & 0xffff) !== hash) {
177 const previous = order.filter(
178 (_, i) => (processed & (1 << (16 + i))) !== 0,
171179 );
172180 processed = hash;
173181 for (const { id, hash } of previous) {
......@@ -182,13 +190,13 @@ export async function main() {
182190 }
183191 mediaFile.setProcessors(
184192 processed,
185 possible.map((p) =>
186 p.id + String.fromCharCode(p.hash >> 8, p.hash & 0xFF)
187 ).join(";"),
193 possible
194 .map((p) => p.id + String.fromCharCode(p.hash >> 8, p.hash & 0xff))
195 .join(";"),
188196 );
189197 } else {
190198 possible = order.map(({ id }) =>
191 UNWRAP(possible.find((p) => p.id === id))
199 UNWRAP(possible.find((p) => p.id === id)),
192200 );
193201 }
194202
......@@ -225,8 +233,9 @@ export async function main() {
225233
226234 async function runUndoProcessors(mediaFile: MediaFile) {
227235 const { processed } = mediaFile;
228 const previous = decodeProcessors(mediaFile.processors)
229 .filter((_, i) => (processed & (1 << (16 + i))) !== 0);
236 const previous = decodeProcessors(mediaFile.processors).filter(
237 (_, i) => (processed & (1 << (16 + i))) !== 0,
238 );
230239 for (const { id } of previous) {
231240 const p = processors.find((p) => p.id === id);
232241 if (!p) continue;
......@@ -244,22 +253,23 @@ export async function main() {
244253 await qProcess.done();
245254
246255 // Update directory metadata
247 const dirs = MediaFile.getDirectoriesToReindex()
248 .sort((a, b) => b.path.length - a.path.length);
256 const dirs = MediaFile.getDirectoriesToReindex().sort(
257 (a, b) => b.path.length - a.path.length,
258 );
249259 for (const dir of dirs) {
250260 const children = dir.getChildren();
251261
252262 // readme.txt
253 const readmeContent = children.find((x) =>
254 x.basename === "readme.txt"
255 )?.contents ?? "";
263 const readmeContent =
264 children.find((x) => x.basename === "readme.txt")?.contents ?? "";
256265
257266 // dirsort
258267 let dirsort: string[] | null = null;
259268 const dirSortRaw =
260269 children.find((x) => x.basename === ".dirsort")?.contents ?? "";
261270 if (dirSortRaw) {
262 dirsort = dirSortRaw.split("\n")
271 dirsort = dirSortRaw
272 .split("\n")
263273 .map((x) => x.trim())
264274 .filter(Boolean);
265275 }
......@@ -284,7 +294,8 @@ export async function main() {
284294 }
285295 }
286296
287 const dirHash = crypto.createHash("sha1")
297 const dirHash = crypto
298 .createHash("sha1")
288299 .update(dir.path + allHashes)
289300 .digest("hex");
290301
......@@ -323,19 +334,21 @@ export async function main() {
323334
324335 console.info(
325336 "Updated file viewer index in \x1b[1m" +
326 ((performance.now() - start) / 1000).toFixed(1) + "s\x1b[0m",
337 ((performance.now() - start) / 1000).toFixed(1) +
338 "s\x1b[0m",
327339 );
328340
329341 MediaFile.db.prepare("VACUUM").run();
330 const { duration, count } = MediaFile.db.prepare<
331 [],
332 { count: number; duration: number }
333 >(`
342 const { duration, count } = MediaFile.db
343 .prepare<[], { count: number; duration: number }>(
344 `
334345 select
335346 count(*) as count,
336347 sum(duration) as duration
337348 from media_files
338 `).getNonNull();
349 `,
350 )
351 .getNonNull();
339352
340353 console.info();
341354 console.info(
......@@ -365,7 +378,7 @@ const execFile: typeof execFileRaw = ((
365378) =>
366379 execFileRaw(...args).catch((e: any) => {
367380 if (e?.message?.startsWith?.("Command failed")) {
368 if (e.code > (2 ** 31)) e.code |= 0;
381 if (e.code > 2 ** 31) e.code |= 0;
369382 const code = e.signal ? `signal ${e.signal}` : `code ${e.code}`;
370383 e.message = `${e.cmd.split(" ")[0]} failed with ${code}`;
371384 }
......@@ -374,11 +387,7 @@ const execFile: typeof execFileRaw = ((
374387const ffprobeBin = testProgram("ffprobe", "--help");
375388const ffmpegBin = testProgram("ffmpeg", "--help");
376389
377const ffmpegOptions = [
378 "-hide_banner",
379 "-loglevel",
380 "warning",
381];
390const ffmpegOptions = ["-hide_banner", "-loglevel", "warning"];
382391
383392const procDuration: Process = {
384393 name: "calculate duration",
......@@ -496,27 +505,23 @@ const procImageSubsets: Process = {
496505 for (const size of targetSizes) {
497506 const { w, h } = resizeDimensions(width, height, size);
498507 for (const { ext, args } of transcodeRules.imagePresets) {
499 spin.text = baseStatus +
500 ` (${w}x${h}, ${ext.slice(1).toUpperCase()})`;
508 spin.text = baseStatus + ` (${w}x${h}, ${ext.slice(1).toUpperCase()})`;
501509
502510 stack.use(
503 await produceAsset(
504 `${mediaFile.hash}/${size}${ext}`,
505 async (out) => {
506 await fs.mkdir(path.dirname(out));
507 await fs.rm(out, { force: true });
508 await execFile(ffmpegBin!, [
509 ...ffmpegOptions,
510 "-i",
511 absPath,
512 "-vf",
513 `scale=${w}:${h}:force_original_aspect_ratio=increase,crop=${w}:${h}`,
514 ...args,
515 out,
516 ]);
517 return [out];
518 },
519 ),
511 await produceAsset(`${mediaFile.hash}/${size}${ext}`, async (out) => {
512 await fs.mkdir(path.dirname(out));
513 await fs.rm(out, { force: true });
514 await execFile(ffmpegBin!, [
515 ...ffmpegOptions,
516 "-i",
517 absPath,
518 "-vf",
519 `scale=${w}:${h}:force_original_aspect_ratio=increase,crop=${w}:${h}`,
520 ...args,
521 out,
522 ]);
523 return [out];
524 }),
520525 );
521526 }
522527 }
......@@ -561,21 +566,17 @@ const procVideos = transcodeRules.videoFormats.map<Process>((preset) => ({
561566 if (config.encoder && typeof config.encoder.videoSrc === "string") {
562567 const { videoSrc, audioSrc, rate } = config.encoder;
563568 inputArgs = [
564 ...rate ? ["-r", String(rate)] : [],
569 ...(rate ? ["-r", String(rate)] : []),
565570 "-i",
566571 videoSrc,
567 ...audioSrc ? ["-i", audioSrc] : [],
572 ...(audioSrc ? ["-i", audioSrc] : []),
568573 ];
569574 }
570575 } catch (err: any) {
571576 if (err?.code !== "ENOENT") throw err;
572577 }
573578
574 const args = transcodeRules.getVideoArgs(
575 preset,
576 base,
577 inputArgs,
578 );
579 const args = transcodeRules.getVideoArgs(preset, base, inputArgs);
579580 try {
580581 const fakeProgress = new Progress({ text: spin.text, spinner: null });
581582 fakeProgress.stop();
......@@ -612,22 +613,25 @@ const procVideos = transcodeRules.videoFormats.map<Process>((preset) => ({
612613const procCompression = [
613614 { name: "gzip", fn: () => zlib.createGzip({ level: 9 }) },
614615 { name: "zstd", fn: () => zlib.createZstdCompress() },
615].map(({ name, fn }) => ({
616 name: `compress ${name}`,
617 exclude: rules.extsPreCompressed,
618 async run({ absPath, mediaFile }) {
619 if ((mediaFile.size ?? 0) < 10) return;
620 await produceAsset(`${mediaFile.hash}/${name}`, async (base) => {
621 fs.mkdirSync(path.dirname(base));
622 await stream.promises.pipeline(
623 fs.createReadStream(absPath),
624 fn(),
625 fs.createWriteStream(base),
626 );
627 return [base];
628 });
629 },
630} satisfies Process as Process));
616].map(
617 ({ name, fn }) =>
618 ({
619 name: `compress ${name}`,
620 exclude: rules.extsPreCompressed,
621 async run({ absPath, mediaFile }) {
622 if ((mediaFile.size ?? 0) < 10) return;
623 await produceAsset(`${mediaFile.hash}/${name}`, async (base) => {
624 fs.mkdirSync(path.dirname(base));
625 await stream.promises.pipeline(
626 fs.createReadStream(absPath),
627 fn(),
628 fs.createWriteStream(base),
629 );
630 return [base];
631 });
632 },
633 }) satisfies Process as Process,
634);
631635
632636const processors = [
633637 procDimensions,
......@@ -637,30 +641,29 @@ const processors = [
637641 procImageSubsets,
638642 ...procVideos,
639643 ...procCompression,
640]
641 .map((process, id, all) => {
642 const strIndex = (id: number) =>
643 String.fromCharCode("a".charCodeAt(0) + id);
644 return {
645 ...process as Process,
646 id: strIndex(id),
647 // Create a unique key.
648 hash: new Uint16Array(
649 crypto.createHash("sha1")
650 .update(
651 process.run.toString() +
652 (process.version ? String(process.version) : ""),
653 )
654 .digest().buffer,
655 ).reduce((a, b) => a ^ b),
656 depends: (process.depends ?? []).map((depend) => {
657 const index = all.findIndex((p) => p.name === depend);
658 if (index === -1) throw new Error(`Cannot find depend '${depend}'`);
659 if (index === id) throw new Error(`Cannot depend on self: '${depend}'`);
660 return strIndex(index);
661 }),
662 };
663 });
644].map((process, id, all) => {
645 const strIndex = (id: number) => String.fromCharCode("a".charCodeAt(0) + id);
646 return {
647 ...(process as Process),
648 id: strIndex(id),
649 // Create a unique key.
650 hash: new Uint16Array(
651 crypto
652 .createHash("sha1")
653 .update(
654 process.run.toString() +
655 (process.version ? String(process.version) : ""),
656 )
657 .digest().buffer,
658 ).reduce((a, b) => a ^ b),
659 depends: (process.depends ?? []).map((depend) => {
660 const index = all.findIndex((p) => p.name === depend);
661 if (index === -1) throw new Error(`Cannot find depend '${depend}'`);
662 if (index === id) throw new Error(`Cannot depend on self: '${depend}'`);
663 return strIndex(index);
664 }),
665 };
666});
664667
665668function resizeDimensions(w: number, h: number, desiredWidth: number) {
666669 ASSERT(desiredWidth < w, `${desiredWidth} < ${w}`);
......@@ -676,10 +679,7 @@ async function produceAsset(
676679 if (asset.refs === 1) {
677680 const paths = await builder(path.join(workDir, key));
678681 asset.addFiles(
679 paths.map((file) =>
680 path.relative(workDir, file)
681 .replaceAll("\\", "/")
682 ),
682 paths.map((file) => path.relative(workDir, file).replaceAll("\\", "/")),
683683 );
684684 }
685685 return {
......@@ -719,7 +719,7 @@ interface ProcessJob {
719719 absPath: string;
720720 stat: fs.Stats;
721721 mediaFile: MediaFile;
722 processor: typeof processors[0];
722 processor: (typeof processors)[0];
723723 index: number;
724724 after: ProcessJob[];
725725 needs: number;
src/global.css+11-15
......@@ -4,7 +4,9 @@
44 font-weight: 400 750;
55 font-style: normal;
66 font-display: swap;
7 font-variation-settings: "CASL" 0.25, "MONO" 0;
7 font-variation-settings:
8 "CASL" 0.25,
9 "MONO" 0;
810 font-style: oblique -15deg 0deg;
911 unicode-range: U+0020-007E;
1012}
......@@ -14,7 +16,9 @@
1416 font-weight: 400 800;
1517 font-style: normal;
1618 font-display: swap;
17 font-variation-settings: "CASL" 0.25, "MONO" 1;
19 font-variation-settings:
20 "CASL" 0.25,
21 "MONO" 1;
1822 font-style: oblique -15deg 0deg;
1923 unicode-range: U+0020-007E;
2024}
......@@ -24,21 +28,13 @@
2428 font-weight: 400 800;
2529 font-style: normal;
2630 font-display: swap;
27 font-variation-settings: "CASL" 0.25, "MONO" 1;
31 font-variation-settings:
32 "CASL" 0.25,
33 "MONO" 1;
2834 font-style: oblique -15deg 0deg;
2935 unicode-range:
30 U+00C0-00FF,
31 U+00A9,
32 U+2190-2193,
33 U+2018,
34 U+2019,
35 U+201C,
36 U+201D,
37 U+2022,
38 U+00A0-00A8,
39 U+00AA-00BF,
40 U+2194-2199,
41 U+0100-017F;
36 U+00C0-00FF, U+00A9, U+2190-2193, U+2018, U+2019, U+201C, U+201D, U+2022,
37 U+00A0-00A8, U+00AA-00BF, U+2194-2199, U+0100-017F;
4238}
4339
4440*,
src/pages/index.marko+1-1
......@@ -29,7 +29,7 @@ export const meta: Meta = {
2929<main>
3030 <div>
3131 <h2>posts</h2>
32 <p>song: <span>in the summer</span> (coming soon, 2025-07-12)</p>
32 <p>song: <a href="/in-the-summer">in the summer</a> (2025-01-01)</p>
3333 <p>song: <a href="/waterfalls">waterfalls</a> (2025-01-01)</p>
3434 <h2>things</h2>
3535 <p><a href="/q+a">questions and answers</a></p>