@heroiclands/package-build 18.2.0 → 20.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 (67) hide show
  1. package/CHANGELOG.md +692 -0
  2. package/CONTENT.md +81 -10
  3. package/bin/content-build.mjs +7 -1
  4. package/ci/ci-docker.mjs +21 -0
  5. package/content-config.mjs +26 -24
  6. package/docs/content-format.md +408 -85
  7. package/engine/actor-compiler.mjs +197 -7
  8. package/engine/address-charset.mjs +23 -5
  9. package/engine/base-compiler.mjs +65 -2
  10. package/engine/bundles.mjs +9 -0
  11. package/engine/content-address.mjs +92 -1
  12. package/engine/content-format.mjs +102 -0
  13. package/engine/content-index.mjs +11 -8
  14. package/engine/content-links.mjs +37 -21
  15. package/engine/field-reference.mjs +57 -5
  16. package/engine/field-spec.mjs +214 -7
  17. package/engine/folder-notes.mjs +88 -1
  18. package/engine/foreign-catalog.mjs +4 -1
  19. package/engine/foundry-entries.mjs +16 -0
  20. package/engine/frontmatter-lint.mjs +215 -28
  21. package/engine/frontmatter.mjs +12 -12
  22. package/engine/generate.mjs +78 -46
  23. package/engine/helpers.mjs +87 -128
  24. package/engine/index.mjs +3 -0
  25. package/engine/item-compiler.mjs +44 -9
  26. package/engine/journals.mjs +27 -16
  27. package/engine/macros.mjs +8 -0
  28. package/engine/map-notes.mjs +7 -7
  29. package/engine/note-ids.mjs +25 -1
  30. package/engine/note-vocabulary.mjs +76 -9
  31. package/engine/retired-fields.mjs +57 -16
  32. package/engine/runtime-only-fields.mjs +204 -0
  33. package/engine/scenes.mjs +19 -28
  34. package/engine/schema-check.mjs +23 -1
  35. package/engine/site-index.mjs +17 -0
  36. package/engine/subtype-registry.mjs +30 -0
  37. package/engine/system-block.mjs +81 -3
  38. package/engine/web-wikilinks.mjs +33 -27
  39. package/engine/wikilink-syntax.mjs +7 -0
  40. package/engine/wikilinks.mjs +74 -16
  41. package/hm3/actors.mjs +70 -23
  42. package/package.json +2 -2
  43. package/sohl/actors.mjs +106 -7
  44. package/sohl/item-fields.mjs +203 -0
  45. package/sohl/note-schemas.mjs +6 -3
  46. package/types/content-config.d.mts +0 -7
  47. package/types/engine/actor-compiler.d.mts +83 -3
  48. package/types/engine/address-charset.d.mts +22 -4
  49. package/types/engine/base-compiler.d.mts +54 -3
  50. package/types/engine/content-address.d.mts +64 -0
  51. package/types/engine/content-format.d.mts +9 -0
  52. package/types/engine/field-spec.d.mts +271 -3
  53. package/types/engine/folder-notes.d.mts +59 -0
  54. package/types/engine/foundry-entries.d.mts +6 -0
  55. package/types/engine/frontmatter-lint.d.mts +18 -2
  56. package/types/engine/frontmatter.d.mts +11 -11
  57. package/types/engine/generate.d.mts +27 -0
  58. package/types/engine/helpers.d.mts +37 -38
  59. package/types/engine/index.d.mts +1 -0
  60. package/types/engine/map-notes.d.mts +2 -2
  61. package/types/engine/note-ids.d.mts +14 -0
  62. package/types/engine/retired-fields.d.mts +29 -13
  63. package/types/engine/runtime-only-fields.d.mts +102 -0
  64. package/types/engine/schema-check.d.mts +10 -1
  65. package/types/engine/subtype-registry.d.mts +21 -0
  66. package/types/engine/system-block.d.mts +28 -2
  67. package/types/sohl/actors.d.mts +3 -3
@@ -63,6 +63,14 @@ import { contentPackage } from "./content-package.mjs";
63
63
  // inferred from the type itself (#79).
64
64
  import { mapsNoteType, noteTypesFor, referencedSubtype } from "./document-subtypes.mjs";
65
65
  import { locateFrontmatterKey } from "./retired-fields.mjs";
66
+ // An `items:` entry's `system:` overlay is merged verbatim, so it reaches the
67
+ // document by a path no field declaration sits on — including, until #330, the
68
+ // fields the document is supposed to write for itself in play.
69
+ import { itemFields } from "./item-registry.mjs";
70
+ import { runtimeOnlyIn, runtimeOnlyMessage } from "./runtime-only-fields.mjs";
71
+ // A `model:` is an address, read by the same grammar every wikilink is (#336),
72
+ // so an author writes one form and meets one set of messages.
73
+ import { readQualifier } from "./wikilinks.mjs";
66
74
 
