@heroiclands/package-build 25.6.2 → 25.7.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 (55) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +19 -1
  3. package/bin/content-commands.mjs +64 -79
  4. package/bin/package-build.mjs +25 -10
  5. package/ci/wip-workflows.test.mjs +252 -0
  6. package/content-config.mjs +118 -8
  7. package/docs/api.md +237 -47
  8. package/docs/commands.md +99 -35
  9. package/docs/configuration.md +93 -44
  10. package/docs/guides/release-and-deploy.md +26 -14
  11. package/docs/guides/site.md +53 -0
  12. package/engine/cdn-publish.mjs +164 -6
  13. package/engine/content-index.mjs +71 -19
  14. package/engine/content-spans.mjs +18 -1
  15. package/engine/content-styles.mjs +85 -0
  16. package/engine/derived-sections.mjs +4 -3
  17. package/engine/event-views.mjs +6 -4
  18. package/engine/helpers.mjs +33 -9
  19. package/engine/index.mjs +1 -0
  20. package/engine/note-cache.mjs +1107 -0
  21. package/engine/note-page.mjs +424 -0
  22. package/engine/pack-config.mjs +47 -4
  23. package/engine/pathnames.mjs +22 -7
  24. package/engine/prepared-only.mjs +32 -0
  25. package/engine/preview-index.mjs +55 -13
  26. package/engine/site-build.mjs +34 -448
  27. package/engine/site-config.mjs +33 -4
  28. package/engine/site-maps.mjs +16 -24
  29. package/engine/site-origins.mjs +23 -0
  30. package/engine/site-page.mjs +648 -0
  31. package/engine/site-render.mjs +142 -10
  32. package/engine/site-serve.mjs +1 -1
  33. package/engine/sql-tables.mjs +12 -3
  34. package/package.json +6 -1
  35. package/site-theme/static/css/style.css +14 -0
  36. package/types/content-config.d.mts +34 -0
  37. package/types/engine/cdn-publish.d.mts +22 -0
  38. package/types/engine/content-index.d.mts +36 -2
  39. package/types/engine/content-styles.d.mts +20 -0
  40. package/types/engine/derived-sections.d.mts +3 -3
  41. package/types/engine/event-views.d.mts +4 -3
  42. package/types/engine/helpers.d.mts +18 -1
  43. package/types/engine/index.d.mts +1 -0
  44. package/types/engine/note-cache.d.mts +149 -0
  45. package/types/engine/note-page.d.mts +92 -0
  46. package/types/engine/pack-config.d.mts +30 -0
  47. package/types/engine/prepared-only.d.mts +14 -0
  48. package/types/engine/preview-index.d.mts +35 -0
  49. package/types/engine/site-build.d.mts +5 -100
  50. package/types/engine/site-config.d.mts +3 -2
  51. package/types/engine/site-maps.d.mts +7 -11
  52. package/types/engine/site-origins.d.mts +9 -0
  53. package/types/engine/site-page.d.mts +261 -0
  54. package/types/engine/site-render.d.mts +20 -1
  55. package/types/engine/sql-tables.d.mts +8 -4
