@heroiclands/package-build 8.1.0 → 10.0.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 (122) hide show
  1. package/CHANGELOG.md +748 -0
  2. package/CONTENT.md +273 -13
  3. package/bin/content-build.mjs +479 -123
  4. package/bin/package-build.mjs +27 -69
  5. package/bin/report.mjs +1 -2
  6. package/bundle.mjs +2 -10
  7. package/config.mjs +31 -106
  8. package/container.mjs +13 -57
  9. package/content-config.mjs +300 -188
  10. package/coverage.mjs +14 -55
  11. package/deploy.mjs +4 -13
  12. package/docs/content-format.md +1418 -0
  13. package/e2e.mjs +16 -55
  14. package/engine/address-charset.mjs +62 -0
  15. package/engine/address-diff.mjs +1 -4
  16. package/engine/alias-index.mjs +153 -0
  17. package/engine/base-compiler.mjs +203 -31
  18. package/engine/code-fences.mjs +4 -13
  19. package/engine/compendiums.mjs +13 -37
  20. package/engine/content-address.mjs +6 -10
  21. package/engine/content-format-check.mjs +570 -0
  22. package/engine/content-format.mjs +253 -0
  23. package/engine/content-links.mjs +144 -99
  24. package/engine/content-lint.mjs +12 -16
  25. package/engine/content-slug.mjs +2 -6
  26. package/engine/content-tables.mjs +26 -79
  27. package/engine/diagnostics.mjs +37 -15
  28. package/engine/document-subtypes.mjs +440 -0
  29. package/engine/field-reference.mjs +6 -20
  30. package/engine/field-spec.mjs +49 -45
  31. package/engine/foreign-catalog.mjs +7 -22
  32. package/engine/foreign-manifests.mjs +1 -4
  33. package/engine/frontmatter-lint.mjs +347 -43
  34. package/engine/frontmatter.mjs +3 -8
  35. package/engine/generate.mjs +36 -20
  36. package/engine/helpers.mjs +54 -81
  37. package/engine/homepage.mjs +4 -15
  38. package/engine/ids.mjs +21 -12
  39. package/engine/index.mjs +15 -0
  40. package/engine/item-registry.mjs +72 -9
  41. package/engine/journals.mjs +4 -14
  42. package/engine/kb-manifest.mjs +41 -24
  43. package/engine/macros.mjs +2 -10
  44. package/engine/manifest-emit.mjs +6 -17
  45. package/engine/map-notes.mjs +53 -87
  46. package/engine/note-claims.mjs +383 -0
  47. package/engine/note-package.mjs +1 -4
  48. package/engine/note-vocabulary.mjs +678 -0
  49. package/engine/pack-config.mjs +56 -60
  50. package/engine/pack-router.mjs +18 -8
  51. package/engine/prose-config.mjs +20 -4
  52. package/engine/prose-lint.mjs +61 -17
  53. package/engine/region-events.mjs +1 -3
  54. package/engine/retired-fields.mjs +117 -3
  55. package/engine/scene-levels.mjs +8 -22
  56. package/engine/scenes.mjs +31 -47
  57. package/engine/schema-check.mjs +348 -7
  58. package/engine/schema-extract.mjs +11 -39
  59. package/engine/site-build.mjs +13 -38
  60. package/engine/site-index.mjs +40 -35
  61. package/engine/system-block.mjs +513 -0
  62. package/engine/web-wikilinks.mjs +115 -92
  63. package/engine/wikilink-syntax.mjs +30 -0
  64. package/engine/wikilinks.mjs +147 -183
  65. package/index.mjs +1 -5
  66. package/lang.mjs +1 -3
  67. package/manifest.mjs +10 -37
  68. package/markdownlint-config.mjs +1 -5
  69. package/package.json +6 -2
  70. package/sohl/actors.mjs +251 -68
  71. package/sohl/being-info.mjs +3 -6
  72. package/sohl/document-subtypes.mjs +82 -0
  73. package/sohl/index.mjs +4 -6
  74. package/sohl/item-builders.mjs +1 -3
  75. package/sohl/item-fields.mjs +16 -34
  76. package/sohl/items.mjs +111 -17
  77. package/sohl/kb-passes.mjs +29 -39
  78. package/sohl/note-schemas.mjs +11 -7
  79. package/sohl/skill-base.mjs +7 -23
  80. package/stage.mjs +3 -13
  81. package/templates.mjs +4 -15
  82. package/types/bundle.d.mts +1 -1
  83. package/types/container.d.mts +2 -2
  84. package/types/content-config.d.mts +48 -4
  85. package/types/coverage.d.mts +1 -1
  86. package/types/e2e.d.mts +4 -4
  87. package/types/engine/address-charset.d.mts +45 -0
  88. package/types/engine/alias-index.d.mts +122 -0
  89. package/types/engine/base-compiler.d.mts +132 -4
  90. package/types/engine/content-address.d.mts +2 -2
  91. package/types/engine/content-format-check.d.mts +163 -0
  92. package/types/engine/content-format.d.mts +101 -0
  93. package/types/engine/content-links.d.mts +16 -1
  94. package/types/engine/content-lint.d.mts +6 -0
  95. package/types/engine/diagnostics.d.mts +29 -0
  96. package/types/engine/document-subtypes.d.mts +233 -0
  97. package/types/engine/field-spec.d.mts +76 -23
  98. package/types/engine/frontmatter-lint.d.mts +47 -2
  99. package/types/engine/generate.d.mts +15 -2
  100. package/types/engine/helpers.d.mts +22 -14
  101. package/types/engine/ids.d.mts +10 -0
  102. package/types/engine/index.d.mts +5 -0
  103. package/types/engine/item-registry.d.mts +21 -2
  104. package/types/engine/kb-manifest.d.mts +35 -8
  105. package/types/engine/map-notes.d.mts +21 -11
  106. package/types/engine/note-claims.d.mts +113 -0
  107. package/types/engine/note-vocabulary.d.mts +251 -0
  108. package/types/engine/pack-config.d.mts +4 -3
  109. package/types/engine/pack-router.d.mts +4 -4
  110. package/types/engine/prose-lint.d.mts +6 -2
  111. package/types/engine/retired-fields.d.mts +73 -2
  112. package/types/engine/schema-check.d.mts +182 -0
  113. package/types/engine/schema-extract.d.mts +1 -1
  114. package/types/engine/site-index.d.mts +1 -1
  115. package/types/engine/system-block.d.mts +281 -0
  116. package/types/engine/web-wikilinks.d.mts +23 -12
  117. package/types/engine/wikilink-syntax.d.mts +29 -0
  118. package/types/manifest.d.mts +1 -1
  119. package/types/sohl/actors.d.mts +62 -6
  120. package/types/sohl/document-subtypes.d.mts +14 -0
  121. package/types/sohl/index.d.mts +1 -0
  122. package/types/sohl/items.d.mts +21 -0