67
75
  /**
68
76
  * Strip compendium-only fields from a predefined item before embedding it
@@ -134,6 +142,61 @@ export function itemAddress(subType, shortcode) {
134
142
  return `${subType}:${shortcode}`;
135
143
  }
136
144
 
145
+ /**
146
+ * The key one predefined item is held under **for the package that publishes
147
+ * it** — the address a `model:` naming that package resolves through (#334).
148
+ *
149
+ * The unqualified {@link itemAddress} stays beside it, and the two answer
150
+ * different questions. A `model` that names no package means *this* one and
151
+ * takes the unqualified key, where a local definition still shadows a
152
+ * dependency's. A `model` that names a package takes this one, which nothing
153
+ * can shadow: that is the whole point of writing the package down.
154
+ *
155
+ * Not the canonical wikilink address, because this map is keyed in the
156
+ * **document's** vocabulary — a Foundry Item subtype — while a canonical address
157
+ * carries the *note* type. The two differ wherever a system maps a type to a
158
+ * differently-named subtype, and translating here would put the translation in
159
+ * two places.
160
+ *
161
+ * @param {string} pkg - The content package that publishes the item.
162
+ * @param {string} subType - The Foundry Item subtype.
163
+ * @param {string} shortcode - The item's `system.shortcode`.
164
+ * @returns {string} The address, `package:subType:shortcode`.
165
+ */
166
+ export function packagedItemAddress(pkg, subType, shortcode) {
167
+ return `${pkg}:${subType}:${shortcode}`;
168
+ }
169
+
170
+ /**
171
+ * The key one predefined item is held under **in the catalogue**, with the
172
+ * shortcode folded to lower case.
173
+ *
174
+ * A shortcode is case-sensitive and routinely mixed — `Clb`, `LtShoe`,
175
+ * `HsTunic` — while an **address** is not: `readQualifier` normalises what it
176
+ * reads, and every canonical address is lowercase. So the moment a `model:` is
177
+ * read as an address (#334), `weapongear-clb` has to find the document whose
178
+ * `system.shortcode` is `Clb`, and an exact match cannot (#346).
179
+ *
180
+ * Folding is safe because the fold is already the address: no two items in any
181
+ * published tree differ only by the case of their shortcode, and #340 will make
182
+ * that impossible rather than merely true.
183
+ *
184
+ * **This is not {@link itemAddress}, and must not become it.** That one seeds
185
+ * {@link embeddedItemId}, so folding there would change the `_id` of every
186
+ * embedded item whose identity carries a capital — a silent re-identification of
187
+ * documents nothing about which had changed. The catalogue is a lookup table;
188
+ * an id is a promise.
189
+ *
190
+ * @param {string} subType - The Foundry Item subtype.
191
+ * @param {string} shortcode - The item's `system.shortcode`, in any case.
192
+ * @param {string} [pkg] - The publishing package, for the qualified form.
193
+ * @returns {string} The catalogue key.
194
+ */
195
+ export function catalogueKey(subType, shortcode, pkg) {
196
+ const folded = String(shortcode).toLowerCase();
197
+ return pkg ? packagedItemAddress(pkg, subType, folded) : itemAddress(subType, folded);
198
+ }
199
+
137
200
  /**
138
201
  * What identifies one embedded item on its actor.
139
202
  *
@@ -243,7 +306,7 @@ export function loadItemsMap(itemsSourceDirs, foreignSourceDirs = []) {
243
306
  }
244
307
  const shortcode = doc?.system?.shortcode;
245
308
  if (!doc?.type || !shortcode) continue;
246
- const address = itemAddress(doc.type, shortcode);
309
+ const address = catalogueKey(doc.type, shortcode);
247
310
  const owner = source.get(address);
248
311
  if (owner && owner !== itemsSourceDir) {
249
312
  throw new Error(
@@ -257,9 +320,17 @@ export function loadItemsMap(itemsSourceDirs, foreignSourceDirs = []) {
257
320
  // eslint-disable-next-line no-unused-vars
258
321
  const { _key, ...rest } = doc;
259
322
  map.set(address, rest);
323
+ // And under this package's own name, so a `model:` that names this
324
+ // package explicitly resolves to the same item (#334).
325
+ map.set(catalogueKey(doc.type, shortcode, contentPackage()), rest);
260
326
  }
261
327
  }
262
- for (const foreignDir of foreignSourceDirs) {
328
+ for (const foreignEntry of foreignSourceDirs) {
329
+ // Each dependency's directory arrives with the package that published
330
+ // it (#334), so a foreign template gets its own canonical address
331
+ // rather than sharing the local address space.
332
+ const foreignDir = typeof foreignEntry === "string" ? foreignEntry : foreignEntry.dir;
333
+ const foreignPackage = typeof foreignEntry === "string" ? null : foreignEntry.package;
263
334
  for (const name of fs.readdirSync(foreignDir)) {
264
335
  if (!name.endsWith(".json")) continue;
265
336
  if (name.startsWith("folder_")) continue;
@@ -277,14 +348,19 @@ export function loadItemsMap(itemsSourceDirs, foreignSourceDirs = []) {
277
348
  }
278
349
  const shortcode = doc?.system?.shortcode;
279
350
  if (!doc?.type || !shortcode) continue;
280
- const address = itemAddress(doc.type, shortcode);
351
+ const address = catalogueKey(doc.type, shortcode);
352
+ // eslint-disable-next-line no-unused-vars
353
+ const { _key, ...rest } = doc;
354
+ // Its own package-qualified address, which a `model:` naming that
355
+ // package resolves through and nothing local can shadow (#334).
356
+ if (foreignPackage) {
357
+ map.set(catalogueKey(doc.type, shortcode, foreignPackage), rest);
358
+ }
281
359
  if (map.has(address)) {
282
360
  // Deliberate: this repository defines it, so its version wins.
283
361
  if (source.has(address)) shadowed.push(address);
284
362
  continue;
285
363
  }
286
- // eslint-disable-next-line no-unused-vars
287
- const { _key, ...rest } = doc;
288
364
  map.set(address, rest);
289
365
  }
290
366
  }
@@ -310,6 +386,21 @@ export class SystemActorCompiler extends BasePackCompiler {
310
386
  static id = "actors";
311
387
  static label = "actor";
312
388
 
389
+ /**
390
+ * **Both**, and they are two independent pictures: `img` is the actor's
391
+ * token art — written onto `document.img` and the prototype token's texture
392
+ * — and `portrait` is the sheet portrait, a declared field each system
393
+ * lands under its own name (`system.portrait` for SoHL,
394
+ * `system.bioImage` for HM3).
395
+ *
396
+ * Declared on the shared class because both subclasses emit both. A system
397
+ * whose actor genuinely carried only one would override it here rather than
398
+ * leave the claim standing.
399
+ *
400
+ * @type {readonly string[]}
401
+ */
402
+ static emitsArt = Object.freeze(["img", "portrait"]);
403
+
313
404
  /**
314
405
  * Which `(actor, subType:identity)` each resolved entry claimed, and the
315
406
  * entry that claimed it first.
@@ -356,6 +447,20 @@ export class SystemActorCompiler extends BasePackCompiler {
356
447
  itemsSourceDirs;
357
448
  foreignSourceDirs;
358
449
 
450
+ /**
451
+ * Every package a `model:` may name besides this one — the dependencies
452
+ * whose item catalogues were supplied (#334).
453
+ *
454
+ * @returns {Set<string>} The dependency package ids.
455
+ */
456
+ get foreignPackages() {
457
+ return new Set(
458
+ (this.foreignSourceDirs ?? [])
459
+ .map((entry) => (typeof entry === "string" ? null : entry?.package))
460
+ .filter(Boolean),
461
+ );
462
+ }
463
+
359
464
  constructor({ itemsSourceDirs = [], foreignSourceDirs = [], ...options }) {
360
465
  super(options);
361
466
  // Where the items passes wrote their JSON. Stated by the caller rather
@@ -498,7 +603,66 @@ export class SystemActorCompiler extends BasePackCompiler {
498
603
  * @returns {object|null} The embedded item, or null when it resolved to
499
604
  * nothing — always with a finding emitted.
500
605
  */
501
- resolveEmbedded(itemsMap, actorId, type, shortcode, overlay, indexKey, ctx, { fmKey } = {}) {
606
+ /**
607
+ * Read an entry's `model:` — the address of the item it is a copy of.
608
+ *
609
+ * The address grammar is the wikilink one (#336), so a `model` is written at
610
+ * whatever length says what it means: `skill-wpnc` within this package,
611
+ * `sohl-sohl-skill-wpnc` to reach another. The system segment defaults from
612
+ * the block the entry sits in — `<system>.items` — which is what makes the
613
+ * short form name an *Item* here while the same string in body prose names
614
+ * a page.
615
+ *
616
+ * It replaced a top-level `shortcode:` that meant something different from
617
+ * the `system.shortcode` beside it and could not say which package a
618
+ * template came from (#334).
619
+ *
620
+ * @param {unknown} model - The authored value.
621
+ * @param {number} index - The entry's position, for the message.
622
+ * @param {string} ctx - Diagnostic context (the actor's label).
623
+ * @returns {{type: string, shortcode: string, package: string|null}|null}
624
+ * The parsed address, or `null` after reporting why it is not one.
625
+ */
626
+ readModel(model, index, ctx) {
627
+ const key = `${this.documentSubtypes.block}.items`;
628
+ const where = () =>
629
+ locateFrontmatterKey(this.currentNote?.absPath, "items", String(model ?? ""));
630
+ if (typeof model !== "string" || !model.trim()) {
631
+ this.noteError(`${ctx}: ${key}[${index}] \`model\` must be an address`, where());
632
+ this.errorCount++;
633
+ return null;
634
+ }
635
+ // The types this system maps, which are the ones a `model` may name, and
636
+ // every package one may reach: this repository's own plus each
637
+ // dependency whose item catalogue was loaded.
638
+ const types = new Set(Object.keys(this.documentSubtypes.types));
639
+ const packages = new Set([contentPackage(), ...this.foreignPackages]);
640
+ const read = readQualifier(model.trim(), types, packages);
641
+ if (!read || read.reason) {
642
+ const why =
643
+ read?.reason === "not-lowercase" ?
644
+ "capitalises a package, system or type segment — those three " +
645
+ "are lowercase, and only the shortcode keeps its case"
646
+ : read?.reason === "unknown-type" ? "names no known content type"
647
+ : "is not an address — write `type-shortcode`, or " +
648
+ "`package-system-type-shortcode` for another package's item";
649
+ this.noteError(`${ctx}: ${key}[${index}] \`model: ${model}\` ${why}`, where());
650
+ this.errorCount++;
651
+ return null;
652
+ }
653
+ return { type: read.type, shortcode: read.shortcode, package: read.package ?? null };
654
+ }
655
+
656
+ resolveEmbedded(
657
+ itemsMap,
658
+ actorId,
659
+ type,
660
+ shortcode,
661
+ overlay,
662
+ indexKey,
663
+ ctx,
664
+ { fmKey, modelPackage = null } = {},
665
+ ) {
502
666
  // Where a finding about this reference points. The value locates the
503
667
  // exact entry in a list; the key is the fallback when it cannot be
504
668
  // found, which still beats naming the note alone.
@@ -511,7 +675,33 @@ export class SystemActorCompiler extends BasePackCompiler {
511
675
  this.errorCount++;
512
676
  return null;
513
677
  }
514
- const address = itemAddress(/** @type {string} */ (subType), shortcode ?? "");
678
+ // A `model:` may name the package its template comes from (#334). Where
679
+ // it does, the packaged address is used and nothing local can shadow
680
+ // it; where it does not, the unqualified one is, and a local definition
681
+ // still wins over a dependency's as it always has.
682
+ const address = catalogueKey(
683
+ /** @type {string} */ (subType),
684
+ shortcode ?? "",
685
+ modelPackage ?? undefined,
686
+ );
687
+
688
+ // The entry's `system:` overlay is merged verbatim, so it reaches the
689
+ // document without passing a single field declaration — which left it
690
+ // the one position a runtime-only field stayed authorable at once #330
691
+ // closed the item note's own. Asked of the **overlay** rather than of
692
+ // the merged result: the template it merges onto is a compiled
693
+ // document, which by then carries none, and a finding has to name what
694
+ // this note wrote.
695
+ const [runtimeOnly] = runtimeOnlyIn(overlay?.system, itemFields(type, this.system));
696
+ if (runtimeOnly) {
697
+ this.noteError(
698
+ `${ctx}: ${indexKey}: ` +
699
+ `${runtimeOnlyMessage(`${indexKey}.system.${runtimeOnly.to}`, runtimeOnly)}.`,
700
+ where(),
701
+ );
702
+ this.errorCount++;
703
+ return null;
704
+ }
515
705
 
516
706
  let base = null;
517
707
  if (shortcode) {
@@ -39,15 +39,33 @@
39
39
  */
40
40
 
41
41
  /**
42
- * The shape every address segment must match: ASCII letters and digits only.
42
+ * The shape every address segment must match: **lowercase** ASCII letters and
43
+ * digits only.
43
44
  *
44
- * Case is deliberately **not** constrained. Hundreds of authored shortcodes are
45
- * mixed-case and collide with nothing, and case has no bearing on the
46
- * separator, which is the whole of what this pattern is protecting.
45
+ * Case *was* deliberately unconstrained, on the reasoning that case has no
46
+ * bearing on the separator — which is true, and beside the point (#340).
47
+ *
48
+ * **Two names that differ only in case are two names nobody can tell apart.** A
49
+ * shortcode is how a person names a thing when writing a reference —
50
+ * `model: weapongear-dgr`, `[[skill-melee|…]]` — and `Dgr` beside `dgr` is a
51
+ * distinction you cannot say out loud and can only see by looking twice.
52
+ *
53
+ * The toolchain had already half-decided it: {@link canonicalKey} lowercases the
54
+ * address it builds, so a note declaring `Clb` published
55
+ * `sohl-sohl-weapongear-clb` and its `_id` derived from that. The authored name
56
+ * and its address disagreed, and everything downstream keys on the address —
57
+ * which left two notes differing only in case sharing one address, one `_id` and
58
+ * one URL, with nothing to report it. It also forced two exceptions elsewhere:
59
+ * #336 had to exempt the shortcode from the lowercase rule it pinned on every
60
+ * other segment, and #346 had to fold the shortcode's case in the item catalogue
61
+ * because an address is lowercased when read.
62
+ *
63
+ * One case, one spelling, no exceptions. Every tree already complies but two,
64
+ * and nothing in any of them collides when folded.
47
65
  *
48
66
  * @type {RegExp}
49
67
  */
50
- export const ADDRESS_SEGMENT_PATTERN = /^[A-Za-z0-9]+$/;
68
+ export const ADDRESS_SEGMENT_PATTERN = /^[a-z0-9]+$/;
51
69
 
52
70
  /**
53
71
  * Whether a value is a well-formed address segment.
@@ -78,6 +78,7 @@ import {
78
78
  import { isNoteRecord, noteFile } from "./index-records.mjs";
79
79
  import { emitDiagnostic } from "./diagnostics.mjs";
80
80
  import { assertNoDeclaredPackage } from "./note-package.mjs";
81
+ import { assertNoDeclaredFolder } from "./folder-notes.mjs";
81
82
  import {
82
83
  assertNoAliasesField,
83
84
  assertNoDraftField,
@@ -198,6 +199,34 @@ export class BasePackCompiler {
198
199
  */
199
200
  static requiresSystemBlock = false;
200
201
 
202
+ /**
203
+ * The **art fields** this pass reads off a note and writes onto its
204
+ * document — `img`, `portrait`, whichever of them reaches the output.
205
+ *
206
+ * Empty by default, and every shipped pass states its own, for the reason
207
+ * {@link BasePackCompiler.readsPackOutputOf} does: the fact belongs to the
208
+ * class that does the writing, and a second list of it somewhere else is a
209
+ * list free to disagree with what is actually emitted.
210
+ *
211
+ * The reader is the frontmatter lint. `img` is a *shared top-level* field —
212
+ * legal on every note whatever its type, because
213
+ * `BLOCK_DOCUMENT_PROPERTIES` maps it onto `document.img` — so a note whose
214
+ * document has no such property authors it, validates, compiles, and loses
215
+ * the value with nothing said. That is #349: `Parrot` in `sohl-thalorna`
216
+ * had declared `img:` since long before the art rule existed and compiled
217
+ * `img: null` exactly as a note declaring nothing does. Naming the fields
218
+ * here is what lets the lint tell an inert key from a live one.
219
+ *
220
+ * A pass that emits art **anywhere** in its document declares it, not only
221
+ * one that writes a top-level `img`: the scenes pass puts the path on the
222
+ * scene's background rather than on a property called `img`, and the value
223
+ * is no less live for it. The question this answers is whether the authored
224
+ * path reaches the output at all.
225
+ *
226
+ * @type {readonly string[]}
227
+ */
228
+ static emitsArt = Object.freeze([]);
229
+
201
230
  /** @type {string} */
202
231
  contentBase;
203
232
  /** @type {string} */
@@ -270,8 +299,9 @@ export class BasePackCompiler {
270
299
  * @param {string} options.dest - Where this pass writes its JSON.
271
300
  * @param {readonly string[]} options.skipDirectories - Directories the walk
272
301
  * never descends into. Required: see {@link assertStatedScope}.
273
- * @param {(path: string|null) => string|null} [options.folderResolver] -
274
- * Resolves a `sohl.folder` id against this pack's folder hierarchy.
302
+ * @param {(address: string|null) => string|null} [options.folderResolver] -
303
+ * Resolves a `packFolder` a folder note's address — to the Foundry
304
+ * folder id it materialises as in this pack (#255, #257).
275
305
  * @param {string} [options.packName] - The pack this pass writes.
276
306
  * @param {string} [options.docType] - The Foundry document type it holds.
277
307
  * @param {{resolve: Function}} [options.router] - The pack router. Omit it
@@ -414,6 +444,31 @@ export class BasePackCompiler {
414
444
  );
415
445
  }
416
446
 
447
+ /**
448
+ * A refusal only this pass can make, because its subject is the note's
449
+ * **type** (#330).
450
+ *
451
+ * The `assertNo*Field` family above it in the walk is type-agnostic by
452
+ * construction: it runs before `selects`, so that a note declaring a
453
+ * retired field is answered whichever pass would have claimed it. A rule
454
+ * about what a *`trauma`* may write cannot live there — it needs the type's
455
+ * field declaration, which only the pass that compiles the type can reach.
456
+ *
457
+ * So it is a hook, called once the note is known to be this pass's, and its
458
+ * throw is counted and located exactly as the family's is: the note is
459
+ * declined rather than skipped, and the build fails naming the line.
460
+ *
461
+ * The default refuses nothing, which is the honest position for a pass
462
+ * whose documents have no schema to have opinions about.
463
+ *
464
+ * @param {object} fm - The note's frontmatter.
465
+ * @returns {void}
466
+ * @throws {Error} When the note authors something its type forbids. The
467
+ * error may carry a `position` for the diagnostic.
468
+ */
469
+ // eslint-disable-next-line no-unused-vars
470
+ assertAuthorable(fm) {}
471
+
417
472
  /**
418
473
  * Whether this pass claims a note. **Required.**
419
474
  *
@@ -895,6 +950,7 @@ export class BasePackCompiler {
895
950
  // neither message may repeat it.
896
951
  try {
897
952
  assertNoDeclaredPackage(fm, { absPath });
953
+ assertNoDeclaredFolder(fm, { absPath });
898
954
  assertNoDraftField(fm, { absPath });
899
955
  assertNoAliasesField(fm, { absPath });
900
956
  assertNoSectionField(fm, { absPath });
@@ -965,6 +1021,13 @@ export class BasePackCompiler {
965
1021
  stats.skippedOther++;
966
1022
  continue;
967
1023
  }
1024
+ // The type-specific half of the retired-field family (#330):
1025
+ // what a note of *this* type may not write, which needs the
1026
+ // type's own field declaration and so cannot be asked before
1027
+ // `selects`. Counted as a declined note for the same reason
1028
+ // they are — the alternative is a tree that compiles fewer
1029
+ // documents than it has notes and exits 0.
1030
+ this.assertAuthorable(fm);
968
1031
  } catch (err) {
969
1032
  stats.declined++;
970
1033
  this.errorCount++;
@@ -135,6 +135,15 @@ export class Bundles extends BasePackCompiler {
135
135
  */
136
136
  static readsPackOutputOf = Object.freeze(["Actor", "Item", "JournalEntry", "Macro", "Scene"]);
137
137
 
138
+ /**
139
+ * An Adventure carries an `img` — what Foundry shows on the import card.
140
+ * There is no default for it: a bundle naming none ships a blank tile,
141
+ * deliberately, since no stand-in artwork means "a set of documents".
142
+ *
143
+ * @type {readonly string[]}
144
+ */
145
+ static emitsArt = Object.freeze(["img"]);
146
+
138
147
  /**
139
148
  * The JSON directories this pass reads its members from, by document type.
140
149
  *
@@ -45,7 +45,9 @@ import { DEFAULT_ADDRESS_SCHEME } from "../content-config.mjs";
45
45
  // The system vocabulary is the `<system>` segment's own registry, and
46
46
  // `engine/systems.mjs` imports nothing but `engine/address-charset.mjs`, so
47
47
  // the direction is toward the leaf and cannot close a cycle.
48
- import { NO_SYSTEM, assertSystemSegment } from "./systems.mjs";
48
+ import { NO_SYSTEM, assertSystemSegment, isSystemSegment } from "./systems.mjs";
49
+ import { systemOf } from "./document-subtypes.mjs";
50
+ import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./subtype-registry.mjs";
49
51
 
50
52
  // `ids.mjs` is a leaf with no local imports — the module note there says why —
51
53
  // so an address may hash itself without any risk of closing a cycle.
@@ -203,6 +205,95 @@ export function canonicalKey(pkg, system, type, shortcode) {
203
205
  return `${pkg}-${system}-${type}-${shortcode}`.toLowerCase();
204
206
  }
205
207
 
208
+ /**
209
+ * Which system a frontmatter key path is written under.
210
+ *
211
+ * The **enclosing system block** decides, at any depth within it, and nothing
212
+ * else does: `sohl.items[3].model` and `sohl.system.body.structure` are both
213
+ * `sohl` because both sit under `sohl:`. Everywhere else is {@link NO_SYSTEM} —
214
+ * top-level frontmatter, the shared `data:` container, and body prose, which has
215
+ * no key path at all and passes `undefined`.
216
+ *
217
+ * It is the block rather than the field, so a `WikiLink` field needs no opinion
218
+ * about systems and no per-field table has to be kept in step with the schema.
219
+ *
220
+ * The first segment must **be** a declared system, not merely look like one:
221
+ * `sohlish.items` is a key called `sohlish`, and `notes.sohl.thing` names no
222
+ * block at all.
223
+ *
224
+ * @param {string} [keyPath] - The dotted frontmatter key path, or `undefined`
225
+ * for body prose.
226
+ * @returns {string} The system id, or `none`.
227
+ */
228
+ export function blockSystem(keyPath) {
229
+ if (typeof keyPath !== "string" || !keyPath) return NO_SYSTEM;
230
+ const first = keyPath.split(".")[0].trim().toLowerCase();
231
+ return isSystemSegment(first) && first !== NO_SYSTEM ? first : NO_SYSTEM;
232
+ }
233
+
234
+ /**
235
+ * Expand a written address to the one canonical address it names.
236
+ *
237
+ * **An omitted segment defaults from where the link is written** (#336) — it is
238
+ * not a wildcard, and resolution is not a search. Package omitted means the
239
+ * citing note's own; system omitted means {@link blockSystem} of the key path it
240
+ * was written under. So every short form has exactly one expansion, computed
241
+ * before anything is looked up, and there is no candidate set to disambiguate.
242
+ *
243
+ * **Under `none`, a system-bearing type addresses its documentation journal.**
244
+ * A note's `none` address *is* its `doc<type>` entry — the Item is the one with
245
+ * a system — so a prose `[[affiliation-sirvadar|…]]` names the page, which is
246
+ * almost always what prose means. A link that means the Item states the system
247
+ * and gets it. This is the defaulting rule applied, not an exception carved out
248
+ * of it.
249
+ *
250
+ * **Only a type whose own document carries a system is redirected.** A `macro`
251
+ * and the map types have documentation journals too, but their own documents
252
+ * are core ones and already live at `none` — so `<pkg>-none-macro-x` names the
253
+ * Macro and `<pkg>-none-docmacro-x` its journal, two live addresses that the
254
+ * redirect would collapse into one. The test is the note type's own system,
255
+ * not merely whether it has a doc entry.
256
+ *
257
+ * A `doc<type>` written explicitly is `none` **wherever** it appears, even
258
+ * inside a system block: no game system defines a JournalEntry, so there is no
259
+ * other system for one to belong to.
260
+ *
261
+ * @param {{type: string, shortcode: string, package?: string, system?: string,
262
+ * itemDoc?: boolean}} read - A qualifier, as `readQualifier` returns one.
263
+ * @param {{package: string, system?: string}} where - The citing context: the
264
+ * tree's own content package, and the system of the block the link sits in.
265
+ * @returns {string} The canonical `package-system-type-shortcode`.
266
+ */
267
+ export function expandAddress(read, where) {
268
+ const pkg = read.package ?? where.package;
269
+ // A documentation journal is a core document, so it is `none` however it was
270
+ // reached; otherwise the block's system, which body prose reports as `none`.
271
+ const system = read.itemDoc ? NO_SYSTEM : (read.system ?? where.system ?? NO_SYSTEM);
272
+ const redirected = system === NO_SYSTEM && isSystemBearing(read.type);
273
+ const type = read.itemDoc || redirected ? `doc${read.type}` : read.type;
274
+ return canonicalKey(pkg, system, type, read.shortcode);
275
+ }
276
+
277
+ /**
278
+ * Whether a note type's **own** document carries a game system.
279
+ *
280
+ * True for the types some shipped map compiles into an Item or an Actor; false
281
+ * for the core-document types — `doc`, `lore`, `place`, `scenario`, `macro` and
282
+ * the map types — whose documents Foundry itself defines and which therefore
283
+ * already live at `none`.
284
+ *
285
+ * It is what {@link expandAddress} tests rather than {@link hasDocEntry}: a
286
+ * `macro` has a documentation journal *and* a `none` address of its own, so
287
+ * redirecting on "has a doc entry" would collapse two live addresses into one
288
+ * and a `[[macro-autoattack|]]` would stop naming the Macro.
289
+ *
290
+ * @param {string} type - The note type.
291
+ * @returns {boolean} True when the type compiles into a system document.
292
+ */
293
+ function isSystemBearing(type) {
294
+ return systemOf(type, KNOWN_DOCUMENT_SUBTYPE_MAPS) !== NO_SYSTEM;
295
+ }
296
+
206
297
  /**
207
298
  * How many segments a canonical key has, and therefore how many the reader
208
299
  * below counts.
@@ -46,6 +46,16 @@
46
46
  * A mapping table's remaining header cells name the systems (`→ sohl`,
47
47
  * `→ hm3`), so the system vocabulary comes from the document too.
48
48
  *
49
+ * **The other half of a type's vocabulary is a bullet list, not a table.** A
50
+ * type's `subType` values are stated as `**subType**:` followed by one bullet
51
+ * per value, `- <value>` or `- <value>: <definition>`, and that is read here
52
+ * for the same reason the tables are: so the specification and
53
+ * `note-vocabulary.mjs` cannot disagree about which genres exist (#345). The
54
+ * one shape is enforced rather than guessed at — the document wrote them five
55
+ * ways, and a reader that accepted every spelling would accept the sixth by
56
+ * reading the section as declaring nothing, which is the drift it exists to
57
+ * catch. An unrecognised shape throws.
58
+ *
49
59
  * **A mapping table before the first `### type:` heading is the shared one.**
50
60
  * The document states the rows every type maps identically once, at the top,
51
61
  * and omits them from all sixteen per-type tables — so a parser that only ever
@@ -92,6 +102,9 @@ export const CONTENT_FORMAT_PATH = path.join(
92
102
  * property — what a note actually writes. `appearance.eye_color` is authored
93
103
  * as `appearance`, so that is the key recorded.
94
104
  * @property {Set<string>} dataPaths - The declared paths, whole.
105
+ * @property {string[]} subTypes - The `subType` values the section enumerates,
106
+ * in document order — empty when it states none, which is the ordinary case
107
+ * for a type that has no `subType` at all.
95
108
  */
96
109
 
97
110
  /**
@@ -178,6 +191,79 @@ function columnOfCell(line, index) {
178
191
  return at + lead + 2;
179
192
  }
180
193
 
194
+ /** The one shape the specification states a type's `subType` values in. */
195
+ const SUBTYPE_MARKER = "**subType**:";
196
+
197
+ /** Any line that reads as a `subType` marker, canonical or not. */
198
+ const SUBTYPE_MARKER_ISH = /^\s*\**\s*subTypes?\s*\**\s*:?\s*$/i;
199
+
200
+ /** One bullet of a values list: `- <value>` or `- <value>: <definition>`. */
201
+ const SUBTYPE_BULLET = /^-\s+(\S+?)\s*(?::|$)/;
202
+
203
+ /**
204
+ * A parse failure, positioned where the document went wrong.
205
+ *
206
+ * Thrown rather than collected, because there is nothing partial to report: a
207
+ * marker the reader does not understand yields a section that appears to
208
+ * declare no subTypes, and every comparison against it then passes vacuously
209
+ * (#345). The message carries the compiler-parseable position the rest of the
210
+ * toolchain's diagnostics use.
211
+ *
212
+ * @param {string} file - The document being read.
213
+ * @param {number} line - 1-based line the fault is on.
214
+ * @param {string} message - What is wrong, and what to write instead.
215
+ * @returns {Error} The failure to throw.
216
+ */
217
+ function specError(file, line, message) {
218
+ return new Error(`${file}:${line}:1: error: ${message}`);
219
+ }
220
+
221
+ /**
222
+ * The `subType` values a section enumerates under its marker.
223
+ *
224
+ * Reads the one contiguous bullet list directly below the marker and stops
225
+ * there: several sections state another closed vocabulary of their own a blank
226
+ * line later — `TransmissionTypes`, `GovernanceModel` — and reading on would
227
+ * quietly attribute its values to `subType`.
228
+ *
229
+ * @param {string[]} lines - The document's lines.
230
+ * @param {number} at - Index of the marker line.
231
+ * @param {string} file - The document, for the failure message.
232
+ * @returns {string[]} The values, in document order.
233
+ */
234
+ function subTypeValues(lines, at, file) {
235
+ /** @type {string[]} */
236
+ const values = [];
237
+ let i = at + 1;
238
+ while (i < lines.length && lines[i].trim() === "") i += 1;
239
+ for (; i < lines.length; i += 1) {
240
+ const line = lines[i];
241
+ // A wrapped definition is indented under its own bullet.
242
+ if (values.length && /^\s+\S/.test(line)) continue;
243
+ if (!line.startsWith("-")) break;
244
+ const bullet = SUBTYPE_BULLET.exec(line);
245
+ const value = bullet?.[1].replace(/`/g, "");
246
+ if (!value || !/^[A-Za-z0-9]+$/.test(value)) {
247
+ throw specError(
248
+ file,
249
+ i + 1,
250
+ `\`${SUBTYPE_MARKER}\` takes one bullet per value, ` +
251
+ "`- <value>` or `- <value>: <definition>`, and this bullet states none.",
252
+ );
253
+ }
254
+ values.push(value);
255
+ }
256
+ if (!values.length) {
257
+ throw specError(
258
+ file,
259
+ at + 1,
260
+ `\`${SUBTYPE_MARKER}\` enumerates no values. A type whose subType values the ` +
261
+ "specification does not state omits the marker.",
262
+ );
263
+ }
264
+ return values;
265
+ }
266
+
181
267
  /**
182
268
  * Parse the specification's tables.
183
269
  *
@@ -211,12 +297,28 @@ export function parseContentFormat(text, { file = CONTENT_FORMAT_PATH } = {}) {
211
297
  line: i + 1,
212
298
  dataKeys: new Set(),
213
299
  dataPaths: new Set(),
300
+ subTypes: [],
214
301
  };
215
302
  types.set(current.name, current);
216
303
  table = undefined;
217
304
  continue;
218
305
  }
219
306
 
307
+ if (current && SUBTYPE_MARKER_ISH.test(line)) {
308
+ if (line.trim() !== SUBTYPE_MARKER) {
309
+ throw specError(
310
+ file,
311
+ i + 1,
312
+ `a type's subType values are stated as \`${SUBTYPE_MARKER}\`, ` +
313
+ `not \`${line.trim()}\`. The specification had five spellings and ` +
314
+ "converged on one, so that a section is never read as declaring none.",
315
+ );
316
+ }
317
+ current.subTypes = subTypeValues(lines, i, file);
318
+ table = undefined;
319
+ continue;
320
+ }
321
+
220
322
  const cells = cellsOf(line);
221
323
  if (!cells) {
222
324
  // Any non-table line ends the table. A blank line between two