@heroiclands/package-build 9.0.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.
- package/CHANGELOG.md +694 -0
- package/CONTENT.md +273 -13
- package/bin/content-build.mjs +437 -7
- package/content-config.mjs +259 -28
- package/docs/content-format.md +1418 -0
- package/engine/address-charset.mjs +62 -0
- package/engine/alias-index.mjs +153 -0
- package/engine/base-compiler.mjs +194 -4
- package/engine/content-address.mjs +4 -4
- package/engine/content-format-check.mjs +570 -0
- package/engine/content-format.mjs +253 -0
- package/engine/content-links.mjs +132 -56
- package/engine/content-lint.mjs +8 -1
- package/engine/diagnostics.mjs +33 -0
- package/engine/document-subtypes.mjs +440 -0
- package/engine/field-spec.mjs +49 -43
- package/engine/frontmatter-lint.mjs +343 -27
- package/engine/generate.mjs +32 -4
- package/engine/helpers.mjs +41 -29
- package/engine/ids.mjs +19 -1
- package/engine/index.mjs +15 -0
- package/engine/item-registry.mjs +72 -5
- package/engine/kb-manifest.mjs +36 -7
- package/engine/map-notes.mjs +34 -18
- package/engine/note-claims.mjs +383 -0
- package/engine/note-vocabulary.mjs +678 -0
- package/engine/pack-config.mjs +39 -22
- package/engine/pack-router.mjs +17 -6
- package/engine/prose-lint.mjs +55 -3
- package/engine/retired-fields.mjs +117 -3
- package/engine/scenes.mjs +19 -1
- package/engine/schema-check.mjs +347 -3
- package/engine/site-build.mjs +1 -1
- package/engine/site-index.mjs +38 -21
- package/engine/system-block.mjs +513 -0
- package/engine/web-wikilinks.mjs +112 -80
- package/engine/wikilink-syntax.mjs +30 -0
- package/engine/wikilinks.mjs +67 -51
- package/package.json +6 -2
- package/sohl/actors.mjs +249 -36
- package/sohl/document-subtypes.mjs +82 -0
- package/sohl/index.mjs +3 -0
- package/sohl/items.mjs +110 -14
- package/sohl/note-schemas.mjs +11 -7
- package/types/content-config.d.mts +48 -4
- package/types/engine/address-charset.d.mts +45 -0
- package/types/engine/alias-index.d.mts +122 -0
- package/types/engine/base-compiler.d.mts +132 -4
- package/types/engine/content-address.d.mts +2 -2
- package/types/engine/content-format-check.d.mts +163 -0
- package/types/engine/content-format.d.mts +101 -0
- package/types/engine/content-links.d.mts +16 -1
- package/types/engine/content-lint.d.mts +6 -0
- package/types/engine/diagnostics.d.mts +29 -0
- package/types/engine/document-subtypes.d.mts +233 -0
- package/types/engine/field-spec.d.mts +76 -23
- package/types/engine/frontmatter-lint.d.mts +47 -2
- package/types/engine/generate.d.mts +14 -1
- package/types/engine/helpers.d.mts +21 -13
- package/types/engine/ids.d.mts +10 -0
- package/types/engine/index.d.mts +5 -0
- package/types/engine/item-registry.d.mts +21 -2
- package/types/engine/kb-manifest.d.mts +35 -8
- package/types/engine/map-notes.d.mts +21 -11
- package/types/engine/note-claims.d.mts +113 -0
- package/types/engine/note-vocabulary.d.mts +251 -0
- package/types/engine/pack-config.d.mts +4 -3
- package/types/engine/pack-router.d.mts +4 -4
- package/types/engine/prose-lint.d.mts +6 -2
- package/types/engine/retired-fields.d.mts +73 -2
- package/types/engine/schema-check.d.mts +182 -0
- package/types/engine/system-block.d.mts +281 -0
- package/types/engine/web-wikilinks.d.mts +23 -12
- package/types/engine/wikilink-syntax.d.mts +29 -0
- package/types/sohl/actors.d.mts +62 -6
- package/types/sohl/document-subtypes.d.mts +14 -0
- package/types/sohl/index.d.mts +1 -0
- 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
|
+
}
|