@kenjura/ursa 0.95.0 → 0.97.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +114 -16
  3. package/bin/ursa.js +14 -1
  4. package/meta/templates/default-template/content-hooks.js +45 -0
  5. package/meta/templates/default-template/index.html +1 -0
  6. package/meta/templates/default-template/menu.js +18 -1
  7. package/meta/templates/default-template/search.js +11 -0
  8. package/meta/templates/default-template/sticky.js +7 -1
  9. package/meta/templates/default-template/toc-generator.js +58 -38
  10. package/meta/templates/default-template/widgets.js +4 -0
  11. package/package.json +1 -2
  12. package/src/dev.js +13 -23
  13. package/src/helper/__test__/contentHash.test.js +16 -6
  14. package/src/helper/__test__/mdxRenderer.test.js +159 -0
  15. package/src/helper/__test__/sourceTimestamps.test.js +0 -0
  16. package/src/helper/assetBundler.js +93 -19
  17. package/src/helper/automenu.js +36 -11
  18. package/src/helper/build/__test__/autoIndex.test.js +2 -132
  19. package/src/helper/build/__test__/graph.test.js +259 -3
  20. package/src/helper/build/__test__/pass.test.js +553 -0
  21. package/src/helper/build/autoIndex.js +2 -371
  22. package/src/helper/build/excludeFilter.js +1 -2
  23. package/src/helper/build/footer.js +27 -14
  24. package/src/helper/build/graph.js +575 -152
  25. package/src/helper/build/index.js +0 -2
  26. package/src/helper/build/metadata.js +19 -5
  27. package/src/helper/build/pass.js +497 -0
  28. package/src/helper/build/precedence.js +174 -0
  29. package/src/helper/build/site.js +1270 -0
  30. package/src/helper/build/templates.js +1 -2
  31. package/src/helper/build/tracedFs.js +247 -0
  32. package/src/helper/contentHash.js +0 -78
  33. package/src/helper/customMenu.js +1 -1
  34. package/src/helper/fileRenderer.js +119 -111
  35. package/src/helper/findScriptJs.js +1 -1
  36. package/src/helper/findStyleCss.js +1 -1
  37. package/src/helper/folderConfig.js +7 -18
  38. package/src/helper/fullTextIndex.js +41 -29
  39. package/src/helper/imageProcessor.js +45 -0
  40. package/src/helper/linkValidator.js +118 -127
  41. package/src/helper/mdxRenderer.js +225 -26
  42. package/src/helper/menuLabels.js +30 -5
  43. package/src/helper/sourceTimestamps.js +139 -0
  44. package/src/helper/ursaConfig.js +3 -49
  45. package/src/helper/whitelistFilter.js +1 -2
  46. package/src/jobs/generate.js +67 -1859
  47. package/src/serve.js +317 -697
  48. package/src/helper/__test__/dependencyTracker.test.js +0 -157
  49. package/src/helper/build/cacheBust.js +0 -141
  50. package/src/helper/build/navCache.js +0 -145
  51. package/src/helper/build/watchCache.js +0 -33
  52. package/src/helper/dependencyTracker.js +0 -384