@@ -0,0 +1,513 @@
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 per-system frontmatter block** — how one note feeds more than one game
16
+ * system (#58).
17
+ *
18
+ * A note is system-agnostic. The only system-specific things it carries are the
19
+ * properties *named after a system*, and within one:
20
+ *
21
+ * | property | maps to |
22
+ * | --- | --- |
23
+ * | `<system>.system` | `document.system` — the DataModel schema, verbatim paths |
24
+ * | `<system>.type` | `document.type` — the subtype (#79's discriminator) |
25
+ * | `<system>.img` | `document.img` |
26
+ * | `<system>.items` | `document.items` — actors only |
27
+ * | `<system>.effects` | `document.effects` |
28
+ * | `<system>.flags` | `document.flags` |
29
+ * | `<system>.pack` | *nothing on the document* — a build directive naming the compendium |
30
+ *
31
+ * Everything else a system declares — `archetype`, `kbcat`, and the
32
+ * *generators* `items` and `attributes`, which expand into embedded documents
33
+ * rather than mapping anywhere — sits directly under the block, which is why it
34
+ * has to be somewhere the schema cannot claim.
35
+ *
36
+ * ## The shared fallback is declared, not name-matched
37
+ *
38
+ * This is the load-bearing part, and the reason the whole module exists.
39
+ * `sohl.system.portrait` and `hm3.system.bioImage` both default from one shared
40
+ * top-level property — and they are two *real* fields with different names. The
41
+ * two Actor schemas one `being` note has to feed share **no field name at all**,
42
+ * so a rule that matched on spelling would not be a rule with exceptions; it
43
+ * would be a rule that never fires.
44
+ *
45
+ * So each field declares where its shared value comes from, and the resolution
46
+ * order for a system `S` is:
47
+ *
48
+ * 1. `S.system.<to>` — authored directly, wins outright;
49
+ * 2. `S.<name>` — the legacy in-block position the corpus still writes, kept
50
+ * until #126 moves it;
51
+ * 3. the shared top-level property the field **declares** as its source, which
52
+ * may be a dotted path (`data.portrait`) rather than a sibling key;
53
+ * 4. the field's own default.
54
+ *
55
+ * `sohlField()` — read `fm.sohl[key]`, fall back to `fm[key]` — is the
56
+ * degenerate case where source and destination happen to share a name. It stops
57
+ * being the general rule; {@link blockField} is what remains of it.
58
+ *
59
+ * ## Nothing here knows a game system
60
+ *
61
+ * A block is addressed by name, and the name arrives from the caller — a
62
+ * system's own document-subtype map declares it (`DocumentSubtypeMap.block`).
63
+ * That is the `engine/` ÷ `sohl/` line this package draws everywhere else
64
+ * (#36): note-format knowledge here, game-system knowledge in the system's own
65
+ * half.
66
+ *
67
+ * @module
68
+ */
69
+
70
+ import { getFrontmatter } from "./frontmatter.mjs";
71
+
72
+ /**
73
+ * The key inside a system block that maps onto the document's `system`
74
+ * property.
75
+ *
76
+ * Named rather than spelled inline: it is the one key whose contents are the
77
+ * *system's* vocabulary rather than this format's, and every check that has to
78
+ * treat it differently reads it from here.
79
+ *
80
+ * @type {string}
81
+ */
82
+ export const SYSTEM_DATA_KEY = "system";
83
+
84
+ /**
85
+ * The properties inside a system block that map onto a document property, block
86
+ * key → document key.
87
+ *
88
+ * The two names are equal in every row today, and are written out anyway for
89
+ * the reason every row of a document-subtype map is: a mapping that exists only
90
+ * because two vocabularies happen to be spelled alike is not a mapping.
91
+ *
92
+ * `effects` is **plural**, matching both the existing top-level frontmatter
93
+ * field (authored on 24 notes in `sohl-kethira-basic`) and the Foundry document
94
+ * property. A singular-to-plural rename applying to one property and not its
95
+ * neighbour reads as a typo for years.
96
+ *
97
+ * `items` exists on **actors only**; an item document has no embedded items,
98
+ * and a note declaring it under a block whose subtype is an Item is authoring
99
+ * something nothing will read.
100
+ *
101
+ * @type {Readonly<Record<string, string>>}
102
+ */
103
+ export const BLOCK_DOCUMENT_PROPERTIES = Object.freeze({
104
+ system: "system",
105
+ type: "type",
106
+ img: "img",
107
+ items: "items",
108
+ effects: "effects",
109
+ flags: "flags",
110
+ });
111
+
112
+ /**
113
+ * Block keys that are **build directives** — they tell the toolchain how to
114
+ * build the document and land on no document property at all.
115
+ *
116
+ * `pack` is the first and, so far, the only one: it names the compendium this
117
+ * system's document is compiled into, which is what makes "one note, several
118
+ * packs" expressible per system. Its shared top-level form already exists
119
+ * (`PACK_FIELD` in `pack-router.mjs`, authored on 352 notes), so the override
120
+ * rule gives it a per-system form for free.
121
+ *
122
+ * @type {readonly string[]}
123
+ */
124
+ export const BLOCK_DIRECTIVES = Object.freeze(["pack"]);
125
+
126
+ /**
127
+ * Every key any system block may carry, whatever the system.
128
+ *
129
+ * A system adds its own on top — `archetype`, `kbcat` and the generators — and
130
+ * names them where it declares its vocabulary; see {@link unknownBlockKeys}.
131
+ *
132
+ * @type {ReadonlySet<string>}
133
+ */
134
+ export const SYSTEM_BLOCK_KEYS = Object.freeze(
135
+ new Set([...Object.keys(BLOCK_DOCUMENT_PROPERTIES), ...BLOCK_DIRECTIVES]),
136
+ );
137
+
138
+ /**
139
+ * Write `value` at a dotted path in a document's `system` block, creating the
140
+ * intermediate objects.
141
+ *
142
+ * Insertion order is the emitted JSON's key order, so a declaration's order is
143
+ * the compiled document's order — which is what lets a field list replace a
144
+ * hand-written object literal without changing a single byte of output.
145
+ *
146
+ * It sits here rather than beside the field declarations because both writers
147
+ * into a `system` block use it: the declared fields, and the verbatim
148
+ * `<system>.system` passthrough. `field-spec.mjs` re-exports it, so the name
149
+ * has one import path as well as one definition.
150
+ *
151
+ * @param {object} target - The object to write into (mutated).
152
+ * @param {string} dotted - Path, e.g. `"locations.flexible"`.
153
+ * @param {any} value - The value to set.
154
+ * @returns {object} `target`, for chaining.
155
+ */
156
+ export function setPath(target, dotted, value) {
157
+ const parts = dotted.split(".");
158
+ const leaf = /** @type {string} */ (parts.pop());
159
+ let cursor = target;
160
+ for (const part of parts) {
161
+ if (
162
+ cursor[part] == null ||
163
+ typeof cursor[part] !== "object" ||
164
+ Array.isArray(cursor[part])
165
+ ) {
166
+ cursor[part] = {};
167
+ }
168
+ cursor = cursor[part];
169
+ }
170
+ cursor[leaf] = value;
171
+ return target;
172
+ }
173
+
174
+ /**
175
+ * Whether a value is a plain object — a mapping, not an array and not `null`.
176
+ *
177
+ * @param {unknown} value - The value.
178
+ * @returns {boolean} True for a mapping.
179
+ */
180
+ function isMapping(value) {
181
+ return value != null && typeof value === "object" && !Array.isArray(value);
182
+ }
183
+
184
+ /**
185
+ * One system's block, or nothing.
186
+ *
187
+ * A block authored as a scalar or a list is **absent** rather than an error
188
+ * here: this module reports what a note carries, and saying what is wrong with
189
+ * a malformed one is the linter's job, which can point at the line.
190
+ *
191
+ * @param {object} fm - The note's frontmatter.
192
+ * @param {string} block - The block key, e.g. `"sohl"`.
193
+ * @returns {Record<string, unknown>|undefined} The block, or `undefined`.
194
+ */
195
+ export function systemBlock(fm, block) {
196
+ if (!isMapping(fm) || !block) return undefined;
197
+ const value = /** @type {Record<string, unknown>} */ (fm)[block];
198
+ return isMapping(value) ? /** @type {Record<string, unknown>} */ (value) : undefined;
199
+ }
200
+
201
+ /**
202
+ * Whether a note carries a system's block at all.
203
+ *
204
+ * This is the pack-eligibility question: a pack declaring a system compiles a
205
+ * note only if the note has something to say about that system. A note that
206
+ * does not carries no system data, and compiling it anyway produces a hollow
207
+ * document — one with a subtype and none of the fields that subtype exists for.
208
+ *
209
+ * @param {object} fm - The note's frontmatter.
210
+ * @param {string} block - The block key.
211
+ * @returns {boolean} True when the block is present and is a mapping.
212
+ */
213
+ export function carriesSystemBlock(fm, block) {
214
+ return systemBlock(fm, block) !== undefined;
215
+ }
216
+
217
+ /**
218
+ * A system block's `system` sub-block — what maps onto `document.system`.
219
+ *
220
+ * @param {object} fm - The note's frontmatter.
221
+ * @param {string} block - The block key.
222
+ * @returns {Record<string, unknown>} The authored data, `{}` when absent.
223
+ */
224
+ export function systemData(fm, block) {
225
+ const value = systemBlock(fm, block)?.[SYSTEM_DATA_KEY];
226
+ return isMapping(value) ? /** @type {Record<string, unknown>} */ (value) : {};
227
+ }
228
+
229
+ /**
230
+ * Read a key from one system's block, falling back to the top level.
231
+ *
232
+ * The generalization of `sohlField()` to any block, and behaviourally identical
233
+ * to it for `"sohl"` — the one system every existing tree authors. What changed
234
+ * is that the block is a parameter rather than a constant, which is the whole
235
+ * of what a second system needs from this reader.
236
+ *
237
+ * @param {object} fm - The note's frontmatter.
238
+ * @param {string} block - The block key.
239
+ * @param {string} key - The property, dotted for a nested one.
240
+ * @param {any} [defaultValue] - Returned when neither declares it.
241
+ * @returns {any} The value.
242
+ */
243
+ export function blockField(fm, block, key, defaultValue = undefined) {
244
+ if (!isMapping(fm)) return defaultValue;
245
+ const declared = systemBlock(fm, block);
246
+ if (declared) {
247
+ if (key in declared) return declared[key] ?? defaultValue;
248
+ const fromNested = getFrontmatter(declared, key, undefined);
249
+ if (fromNested !== undefined) return fromNested;
250
+ }
251
+ return getFrontmatter(fm, key, defaultValue);
252
+ }
253
+
254
+ /**
255
+ * A **shared** top-level property, read by a possibly-dotted path.
256
+ *
257
+ * Deliberately blind to every system block: this is the third step of the
258
+ * resolution order, and letting a block answer it would make the second step
259
+ * and the third the same question.
260
+ *
261
+ * @param {object} fm - The note's frontmatter.
262
+ * @param {string} source - The property, dotted for a path into a container.
263
+ * @param {any} [defaultValue] - Returned when the path resolves to nothing.
264
+ * @returns {any} The value.
265
+ */
266
+ export function sharedProperty(fm, source, defaultValue = undefined) {
267
+ return getFrontmatter(fm, source, defaultValue);
268
+ }
269
+
270
+ /**
271
+ * A property a system block may override, else the shared top-level one.
272
+ *
273
+ * This is what gives `pack`, `effects`, `flags` and `img` their per-system form
274
+ * without inventing a mechanism for each: a note that wants one value for both
275
+ * systems says it once at the top, and a note that needs them to differ says so
276
+ * in the block that differs.
277
+ *
278
+ * @param {object} fm - The note's frontmatter.
279
+ * @param {string} block - The block key.
280
+ * @param {string} key - The property.
281
+ * @param {any} [defaultValue] - Returned when neither declares it.
282
+ * @returns {any} The value.
283
+ */
284
+ export function blockProperty(fm, block, key, defaultValue = undefined) {
285
+ const declared = systemBlock(fm, block);
286
+ if (declared && declared[key] !== undefined && declared[key] !== null) {
287
+ return declared[key];
288
+ }
289
+ const shared = isMapping(fm) ? /** @type {Record<string, unknown>} */ (fm)[key] : undefined;
290
+ return shared === undefined || shared === null ? defaultValue : shared;
291
+ }
292
+
293
+ /**
294
+ * Where a declared field's value came from.
295
+ *
296
+ * Reported alongside the value so a caller — a linter, a migration, a test —
297
+ * can distinguish a value an author wrote from one a default supplied, which
298
+ * the value alone never says.
299
+ *
300
+ * @typedef {"system"|"block"|"shared"|"default"|"value"} FieldSource
301
+ */
302
+
303
+ /**
304
+ * Resolve one declared field against a note, in the declared order.
305
+ *
306
+ * See the module note for the order and why it is declared rather than
307
+ * name-matched. The value comes back **raw**; applying the field's own `read`
308
+ * is {@link module:engine/field-spec.readField}'s job, and it applies the same
309
+ * coercion wherever the value was authored.
310
+ *
311
+ * @param {import("./field-spec.mjs").FieldSpec} field - The declaration.
312
+ * @param {object} fm - The note's frontmatter.
313
+ * @param {object} [options] - Options.
314
+ * @param {string} [options.block="sohl"] - The system block to resolve against.
315
+ * @returns {{value: any, from: FieldSource}} The raw value and where it came
316
+ * from.
317
+ */
318
+ export function resolveFieldValue(field, fm, { block = "sohl" } = {}) {
319
+ // A field with no shared source is a constant or a derived value — it is
320
+ // not authored anywhere, so no position can answer for it.
321
+ if (field.name === undefined) {
322
+ return {
323
+ value: typeof field.value === "function" ? field.value(fm) : field.value,
324
+ from: "value",
325
+ };
326
+ }
327
+
328
+ // 1. Authored directly at the destination path, in this system's own data.
329
+ // A declaration with no `to` has no destination to author, so there is
330
+ // nothing to look for — every emitted field has one, but the field lists
331
+ // a linter is handed do not all describe emitted fields.
332
+ if (typeof field.to === "string" && field.to !== "") {
333
+ const own = getFrontmatter(systemData(fm, block), field.to, undefined);
334
+ if (own !== undefined) return { value: own, from: "system" };
335
+ }
336
+
337
+ // 2. The legacy in-block position. Every note in every tree writes here
338
+ // today, and will until #126 moves them; dropping it would be a corpus
339
+ // migration disguised as a mechanism change.
340
+ const declared = systemBlock(fm, block);
341
+ if (declared) {
342
+ if (field.name in declared) {
343
+ const value = declared[field.name];
344
+ return { value: value ?? field.default, from: "block" };
345
+ }
346
+ const nested = getFrontmatter(declared, field.name, undefined);
347
+ if (nested !== undefined) return { value: nested, from: "block" };
348
+ }
349
+
350
+ // 3. The shared property this field declares as its source.
351
+ const shared = getFrontmatter(fm, field.name, undefined);
352
+ if (shared !== undefined) return { value: shared, from: "shared" };
353
+
354
+ // 4. The field's own default.
355
+ return { value: field.default, from: "default" };
356
+ }
357
+
358
+ /**
359
+ * Every path a note authors under `<system>.system`, containers included.
360
+ *
361
+ * A container is listed as well as its leaves because a published schema
362
+ * declares both — `charges` as a `SchemaField` and `charges.value` beneath it —
363
+ * so a check that knew only the leaves could not tell a misspelled container
364
+ * from a misspelled leaf.
365
+ *
366
+ * An **empty** mapping is a leaf: `body: {}` is a value the author wrote, and
367
+ * walking into it would make it vanish.
368
+ *
369
+ * @param {Record<string, unknown>} data - The authored `system` data.
370
+ * @param {string} [prefix] - Internal: the path so far.
371
+ * @returns {string[]} The dotted paths.
372
+ */
373
+ export function systemDataPaths(data, prefix = "") {
374
+ const out = [];
375
+ if (!isMapping(data)) return out;
376
+ for (const [key, value] of Object.entries(data)) {
377
+ const path = prefix ? `${prefix}.${key}` : key;
378
+ out.push(path);
379
+ if (isMapping(value) && Object.keys(value).length) {
380
+ out.push(...systemDataPaths(/** @type {Record<string, unknown>} */ (value), path));
381
+ }
382
+ }
383
+ return out;
384
+ }
385
+
386
+ /**
387
+ * The authored paths a system's published schema does not declare.
388
+ *
389
+ * **Reported at the shallowest undeclared path.** Everything beneath an
390
+ * undeclared container is undeclared by construction, so listing it all buries
391
+ * the one mistake in a wall of consequences — one finding per typo is what an
392
+ * author can act on.
393
+ *
394
+ * Foundry discards an unknown `system` key at construction and says nothing, so
395
+ * this is the difference between "the field is lost at load" and "the build
396
+ * told you where".
397
+ *
398
+ * @param {Record<string, unknown>} data - The authored `system` data.
399
+ * @param {ReadonlySet<string>} declared - Every field path the schema declares
400
+ * for this subtype, inherited ones included.
401
+ * @param {string} [prefix] - Internal: the path so far.
402
+ * @returns {string[]} The undeclared paths, shallowest-first.
403
+ */
404
+ export function undeclaredPaths(data, declared, prefix = "") {
405
+ const out = [];
406
+ if (!isMapping(data)) return out;
407
+ for (const [key, value] of Object.entries(data)) {
408
+ const path = prefix ? `${prefix}.${key}` : key;
409
+ if (!declared.has(path)) {
410
+ out.push(path);
411
+ continue;
412
+ }
413
+ if (isMapping(value) && Object.keys(value).length) {
414
+ out.push(
415
+ ...undeclaredPaths(/** @type {Record<string, unknown>} */ (value), declared, path),
416
+ );
417
+ }
418
+ }
419
+ return out;
420
+ }
421
+
422
+ /**
423
+ * Keys directly under a system block that neither this format nor the system
424
+ * recognizes.
425
+ *
426
+ * Until now an unrecognized key under `sohl:` was reported only against SoHL's
427
+ * *field* vocabulary, and a key under any other system's block was not looked
428
+ * at at all — dropped in silence, which is the failure class the frontmatter
429
+ * lint exists for.
430
+ *
431
+ * @param {object} fm - The note's frontmatter.
432
+ * @param {string} block - The block key.
433
+ * @param {object} options - Options.
434
+ * @param {Iterable<string>} options.known - The keys this system declares on
435
+ * top of the shared vocabulary: its generators, its toolchain keys, and —
436
+ * until #126 moves them — the field names its notes still author in the
437
+ * block.
438
+ * @returns {string[]} The unrecognized keys, in authored order.
439
+ */
440
+ export function unknownBlockKeys(fm, block, { known }) {
441
+ const declared = systemBlock(fm, block);
442
+ if (!declared) return [];
443
+ const accepted = new Set(known);
444
+ return Object.keys(declared).filter((key) => !SYSTEM_BLOCK_KEYS.has(key) && !accepted.has(key));
445
+ }
446
+
447
+ /**
448
+ * The `system` paths a field declaration writes, for {@link mergeSystemData}.
449
+ *
450
+ * Exactly each field's `to`, and deliberately **not** its ancestors: a field
451
+ * writing `locations.flexible` does not own `locations`, and claiming the
452
+ * container would make an authored `locations.facing` disappear — a silent drop
453
+ * inside the mechanism built to stop them.
454
+ *
455
+ * @param {readonly {to?: string}[]} [fields] - A type's field declaration.
456
+ * @returns {Set<string>} The claimed destinations.
457
+ */
458
+ export function claimedPaths(fields) {
459
+ return new Set(
460
+ (fields ?? [])
461
+ .map((field) => field?.to)
462
+ .filter((to) => typeof to === "string" && to !== ""),
463
+ );
464
+ }
465
+
466
+ /**
467
+ * Merge a note's `<system>.system` onto a built `system` block, verbatim.
468
+ *
469
+ * **Verbatim means the paths are the schema's, not that the merge is a
470
+ * replacement.** A container the builder already wrote is merged into rather
471
+ * than overwritten, so authoring one leaf of `body` does not silently discard
472
+ * the rest of it.
473
+ *
474
+ * A path a **declared field** already claims is left alone. That field's value
475
+ * came from the same authored place, through the field's own `read`; writing it
476
+ * again uncoerced would make the coercion depend on which of two mechanisms ran
477
+ * last — the drift a single statement of the mapping exists to prevent.
478
+ *
479
+ * @param {object} built - The `system` block the builder produced (mutated).
480
+ * @param {object} fm - The note's frontmatter.
481
+ * @param {object} options - Options.
482
+ * @param {string} options.block - The block key.
483
+ * @param {ReadonlySet<string>} [options.claimed] - Paths a declared field
484
+ * writes, which this merge leaves to it.
485
+ * @returns {object} `built`, for chaining.
486
+ */
487
+ export function mergeSystemData(built, fm, { block, claimed = new Set() }) {
488
+ const data = systemData(fm, block);
489
+ for (const path of systemDataPaths(data)) {
490
+ const value = getFrontmatter(data, path, undefined);
491
+ // Walk *through* a container: its children are written individually, so
492
+ // a builder's other keys under the same container survive.
493
+ if (isMapping(value) && Object.keys(value).length) continue;
494
+ if (claimedBy(path, claimed)) continue;
495
+ setPath(built, path, value);
496
+ }
497
+ return built;
498
+ }
499
+
500
+ /**
501
+ * Whether a declared field claims a path, or any container above it.
502
+ *
503
+ * @param {string} path - The authored path.
504
+ * @param {ReadonlySet<string>} claimed - The declared destinations.
505
+ * @returns {boolean} True when a field already writes it.
506
+ */
507
+ function claimedBy(path, claimed) {
508
+ const parts = path.split(".");
509
+ for (let i = parts.length; i >= 1; i -= 1) {
510
+ if (claimed.has(parts.slice(0, i).join("."))) return true;
511
+ }
512
+ return false;
513
+ }