@@ -0,0 +1,1107 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * **The content tree's records, kept under `build/` between renders of one
16
+ * note.** {@link noteTree} answers what `./note-page`'s `renderNoteFile`
17
+ * renders against: the tree's index records and the note's own query answers.
18
+ * It derives them as every build does, through
19
+ * {@link module:engine/content-index.indexRecordsFor} and
20
+ * {@link module:engine/sql-tables.prepareTreeSqlTables}, and keeps what it
21
+ * derived in {@link noteCacheDirectory}.
22
+ *
23
+ * **Nothing stale is ever served.** Every stored answer is keyed by a hash of
24
+ * everything it was derived from, and is used only when that hash is the one
25
+ * the inputs give now:
26
+ *
27
+ * - **the toolchain**: package-build's version and the bytes of its own
28
+ * modules, the Node.js version, and the DuckDB version;
29
+ * - **the configuration**: the configuration file's bytes and the resolved
30
+ * configuration;
31
+ * - **the dependencies**: the bytes of every fetched content index, and the
32
+ * installed script fonts' version and manifest;
33
+ * - **the tree**: every note's path and the hash of its text, and the
34
+ * package's asset records.
35
+ *
36
+ * Three stores answer at three grains, so a change discards only what it
37
+ * reaches:
38
+ *
39
+ * - `notes.v8` holds each note's parse, keyed by the hash of its text, and
40
+ * the records derived from that note alone, keyed also by everything the
41
+ * tree contributes to them (the calendars, the addresses the notes declare,
42
+ * the assets). A changed note re-derives itself and nothing else here.
43
+ * - `tree.v8` holds the whole tree's records and parses, keyed by every
44
+ * input; any change replaces it.
45
+ * - Beside the records, `tree.v8` holds each rendered note's query answers,
46
+ * valid for exactly the records they sit beside.
47
+ *
48
+ * A store that cannot be read, or that was written for other inputs, is
49
+ * derived again and overwritten; a store is replaced by renaming a complete
50
+ * file over it, so a reader never sees half of one. The cache is written
51
+ * nowhere but its directory, and deleting the directory loses nothing but
52
+ * time.
53
+ *
54
+ * @module
55
+ */
56
+
57
+ import crypto from "node:crypto";
58
+ import fs from "node:fs";
59
+ import path from "node:path";
60
+ import { createRequire } from "node:module";
61
+ import v8 from "node:v8";
62
+
63
+ import { AddressEntries, AddressLink } from "./address-values.mjs";
64
+ import { isAddressTuple, readCanonicalKey, renderAddress } from "./address.mjs";
65
+ import { collectAssetRecords } from "./asset-index.mjs";
66
+ import { walkMarkdownFiles, parseMarkdownFile } from "./helpers.mjs";
67
+ import { cachedMetadataFiles, loadForeignIndexes } from "./metadata-index.mjs";
68
+ import { configFileIn } from "./note-calendars.mjs";
69
+ import { CONFIG_FILENAMES, readPackConfig } from "./pack-config.mjs";
70
+ import { PACKAGEBUILD_ROOT } from "./packagebuild-index.mjs";
71
+ import { resolveScriptFonts } from "./script-fonts.mjs";
72
+
73
+ /** The layout of the stores; a store of another layout is derived again. */
74
+ const FORMAT = 1;
75
+
76
+ /** The key that marks a typed value in a packed tree. */
77
+ const TAG = "\u0000pb";
78
+
79
+ /** Thrown for a value the cache cannot hold, which is then derived each time. */
80
+ class Uncacheable extends Error {}
81
+
82
+ /**
83
+ * Whether a value is a plain object: one the codec copies key by key.
84
+ *
85
+ * @param {object} value - An object.
86
+ * @returns {boolean} Whether its prototype is `Object.prototype` or `null`.
87
+ */
88
+ function isPlain(value) {
89
+ const proto = Object.getPrototypeOf(value);
90
+ return proto === Object.prototype || proto === null;
91
+ }
92
+
93
+ /**
94
+ * A value's own non-index properties, encoded, or nothing when it has none.
95
+ *
96
+ * @param {Array<unknown>|Map<unknown, unknown>|Set<unknown>} value - A container.
97
+ * @param {WeakMap<object, unknown>} seen - Encoded containers.
98
+ * @param {boolean} sources - As {@link encode} takes it.
99
+ * @returns {Record<string, unknown>|undefined} The properties.
100
+ */
101
+ function extraProperties(value, seen, sources) {
102
+ const keys = Object.keys(value).filter((key) => !Array.isArray(value) || !/^\d+$/.test(key));
103
+ if (!keys.length) return undefined;
104
+ return Object.fromEntries(keys.map((key) => [key, encode(value[key], seen, sources)]));
105
+ }
106
+
107
+ /**
108
+ * Encode a value as one `v8.serialize` keeps whole: an Address, an anchored
109
+ * link, an Address map, a Map or a Set, and an array carrying properties
110
+ * besides its elements, each become a tagged plain object. A value shared by
111
+ * two places stays shared.
112
+ *
113
+ * @param {unknown} value - The value.
114
+ * @param {WeakMap<object, unknown>} seen - Encoded objects, by original.
115
+ * @param {boolean} [sources] - Encode a function as its source text, for a
116
+ * value that is hashed and never restored.
117
+ * @returns {unknown} The encoded value.
118
+ * @throws {Uncacheable} For a function, unless `sources`; for a symbol; and
119
+ * for an object of any other class.
120
+ */
121
+ function encode(value, seen, sources = false) {
122
+ if (typeof value === "function" && sources) return { [TAG]: "source", text: String(value) };
123
+ if (typeof value === "function" || typeof value === "symbol")
124
+ throw new Uncacheable(`a ${typeof value} cannot be cached`);
125
+ if (value === null || typeof value !== "object") return value;
126
+ if (seen.has(value)) return seen.get(value);
127
+ let out;
128
+ if (isAddressTuple(value)) out = { [TAG]: "address", key: renderAddress(value) };
129
+ else if (value instanceof AddressLink) {
130
+ out = { [TAG]: "link", anchor: value.anchor, kind: value.kind };
131
+ seen.set(value, out);
132
+ out.target = encode(value.target, seen, sources);
133
+ } else if (value instanceof AddressEntries) {
134
+ out = { [TAG]: "entries" };
135
+ seen.set(value, out);
136
+ out.entries = encode(value.entries, seen, sources);
137
+ } else if (value instanceof Map || value instanceof Set) {
138
+ out = { [TAG]: value instanceof Map ? "map" : "set" };
139
+ seen.set(value, out);
140
+ out.items = [...value].map((item) => encode(item, seen, sources));
141
+ out.props = extraProperties(value, seen, sources);
142
+ } else if (Array.isArray(value)) {
143
+ const props = Object.keys(value).some((key) => !/^\d+$/.test(key));
144
+ if (props) {
145
+ out = { [TAG]: "array" };
146
+ seen.set(value, out);
147
+ out.items = Array.from(value, (item) => encode(item, seen, sources));
148
+ out.props = extraProperties(value, seen, sources);
149
+ } else {
150
+ out = [];
151
+ seen.set(value, out);
152
+ for (const item of value) out.push(encode(item, seen, sources));
153
+ }
154
+ } else if (value instanceof Date || value instanceof RegExp) out = value;
155
+ else if (isPlain(value)) {
156
+ const plain = Object.getPrototypeOf(value) === null;
157
+ // A plain object that happens to hold the tag key is wrapped, so it
158
+ // never reads back as a typed value.
159
+ if (plain || Object.hasOwn(value, TAG)) {
160
+ out = { [TAG]: plain ? "bare" : "object" };
161
+ seen.set(value, out);
162
+ out.entries = Object.entries(value).map(([key, item]) => [
163
+ key,
164
+ encode(item, seen, sources),
165
+ ]);
166
+ } else {
167
+ out = {};
168
+ seen.set(value, out);
169
+ for (const [key, item] of Object.entries(value)) out[key] = encode(item, seen, sources);
170
+ }
171
+ } else throw new Uncacheable(`a ${value.constructor?.name ?? "class"} cannot be cached`);
172
+ seen.set(value, out);
173
+ return out;
174
+ }
175
+
176
+ /**
177
+ * Restore what {@link encode} wrote.
178
+ *
179
+ * @param {unknown} value - An encoded value.
180
+ * @param {Map<object, unknown>} seen - Decoded objects, by encoded one.
181
+ * @returns {unknown} The value.
182
+ */
183
+ function decode(value, seen) {
184
+ if (value === null || typeof value !== "object") return value;
185
+ if (seen.has(value)) return seen.get(value);
186
+ if (value instanceof Date || value instanceof RegExp) return value;
187
+ if (Array.isArray(value)) {
188
+ const out = [];
189
+ seen.set(value, out);
190
+ for (const item of value) out.push(decode(item, seen));
191
+ return out;
192
+ }
193
+ const assign = (target, props) => {
194
+ for (const [key, item] of Object.entries(props ?? {})) target[key] = decode(item, seen);
195
+ return target;
196
+ };
197
+ switch (value[TAG]) {
198
+ case undefined: {
199
+ const out = {};
200
+ seen.set(value, out);
201
+ return assign(out, value);
202
+ }
203
+ case "address": {
204
+ const out = readCanonicalKey(value.key);
205
+ seen.set(value, out);
206
+ return out;
207
+ }
208
+ case "link": {
209
+ const out = new AddressLink(decode(value.target, seen), value.anchor, value.kind);
210
+ seen.set(value, out);
211
+ return out;
212
+ }
213
+ case "entries": {
214
+ const out = Object.create(AddressEntries.prototype);
215
+ seen.set(value, out);
216
+ out.entries = decode(value.entries, seen);
217
+ return out;
218
+ }
219
+ case "map":
220
+ case "set": {
221
+ const out = value[TAG] === "map" ? new Map() : new Set();
222
+ seen.set(value, out);
223
+ for (const item of value.items) {
224
+ const decoded = decode(item, seen);
225
+ if (out instanceof Map) out.set(decoded[0], decoded[1]);
226
+ else out.add(decoded);
227
+ }
228
+ return assign(out, value.props);
229
+ }
230
+ case "array": {
231
+ const out = [];
232
+ seen.set(value, out);
233
+ for (const item of value.items) out.push(decode(item, seen));
234
+ return assign(out, value.props);
235
+ }
236
+ case "object":
237
+ case "bare": {
238
+ const out = value[TAG] === "bare" ? Object.create(null) : {};
239
+ seen.set(value, out);
240
+ for (const [key, item] of value.entries) out[key] = decode(item, seen);
241
+ return out;
242
+ }
243
+ default:
244
+ throw new Error(`unknown cached value ${JSON.stringify(value[TAG])}`);
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Pack a value into bytes {@link unpackValue} restores as an equal copy:
250
+ * Addresses, anchored links and Address maps keep their types, Maps, Sets,
251
+ * Dates and `undefined` survive, and a value shared by two places is still
252
+ * shared.
253
+ *
254
+ * @param {unknown} value - The value.
255
+ * @returns {Buffer} The bytes.
256
+ * @throws {Error} For a value holding a function, a symbol, or an object of
257
+ * another class.
258
+ */
259
+ export function packValue(value) {
260
+ return v8.serialize(encode(value, new WeakMap()));
261
+ }
262
+
263
+ /**
264
+ * Restore a value {@link packValue} packed. Every call returns a fresh copy.
265
+ *
266
+ * @param {Buffer|Uint8Array} bytes - The packed value.
267
+ * @returns {unknown} The value.
268
+ * @throws {Error} When the bytes are not a packed value.
269
+ */
270
+ export function unpackValue(bytes) {
271
+ return decode(v8.deserialize(bytes), new Map());
272
+ }
273
+
274
+ /**
275
+ * {@link packValue}, or nothing for a value the cache cannot hold.
276
+ *
277
+ * @param {unknown} value - The value.
278
+ * @returns {Buffer|undefined} The bytes.
279
+ */
280
+ function packIfCacheable(value) {
281
+ try {
282
+ return packValue(value);
283
+ } catch (error) {
284
+ if (error instanceof Uncacheable) return undefined;
285
+ throw error;
286
+ }
287
+ }
288
+
289
+ /**
290
+ * A hex SHA-256 of the given parts.
291
+ *
292
+ * @param {...(string|Buffer|Uint8Array)} parts - What is hashed, in order,
293
+ * each length-prefixed so no two lists hash alike.
294
+ * @returns {string} The digest.
295
+ */
296
+ function digest(...parts) {
297
+ const hash = crypto.createHash("sha256");
298
+ for (const part of parts) {
299
+ const bytes = typeof part === "string" ? Buffer.from(part) : part;
300
+ hash.update(`${bytes.length}:`);
301
+ hash.update(bytes);
302
+ }
303
+ return hash.digest("hex");
304
+ }
305
+
306
+ /**
307
+ * Where a package's note cache is kept: `build/cache/note-page/` under the
308
+ * package root.
309
+ *
310
+ * @param {{rootDir: string}} config - The resolved configuration.
311
+ * @returns {string} The directory.
312
+ */
313
+ export function noteCacheDirectory(config) {
314
+ return path.join(config.rootDir, "build", "cache", "note-page");
315
+ }
316
+
317
+ /**
318
+ * Every file under a directory, recursively, in a stable order.
319
+ *
320
+ * @param {string} dir - The directory.
321
+ * @param {(name: string) => boolean} keep - Which file names count.
322
+ * @returns {string[]} Absolute paths.
323
+ */
324
+ function filesUnder(dir, keep) {
325
+ if (!fs.existsSync(dir)) return [];
326
+ return fs
327
+ .readdirSync(dir, { recursive: true, withFileTypes: true })
328
+ .filter((entry) => entry.isFile() && keep(entry.name))
329
+ .map((entry) => path.join(entry.parentPath, entry.name))
330
+ .sort();
331
+ }
332
+
333
+ /**
334
+ * The version an installed package declares, resolved from `from`.
335
+ *
336
+ * @param {string} name - The package.
337
+ * @param {string} from - A file the resolution starts at.
338
+ * @returns {string} Its version, or `absent`.
339
+ */
340
+ function installedVersion(name, from) {
341
+ try {
342
+ const file = createRequire(from).resolve(`${name}/package.json`);
343
+ return `${file}@${JSON.parse(fs.readFileSync(file, "utf8")).version}`;
344
+ } catch {
345
+ return "absent";
346
+ }
347
+ }
348
+
349
+ /**
350
+ * The toolchain a cached answer was derived with: package-build's version and
351
+ * the bytes of its own modules, the Node.js version, and the DuckDB version
352
+ * the query answers came from.
353
+ *
354
+ * @returns {string} Its hash.
355
+ */
356
+ export function toolchainFingerprint() {
357
+ const own = path.join(PACKAGEBUILD_ROOT, "package.json");
358
+ const modules = [
359
+ ...filesUnder(path.join(PACKAGEBUILD_ROOT, "engine"), (name) => name.endsWith(".mjs")),
360
+ ...fs
361
+ .readdirSync(PACKAGEBUILD_ROOT)
362
+ .filter((name) => name.endsWith(".mjs"))
363
+ .sort()
364
+ .map((name) => path.join(PACKAGEBUILD_ROOT, name)),
365
+ ];
366
+ return digest(
367
+ String(FORMAT),
368
+ fs.readFileSync(own),
369
+ process.version,
370
+ installedVersion("@duckdb/node-api", own),
371
+ ...modules.flatMap((file) => [
372
+ path.relative(PACKAGEBUILD_ROOT, file),
373
+ fs.readFileSync(file),
374
+ ]),
375
+ );
376
+ }
377
+
378
+ /**
379
+ * The configuration and dependencies a cached answer was derived with: the
380
+ * configuration file's bytes, the resolved configuration, every fetched
381
+ * content index's bytes, and the installed script fonts.
382
+ *
383
+ * @param {object} config - The resolved configuration.
384
+ * @returns {string} Its hash.
385
+ * @throws {Error} When a declared dependency has not been fetched, naming
386
+ * `package-build deps fetch`.
387
+ */
388
+ function packageFingerprint(config) {
389
+ const configFile = configFileIn(config.rootDir, CONFIG_FILENAMES);
390
+ const resolved = resolvedText(config);
391
+ const fonts = resolveScriptFonts(config.rootDir);
392
+ return digest(
393
+ configFile ? fs.readFileSync(configFile) : "",
394
+ resolved,
395
+ JSON.stringify(fonts ? { root: fonts.root, version: fonts.version } : null),
396
+ fonts?.root && fs.existsSync(path.join(fonts.root, "fonts.json")) ?
397
+ fs.readFileSync(path.join(fonts.root, "fonts.json"))
398
+ : "",
399
+ ...cachedMetadataFiles(config).flatMap((file) => [file, fs.readFileSync(file)]),
400
+ );
401
+ }
402
+
403
+ /**
404
+ * A resolved configuration as text, a function written as its source.
405
+ *
406
+ * @param {object} config - The resolved configuration.
407
+ * @returns {string} The text.
408
+ */
409
+ function resolvedText(config) {
410
+ return JSON.stringify(config, (_, value) =>
411
+ typeof value === "function" ? `function ${value.toString()}` : value,
412
+ );
413
+ }
414
+
415
+ /**
416
+ * Whether the configuration file now resolves to other than the configuration a
417
+ * caller supplied, so nothing derived with that configuration may be keyed by
418
+ * the file's present bytes. A code configuration is loaded once per process,
419
+ * so only a data file is resolved again.
420
+ *
421
+ * @param {object} config - The resolved configuration.
422
+ * @returns {boolean} Whether the file has moved on from it.
423
+ */
424
+ function configMovedOn(config) {
425
+ const file = configFileIn(config.rootDir, CONFIG_FILENAMES);
426
+ if (!file || file.endsWith(".mjs")) return false;
427
+ try {
428
+ return resolvedText(readPackConfig(file)) !== resolvedText(config);
429
+ } catch {
430
+ return true;
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Read a store, or nothing when it is absent, unreadable, of another layout,
436
+ * or written for other inputs.
437
+ *
438
+ * @param {string} file - The store.
439
+ * @param {string} key - The inputs it must have been written for.
440
+ * @returns {Record<string, any>|undefined} The store.
441
+ */
442
+ function readStore(file, key) {
443
+ let store;
444
+ try {
445
+ store = v8.deserialize(fs.readFileSync(file));
446
+ } catch {
447
+ return undefined;
448
+ }
449
+ if (!store || typeof store !== "object" || store.format !== FORMAT || store.key !== key)
450
+ return undefined;
451
+ return store;
452
+ }
453
+
454
+ /**
455
+ * Write a store whole: to a file beside it, which then takes its place.
456
+ *
457
+ * @param {string} file - The store.
458
+ * @param {Record<string, any>} store - Its contents.
459
+ * @returns {void}
460
+ */
461
+ function writeStore(file, store) {
462
+ fs.mkdirSync(path.dirname(file), { recursive: true });
463
+ const partial = `${file}.${process.pid}.${crypto.randomUUID()}.partial`;
464
+ try {
465
+ fs.writeFileSync(partial, v8.serialize({ ...store, format: FORMAT }));
466
+ fs.renameSync(partial, file);
467
+ } finally {
468
+ fs.rmSync(partial, { force: true });
469
+ }
470
+ }
471
+
472
+ /** Whether a value is a non-null object. */
473
+ const isObject = (value) => value !== null && typeof value === "object";
474
+
475
+ /** Whether every item of a value is an object: the shape of index records. */
476
+ const isRecords = (value) => Array.isArray(value) && value.every(isObject);
477
+
478
+ /** Whether a value is a parse as {@link parseMarkdownFile} returns one, its text aside. */
479
+ const isParse = (value) =>
480
+ isObject(value) &&
481
+ typeof value.body === "string" &&
482
+ (value.frontmatter === null || isObject(value.frontmatter));
483
+
484
+ /**
485
+ * Unpack bytes and keep the value only when it has the shape a caller needs.
486
+ *
487
+ * @param {unknown} bytes - Packed bytes, or anything else a store held.
488
+ * @param {(value: unknown) => boolean} valid - The shape it must have.
489
+ * @returns {unknown} The value, or `undefined` when the bytes cannot be
490
+ * unpacked or the value has another shape.
491
+ */
492
+ function unpackChecked(bytes, valid) {
493
+ if (!(bytes instanceof Uint8Array)) return undefined;
494
+ try {
495
+ const value = unpackValue(bytes);
496
+ return valid(value) ? value : undefined;
497
+ } catch {
498
+ return undefined;
499
+ }
500
+ }
501
+
502
+ /**
503
+ * The per-note store as {@link module:engine/content-index.indexRecordsFor}
504
+ * consults it: each note's parse by the hash of its text, and the records
505
+ * derived from that note alone. An entry that cannot be unpacked, or that
506
+ * unpacks to another shape, is derived again and replaced.
507
+ *
508
+ * @param {Record<string, any>|undefined} stored - The store read from disk.
509
+ * @param {Map<string, {raw: string, hash: string}>} texts - Every note's text
510
+ * and its hash, by absolute path.
511
+ * @param {string} contentBase - The content tree.
512
+ */
513
+ function noteMemo(stored, texts, contentBase) {
514
+ const before = stored?.notes instanceof Map ? stored.notes : new Map();
515
+ const after = new Map();
516
+ let derived = 0;
517
+ let changed = false;
518
+ const entryFor = (absPath) => {
519
+ const rel = path.relative(contentBase, absPath);
520
+ const hash = texts.get(absPath)?.hash;
521
+ if (!after.has(rel)) {
522
+ const old = before.get(rel);
523
+ const kept = isObject(old) && old.hash === hash;
524
+ after.set(rel, kept ? old : { hash });
525
+ if (!kept) changed = true;
526
+ }
527
+ return after.get(rel);
528
+ };
529
+ return {
530
+ /** @type {typeof parseMarkdownFile} */
531
+ parse(absPath, options = {}) {
532
+ const text = texts.get(absPath);
533
+ if (!text || options.addressContext)
534
+ return parseMarkdownFile(absPath, { ...options, raw: options.raw ?? text?.raw });
535
+ const entry = entryFor(absPath);
536
+ const key = options.renderDescription === false ? "bare" : "described";
537
+ const kept = unpackChecked(entry[key], isParse);
538
+ if (kept) return { .../** @type {object} */ (kept), raw: text.raw };
539
+ const parsed = parseMarkdownFile(absPath, { ...options, raw: text.raw });
540
+ const { raw: _, ...rest } = parsed;
541
+ const packed = packIfCacheable(rest);
542
+ if (packed) entry[key] = packed;
543
+ else delete entry[key];
544
+ changed = true;
545
+ return parsed;
546
+ },
547
+ /**
548
+ * The records a note derives, from the store when they were derived
549
+ * from the same text in the same context.
550
+ *
551
+ * @param {string} absPath - The note.
552
+ * @param {string|undefined} context - The hash of the context.
553
+ * @param {() => object[]} derive - Derives them.
554
+ * @returns {object[]} The records, a copy the caller owns.
555
+ */
556
+ records(absPath, context, derive) {
557
+ const entry = texts.has(absPath) && context ? entryFor(absPath) : undefined;
558
+ if (entry && entry.context === context) {
559
+ const kept = unpackChecked(entry.records, isRecords);
560
+ if (kept) return /** @type {object[]} */ (kept);
561
+ }
562
+ derived++;
563
+ const made = derive();
564
+ if (entry) {
565
+ const packed = packIfCacheable(made);
566
+ if (packed) {
567
+ entry.context = context;
568
+ entry.records = packed;
569
+ } else {
570
+ delete entry.context;
571
+ delete entry.records;
572
+ }
573
+ changed = true;
574
+ }
575
+ return made;
576
+ },
577
+ /**
578
+ * The hash of what the tree contributes to every note's records, or
579
+ * nothing when it cannot be hashed, and then nothing is reused. A
580
+ * function in it, such as the pack router the configuration builds,
581
+ * hashes as its source; what it closes over is the configuration,
582
+ * which the store's own key already covers.
583
+ *
584
+ * @param {unknown} value - The context.
585
+ * @returns {string|undefined} The hash.
586
+ */
587
+ context(value) {
588
+ try {
589
+ return digest(v8.serialize(encode(value, new WeakMap(), true)));
590
+ } catch (error) {
591
+ if (error instanceof Uncacheable) return undefined;
592
+ throw error;
593
+ }
594
+ },
595
+ /** How many notes derived their records rather than reading them. */
596
+ get derived() {
597
+ return derived;
598
+ },
599
+ /**
600
+ * The store to write, holding only the notes the tree still has, or
601
+ * nothing when it is unchanged.
602
+ *
603
+ * @returns {Map<string, object>|undefined} The entries.
604
+ */
605
+ changes() {
606
+ return changed || after.size !== before.size ? after : undefined;
607
+ },
608
+ };
609
+ }
610
+
611
+ /** The tag of one note's stored query answers. */
612
+ const ANSWERS = "note-answers";
613
+
614
+ /** Whether a value is absent or an array of objects. */
615
+ const isObjects = (value) => value === undefined || (Array.isArray(value) && value.every(isObject));
616
+
617
+ /** Whether a value is absent or an array whose every item passes `check`. */
618
+ const isListOf = (value, check) =>
619
+ value === undefined || (Array.isArray(value) && value.every(check));
620
+
621
+ /** Whether a value is a count: a whole number, zero or more. */
622
+ const isCount = (value) => Number.isInteger(value) && value >= 0;
623
+
624
+ /** Whether a value is absent or a boolean, as a directive's `allow-empty`. */
625
+ const isFlag = (value) => value === undefined || typeof value === "boolean";
626
+
627
+ /** Whether a value is absent or a 1-based position. */
628
+ const isPosition = (value) => value === undefined || (Number.isInteger(value) && value > 0);
629
+
630
+ /** Whether a value is a failure's message: a string the renderer reads as true. */
631
+ const isMessage = (value) => typeof value === "string" && value !== "";
632
+
633
+ /**
634
+ * Whether a value is one query table's result, as the expander reads it:
635
+ * either a failure, with its `reason` and where it sits, or a table, with its
636
+ * Markdown and its counts.
637
+ */
638
+ const isTableResult = (value) =>
639
+ isObject(value) &&
640
+ isFlag(value.allowEmpty) &&
641
+ (isMessage(value.reason) ?
642
+ isPosition(value.line) && isPosition(value.column)
643
+ : value.reason === undefined &&
644
+ typeof value.markdown === "string" &&
645
+ isCount(value.rows) &&
646
+ (value.stubsExcluded === undefined || isCount(value.stubsExcluded)));
647
+
648
+ /**
649
+ * Whether a value is one page list's result: either a failure, with its
650
+ * `reason`, or a list, with its Markdown, its page count and what it says
651
+ * when empty.
652
+ */
653
+ const isPageListResult = (value) =>
654
+ isObject(value) &&
655
+ isFlag(value.allowEmpty) &&
656
+ (isMessage(value.reason) ||
657
+ (value.reason === undefined &&
658
+ typeof value.markdown === "string" &&
659
+ isCount(value.pages) &&
660
+ (value.empty === undefined || typeof value.empty === "string")));
661
+
662
+ /** Whether a value is an anchor as a page's index carries it. */
663
+ const isAnchor = (value) => isObject(value) && typeof value.slug === "string";
664
+
665
+ /** Whether a value is a declared section the expander inserts. */
666
+ const isDeclared = (value) =>
667
+ isObject(value) &&
668
+ typeof value.slug === "string" &&
669
+ typeof value.heading === "string" &&
670
+ typeof value.markdown === "string";
671
+
672
+ /** Whether a value is an automatic lexical section the expander inserts. */
673
+ const isLexical = (value) =>
674
+ isObject(value) &&
675
+ typeof value.slug === "string" &&
676
+ typeof value.heading === "string" &&
677
+ Array.isArray(value.aliases) &&
678
+ isObject(value.result) &&
679
+ Array.isArray(value.result.columns) &&
680
+ Array.isArray(value.result.rows) &&
681
+ isObject(value.options);
682
+
683
+ /**
684
+ * Whether a value is one note's query-table results, as
685
+ * {@link module:engine/sql-tables.prepareTreeSqlTables} keys them by note:
686
+ * an array of table results, carrying its anchors, generated and declared
687
+ * sections, lexical lists and Foundry anchor maps beside them.
688
+ */
689
+ const isRows = (value) =>
690
+ value === undefined ||
691
+ (Array.isArray(value) &&
692
+ value.every(isTableResult) &&
693
+ isListOf(value.anchors, isAnchor) &&
694
+ isListOf(value.declared, isDeclared) &&
695
+ isListOf(value.lexical, isLexical) &&
696
+ (value.generated === undefined || typeof value.generated === "string") &&
697
+ (value.anchorUuids === undefined || isObject(value.anchorUuids)) &&
698
+ (value.docAnchorUuids === undefined || isObject(value.docAnchorUuids)));
699
+
700
+ /** Whether a value is one inline query's answer: a value, or an error's message. */
701
+ const isInlineAnswer = (value) =>
702
+ isObject(value) &&
703
+ (Object.hasOwn(value, "error") ? isMessage(value.error) : Object.hasOwn(value, "value"));
704
+
705
+ /** Whether a value is one note's stored query answers, every container and result its own type. */
706
+ const isAnswers = (value) =>
707
+ isObject(value) &&
708
+ value.kind === ANSWERS &&
709
+ isRows(value.rows) &&
710
+ (value.inline === undefined ||
711
+ (value.inline instanceof Map &&
712
+ [...value.inline].every(
713
+ ([query, answer]) => typeof query === "string" && isInlineAnswer(answer),
714
+ ))) &&
715
+ isListOf(value.pageLists, isPageListResult);
716
+
717
+ /** Whether a value is the tree's parses: each note's file to its parse and text. */
718
+ const isParses = (value) =>
719
+ value instanceof Map &&
720
+ [...value].every(
721
+ ([file, parse]) =>
722
+ typeof file === "string" && isParse(parse) && typeof parse.raw === "string",
723
+ );
724
+
725
+ /**
726
+ * One note's part of a preparation.
727
+ *
728
+ * @param {Map<string, object[]>|undefined} prepared - From
729
+ * {@link module:engine/sql-tables.prepareTreeSqlTables}.
730
+ * @param {string} file - The note.
731
+ * @returns {{kind: string, rows?: object[], inline?: unknown, pageLists?: unknown}} Its answers.
732
+ */
733
+ function answersOf(prepared, file) {
734
+ return {
735
+ kind: ANSWERS,
736
+ rows: prepared?.get(file),
737
+ inline: prepared?.inline?.get(file),
738
+ pageLists: prepared?.pageLists?.get(file),
739
+ };
740
+ }
741
+
742
+ /**
743
+ * What a note that links another needs of its answers, for answers the cache
744
+ * cannot hold whole: everything but the automatic lexical lists, whose
745
+ * rendering options are functions. The lists add dictionary and glossary
746
+ * entries and their anchors; the anchors are kept, in `anchors`, and the
747
+ * entries hold no figure, so a linked note's anchors and figures read the
748
+ * same from this view. The view is stored marked `linkedOnly`, and the note
749
+ * itself is always prepared again when it is the one rendered.
750
+ *
751
+ * @param {ReturnType<typeof answersOf>} answers - One note's answers.
752
+ * @returns {ReturnType<typeof answersOf> & {linkedOnly: true}} The view.
753
+ */
754
+ function linkedView(answers) {
755
+ const rows = answers.rows && Object.assign([...answers.rows], answers.rows);
756
+ if (rows) delete rows.lexical;
757
+ return { ...answers, rows, linkedOnly: true };
758
+ }
759
+
760
+ /**
761
+ * Several notes' answers as one preparation, shaped as
762
+ * {@link module:engine/sql-tables.prepareTreeSqlTables} returns it.
763
+ *
764
+ * @param {Map<string, ReturnType<typeof answersOf>>} byFile - Each note's answers.
765
+ * @returns {Map<string, object[]>} The preparation.
766
+ */
767
+ function preparedOf(byFile) {
768
+ const prepared = /** @type {Map<string, object[]> & {inline: Map, pageLists: Map}} */ (
769
+ new Map()
770
+ );
771
+ prepared.inline = new Map();
772
+ prepared.pageLists = new Map();
773
+ for (const [file, answers] of byFile) {
774
+ if (answers.rows !== undefined) prepared.set(file, answers.rows);
775
+ if (answers.inline !== undefined) prepared.inline.set(file, answers.inline);
776
+ if (answers.pageLists !== undefined) prepared.pageLists.set(file, answers.pageLists);
777
+ }
778
+ return prepared;
779
+ }
780
+
781
+ /**
782
+ * What a note of a package renders against.
783
+ *
784
+ * @typedef {object} NoteTree
785
+ * @property {object[]} records - The tree's index records, as
786
+ * {@link module:engine/content-index.indexRecordsFor} derives them.
787
+ * @property {{index: Map<string, object>}} foreign - The dependencies'
788
+ * indexes, as {@link module:engine/metadata-index.loadForeignIndexes}
789
+ * returns them: the very ones the records were derived with.
790
+ * @property {(files: Iterable<string>, own?: string) => Promise<{sqlTables: Map<string, object[]>, tables: "hit"|"miss"|"off"}>} answers -
791
+ * The query answers and generated sections of the notes named, by absolute
792
+ * path, as {@link module:engine/sql-tables.prepareTreeSqlTables} prepares
793
+ * them; `tables` is `"hit"` when every one was read from the store. `own`
794
+ * is the note being rendered, whose answers are always whole; every other
795
+ * note named may be answered with the view a linking note needs. Rejects
796
+ * with {@link NoteInputsChanged}, storing nothing, when the configuration,
797
+ * fonts or dependency indexes are not the ones the records were derived
798
+ * from, before or after the preparation.
799
+ * @property {(file: string) => string} textOf - A note's text, as the records
800
+ * were derived from it.
801
+ * @property {{tree: "hit"|"miss"|"off", notes: number}} cache - What the
802
+ * cache answered: the tree's records whole or not, and how many notes
803
+ * derived their own records.
804
+ */
805
+
806
+ /**
807
+ * The content tree's records, and its notes' query answers on demand, from
808
+ * the cache under `build/` while every input they were derived from is
809
+ * unchanged, and derived and stored otherwise.
810
+ *
811
+ * The dependencies' indexes are read here, between two readings of every
812
+ * input the store keys by: when the two differ, something changed while it
813
+ * was read, and the whole reading is done again, so what is stored is always
814
+ * derived from the bytes its key names.
815
+ *
816
+ * @param {object} config - The resolved configuration.
817
+ * @param {object} [options]
818
+ * @param {boolean} [options.cache=true] - Whether to read and write the
819
+ * cache; `false` derives everything and touches nothing.
820
+ * @param {string} [options.toolchain] - The toolchain's fingerprint;
821
+ * {@link toolchainFingerprint} by default.
822
+ * @param {typeof import("./sql-tables.mjs").prepareTreeSqlTables} [options.prepareTables] -
823
+ * Prepares query answers; {@link module:engine/sql-tables.prepareTreeSqlTables}
824
+ * by default.
825
+ * @param {typeof loadForeignIndexes} [options.loadForeign] - Reads the
826
+ * dependencies' indexes; {@link module:engine/metadata-index.loadForeignIndexes}
827
+ * by default.
828
+ * @returns {Promise<NoteTree>} The records, the indexes, the answers and what
829
+ * the cache did.
830
+ * @throws {Error} As the derivation does: a note that cannot be indexed, a
831
+ * dependency that has not been fetched or cannot be read, naming
832
+ * `package-build deps fetch`; and when the inputs keep changing while they
833
+ * are read.
834
+ */
835
+ export async function noteTree(
836
+ config,
837
+ {
838
+ cache = true,
839
+ toolchain = cache ? toolchainFingerprint() : "",
840
+ loadForeign = loadForeignIndexes,
841
+ prepareTables,
842
+ } = {},
843
+ ) {
844
+ for (let attempt = 0; attempt < READ_ATTEMPTS; attempt++) {
845
+ const read = await readTree(config, { cache, toolchain, loadForeign, prepareTables });
846
+ if (read) return read;
847
+ }
848
+ throw new NoteInputsChanged(
849
+ `the configuration, fonts or dependency indexes of ${config.rootDir} changed ` +
850
+ `while they were read, ${READ_ATTEMPTS} times; render again once they are settled`,
851
+ );
852
+ }
853
+
854
+ /** The error a reading raises when its inputs moved while it was read. */
855
+ export class NoteInputsChanged extends Error {}
856
+
857
+ /**
858
+ * The error for inputs that moved while a reading was under way.
859
+ *
860
+ * @param {{rootDir: string}} config - The resolved configuration.
861
+ * @returns {NoteInputsChanged} The error.
862
+ */
863
+ function inputsChanged(config) {
864
+ return new NoteInputsChanged(
865
+ `the configuration, fonts or dependency indexes of ${config.rootDir} changed ` +
866
+ "while they were read",
867
+ );
868
+ }
869
+
870
+ /**
871
+ * The tree's parses, from the texts its key was taken over, for a store whose
872
+ * own parses cannot be used.
873
+ *
874
+ * @param {Map<string, {raw: string}>} texts - Every note's text, by absolute path.
875
+ * @returns {Map<string, object>} Each note's parse.
876
+ */
877
+ function parsesOf(texts) {
878
+ return new Map(
879
+ [...texts].map(([file, { raw }]) => [
880
+ file,
881
+ parseMarkdownFile(file, { raw, renderDescription: false }),
882
+ ]),
883
+ );
884
+ }
885
+
886
+ /** How often a reading is done again when its inputs move under it. */
887
+ export const READ_ATTEMPTS = 3;
888
+
889
+ /**
890
+ * The dependencies' indexes, refused when one is unusable.
891
+ *
892
+ * @param {object} config - The resolved configuration.
893
+ * @param {typeof loadForeignIndexes} loadForeign - Reads them.
894
+ * @returns {ReturnType<typeof loadForeignIndexes>} The indexes.
895
+ */
896
+ function readForeign(config, loadForeign) {
897
+ const foreign = loadForeign(config, [config.contentPackage]);
898
+ if (foreign.stale.length)
899
+ throw new Error(
900
+ foreign.stale
901
+ .map((s) => `${s.package}: unusable content index: ${s.reason}`)
902
+ .join("\n") + "\nrun `package-build deps fetch` again",
903
+ );
904
+ return foreign;
905
+ }
906
+
907
+ /**
908
+ * One reading of {@link noteTree}, or nothing when an input changed while it
909
+ * was read.
910
+ *
911
+ * @param {object} config - The resolved configuration.
912
+ * @param {{cache: boolean, toolchain: string, loadForeign: typeof loadForeignIndexes,
913
+ * prepareTables?: typeof import("./sql-tables.mjs").prepareTreeSqlTables}} options
914
+ * @returns {Promise<NoteTree|undefined>} The reading.
915
+ */
916
+ async function readTree(config, { cache, toolchain, loadForeign, prepareTables }) {
917
+ const { indexRecordsFor } = await import("./content-index.mjs");
918
+ const prepareTreeSqlTables =
919
+ prepareTables ?? (await import("./sql-tables.mjs")).prepareTreeSqlTables;
920
+ const contentBase = path.resolve(config.paths.content);
921
+ const skipDirectories = config.skipDirectories;
922
+ const prepare = (records, parsed, files) =>
923
+ prepareTreeSqlTables(contentBase, {
924
+ config,
925
+ skipDirectories,
926
+ records,
927
+ parsed,
928
+ audience: "public",
929
+ foreignIndex: foreign.index,
930
+ only: new Set(files),
931
+ });
932
+
933
+ // A configuration the file has moved on from is the caller's to resolve
934
+ // again; reading the tree again with it would answer the same way.
935
+ if (cache && configMovedOn(config)) throw inputsChanged(config);
936
+ const before = cache ? packageFingerprint(config) : "";
937
+ const foreign = readForeign(config, loadForeign);
938
+ if (cache && packageFingerprint(config) !== before) return undefined;
939
+
940
+ if (!cache) {
941
+ const parsed = new Map();
942
+ const records = indexRecordsFor({
943
+ contentBase,
944
+ config,
945
+ skipDirectories,
946
+ parsed,
947
+ foreignIndex: foreign.index,
948
+ });
949
+ return {
950
+ records,
951
+ foreign,
952
+ answers: async (files) => {
953
+ const wanted = [...new Set(files)];
954
+ const prepared = await prepare(records, parsed, wanted);
955
+ return {
956
+ sqlTables: preparedOf(
957
+ new Map(wanted.map((file) => [file, answersOf(prepared, file)])),
958
+ ),
959
+ tables: "off",
960
+ };
961
+ },
962
+ textOf: (file) => fs.readFileSync(file, "utf8"),
963
+ cache: { tree: "off", notes: parsed.size },
964
+ };
965
+ }
966
+
967
+ const dir = noteCacheDirectory(config);
968
+ const inputs = digest(toolchain, before, contentBase);
969
+ const texts = new Map();
970
+ for (const file of walkMarkdownFiles(contentBase, { skipDirectories })) {
971
+ const raw = fs.readFileSync(file, "utf8");
972
+ texts.set(file, { raw, hash: digest(raw) });
973
+ }
974
+ // The asset records themselves, which are all the tree's assets give the
975
+ // derivation.
976
+ const assets = packIfCacheable(
977
+ config.paths.assets ?
978
+ collectAssetRecords(path.resolve(config.paths.assets), {
979
+ contentPackage: config.contentPackage,
980
+ problems: [],
981
+ })
982
+ : [],
983
+ );
984
+ const treeKey = digest(
985
+ inputs,
986
+ assets ?? crypto.randomUUID(),
987
+ ...[...texts]
988
+ .map(([file, { hash }]) => [path.relative(contentBase, file), hash])
989
+ .sort(([a], [b]) =>
990
+ a < b ? -1
991
+ : a > b ? 1
992
+ : 0,
993
+ )
994
+ .flat(),
995
+ );
996
+ const treeFile = path.join(dir, "tree.v8");
997
+ const textOf = (file) => texts.get(file)?.raw ?? fs.readFileSync(file, "utf8");
998
+
999
+ /**
1000
+ * The answers for `files`: each from the store when it holds them, the
1001
+ * rest prepared together and added to it.
1002
+ *
1003
+ * @param {object[]} records - The records.
1004
+ * @param {() => Map<string, object>|undefined} parsedOf - The parses, when
1005
+ * they can be had.
1006
+ * @param {Record<string, any>} store - What `tree.v8` holds or will hold.
1007
+ * @param {Iterable<string>} files - The notes, by absolute path.
1008
+ */
1009
+ const answersFor = async (records, parsedOf, store, files, own) => {
1010
+ const wanted = [...new Set(files)];
1011
+ const byFile = new Map();
1012
+ const missing = [];
1013
+ for (const file of wanted) {
1014
+ const kept = /** @type {any} */ (
1015
+ unpackChecked(store.tables.get(path.relative(contentBase, file)), isAnswers)
1016
+ );
1017
+ // A linked note's view serves only a note that links it.
1018
+ if (kept && !(kept.linkedOnly && file === own)) byFile.set(file, kept);
1019
+ else missing.push(file);
1020
+ }
1021
+ if (missing.length) {
1022
+ // The preparation reads the dependency indexes again, so it must
1023
+ // read the bytes the records were derived from.
1024
+ const moved = () => configMovedOn(config) || packageFingerprint(config) !== before;
1025
+ if (moved()) throw inputsChanged(config);
1026
+ const prepared = await prepare(records, parsedOf(), missing);
1027
+ if (moved()) throw inputsChanged(config);
1028
+ let added = false;
1029
+ for (const file of missing) {
1030
+ const answers = answersOf(prepared, file);
1031
+ byFile.set(file, answers);
1032
+ const packed = packIfCacheable(answers) ?? packIfCacheable(linkedView(answers));
1033
+ if (packed) {
1034
+ store.tables.set(path.relative(contentBase, file), packed);
1035
+ added = true;
1036
+ }
1037
+ }
1038
+ if (added && store.writable) {
1039
+ const { writable: _, ...kept } = store;
1040
+ writeStore(treeFile, kept);
1041
+ }
1042
+ }
1043
+ return { sqlTables: preparedOf(byFile), tables: missing.length ? "miss" : "hit" };
1044
+ };
1045
+
1046
+ const tree = readStore(treeFile, treeKey);
1047
+ const records =
1048
+ tree && tree.tables instanceof Map ? unpackChecked(tree.records, isRecords) : undefined;
1049
+ if (records) {
1050
+ const store = { ...tree, writable: true };
1051
+ let parsed;
1052
+ return {
1053
+ records: /** @type {object[]} */ (records),
1054
+ foreign,
1055
+ answers: (files, own) =>
1056
+ answersFor(
1057
+ /** @type {object[]} */ (records),
1058
+ () =>
1059
+ (parsed ??=
1060
+ /** @type {Map<string, object>|undefined} */ (
1061
+ unpackChecked(tree.parsed, isParses)
1062
+ ) ?? parsesOf(texts)),
1063
+ store,
1064
+ files,
1065
+ own,
1066
+ ),
1067
+ textOf,
1068
+ cache: { tree: "hit", notes: 0 },
1069
+ };
1070
+ }
1071
+
1072
+ const notesFile = path.join(dir, "notes.v8");
1073
+ const memo = noteMemo(readStore(notesFile, inputs), texts, contentBase);
1074
+ const parsed = new Map();
1075
+ const derived = indexRecordsFor({
1076
+ contentBase,
1077
+ config,
1078
+ skipDirectories,
1079
+ parsed,
1080
+ foreignIndex: foreign.index,
1081
+ cache: memo,
1082
+ });
1083
+ if (configMovedOn(config)) throw inputsChanged(config);
1084
+ if (packageFingerprint(config) !== before) return undefined;
1085
+ const notes = memo.changes();
1086
+ if (notes) writeStore(notesFile, { key: inputs, notes });
1087
+ const packedRecords = packIfCacheable(derived);
1088
+ const packedParsed = packIfCacheable(parsed);
1089
+ const store = {
1090
+ key: treeKey,
1091
+ records: packedRecords,
1092
+ parsed: packedParsed,
1093
+ tables: new Map(),
1094
+ writable: Boolean(packedRecords && packedParsed),
1095
+ };
1096
+ if (store.writable) {
1097
+ const { writable: _, ...kept } = store;
1098
+ writeStore(treeFile, kept);
1099
+ }
1100
+ return {
1101
+ records: derived,
1102
+ foreign,
1103
+ answers: (files, own) => answersFor(derived, () => parsed, store, files, own),
1104
+ textOf,
1105
+ cache: { tree: "miss", notes: memo.derived },
1106
+ };
1107
+ }