@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,440 @@
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 note-type → document-subtype map** — the mechanism that stops a build
16
+ * inferring a Foundry document's subtype from the markdown note's `type`
17
+ * (#79).
18
+ *
19
+ * A note's `type` and the subtype of the document it compiles into are two
20
+ * vocabularies, and until now they were the same identifier for one reason
21
+ * only: a builder wrote the same string twice. `sohl/actors.mjs` declared
22
+ * `ACTOR_VAULT_TYPE = "being"` and emitted `type: "being"` several hundred
23
+ * lines below it, under a comment reading _"One content type, named for the
24
+ * Foundry actor it produces."_ Nothing related the two, so changing one and
25
+ * not the other produced a wrongly-typed document in silence — a wrong-output
26
+ * risk with **one** system, not merely with two.
27
+ *
28
+ * **The mechanism is here; the declaration is the system's.** That is the
29
+ * `engine/` ÷ `sohl/` line everywhere else in this package (#36): note-format
30
+ * knowledge here, game-system knowledge there. `sohl/document-subtypes.mjs`
31
+ * declares SoHL's own map, *including its identity rows* — the coincidence of
32
+ * names may never stand in for a mapping, so `skill` → `skill` is written out
33
+ * like any other row rather than derived from the registry's keys.
34
+ *
35
+ * **Three properties the map exists to give:**
36
+ *
37
+ * - _A mapped type compiles to the subtype the row declares_, whatever the
38
+ * note calls itself.
39
+ * - _An unmapped type produces no document for that system_ —
40
+ * {@link documentSubtype} answers `undefined` and the pass claims nothing.
41
+ * Silent and correct: a `place` note is not a SoHL document, and never was.
42
+ * - _A one-to-many row is resolved by the note_, which supplies the
43
+ * discriminator in that system's own block. An absent one is an **error
44
+ * naming the note**, never a default — a default would pick one of the
45
+ * subtypes for the author and be right about half the time.
46
+ *
47
+ * A row is one of exactly two shapes, and declaring both or neither is refused
48
+ * at definition time:
49
+ *
50
+ * ```js
51
+ * skill: { document: "Item", subType: "skill" }
52
+ * being: { document: "Actor", discriminator: "kindOf",
53
+ * subTypes: ["character", "creature"] }
54
+ * ```
55
+ *
56
+ * **A note is not the only thing that names a type.** A being's frontmatter
57
+ * addresses each of its embedded items by `(type, shortcode)`, and that `type`
58
+ * is the note vocabulary too — while the items it resolves against are
59
+ * *compiled documents*, which carry only the subtype. {@link referencedSubtype}
60
+ * is the translation for that side, and the reason it is separate from
61
+ * {@link documentSubtype} is that a reference has no frontmatter of its own to
62
+ * read a discriminator from (#140).
63
+ *
64
+ * @module
65
+ */
66
+
67
+ import { assertTypeNotRetired } from "./ids.mjs";
68
+ import { locateFrontmatterKey } from "./retired-fields.mjs";
69
+
70
+ /**
71
+ * One row of a system's map: what a note of this type becomes.
72
+ *
73
+ * @typedef {object} DocumentSubtypeRow
74
+ * @property {string} document - The Foundry document class — `"Item"`,
75
+ * `"Actor"`, and so on. It must agree with what the engine's own
76
+ * {@link packForType} routes the type to; they are two statements about one
77
+ * fact, and a test holds them together.
78
+ * @property {string} [subType] - The document subtype, for a one-to-one row.
79
+ * Mutually exclusive with `discriminator`.
80
+ * @property {string} [discriminator] - For a one-to-many row: the key the note
81
+ * supplies in this system's own frontmatter block to say which subtype it
82
+ * is.
83
+ * @property {readonly string[]} [subTypes] - The subtypes `discriminator` may
84
+ * name. Required with it, and never empty — a row permitting nothing is a
85
+ * row that can never resolve.
86
+ */
87
+
88
+ /**
89
+ * A system's whole declaration: which system it belongs to, which frontmatter
90
+ * block its notes write, and every note type it maps.
91
+ *
92
+ * @typedef {object} DocumentSubtypeMap
93
+ * @property {string} system - The system this map belongs to (`"sohl"`).
94
+ * @property {string} block - The note frontmatter block this system's notes
95
+ * write, and the only place a discriminator is read from. Defaults to
96
+ * `system`.
97
+ * @property {Readonly<Record<string, DocumentSubtypeRow>>} types - Note type →
98
+ * row.
99
+ */
100
+
101
+ /**
102
+ * Declare one system's note-type → document-subtype map.
103
+ *
104
+ * Every malformed row is refused **here**, at module evaluation, rather than
105
+ * when some note happens to reach it: a map is a small piece of authored data
106
+ * loaded once per build, so a mistake in it should stop the build immediately
107
+ * and name the row, not surface as a missing document a thousand notes later.
108
+ *
109
+ * @param {object} declaration - The system's declaration.
110
+ * @param {string} declaration.system - The system this map belongs to.
111
+ * @param {string} [declaration.block] - The frontmatter block its notes write.
112
+ * Defaults to `system`.
113
+ * @param {Readonly<Record<string, DocumentSubtypeRow>>} declaration.types -
114
+ * Note type → row.
115
+ * @returns {DocumentSubtypeMap} The frozen map, rows and all.
116
+ * @throws {Error} When the declaration names no system, or any row is neither
117
+ * of the two permitted shapes.
118
+ */
119
+ export function defineDocumentSubtypes({ system, block, types } = /** @type {never} */ ({})) {
120
+ if (typeof system !== "string" || system === "") {
121
+ throw new Error(
122
+ "A document-subtype map must name the `system` it belongs to — it is " +
123
+ "read per system, and its rows are only meaningful against one.",
124
+ );
125
+ }
126
+ if (types == null || typeof types !== "object") {
127
+ throw new Error(`The "${system}" document-subtype map declares no \`types\`.`);
128
+ }
129
+
130
+ const rows = Object.entries(types).map(([noteType, row]) => [
131
+ noteType,
132
+ frozenRow(system, noteType, row),
133
+ ]);
134
+
135
+ return Object.freeze({
136
+ system,
137
+ block: typeof block === "string" && block !== "" ? block : system,
138
+ types: Object.freeze(Object.fromEntries(rows)),
139
+ });
140
+ }
141
+
142
+ /**
143
+ * Validate one row and freeze it.
144
+ *
145
+ * @param {string} system - The declaring system, named in every message.
146
+ * @param {string} noteType - The note type this row is keyed by.
147
+ * @param {DocumentSubtypeRow} row - The row as declared.
148
+ * @returns {Readonly<DocumentSubtypeRow>} The frozen row.
149
+ * @throws {Error} When the row is neither of the two permitted shapes.
150
+ */
151
+ function frozenRow(system, noteType, row) {
152
+ const where = `The "${system}" document-subtype map's "${noteType}" row`;
153
+ if (row == null || typeof row !== "object") {
154
+ throw new Error(`${where} is not a row — write \`{ document, subType }\`.`);
155
+ }
156
+ if (typeof row.document !== "string" || row.document === "") {
157
+ throw new Error(
158
+ `${where} names no \`document\` — say which Foundry document class ` +
159
+ `it compiles into ("Item", "Actor", …).`,
160
+ );
161
+ }
162
+
163
+ const oneToOne = typeof row.subType === "string" && row.subType !== "";
164
+ const oneToMany = typeof row.discriminator === "string" && row.discriminator !== "";
165
+ if (oneToOne && oneToMany) {
166
+ throw new Error(
167
+ `${where} declares both a \`subType\` and a \`discriminator\` — a row ` +
168
+ `is one or the other, and a fixed subtype cannot also be chosen ` +
169
+ `per note.`,
170
+ );
171
+ }
172
+ if (!oneToOne && !oneToMany) {
173
+ throw new Error(
174
+ `${where} declares neither a \`subType\` nor a \`discriminator\` — an ` +
175
+ `identity row is written out (\`subType: "${noteType}"\`) rather ` +
176
+ `than left to the coincidence of the names matching.`,
177
+ );
178
+ }
179
+ if (oneToMany && (!Array.isArray(row.subTypes) || row.subTypes.length === 0)) {
180
+ throw new Error(
181
+ `${where} names a \`discriminator\` but no \`subTypes\` — list the ` +
182
+ `values it may take, since nothing else says what a note is ` +
183
+ `allowed to write.`,
184
+ );
185
+ }
186
+
187
+ return Object.freeze({
188
+ document: row.document,
189
+ ...(oneToOne ? { subType: row.subType } : {}),
190
+ ...(oneToMany ?
191
+ {
192
+ discriminator: row.discriminator,
193
+ subTypes: Object.freeze([...(row.subTypes ?? [])]),
194
+ }
195
+ : {}),
196
+ });
197
+ }
198
+
199
+ /**
200
+ * The row a system declares for a note type, or nothing.
201
+ *
202
+ * @param {DocumentSubtypeMap} map - The system's map.
203
+ * @param {string|undefined} noteType - The note's declared `type`.
204
+ * @returns {Readonly<DocumentSubtypeRow>|undefined} The row, or `undefined`
205
+ * where this system maps the type at all.
206
+ */
207
+ export function subtypeRow(map, noteType) {
208
+ if (!noteType || typeof noteType !== "string") return undefined;
209
+ return map?.types?.[noteType];
210
+ }
211
+
212
+ /**
213
+ * Whether a system maps a note type — optionally, onto one document class.
214
+ *
215
+ * This is what a pass asks to decide whether it claims a note, which is the
216
+ * whole of the "no mapping, no document" property: a type the map does not
217
+ * carry is skipped exactly as quietly as the thousands of notes that belong to
218
+ * another pass.
219
+ *
220
+ * @param {DocumentSubtypeMap} map - The system's map.
221
+ * @param {string|undefined} noteType - The note's declared `type`.
222
+ * @param {string} [document] - Restrict the question to one document class.
223
+ * @returns {boolean} True when the map carries a matching row.
224
+ */
225
+ export function mapsNoteType(map, noteType, document) {
226
+ const row = subtypeRow(map, noteType);
227
+ if (!row) return false;
228
+ return document === undefined || row.document === document;
229
+ }
230
+
231
+ /**
232
+ * Every note type a system maps onto one document class, sorted.
233
+ *
234
+ * A pass reports what it skipped by naming what it would have claimed, and
235
+ * that list is the map's — never a constant restating it.
236
+ *
237
+ * @param {DocumentSubtypeMap} map - The system's map.
238
+ * @param {string} document - The Foundry document class.
239
+ * @returns {string[]} The note types, in sorted order.
240
+ */
241
+ export function noteTypesFor(map, document) {
242
+ return Object.entries(map?.types ?? {})
243
+ .filter(([, row]) => row.document === document)
244
+ .map(([noteType]) => noteType)
245
+ .sort();
246
+ }
247
+
248
+ /**
249
+ * The document subtype a note compiles into for one system.
250
+ *
251
+ * @param {DocumentSubtypeMap} map - The system's map.
252
+ * @param {string|undefined} noteType - The note's declared `type`.
253
+ * @param {object} fm - The note's frontmatter, read only for a one-to-many
254
+ * row's discriminator.
255
+ * @param {object} [options] - Options.
256
+ * @param {string} [options.file] - The note's path, appended to the message.
257
+ * Omit it where the caller emits through a diagnostic, which already puts
258
+ * the locator at the start of the line.
259
+ * @param {string} [options.absPath] - The note's file on disk, read only on
260
+ * the failing path to locate the offending line. The position rides on the
261
+ * thrown error as `position`, for a caller that emits a diagnostic.
262
+ * @returns {string|undefined} The subtype, or `undefined` where this system
263
+ * maps nothing for the type — which means no document, not an error.
264
+ * @throws {Error} When a one-to-many row's discriminator is absent, blank, or
265
+ * names a value the row does not permit.
266
+ */
267
+ export function documentSubtype(map, noteType, fm, { file, absPath } = {}) {
268
+ const row = subtypeRow(map, noteType);
269
+ if (!row) return undefined;
270
+ if (row.subType) return row.subType;
271
+
272
+ const block = map.block;
273
+ const field = `${block}.${row.discriminator}`;
274
+ const permitted = /** @type {readonly string[]} */ (row.subTypes);
275
+ const declared = readDiscriminator(fm, block, /** @type {string} */ (row.discriminator));
276
+
277
+ if (declared === undefined) {
278
+ throw located(
279
+ `a "${noteType}" note compiles into more than one ${map.system} ` +
280
+ `${row.document} subtype, so it must say which: write ` +
281
+ `\`${field}\` as one of ${list(permitted)}`,
282
+ { file, absPath, key: block },
283
+ );
284
+ }
285
+ if (!permitted.includes(declared)) {
286
+ throw located(
287
+ `\`${field}: ${declared}\` is not a ${map.system} ${row.document} ` +
288
+ `subtype a "${noteType}" note may compile into — write one of ` +
289
+ `${list(permitted)}`,
290
+ { file, absPath, key: row.discriminator, value: declared },
291
+ );
292
+ }
293
+ return declared;
294
+ }
295
+
296
+ /**
297
+ * The answer {@link referencedSubtype} gives: a subtype, or why there is none.
298
+ *
299
+ * Exactly one of the two fields is set. A `problem` is a sentence a caller
300
+ * prefixes with its own context and emits as a finding — it never throws,
301
+ * because the caller resolving a reference is walking a list and has to report
302
+ * this one and carry on.
303
+ *
304
+ * @typedef {object} ReferencedSubtype
305
+ * @property {string} [subType] - The document subtype the reference addresses.
306
+ * @property {string} [problem] - Why the reference names no document subtype.
307
+ */
308
+
309
+ /**
310
+ * The document subtype a `(type, shortcode)` **reference** addresses (#140).
311
+ *
312
+ * A being's frontmatter names each embedded item by the *note's* type — the
313
+ * vocabulary an author writes — while the predefined items it resolves against
314
+ * are compiled documents, which carry only the *subtype*. One side has to
315
+ * translate, and it is this one: the map is a function from note type to
316
+ * subtype by construction, whereas the reverse is not — two note types may
317
+ * compile into one subtype, and a compiled document records nothing about the
318
+ * note that produced it. So the addresses stay keyed on the **document
319
+ * subtype**, which is the only vocabulary both a local pack and an extracted
320
+ * dependency catalogue actually carry, and a reference is translated forward
321
+ * here before it is looked up.
322
+ *
323
+ * Four answers, and only the first resolves:
324
+ *
325
+ * - _A one-to-one row_ → the subtype it declares. `armor` addresses an
326
+ * `armorgear`.
327
+ * - _No row at all_ → the note type itself. A consumer declares its own item
328
+ * types in its `itemBuilders` table rather than in this system's map, and the
329
+ * Item pass stamps such a document with the note type; the reference has to
330
+ * agree, or a consumer's own items would stop resolving the moment a map
331
+ * existed.
332
+ * - _A row for another document class_ → a problem. A being is not an item,
333
+ * however the address is spelled.
334
+ * - _A one-to-many row_ → a problem naming the candidates. The note that owns
335
+ * such a row resolves it from its own frontmatter block; a reference has no
336
+ * block, so nothing here can choose, and choosing anyway would be right about
337
+ * half the time. No system declares a one-to-many **Item** row today, so this
338
+ * is a guard rather than a behaviour — but it is a loud one, which is the
339
+ * whole point of the issue.
340
+ *
341
+ * A **retired** spelling is refused by name before any of that. Without it a
342
+ * reference left behind by a rename would take the unmapped fallback and
343
+ * address a document of the old name — resolving silently, which is precisely
344
+ * what a retirement exists to stop (#78).
345
+ *
346
+ * @param {DocumentSubtypeMap} map - The system's map.
347
+ * @param {string|undefined} noteType - The type the reference names.
348
+ * @param {string} document - The Foundry document class the reference must
349
+ * address — `"Item"` for a being's embedded items.
350
+ * @returns {ReferencedSubtype} The subtype, or why there is none.
351
+ */
352
+ export function referencedSubtype(map, noteType, document) {
353
+ if (!noteType || typeof noteType !== "string") {
354
+ return { problem: "the reference names no type" };
355
+ }
356
+ try {
357
+ assertTypeNotRetired(noteType);
358
+ } catch (err) {
359
+ return { problem: /** @type {Error} */ (err).message };
360
+ }
361
+
362
+ const row = subtypeRow(map, noteType);
363
+ // No row: the type is the consumer's own, and its document is stamped with
364
+ // the note type. See the note on the unmapped fallback above.
365
+ if (!row) return { subType: noteType };
366
+
367
+ if (row.document !== document) {
368
+ return {
369
+ problem:
370
+ `${map.system} compiles a "${noteType}" note into a ` +
371
+ `${row.document}, not a ${document}`,
372
+ };
373
+ }
374
+ if (!row.subType) {
375
+ const permitted = /** @type {readonly string[]} */ (row.subTypes);
376
+ return {
377
+ problem:
378
+ `a "${noteType}" note compiles into more than one ${map.system} ` +
379
+ `${document} subtype (${list(permitted)}), and a ` +
380
+ `(type, shortcode) reference cannot say which`,
381
+ };
382
+ }
383
+ return { subType: row.subType };
384
+ }
385
+
386
+ /**
387
+ * Read a discriminator out of one system's block, and nowhere else.
388
+ *
389
+ * Deliberately **not** {@link sohlField}, which falls back to the top level: a
390
+ * discriminator is a statement about *this system's* document, so a note that
391
+ * wrote it outside this system's block has not supplied it. Accepting it
392
+ * anyway is how a second system's block would silently answer for the first.
393
+ *
394
+ * @param {object} fm - The note's frontmatter.
395
+ * @param {string} block - The system's block key.
396
+ * @param {string} key - The discriminator key within it.
397
+ * @returns {string|undefined} The declared value, or `undefined` when absent
398
+ * or blank — the two are the same thing to an author.
399
+ */
400
+ function readDiscriminator(fm, block, key) {
401
+ if (fm == null || typeof fm !== "object") return undefined;
402
+ const declared = /** @type {Record<string, unknown>} */ (fm)[block];
403
+ if (declared == null || typeof declared !== "object") return undefined;
404
+ const value = /** @type {Record<string, unknown>} */ (declared)[key];
405
+ if (value == null) return undefined;
406
+ const text = String(value).trim();
407
+ return text === "" ? undefined : text;
408
+ }
409
+
410
+ /**
411
+ * Build an error carrying the position of the frontmatter key it is about.
412
+ *
413
+ * @param {string} message - What is wrong, in one sentence.
414
+ * @param {object} at - Where.
415
+ * @param {string} [at.file] - The note's path, appended to the message.
416
+ * @param {string} [at.absPath] - The note's file, read to locate the key.
417
+ * @param {string} [at.key] - The frontmatter key to locate.
418
+ * @param {string} [at.value] - Prefer the line carrying this value.
419
+ * @returns {Error & {position?: {line?: number, column?: number}}} The error.
420
+ */
421
+ function located(message, { file, absPath, key, value } = {}) {
422
+ const err = /** @type {Error & {position?: object}} */ (
423
+ new Error(message + (file ? ` — ${file}` : ""))
424
+ );
425
+ const position = key ? locateFrontmatterKey(absPath, key, value) : undefined;
426
+ if (position) err.position = position;
427
+ return err;
428
+ }
429
+
430
+ /**
431
+ * A readable list of permitted values, for a message an author acts on.
432
+ *
433
+ * @param {readonly string[]} values - The values.
434
+ * @returns {string} `"a", "b" or "c"`.
435
+ */
436
+ function list(values) {
437
+ const quoted = values.map((value) => `"${value}"`);
438
+ if (quoted.length <= 1) return quoted.join("");
439
+ return `${quoted.slice(0, -1).join(", ")} or ${quoted[quoted.length - 1]}`;
440
+ }
@@ -47,10 +47,8 @@ import { loadPackConfig } from "./pack-config.mjs";
47
47
  function renderDefault(value) {
48
48
  if (value === undefined) return "—";
49
49
  if (value === null) return "`null`";
50
- if (typeof value === "string")
51
- return value === "" ? '`""`' : `\`${value}\``;
52
- if (Array.isArray(value))
53
- return value.length === 0 ? "`[]`" : `\`${JSON.stringify(value)}\``;
50
+ if (typeof value === "string") return value === "" ? '`""`' : `\`${value}\``;
51
+ if (Array.isArray(value)) return value.length === 0 ? "`[]`" : `\`${JSON.stringify(value)}\``;
54
52
  if (typeof value === "object") {
55
53
  const json = JSON.stringify(value);
56
54
  return json === "{}" ? "`{}`" : `\`${json}\``;
@@ -83,17 +81,10 @@ function cell(text) {
83
81
  * @returns {string[]} Markdown lines.
84
82
  */
85
83
  function padTable(rows) {
86
- const widths = rows[0].map((_, column) =>
87
- Math.max(...rows.map((row) => row[column].length)),
88
- );
89
- const line = (cells) =>
90
- `| ${cells.map((c, i) => c.padEnd(widths[i])).join(" | ")} |`;
84
+ const widths = rows[0].map((_, column) => Math.max(...rows.map((row) => row[column].length)));
85
+ const line = (cells) => `| ${cells.map((c, i) => c.padEnd(widths[i])).join(" | ")} |`;
91
86
  const [header, ...body] = rows;
92
- return [
93
- line(header),
94
- `| ${widths.map((w) => "-".repeat(w)).join(" | ")} |`,
95
- ...body.map(line),
96
- ];
87
+ return [line(header), `| ${widths.map((w) => "-".repeat(w)).join(" | ")} |`, ...body.map(line)];
97
88
  }
98
89
 
99
90
  /**
@@ -156,12 +147,7 @@ function workedExample(type, fields) {
156
147
  for (const field of required) {
157
148
  lines.push(` ${field.name}: <${field.shape ?? "value"}>`);
158
149
  }
159
- lines.push(
160
- "---",
161
- "",
162
- "The prose here compiles into the item's documentation.",
163
- "```",
164
- );
150
+ lines.push("---", "", "The prose here compiles into the item's documentation.", "```");
165
151
  return lines;
166
152
  }
167
153
 
@@ -43,14 +43,28 @@
43
43
  * @module
44
44
  */
45
45
 
46
- import { sohlField } from "./frontmatter.mjs";
46
+ import { resolveFieldValue, setPath } from "./system-block.mjs";
47
+
48
+ export { setPath };
47
49
 
48
50
  /**
49
51
  * @typedef {object} FieldSpec
50
- * @property {string} to - Dotted path in the emitted `system` block.
51
- * @property {string} [name] - Frontmatter key under `sohl:`, dotted for a
52
- * nested one (`impact.die`). Absent means the value is not authored — see
53
- * `value`.
52
+ * @property {string} to - Dotted path in the emitted `system` block — and,
53
+ * since #58, the path a note authors the field at directly:
54
+ * `<system>.system.<to>`.
55
+ * @property {string} [name] - The **shared, top-level property this field draws
56
+ * from** when the note authors no value at `<system>.system.<to>`. Dotted for
57
+ * a path into a shared container (`data.portrait`), which is now the ordinary
58
+ * case: `data:` (#128) puts every type-specific fact under one.
59
+ *
60
+ * It used to mean "frontmatter key under `sohl:`", and that reading is the
61
+ * degenerate case where the shared source and the system destination happen
62
+ * to share a name. They constantly do not — one shared `data.portrait` feeds
63
+ * `sohl.system.portrait` *and* `hm3.system.bioImage` — so the source is
64
+ * declared rather than matched by spelling (#58). The in-block position is
65
+ * still read, second, until #126 moves the corpus off it.
66
+ *
67
+ * Absent means the value is not authored at all — see `value`.
54
68
  * @property {string} [shape] - Human-readable shape, for documentation. Comes
55
69
  * paired with `read` from one of the coercion constants below.
56
70
  * @property {(raw: any, ctx: {fm: object, field: FieldSpec}) => any} [read] -
@@ -58,6 +72,15 @@ import { sohlField } from "./frontmatter.mjs";
58
72
  * @property {any} [default] - Emitted when the note does not carry the field.
59
73
  * @property {boolean} [required] - Whether a note must carry it. A required
60
74
  * field's `read` is expected to throw when it is missing.
75
+ * @property {boolean} [shared] - Whether the field is authored at the note's
76
+ * **top level** rather than inside a system block, because what it states is
77
+ * not system-specific — a map's background art is the same art whichever
78
+ * system reads the note. It changes no reader: the top level is already the
79
+ * third step of {@link module:engine/system-block.resolveFieldValue}'s order,
80
+ * so the value resolves whichever region carries it. What it tells the
81
+ * author-facing surfaces is which of the two is the field's *home*, so a
82
+ * message about it names `img` rather than sending an author to `sohl.img`
83
+ * (#142).
61
84
  * @property {"string"|"number"|"boolean"|"list"|"map"} [kind] - The value's
62
85
  * shape, for the frontmatter linter (#19). Distinct from `shape`, which is
63
86
  * prose for a reader, and from `read`, which is what the compiler does: a
@@ -139,64 +162,45 @@ export const BLANK_IS_DEFAULT = Object.freeze({
139
162
  /* Applying a declaration */
140
163
  /* --------------------------------------------------------------------- */
141
164
 
142
- /**
143
- * Write `value` at a dotted path, creating the intermediate objects.
144
- *
145
- * Insertion order is the emitted JSON's key order, so a declaration's order is
146
- * the compiled document's order — which is what lets a field list replace a
147
- * hand-written object literal without changing a single byte of output.
148
- *
149
- * @param {object} target - The object to write into (mutated).
150
- * @param {string} dotted - Path, e.g. `"locations.flexible"`.
151
- * @param {any} value - The value to set.
152
- * @returns {object} `target`, for chaining.
153
- */
154
- export function setPath(target, dotted, value) {
155
- const parts = dotted.split(".");
156
- const leaf = parts.pop();
157
- let cursor = target;
158
- for (const part of parts) {
159
- if (
160
- cursor[part] == null ||
161
- typeof cursor[part] !== "object" ||
162
- Array.isArray(cursor[part])
163
- ) {
164
- cursor[part] = {};
165
- }
166
- cursor = cursor[part];
167
- }
168
- cursor[leaf] = value;
169
- return target;
170
- }
171
-
172
165
  /**
173
166
  * Read one declared field out of a note's frontmatter.
174
167
  *
168
+ * The *position* is resolved by {@link resolveFieldValue} — `<system>.system`
169
+ * first, then the legacy in-block key, then the declared shared source, then
170
+ * the default (#58). The **coercion** is applied here, once, wherever the value
171
+ * came from: a field's `read` is a statement about the field, not about where
172
+ * an author happened to write it, so `weight: "7"` reads as `7` at every one of
173
+ * those positions.
174
+ *
175
175
  * @param {FieldSpec} field - The declaration.
176
176
  * @param {object} fm - The note's frontmatter.
177
+ * @param {object} [options] - Options.
178
+ * @param {string} [options.block="sohl"] - Which system's block to resolve
179
+ * against. The default is the one block every existing tree authors; a
180
+ * second system passes its own.
177
181
  * @returns {any} The value to emit.
178
182
  */
179
- export function readField(field, fm) {
180
- if (field.name === undefined) {
181
- return typeof field.value === "function" ?
182
- field.value(fm)
183
- : field.value;
184
- }
185
- const raw = sohlField(fm, field.name, field.default);
186
- return field.read ? field.read(raw, { fm, field }) : raw;
183
+ export function readField(field, fm, { block = "sohl" } = {}) {
184
+ const { value, from } = resolveFieldValue(field, fm, { block });
185
+ if (from === "value") return value;
186
+ return field.read ? field.read(value, { fm, field }) : value;
187
187
  }
188
188
 
189
189
  /**
190
190
  * Turn a field declaration into the builder it declares.
191
191
  *
192
192
  * @param {readonly FieldSpec[]} fields - The declaration, in emission order.
193
+ * @param {object} [options] - Options.
194
+ * @param {string} [options.block="sohl"] - Which system's block the builder
195
+ * reads. One declaration compiles against any block, which is what lets two
196
+ * systems declare the same shared source and different destinations.
193
197
  * @returns {(fm: object) => object} A `system`-block builder.
194
198
  */
195
- export function buildFromFields(fields) {
199
+ export function buildFromFields(fields, { block = "sohl" } = {}) {
196
200
  return function buildDeclaredSystem(fm) {
197
201
  const out = {};
198
202
  for (const field of fields) {
199
- setPath(out, field.to, readField(field, fm));
203
+ setPath(out, field.to, readField(field, fm, { block }));
200
204
  }
201
205
  return out;
202
206
  };