@@ -0,0 +1,553 @@
1
+ /**
2
+ * The build pass against the acceptance scenarios in docs/SERVE.md §10.
3
+ *
4
+ * Each scenario runs a cold build, mutates the source, runs a warm pass, and
5
+ * checks two things: that exactly the expected outputs were rewritten
6
+ * (minimality) and that the output directory equals a clean build of the same
7
+ * tree (convergence), modulo the footer's per-run build metadata (§7).
8
+ */
9
+
10
+ import { join, relative } from "path";
11
+ import { mkdtemp, mkdir, writeFile, rm, readFile, rename, unlink, readdir } from "fs/promises";
12
+ import { existsSync } from "fs";
13
+ import { tmpdir } from "os";
14
+ import { createBuild } from "../pass.js";
15
+ import { hashBytes } from "../tracedFs.js";
16
+ import { deflateSync } from "zlib";
17
+
18
+ let tempDir;
19
+ let source;
20
+ let meta;
21
+ let output;
22
+
23
+ const TEMPLATE = `<!DOCTYPE html>
24
+ <html><head><title>\${title}</title>
25
+ <link rel="stylesheet" href="/public/base.css" />
26
+ \${styleLink}
27
+ </head>
28
+ <body><nav id="nav-main">\${menu}</nav><article>\${body}</article><footer>\${footer}</footer>
29
+ <script src="/public/app.js"></script>
30
+ \${customScript}
31
+ </body></html>`;
32
+
33
+ async function write(rel, contents) {
34
+ const full = join(source, rel);
35
+ await mkdir(join(full, ".."), { recursive: true });
36
+ await writeFile(full, contents);
37
+ }
38
+
39
+ async function writeMeta(rel, contents) {
40
+ const full = join(meta, rel);
41
+ await mkdir(join(full, ".."), { recursive: true });
42
+ await writeFile(full, contents);
43
+ }
44
+
45
+ beforeEach(async () => {
46
+ tempDir = await mkdtemp(join(tmpdir(), "ursa-pass-"));
47
+ source = join(tempDir, "src");
48
+ meta = join(tempDir, "meta");
49
+ output = join(tempDir, "out");
50
+ await mkdir(source, { recursive: true });
51
+ await writeMeta("templates/default-template/index.html", TEMPLATE);
52
+ await writeMeta("templates/default-template/base.css", "body { font-family: url(font.woff) }\n");
53
+ await writeMeta("templates/default-template/app.js", "fetch('/public/menu-data.json');\n");
54
+ await writeMeta("shared/font.woff", "FONT-V1");
55
+
56
+ await write("index.md", "# Home\n\nSee [rules](/rules/) and [grappling](/rules/grappling).\n");
57
+ await write("rules/index.md", "---\nmenu-label: The Rules\n---\n\n# Rules\n\nBody.\n");
58
+ await write("rules/combat.md", "# Combat\n\n![map](img/map.png)\n");
59
+ await write("character/powers/absorb.md", "---\nclass: Witch\n---\n\n# Absorb\n\nTouch.\n");
60
+ await write("character/powers/blast.md", "# Blast\n\nBoom.\n");
61
+ await write("style.css", "body { color: red }\n");
62
+ });
63
+
64
+ afterEach(async () => {
65
+ await rm(tempDir, { recursive: true, force: true });
66
+ });
67
+
68
+ /** A build over the fixture; `runPass` returns the summary with `wrote` (rels). */
69
+ async function build(opts = {}) {
70
+ const wrote = [];
71
+ const b = await createBuild({ source, meta, output, log: () => {}, ...opts });
72
+ b.env.onWrite = (rel) => wrote.push(rel);
73
+ return {
74
+ b,
75
+ async pass(passOpts) {
76
+ wrote.length = 0;
77
+ // No watcher here: re-check every leaf, as a warm start does
78
+ const summary = await b.runPass({ rescan: true, ...passOpts });
79
+ return { ...summary, wrote: [...wrote].sort() };
80
+ },
81
+ close: () => b.close(),
82
+ };
83
+ }
84
+
85
+ async function coldBuild() {
86
+ const built = await build({ clean: true });
87
+ await built.pass();
88
+ return built;
89
+ }
90
+
91
+ /** Output tree as rel → content hash, with per-run build metadata normalised. */
92
+ async function snapshot(dir) {
93
+ const out = new Map();
94
+ const walk = async (d) => {
95
+ for (const entry of await readdir(d, { withFileTypes: true })) {
96
+ const p = join(d, entry.name);
97
+ if (entry.isDirectory()) await walk(p);
98
+ else {
99
+ let buf = await readFile(p);
100
+ if (entry.name.endsWith(".html")) {
101
+ const text = buf
102
+ .toString("utf8")
103
+ .replace(/<div class="footer-meta">[\s\S]*?<\/div>/, "")
104
+ .replace(/<!-- git: [^>]*-->/, "")
105
+ .replace(/ data-build="\d+"/, "");
106
+ buf = Buffer.from(text);
107
+ }
108
+ out.set(relative(dir, p), hashBytes(buf));
109
+ }
110
+ }
111
+ };
112
+ await walk(dir);
113
+ return out;
114
+ }
115
+
116
+ /** The output converges on a clean build of the same source (§1, invariant 1). */
117
+ async function expectConverged() {
118
+ // Same basename: the root page's title is derived from the docroot's name
119
+ const cleanOut = join(tempDir, "clean", "out");
120
+ const cleanSrc = join(tempDir, "clean", "src");
121
+ // Copy the source (without .ursa) so the clean build has its own graph
122
+ await copyTree(source, cleanSrc, (rel) => !rel.startsWith(".ursa"));
123
+ const b = await createBuild({ source: cleanSrc, meta, output: cleanOut, clean: true, log: () => {} });
124
+ await b.runPass();
125
+ await b.close();
126
+ const a = await snapshot(output);
127
+ const c = await snapshot(cleanOut);
128
+ const diff = [];
129
+ // Recent activity is dated by mtime outside git, and the copy has new mtimes
130
+ a.delete("public/recent-activity.json");
131
+ c.delete("public/recent-activity.json");
132
+ for (const [k, v] of a) if (c.get(k) !== v) diff.push(`${k}: ${c.has(k) ? "differs" : "extra in warm"}`);
133
+ for (const k of c.keys()) if (!a.has(k)) diff.push(`${k}: missing from warm`);
134
+ expect(diff).toEqual([]);
135
+ }
136
+
137
+ async function copyTree(from, to, keep) {
138
+ await mkdir(to, { recursive: true });
139
+ for (const entry of await readdir(from, { withFileTypes: true })) {
140
+ const rel = relative(source, join(from, entry.name));
141
+ if (!keep(rel)) continue;
142
+ const src = join(from, entry.name);
143
+ const dst = join(to, entry.name);
144
+ if (entry.isDirectory()) await copyTree(src, dst, keep);
145
+ else await writeFile(dst, await readFile(src));
146
+ }
147
+ }
148
+
149
+ const read = (rel) => readFile(join(output, rel), "utf8");
150
+
151
+ // ---------------------------------------------------------------------------
152
+
153
+ describe("minimality: a pass rewrites only what consumed a changed input", () => {
154
+ it("1. an article body edit touches only its outputs, the full-text index and recent activity", async () => {
155
+ const built = await coldBuild();
156
+ await write("character/powers/blast.md", "# Blast\n\nBigger boom.\n");
157
+ const r = await built.pass();
158
+ expect(r.wrote).toEqual([
159
+ "character/powers/blast.html",
160
+ "character/powers/blast.json",
161
+ "character/powers/blast.xml",
162
+ "public/fulltext-index.json",
163
+ "public/recent-activity.json",
164
+ ]);
165
+ expect(await read("character/powers/blast.html")).toContain("Bigger boom");
166
+ await built.close();
167
+ await expectConverged();
168
+ });
169
+
170
+ it("2. a menu-label edit in a non-root article updates menu data and listings, not every page", async () => {
171
+ const built = await coldBuild();
172
+ await write("character/powers/absorb.md", "---\nclass: Witch\nmenu-label: Absorb!\n---\n\n# Absorb\n\nTouch.\n");
173
+ const r = await built.pass();
174
+ expect(r.wrote).toContain("public/menu-data.json");
175
+ expect(r.wrote).toContain("character/powers/index.html"); // the listing shows the label
176
+ expect(r.wrote).not.toContain("index.html"); // root-level menu unchanged
177
+ expect(r.wrote).not.toContain("rules/combat.html");
178
+ await built.close();
179
+ await expectConverged();
180
+ });
181
+
182
+ it("4. touching a file without changing its bytes writes nothing", async () => {
183
+ const built = await coldBuild();
184
+ await new Promise((res) => setTimeout(res, 10));
185
+ await write("style.css", "body { color: red }\n");
186
+ const r = await built.pass();
187
+ expect(r.wrote).toEqual([]);
188
+ expect([...r.changedNodes]).toEqual([]);
189
+ await built.close();
190
+ });
191
+ });
192
+
193
+ describe("3. inherited stylesheets: the zone is the subtree", () => {
194
+ it("creating, editing and deleting a folder's style.css rewrites exactly that subtree", async () => {
195
+ const built = await coldBuild();
196
+
197
+ await write("character/style.css", "p { color: blue }\n");
198
+ let r = await built.pass();
199
+ expect(r.wrote.sort()).toEqual([
200
+ "character.html", // the folder's listing page lists the new file
201
+ "character/index.html",
202
+ "character/powers/absorb.html",
203
+ "character/powers/blast.html",
204
+ "character/powers/index.html",
205
+ "public/character-powers.bundle.css",
206
+ "public/character.bundle.css",
207
+ ]);
208
+ expect(await read("character/powers/absorb.html")).toMatch(/character-powers\.bundle\.css\?v=[0-9a-f]{16}/);
209
+
210
+ await write("character/style.css", "p { color: green }\n");
211
+ r = await built.pass();
212
+ expect(r.wrote).toHaveLength(6);
213
+ expect(r.wrote).not.toContain("rules/combat.html");
214
+
215
+ await unlink(join(source, "character/style.css"));
216
+ r = await built.pass();
217
+ expect(r.wrote).toHaveLength(7);
218
+ await built.close();
219
+ await expectConverged();
220
+ });
221
+
222
+ it("renaming style.css to _style.css with the same content rewrites nothing (early cutoff)", async () => {
223
+ await write("character/style.css", "p { color: blue }\n");
224
+ const built = await coldBuild();
225
+ await rename(join(source, "character/style.css"), join(source, "character/_style.css"));
226
+ const r = await built.pass();
227
+ // Only the listing page, which names the file; no bundle, no document page
228
+ expect(r.wrote.filter((w) => w.endsWith(".html") || w.endsWith(".css"))).toEqual(["character.html"]);
229
+ await built.close();
230
+ });
231
+
232
+ it("a root style.css edit rewrites every page, and the whole set converges", async () => {
233
+ const built = await coldBuild();
234
+ await write("style.css", "body { color: purple }\n");
235
+ const r = await built.pass();
236
+ const pages = r.wrote.filter((w) => w.endsWith(".html"));
237
+ expect(pages).toContain("index.html");
238
+ expect(pages).toContain("rules/combat.html");
239
+ expect(pages).toContain("character/powers/index.html");
240
+ await built.close();
241
+ await expectConverged();
242
+ });
243
+ });
244
+
245
+ describe("meta: templates and shared assets", () => {
246
+ it("5. replacing a font in meta/shared rewrites the meta bundle and every page using the template", async () => {
247
+ const built = await coldBuild();
248
+ const before = await read("index.html");
249
+ await writeMeta("shared/font.woff", "FONT-V2");
250
+ const r = await built.pass();
251
+ expect(r.wrote).toContain("public/font.woff");
252
+ expect(r.wrote).toContain("public/default-template.bundle.css");
253
+ expect(r.wrote).toContain("index.html");
254
+ expect(await read("index.html")).not.toEqual(before);
255
+ await built.close();
256
+ await expectConverged();
257
+ });
258
+
259
+ it("6. editing a template's index.html rewrites pages using it, including generated index pages", async () => {
260
+ const built = await coldBuild();
261
+ await writeMeta("templates/default-template/index.html", TEMPLATE.replace("<article>", '<article class="v2">'));
262
+ const r = await built.pass();
263
+ expect(r.wrote).toContain("index.html");
264
+ expect(r.wrote).toContain("character/index.html"); // auto-index page
265
+ expect(r.wrote).toContain("character/powers.html"); // listing page
266
+ expect(await read("character/index.html")).toContain('<article class="v2">');
267
+ await built.close();
268
+ await expectConverged();
269
+ });
270
+ });
271
+
272
+ describe("7–8. several sources for one output", () => {
273
+ it("index.mdx shadows index.md; deleting index.md changes nothing; deleting index.mdx hands over the page", async () => {
274
+ const built = await coldBuild();
275
+ // Same menu-label as index.md: the folder's root-level label must not move
276
+ await write("rules/index.mdx", "---\nmenu-label: The Rules\n---\n\n# Rules from MDX\n");
277
+ let r = await built.pass();
278
+ expect(await read("rules/index.html")).toContain("Rules from MDX");
279
+
280
+ await unlink(join(source, "rules/index.md"));
281
+ r = await built.pass();
282
+ expect(r.wrote).not.toContain("rules/index.html");
283
+ expect(await read("rules/index.html")).toContain("Rules from MDX");
284
+
285
+ await unlink(join(source, "rules/index.mdx"));
286
+ r = await built.pass();
287
+ expect(await read("rules/index.html")).toContain('class="auto-index');
288
+ expect(r.deleted).toBeGreaterThan(0);
289
+ await built.close();
290
+ await expectConverged();
291
+ });
292
+
293
+ it("foo/index.md beside foo.md: two outputs, the file wins the extensionless URL", async () => {
294
+ await write("character.md", "# Character Doc\n\nLink: [c](/character)\n");
295
+ const built = await coldBuild();
296
+ expect(await read("character.html")).toContain("Character Doc");
297
+ expect(await read("character.html")).toContain('href="/character.html"');
298
+ await write("character/index.md", "# Character Index\n");
299
+ const r = await built.pass();
300
+ expect(await read("character/index.html")).toContain("Character Index");
301
+ expect(r.wrote).not.toContain("character.html");
302
+ await built.close();
303
+ await expectConverged();
304
+ });
305
+
306
+ it("a hand-written .html outranks a rendered document of the same name", async () => {
307
+ await write("rules/combat.html", "<html><body>Hand-written</body></html>");
308
+ const built = await coldBuild();
309
+ expect(await read("rules/combat.html")).toContain("Hand-written");
310
+ expect(existsSync(join(output, "rules/combat.json"))).toBe(false);
311
+ await built.close();
312
+ });
313
+ });
314
+
315
+ describe("9–10. renames and deletions leave no ghosts", () => {
316
+ it("renaming a document removes its old outputs", async () => {
317
+ const built = await coldBuild();
318
+ await rename(join(source, "character/powers/blast.md"), join(source, "character/powers/blaze.md"));
319
+ const r = await built.pass();
320
+ expect(existsSync(join(output, "character/powers/blast.html"))).toBe(false);
321
+ expect(existsSync(join(output, "character/powers/blast.json"))).toBe(false);
322
+ expect(existsSync(join(output, "character/powers/blaze.html"))).toBe(true);
323
+ expect(r.deleted).toBe(3);
324
+ await built.close();
325
+ await expectConverged();
326
+ });
327
+
328
+ it("renaming a folder removes the old subtree, its bundles and listing", async () => {
329
+ await write("character/powers/style.css", "p{color:red}\n");
330
+ const built = await coldBuild();
331
+ expect(existsSync(join(output, "public/character-powers.bundle.css"))).toBe(true);
332
+ await rename(join(source, "character/powers"), join(source, "character/spells"));
333
+ await built.pass();
334
+ expect(existsSync(join(output, "character/powers"))).toBe(false);
335
+ expect(existsSync(join(output, "character/powers.html"))).toBe(false);
336
+ expect(existsSync(join(output, "public/character-powers.bundle.css"))).toBe(false);
337
+ expect(existsSync(join(output, "character/spells/absorb.html"))).toBe(true);
338
+ expect(existsSync(join(output, "public/character-spells.bundle.css"))).toBe(true);
339
+ const menu = JSON.parse(await read("public/menu-data.json"));
340
+ expect(JSON.stringify(menu)).toContain("/character/spells/");
341
+ expect(JSON.stringify(menu)).not.toContain("/character/powers/");
342
+ await built.close();
343
+ await expectConverged();
344
+ });
345
+
346
+ it("deleting the only index.md of a folder hands the URL to the auto-index in the same pass", async () => {
347
+ const built = await coldBuild();
348
+ await unlink(join(source, "rules/index.md"));
349
+ await built.pass();
350
+ const html = await read("rules/index.html");
351
+ expect(html).toContain('class="auto-index');
352
+ expect(html).toContain("combat");
353
+ await built.close();
354
+ await expectConverged();
355
+ });
356
+ });
357
+
358
+ describe("11–12. dead links that come alive", () => {
359
+ it("adding a missing image gives the page its preview and lightbox without editing the page", async () => {
360
+ const built = await coldBuild();
361
+ expect(await read("rules/combat.html")).toContain('<img src="/rules/img/map.png"');
362
+ await write("rules/img/map.png", pngOf(1200, 900));
363
+ let r = await built.pass();
364
+ expect(r.wrote).toContain("rules/combat.html");
365
+ expect(r.wrote).toContain("rules/img/map.png");
366
+ expect(r.wrote).toContain("rules/img/map.preview.webp");
367
+ const html = await read("rules/combat.html");
368
+ expect(html).toMatch(/<a href="\/rules\/img\/map\.png" target="_blank" class="image-link"><img src="\/rules\/img\/map\.preview\.webp\?v=[0-9a-f]{16}"/);
369
+
370
+ // Replacing the image changes the ?v= token
371
+ await write("rules/img/map.png", pngOf(1300, 900));
372
+ r = await built.pass();
373
+ expect(r.wrote).toContain("rules/combat.html");
374
+ expect(await read("rules/combat.html")).not.toEqual(html);
375
+ await built.close();
376
+ await expectConverged();
377
+ });
378
+
379
+ it("creating a linked-to document rewrites only the pages that link to it", async () => {
380
+ const built = await coldBuild();
381
+ expect(await read("index.html")).toContain('class="inactive" href="/rules/grappling"');
382
+ await write("rules/grappling.md", "# Grappling\n");
383
+ const r = await built.pass();
384
+ expect(r.wrote).toContain("index.html");
385
+ expect(r.wrote).not.toContain("character/powers/absorb.html");
386
+ expect(await read("index.html")).toContain('href="/rules/grappling.html"');
387
+ expect(await read("index.html")).not.toContain("inactive");
388
+ await built.close();
389
+ await expectConverged();
390
+ });
391
+ });
392
+
393
+ describe("13. MDX components", () => {
394
+ it("editing a .tsx re-renders exactly the documents that import it", async () => {
395
+ await write("character/_components/Card.tsx", "export default function Card({name}) { return <b>{name}</b> }\n");
396
+ await write("character/one.mdx", 'import Card from "_components/Card.tsx"\n\n# One\n\n<Card name="A" />\n');
397
+ await write("character/powers/two.mdx", 'import Card from "_components/Card.tsx"\n\n# Two\n\n<Card name="B" />\n');
398
+ const built = await coldBuild();
399
+ await write("character/_components/Card.tsx", "export default function Card({name}) { return <i>{name}</i> }\n");
400
+ const r = await built.pass();
401
+ expect(r.wrote.filter((w) => w.endsWith(".html")).sort()).toEqual(["character/one.html", "character/powers/two.html"]);
402
+ expect(await read("character/one.html")).toContain("<i>A</i>");
403
+ await built.close();
404
+ await expectConverged();
405
+ }, 20000);
406
+ });
407
+
408
+ describe("16. failure", () => {
409
+ it("a failing page keeps its previous output and is retried next pass", async () => {
410
+ const built = await coldBuild();
411
+ const before = await read("character/powers/blast.html");
412
+ await write("character/powers/blast.md", "---\ntemplate: no-such-template\n---\n# Blast\n");
413
+ let r = await built.pass();
414
+ expect([...r.failures.keys()]).toEqual(["pageHtml:character/powers/blast.md"]);
415
+ expect(await read("character/powers/blast.html")).toEqual(before);
416
+
417
+ await write("character/powers/blast.md", "# Blast\n\nFixed.\n");
418
+ r = await built.pass();
419
+ expect(r.failures.size).toBe(0);
420
+ expect(await read("character/powers/blast.html")).toContain("Fixed.");
421
+ await built.close();
422
+ });
423
+ });
424
+
425
+ describe("18. warm start", () => {
426
+ it("changes made while ursa was not running are caught by the startup pass", async () => {
427
+ const first = await coldBuild();
428
+ await first.close();
429
+
430
+ await unlink(join(source, "rules/combat.md"));
431
+ await rename(join(source, "character/powers/absorb.md"), join(source, "character/powers/soak.md"));
432
+ await write("rules/new.md", "# New\n");
433
+ await write("style.css", "body { color: teal }\n");
434
+
435
+ const second = await build();
436
+ const r = await second.pass();
437
+ expect(existsSync(join(output, "rules/combat.html"))).toBe(false);
438
+ expect(existsSync(join(output, "character/powers/absorb.html"))).toBe(false);
439
+ expect(existsSync(join(output, "character/powers/soak.html"))).toBe(true);
440
+ expect(existsSync(join(output, "rules/new.html"))).toBe(true);
441
+ expect(r.wrote).toContain("index.html");
442
+ await second.close();
443
+ await expectConverged();
444
+ });
445
+
446
+ it("an unchanged tree verifies clean: nothing written, nothing changed", async () => {
447
+ const first = await coldBuild();
448
+ await first.close();
449
+ const second = await build();
450
+ const r = await second.pass();
451
+ expect(r.wrote).toEqual([]);
452
+ expect([...r.changedNodes]).toEqual([]);
453
+ await second.close();
454
+ });
455
+ });
456
+
457
+ describe("hidden folders, whitelist and exclude", () => {
458
+ it("hiding a folder with config.json deletes its outputs; unhiding restores them", async () => {
459
+ const built = await coldBuild();
460
+ expect(existsSync(join(output, "character/powers/absorb.html"))).toBe(true);
461
+ await write("character/config.json", JSON.stringify({ hidden: true }));
462
+ let r = await built.pass();
463
+ expect(existsSync(join(output, "character/powers/absorb.html"))).toBe(false);
464
+ expect(existsSync(join(output, "character"))).toBe(false);
465
+ expect(await read("public/menu-data.json")).not.toContain("character");
466
+ expect(r.deleted).toBeGreaterThan(0);
467
+
468
+ await unlink(join(source, "character/config.json"));
469
+ r = await built.pass();
470
+ expect(existsSync(join(output, "character/powers/absorb.html"))).toBe(true);
471
+ await built.close();
472
+ await expectConverged();
473
+ });
474
+
475
+ it("editing the whitelist file adds and removes documents live", async () => {
476
+ const whitelist = join(tempDir, "whitelist.txt");
477
+ await writeFile(whitelist, "rules/\nindex.md\n");
478
+ const built = await build({ clean: true, whitelist });
479
+ await built.pass();
480
+ expect(existsSync(join(output, "rules/combat.html"))).toBe(true);
481
+ expect(existsSync(join(output, "character/powers/absorb.html"))).toBe(false);
482
+
483
+ await writeFile(whitelist, "rules/\nindex.md\ncharacter/\n");
484
+ await built.pass();
485
+ expect(existsSync(join(output, "character/powers/absorb.html"))).toBe(true);
486
+
487
+ await writeFile(whitelist, "index.md\n");
488
+ await built.pass();
489
+ expect(existsSync(join(output, "rules/combat.html"))).toBe(false);
490
+ expect(existsSync(join(output, "character/powers/absorb.html"))).toBe(false);
491
+ await built.close();
492
+ });
493
+ });
494
+
495
+ describe("json-only", () => {
496
+ it("a full build after a JSON-only build writes the HTML and XML", async () => {
497
+ const jsonOnly = await build({ clean: true, jsonOnly: true });
498
+ await jsonOnly.pass();
499
+ await jsonOnly.close();
500
+ expect(existsSync(join(output, "character/powers/absorb.json"))).toBe(true);
501
+ expect(existsSync(join(output, "character/powers/absorb.html"))).toBe(false);
502
+
503
+ const full = await build();
504
+ await full.pass();
505
+ await full.close();
506
+ expect(existsSync(join(output, "character/powers/absorb.html"))).toBe(true);
507
+ expect(existsSync(join(output, "character/powers/absorb.xml"))).toBe(true);
508
+ await expectConverged();
509
+ });
510
+ });
511
+
512
+ describe("determinism", () => {
513
+ it("two clean builds of the same tree are byte-identical modulo build metadata", async () => {
514
+ const built = await coldBuild();
515
+ await built.close();
516
+ await expectConverged();
517
+ });
518
+ });
519
+
520
+ /** A tiny valid PNG of the given size (red). */
521
+ function pngOf(w, h) {
522
+ const crcTable = [];
523
+ for (let n = 0; n < 256; n++) {
524
+ let c = n;
525
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
526
+ crcTable[n] = c >>> 0;
527
+ }
528
+ const crc32 = (buf) => {
529
+ let c = 0xffffffff;
530
+ for (const b of buf) c = crcTable[(c ^ b) & 0xff] ^ (c >>> 8);
531
+ return (c ^ 0xffffffff) >>> 0;
532
+ };
533
+ const chunk = (type, data) => {
534
+ const len = Buffer.alloc(4);
535
+ len.writeUInt32BE(data.length);
536
+ const td = Buffer.concat([Buffer.from(type), data]);
537
+ const crc = Buffer.alloc(4);
538
+ crc.writeUInt32BE(crc32(td));
539
+ return Buffer.concat([len, td, crc]);
540
+ };
541
+ const ihdr = Buffer.alloc(13);
542
+ ihdr.writeUInt32BE(w, 0);
543
+ ihdr.writeUInt32BE(h, 4);
544
+ ihdr[8] = 8; ihdr[9] = 2; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
545
+ const row = Buffer.concat([Buffer.from([0]), Buffer.alloc(w * 3, 0).fill(Buffer.from([255, 0, 0]))]);
546
+ const raw = Buffer.concat(Array.from({ length: h }, () => row));
547
+ return Buffer.concat([
548
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
549
+ chunk("IHDR", ihdr),
550
+ chunk("IDAT", deflateSync(raw)),
551
+ chunk("IEND", Buffer.alloc(0)),
552
+ ]);
553
+ }