@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.
- package/CHANGELOG.md +23 -0
- package/README.md +19 -1
- package/bin/content-commands.mjs +64 -79
- package/bin/package-build.mjs +25 -10
- package/ci/wip-workflows.test.mjs +252 -0
- package/content-config.mjs +118 -8
- package/docs/api.md +237 -47
- package/docs/commands.md +99 -35
- package/docs/configuration.md +93 -44
- package/docs/guides/release-and-deploy.md +26 -14
- package/docs/guides/site.md +53 -0
- package/engine/cdn-publish.mjs +164 -6
- package/engine/content-index.mjs +71 -19
- package/engine/content-spans.mjs +18 -1
- package/engine/content-styles.mjs +85 -0
- package/engine/derived-sections.mjs +4 -3
- package/engine/event-views.mjs +6 -4
- package/engine/helpers.mjs +33 -9
- package/engine/index.mjs +1 -0
- package/engine/note-cache.mjs +1107 -0
- package/engine/note-page.mjs +424 -0
- package/engine/pack-config.mjs +47 -4
- package/engine/pathnames.mjs +22 -7
- package/engine/prepared-only.mjs +32 -0
- package/engine/preview-index.mjs +55 -13
- package/engine/site-build.mjs +34 -448
- package/engine/site-config.mjs +33 -4
- package/engine/site-maps.mjs +16 -24
- package/engine/site-origins.mjs +23 -0
- package/engine/site-page.mjs +648 -0
- package/engine/site-render.mjs +142 -10
- package/engine/site-serve.mjs +1 -1
- package/engine/sql-tables.mjs +12 -3
- package/package.json +6 -1
- package/site-theme/static/css/style.css +14 -0
- package/types/content-config.d.mts +34 -0
- package/types/engine/cdn-publish.d.mts +22 -0
- package/types/engine/content-index.d.mts +36 -2
- package/types/engine/content-styles.d.mts +20 -0
- package/types/engine/derived-sections.d.mts +3 -3
- package/types/engine/event-views.d.mts +4 -3
- package/types/engine/helpers.d.mts +18 -1
- package/types/engine/index.d.mts +1 -0
- package/types/engine/note-cache.d.mts +149 -0
- package/types/engine/note-page.d.mts +92 -0
- package/types/engine/pack-config.d.mts +30 -0
- package/types/engine/prepared-only.d.mts +14 -0
- package/types/engine/preview-index.d.mts +35 -0
- package/types/engine/site-build.d.mts +5 -100
- package/types/engine/site-config.d.mts +3 -2
- package/types/engine/site-maps.d.mts +7 -11
- package/types/engine/site-origins.d.mts +9 -0
- package/types/engine/site-page.d.mts +261 -0
- package/types/engine/site-render.d.mts +20 -1
- 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
|
+
}
|