@heroiclands/package-build 19.0.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 (65) hide show
  1. package/CHANGELOG.md +624 -0
  2. package/CONTENT.md +79 -8
  3. package/bin/content-build.mjs +7 -1
  4. package/content-config.mjs +10 -1
  5. package/docs/content-format.md +394 -72
  6. package/engine/actor-compiler.mjs +197 -7
  7. package/engine/address-charset.mjs +23 -5
  8. package/engine/base-compiler.mjs +63 -2
  9. package/engine/bundles.mjs +9 -0
  10. package/engine/content-address.mjs +92 -1
  11. package/engine/content-format.mjs +102 -0
  12. package/engine/content-index.mjs +11 -8
  13. package/engine/content-links.mjs +37 -21
  14. package/engine/field-reference.mjs +57 -5
  15. package/engine/field-spec.mjs +214 -7
  16. package/engine/folder-notes.mjs +24 -1
  17. package/engine/foreign-catalog.mjs +4 -1
  18. package/engine/foundry-entries.mjs +14 -0
  19. package/engine/frontmatter-lint.mjs +186 -27
  20. package/engine/frontmatter.mjs +11 -11
  21. package/engine/generate.mjs +67 -10
  22. package/engine/helpers.mjs +86 -9
  23. package/engine/index.mjs +3 -0
  24. package/engine/item-compiler.mjs +37 -0
  25. package/engine/journals.mjs +21 -4
  26. package/engine/macros.mjs +8 -0
  27. package/engine/map-notes.mjs +7 -7
  28. package/engine/note-ids.mjs +25 -1
  29. package/engine/note-vocabulary.mjs +76 -9
  30. package/engine/retired-fields.mjs +57 -16
  31. package/engine/runtime-only-fields.mjs +204 -0
  32. package/engine/scenes.mjs +12 -19
  33. package/engine/schema-check.mjs +23 -1
  34. package/engine/site-index.mjs +17 -0
  35. package/engine/subtype-registry.mjs +30 -0
  36. package/engine/system-block.mjs +81 -3
  37. package/engine/web-wikilinks.mjs +33 -27
  38. package/engine/wikilink-syntax.mjs +7 -0
  39. package/engine/wikilinks.mjs +74 -16
  40. package/hm3/actors.mjs +63 -13
  41. package/package.json +2 -2
  42. package/sohl/actors.mjs +106 -7
  43. package/sohl/item-fields.mjs +203 -0
  44. package/sohl/note-schemas.mjs +6 -3
  45. package/types/engine/actor-compiler.d.mts +83 -3
  46. package/types/engine/address-charset.d.mts +22 -4
  47. package/types/engine/base-compiler.d.mts +54 -3
  48. package/types/engine/content-address.d.mts +64 -0
  49. package/types/engine/content-format.d.mts +9 -0
  50. package/types/engine/field-spec.d.mts +271 -3
  51. package/types/engine/folder-notes.d.mts +20 -0
  52. package/types/engine/foundry-entries.d.mts +6 -0
  53. package/types/engine/frontmatter-lint.d.mts +18 -2
  54. package/types/engine/frontmatter.d.mts +11 -11
  55. package/types/engine/generate.d.mts +27 -0
  56. package/types/engine/helpers.d.mts +37 -9
  57. package/types/engine/index.d.mts +1 -0
  58. package/types/engine/map-notes.d.mts +2 -2
  59. package/types/engine/note-ids.d.mts +14 -0
  60. package/types/engine/retired-fields.d.mts +29 -13
  61. package/types/engine/runtime-only-fields.d.mts +102 -0
  62. package/types/engine/schema-check.d.mts +10 -1
  63. package/types/engine/subtype-registry.d.mts +21 -0
  64. package/types/engine/system-block.d.mts +28 -2
  65. package/types/sohl/actors.d.mts +3 -3
package/sohl/actors.mjs CHANGED
@@ -63,6 +63,14 @@ import { SOHL_DOCUMENT_SUBTYPES } from "./document-subtypes.mjs";
63
63
  // verbatim, and `sohl.img` / `sohl.effects` / `sohl.flags` overriding their
64
64
  // shared top-level forms for this system alone (#58).
65
65
  import { blockProperty, mergeSystemData } from "../engine/system-block.mjs";
66
+ import { readField, retiredTopLevelKey } from "../engine/field-spec.mjs";
67
+ // The retirement window's reports, shared with the frontmatter lint so the two
68
+ // cannot say different things about the same key (#305, #332).
69
+ import {
70
+ legacyKeyMessage,
71
+ locateFrontmatterKey,
72
+ retiredTopLevelMessage,
73
+ } from "../engine/retired-fields.mjs";
66
74
 
67
75
  /**
68
76
  * The system this pass compiles for — the block its notes write.
@@ -81,6 +89,38 @@ const DEFAULT_IMG = {
81
89
  being: "systems/sohl/assets/icons/game-icons/delapouite/person.svg",
82
90
  };
83
91
 
92
+ /**
93
+ * The being's sheet portrait — the one row of the content format's actor
94
+ * mapping table that is authored rather than derived.
95
+ *
96
+ * **Declared, because the position is not a spelling anyone can guess.** This
97
+ * was read with `blockProperty(fm, SYSTEM, "portrait")`, which knows the block
98
+ * and the note's top level and nothing else — so `data.portrait`, the position
99
+ * the specification names and `sohl-thalorna` writes on 646 beings, was
100
+ * invisible, and `?? defaultImg` on the next line turned every miss into the
101
+ * generic person icon rather than into a complaint (#332). Going through
102
+ * {@link module:engine/field-spec.readField} is what makes the mapping table
103
+ * executable here as it already is for HM3's `data.species`.
104
+ *
105
+ * `img` is deliberately **not** declared beside it: the mapping table keeps a
106
+ * note's token art at the top level, so `blockProperty` is the whole of its
107
+ * resolution and there is no `data.img` to reach for.
108
+ *
109
+ * @type {import("../engine/field-spec.mjs").FieldSpec}
110
+ */
111
+ const PORTRAIT_FIELD = Object.freeze({
112
+ name: "data.portrait",
113
+ legacyKey: "portrait",
114
+ to: "portrait",
115
+ shape: "path",
116
+ // The two empties survive, because the caller's `?? defaultImg` is what
117
+ // tells them apart: `null` and an absent key mean "no art named, default
118
+ // me", `""` means "ship blank on purpose" (#218).
119
+ read: (raw) => resolveImg(raw),
120
+ default: null,
121
+ describe: "Path to the portrait image.",
122
+ });
123
+
84
124
  /**
85
125
  * The default art for an actor subtype.
86
126
  *
@@ -206,21 +246,48 @@ export class Actors extends SystemActorCompiler {
206
246
  this.errorCount++;
207
247
  return;
208
248
  }
209
- const { shortcode, type, ...rest } = entry;
210
- if (!type) {
211
- this.noteError(`${ctx}: sohl.items[${index}] missing type`);
249
+ const { model, shortcode, type, ...rest } = entry;
250
+ if (shortcode !== undefined) {
251
+ this.noteError(
252
+ `${ctx}: sohl.items[${index}] carries a top-level ` +
253
+ `\`shortcode\` — that key is retired. Name the item ` +
254
+ `this entry copies with \`model:\` (an address, e.g. ` +
255
+ `\`${type ?? "skill"}-${shortcode}\`), and use ` +
256
+ `\`system.shortcode\` for this item's own identity.`,
257
+ );
258
+ this.errorCount++;
259
+ return;
260
+ }
261
+ const read = model === undefined ? null : this.readModel(model, index, ctx);
262
+ if (model !== undefined && !read) return;
263
+ if (read && type !== undefined) {
264
+ this.noteError(
265
+ `${ctx}: sohl.items[${index}] states both \`model\` and ` +
266
+ `\`type\` — the model's address already names the ` +
267
+ `type, so the second is a place to be wrong. Drop \`type\`.`,
268
+ );
269
+ this.errorCount++;
270
+ return;
271
+ }
272
+ const effectiveType = read ? read.type : type;
273
+ if (!effectiveType) {
274
+ this.noteError(
275
+ `${ctx}: sohl.items[${index}] names no \`model\` and no ` +
276
+ `\`type\` — an entry that copies nothing must state ` +
277
+ `\`name\`, \`type\` and \`system.shortcode\`.`,
278
+ );
212
279
  this.errorCount++;
213
280
  return;
214
281
  }
215
282
  const embedded = this.resolveEmbedded(
216
283
  itemsMap,
217
284
  actorId,
218
- type,
219
- shortcode || null,
285
+ effectiveType,
286
+ read ? read.shortcode : null,
220
287
  rest,
221
288
  `items:${index}`,
222
289
  ctx,
223
- { fmKey: "items" },
290
+ { fmKey: "items", modelPackage: read?.package ?? null },
224
291
  );
225
292
  if (embedded) items.push(embedded);
226
293
  });
@@ -298,6 +365,36 @@ export class Actors extends SystemActorCompiler {
298
365
  const { value: authoredFolder, isAddress } = folderField(fm);
299
366
  const folder = this.folderResolver(authoredFolder, { isAddress });
300
367
 
368
+ // The two retiring positions a declared field may be read from (#305,
369
+ // #332). **Warnings**, on the pattern every retirement in this package
370
+ // follows: the note compiles to the correct document either way, so
371
+ // reddening a tree over one would refuse before the sweep rather than
372
+ // after it. What they buy is a count — the whole reason #332 was
373
+ // invisible for so long is that nothing said which position a value
374
+ // had come from, and a default is indistinguishable from a miss.
375
+ const portraitReports = {
376
+ block: SYSTEM,
377
+ onLegacyKey: (field) =>
378
+ this.noteWarn(
379
+ legacyKeyMessage(SYSTEM, field),
380
+ locateFrontmatterKey(this.currentNote?.absPath, field.legacyKey),
381
+ ),
382
+ // Anchored at column 1: the two positions share a spelling here —
383
+ // `sohl.portrait` and `portrait` — so a locator that took the first
384
+ // match would point at the block key while the message named the
385
+ // top-level one.
386
+ onRetiredTopLevel: (field) =>
387
+ this.noteWarn(
388
+ retiredTopLevelMessage(field),
389
+ locateFrontmatterKey(
390
+ this.currentNote?.absPath,
391
+ retiredTopLevelKey(field),
392
+ undefined,
393
+ { topLevel: true },
394
+ ),
395
+ ),
396
+ };
397
+
301
398
  const system = {
302
399
  // The frontmatter shortcode is the actor's stable `(type, shortcode)`
303
400
  // key — and, for a being that is an archetype, its archetype
@@ -311,7 +408,9 @@ export class Actors extends SystemActorCompiler {
311
408
  templatePriority: systemTemplatePriority(fm, ctx),
312
409
  // Nullish, not `||` (#218): a note that names no portrait gets the
313
410
  // subtype's default, one that writes `""` ships blank on purpose.
314
- portrait: resolveImg(blockProperty(fm, SYSTEM, "portrait")) ?? defaultImg,
411
+ // Resolved through the declaration so `data.portrait` is reached at
412
+ // all — see {@link PORTRAIT_FIELD} (#332).
413
+ portrait: readField(PORTRAIT_FIELD, fm, portraitReports) ?? defaultImg,
315
414
  appearance: renderSection(body || "", "appearance"),
316
415
  dossier: renderSection(body || "", "dossier"),
317
416
  };
@@ -103,6 +103,40 @@ function noteContext(fm, type) {
103
103
  return `${type} "${fm?.name?.full ?? fm?.shortcode ?? "?"}"`;
104
104
  }
105
105
 
106
+ /**
107
+ * A timed phase's duration **formula** — what is rolled to seed the interval.
108
+ *
109
+ * SoHL's own field is a nullable, non-blank `StringField`, and the interval it
110
+ * yields is in **seconds** (`"432000"` is the five days the world setting
111
+ * defaults to). A bare number is as valid as a dice expression, and both are
112
+ * stringified here so a note may write either — which is also why no `kind` is
113
+ * declared: a lint claiming this must be a string would report `86400` as an
114
+ * authoring mistake when it is the commonest thing to write.
115
+ *
116
+ * Blank reads as unset rather than as `""`, matching the field's `blank: false`
117
+ * — but a field declaring {@link FieldSpec.omitWhenAbsent} skips before this
118
+ * runs when the note carries nothing, so the `null` is only ever reached by a
119
+ * note that cleared the key on purpose.
120
+ */
121
+ const DURATION_FORMULA = Object.freeze({
122
+ shape: "roll formula, or a whole number of seconds",
123
+ read: (raw) => (raw == null || raw === "" ? null : String(raw)),
124
+ });
125
+
126
+ /**
127
+ * A timed phase's rolled duration, in **seconds** — the fact, where the formula
128
+ * above is the definition.
129
+ *
130
+ * Authoring it states the interval outright instead of leaving it to a roll,
131
+ * which is what a fixed-cadence phase wants. Non-numeric reads `0` rather than
132
+ * `NaN`, as every other count in this vocabulary does.
133
+ */
134
+ const DURATION_BASE = Object.freeze({
135
+ shape: "whole number of seconds",
136
+ kind: "number",
137
+ read: (raw) => (raw == null || raw === "" ? null : Number(raw) || 0),
138
+ });
139
+
106
140
  /** Aptitude weights per skill selector, validated as whole numbers. */
107
141
  const SKILL_APTITUDES = Object.freeze({
108
142
  shape: "map of skill selector → whole number",
@@ -414,6 +448,104 @@ export const ITEM_FIELDS = Object.freeze({
414
448
  describe:
415
449
  "What running the course to the end does to the host: `death`, or the benign default `cured`.",
416
450
  },
451
+ // The two **authored** thirds of each timed-phase triplet (#329). Every
452
+ // one omits its key when the note does not carry it, because the value
453
+ // that stands otherwise is the DataModel's `initial: null` — writing a
454
+ // compile-time `null` over it would say "this phase takes no time",
455
+ // which is a different claim from "this note does not set the phase",
456
+ // and `AfflictionLogic.rollDuration()` opens `if (!formula) return 0`.
457
+ //
458
+ // Until they were declared here the only way to author one was the raw
459
+ // `system:` passthrough — undocumented, uncoerced, and absent from the
460
+ // field list every author-facing surface is built from — so no note in
461
+ // any tree wrote one and every shipped affliction had zero-length
462
+ // phases.
463
+ // Spelled out, one entry per field, for the reason the dates below are:
464
+ // a name assembled from a phase argument is not in the source, so it
465
+ // cannot be grepped, and each phase's prose differs anyway — which is
466
+ // the whole content of the declaration.
467
+ {
468
+ name: "onsetDurationFormula",
469
+ to: "onsetDurationFormula",
470
+ ...DURATION_FORMULA,
471
+ omitWhenAbsent: true,
472
+ describe:
473
+ "Interval from contracting the affliction to the start of onset. Omitted when unset, leaving no incubation.",
474
+ },
475
+ {
476
+ name: "onsetDurationBase",
477
+ to: "onsetDurationBase",
478
+ ...DURATION_BASE,
479
+ omitWhenAbsent: true,
480
+ describe:
481
+ "The onset interval in seconds, standing in for a roll of the formula. Omitted when unset.",
482
+ },
483
+ {
484
+ name: "healingCheckDurationFormula",
485
+ to: "healingCheckDurationFormula",
486
+ ...DURATION_FORMULA,
487
+ omitWhenAbsent: true,
488
+ describe:
489
+ "Interval between healing checks, once the affliction is symptomatic. Omitted when unset.",
490
+ },
491
+ {
492
+ name: "healingCheckDurationBase",
493
+ to: "healingCheckDurationBase",
494
+ ...DURATION_BASE,
495
+ omitWhenAbsent: true,
496
+ describe:
497
+ "The healing-check interval in seconds, standing in for a roll of the formula. Omitted when unset.",
498
+ },
499
+ {
500
+ name: "resolutionDurationFormula",
501
+ to: "resolutionDurationFormula",
502
+ ...DURATION_FORMULA,
503
+ omitWhenAbsent: true,
504
+ describe:
505
+ "Interval from onset to the affliction running its course. Omitted when unset.",
506
+ },
507
+ {
508
+ name: "resolutionDurationBase",
509
+ to: "resolutionDurationBase",
510
+ ...DURATION_BASE,
511
+ omitWhenAbsent: true,
512
+ describe:
513
+ "The resolution interval in seconds, standing in for a roll of the formula. Omitted when unset.",
514
+ },
515
+ // The `…Date` third of every timed-phase triplet, declared as **runtime
516
+ // state** (#330). A phase is authored as its `…DurationFormula`; the
517
+ // `…DurationBase` records what that rolled to; the date records *when
518
+ // it happened*, and only play can know that. All four are
519
+ // `worldTimeDateField()`s in SoHL's schema — nullable rather than
520
+ // sentinelled precisely because `0` is itself a valid world time — so
521
+ // there is no blank an author could honestly write either.
522
+ //
523
+ // Declaring them does two things at once: the key is left out of the
524
+ // compiled document, so SoHL's own `initial: null` stands, and a note
525
+ // authoring one is refused with a message naming the field and its
526
+ // reason. Written out rather than generated from a phase name, as the
527
+ // schema they mirror now is: a field name assembled from an argument is
528
+ // not in the source, and cannot be grepped, read or reported on.
529
+ {
530
+ to: "contractDate",
531
+ runtimeOnly: "the world time the host contracted the affliction",
532
+ describe: "When the host contracted it. Play state, never authored.",
533
+ },
534
+ {
535
+ to: "onsetDate",
536
+ runtimeOnly: "the world time onset fired at",
537
+ describe: "When onset fired. Play state, never authored.",
538
+ },
539
+ {
540
+ to: "treatmentDate",
541
+ runtimeOnly: "the world time the affliction was last treated",
542
+ describe: "When it was last treated. Play state, never authored.",
543
+ },
544
+ {
545
+ to: "resolutionDate",
546
+ runtimeOnly: "the world time the affliction ran its course",
547
+ describe: "When it resolved. Play state, never authored.",
548
+ },
417
549
  ]),
418
550
 
419
551
  armorgear: Object.freeze([
@@ -817,6 +949,77 @@ export const ITEM_FIELDS = Object.freeze({
817
949
  default: null,
818
950
  describe: "Shortcode of the body location injured. Unset on a descriptive condition.",
819
951
  },
952
+ // A trauma's three timed phases (#329). Its healing check and course
953
+ // both fall back to a **world setting** when the trauma sets neither
954
+ // half, which is the sharper reason these omit rather than default: a
955
+ // compile-time `null` written here is still a value, and the seeding in
956
+ // `TraumaDataModel._preCreate` reads `data.courseDurationFormula == null`
957
+ // to decide whether the GM's configured interval applies. A default
958
+ // would answer that question for every trauma in every world.
959
+ //
960
+ // `bloodLossAdvance*` is the one an author reaches for most: a trauma
961
+ // that sets it bleeds, and one that leaves it unset does not.
962
+ {
963
+ name: "healingCheckDurationFormula",
964
+ to: "healingCheckDurationFormula",
965
+ ...DURATION_FORMULA,
966
+ omitWhenAbsent: true,
967
+ describe:
968
+ "Interval between healing checks. Omitted when unset, leaving the world's configured interval to apply.",
969
+ },
970
+ {
971
+ name: "healingCheckDurationBase",
972
+ to: "healingCheckDurationBase",
973
+ ...DURATION_BASE,
974
+ omitWhenAbsent: true,
975
+ describe:
976
+ "The healing-check interval in seconds, standing in for a roll of the formula. Omitted when unset.",
977
+ },
978
+ {
979
+ name: "bloodLossAdvanceDurationFormula",
980
+ to: "bloodLossAdvanceDurationFormula",
981
+ ...DURATION_FORMULA,
982
+ omitWhenAbsent: true,
983
+ describe:
984
+ "Interval between blood-loss advances. Omitted when unset, leaving the world's configured interval to apply.",
985
+ },
986
+ {
987
+ name: "bloodLossAdvanceDurationBase",
988
+ to: "bloodLossAdvanceDurationBase",
989
+ ...DURATION_BASE,
990
+ omitWhenAbsent: true,
991
+ describe:
992
+ "The blood-loss interval in seconds. Setting it is what makes the wound bleed; omitted when unset, and the wound does not.",
993
+ },
994
+ {
995
+ name: "courseDurationFormula",
996
+ to: "courseDurationFormula",
997
+ ...DURATION_FORMULA,
998
+ omitWhenAbsent: true,
999
+ describe:
1000
+ "Interval between course tests, for a condition that runs one — shock, coma, infection. Omitted when unset.",
1001
+ },
1002
+ {
1003
+ name: "courseDurationBase",
1004
+ to: "courseDurationBase",
1005
+ ...DURATION_BASE,
1006
+ omitWhenAbsent: true,
1007
+ describe:
1008
+ "The course-test interval in seconds, standing in for a roll of the formula. Omitted when unset.",
1009
+ },
1010
+ // Runtime state, for the reason `affliction`'s four are (#330): a
1011
+ // trauma's dates are crystallized when the phase fires, and world time
1012
+ // does not exist while content is compiled.
1013
+ {
1014
+ to: "contractDate",
1015
+ runtimeOnly: "the world time the injury was taken",
1016
+ describe: "When the injury was taken. Play state, never authored.",
1017
+ },
1018
+ {
1019
+ to: "treatmentDate",
1020
+ runtimeOnly: "the world time the injury was last treated",
1021
+ describe: "When it was last treated. Play state, never authored.",
1022
+ },
820
1023
  ]),
821
1024
 
822
1025
  weapongear: Object.freeze([
@@ -148,8 +148,8 @@ const BEING_FIELDS = Object.freeze([
148
148
  *
149
149
  * `img` is the one required field — the compiler refuses a map note without it,
150
150
  * since a scene with no background is not a map. It was spelled `image` and
151
- * read from the `sohl:` block until #142; both spellings still compile, and the
152
- * retired one is reported rather than refused.
151
+ * read from the `sohl:` block until #142, which read both while the trees were
152
+ * swept; that retirement has since completed and `image` is gone (#149).
153
153
  *
154
154
  * @type {readonly import("../engine/field-spec.mjs").FieldSpec[]}
155
155
  */
@@ -161,7 +161,10 @@ const MAP_FIELDS = Object.freeze([
161
161
  // Art is not system-specific — a Scene is a core Foundry document, and
162
162
  // every other note type carries its `img` at the note's top level.
163
163
  shared: true,
164
- describe: "The scene's background image.",
164
+ describe:
165
+ "The scene's background image. Owned by whichever package its first segment " +
166
+ "names — `systems/…` and `modules/…` are emitted unchanged, anything else is " +
167
+ "this package's own and is rooted under its assets.",
165
168
  },
166
169
  {
167
170
  name: "dimensions",
@@ -41,6 +41,54 @@ export function deepMerge(base: any, overlay: any): any;
41
41
  * @returns {string} The address, `subType:shortcode`.
42
42
  */
43
43
  export function itemAddress(subType: string, shortcode: string): string;
44
+ /**
45
+ * The key one predefined item is held under **for the package that publishes
46
+ * it** — the address a `model:` naming that package resolves through (#334).
47
+ *
48
+ * The unqualified {@link itemAddress} stays beside it, and the two answer
49
+ * different questions. A `model` that names no package means *this* one and
50
+ * takes the unqualified key, where a local definition still shadows a
51
+ * dependency's. A `model` that names a package takes this one, which nothing
52
+ * can shadow: that is the whole point of writing the package down.
53
+ *
54
+ * Not the canonical wikilink address, because this map is keyed in the
55
+ * **document's** vocabulary — a Foundry Item subtype — while a canonical address
56
+ * carries the *note* type. The two differ wherever a system maps a type to a
57
+ * differently-named subtype, and translating here would put the translation in
58
+ * two places.
59
+ *
60
+ * @param {string} pkg - The content package that publishes the item.
61
+ * @param {string} subType - The Foundry Item subtype.
62
+ * @param {string} shortcode - The item's `system.shortcode`.
63
+ * @returns {string} The address, `package:subType:shortcode`.
64
+ */
65
+ export function packagedItemAddress(pkg: string, subType: string, shortcode: string): string;
66
+ /**
67
+ * The key one predefined item is held under **in the catalogue**, with the
68
+ * shortcode folded to lower case.
69
+ *
70
+ * A shortcode is case-sensitive and routinely mixed — `Clb`, `LtShoe`,
71
+ * `HsTunic` — while an **address** is not: `readQualifier` normalises what it
72
+ * reads, and every canonical address is lowercase. So the moment a `model:` is
73
+ * read as an address (#334), `weapongear-clb` has to find the document whose
74
+ * `system.shortcode` is `Clb`, and an exact match cannot (#346).
75
+ *
76
+ * Folding is safe because the fold is already the address: no two items in any
77
+ * published tree differ only by the case of their shortcode, and #340 will make
78
+ * that impossible rather than merely true.
79
+ *
80
+ * **This is not {@link itemAddress}, and must not become it.** That one seeds
81
+ * {@link embeddedItemId}, so folding there would change the `_id` of every
82
+ * embedded item whose identity carries a capital — a silent re-identification of
83
+ * documents nothing about which had changed. The catalogue is a lookup table;
84
+ * an id is a promise.
85
+ *
86
+ * @param {string} subType - The Foundry Item subtype.
87
+ * @param {string} shortcode - The item's `system.shortcode`, in any case.
88
+ * @param {string} [pkg] - The publishing package, for the qualified form.
89
+ * @returns {string} The catalogue key.
90
+ */
91
+ export function catalogueKey(subType: string, shortcode: string, pkg?: string): string;
44
92
  /**
45
93
  * What identifies one embedded item on its actor.
46
94
  *
@@ -136,6 +184,13 @@ export class SystemActorCompiler extends BasePackCompiler {
136
184
  /** @type {readonly string[]} */
137
185
  itemsSourceDirs: readonly string[];
138
186
  foreignSourceDirs: any;
187
+ /**
188
+ * Every package a `model:` may name besides this one — the dependencies
189
+ * whose item catalogues were supplied (#334).
190
+ *
191
+ * @returns {Set<string>} The dependency package ids.
192
+ */
193
+ get foreignPackages(): Set<string>;
139
194
  /**
140
195
  * This pass's system map, or a message naming the class that forgot it.
141
196
  *
@@ -195,9 +250,34 @@ export class SystemActorCompiler extends BasePackCompiler {
195
250
  * @returns {object|null} The embedded item, or null when it resolved to
196
251
  * nothing — always with a finding emitted.
197
252
  */
198
- resolveEmbedded(itemsMap: Map<string, object>, actorId: string, type: string, shortcode: string | null, overlay?: object, indexKey: string, ctx: string, { fmKey }?: {
199
- fmKey?: string | undefined;
200
- }): object | null;
253
+ /**
254
+ * Read an entry's `model:` — the address of the item it is a copy of.
255
+ *
256
+ * The address grammar is the wikilink one (#336), so a `model` is written at
257
+ * whatever length says what it means: `skill-wpnc` within this package,
258
+ * `sohl-sohl-skill-wpnc` to reach another. The system segment defaults from
259
+ * the block the entry sits in — `<system>.items` — which is what makes the
260
+ * short form name an *Item* here while the same string in body prose names
261
+ * a page.
262
+ *
263
+ * It replaced a top-level `shortcode:` that meant something different from
264
+ * the `system.shortcode` beside it and could not say which package a
265
+ * template came from (#334).
266
+ *
267
+ * @param {unknown} model - The authored value.
268
+ * @param {number} index - The entry's position, for the message.
269
+ * @param {string} ctx - Diagnostic context (the actor's label).
270
+ * @returns {{type: string, shortcode: string, package: string|null}|null}
271
+ * The parsed address, or `null` after reporting why it is not one.
272
+ */
273
+ readModel(model: unknown, index: number, ctx: string): {
274
+ type: string;
275
+ shortcode: string;
276
+ package: string | null;
277
+ } | null;
278
+ resolveEmbedded(itemsMap: any, actorId: any, type: any, shortcode: any, overlay: any, indexKey: any, ctx: any, { fmKey, modelPackage }?: {
279
+ modelPackage?: null | undefined;
280
+ }): any;
201
281
  #private;
202
282
  }
203
283
  import { BasePackCompiler } from "./base-compiler.mjs";
@@ -37,11 +37,29 @@ export function isAddressSegment(value: unknown): boolean;
37
37
  * @module
38
38
  */
39
39
  /**
40
- * The shape every address segment must match: ASCII letters and digits only.
40
+ * The shape every address segment must match: **lowercase** ASCII letters and
41
+ * digits only.
41
42
  *
42
- * Case is deliberately **not** constrained. Hundreds of authored shortcodes are
43
- * mixed-case and collide with nothing, and case has no bearing on the
44
- * separator, which is the whole of what this pattern is protecting.
43
+ * Case *was* deliberately unconstrained, on the reasoning that case has no
44
+ * bearing on the separator — which is true, and beside the point (#340).
45
+ *
46
+ * **Two names that differ only in case are two names nobody can tell apart.** A
47
+ * shortcode is how a person names a thing when writing a reference —
48
+ * `model: weapongear-dgr`, `[[skill-melee|…]]` — and `Dgr` beside `dgr` is a
49
+ * distinction you cannot say out loud and can only see by looking twice.
50
+ *
51
+ * The toolchain had already half-decided it: {@link canonicalKey} lowercases the
52
+ * address it builds, so a note declaring `Clb` published
53
+ * `sohl-sohl-weapongear-clb` and its `_id` derived from that. The authored name
54
+ * and its address disagreed, and everything downstream keys on the address —
55
+ * which left two notes differing only in case sharing one address, one `_id` and
56
+ * one URL, with nothing to report it. It also forced two exceptions elsewhere:
57
+ * #336 had to exempt the shortcode from the lowercase rule it pinned on every
58
+ * other segment, and #346 had to fold the shortcode's case in the item catalogue
59
+ * because an address is lowercased when read.
60
+ *
61
+ * One case, one spelling, no exceptions. Every tree already complies but two,
62
+ * and nothing in any of them collides when folded.
45
63
  *
46
64
  * @type {RegExp}
47
65
  */
@@ -99,14 +99,42 @@ export class BasePackCompiler {
99
99
  * @type {boolean}
100
100
  */
101
101
  static requiresSystemBlock: boolean;
102
+ /**
103
+ * The **art fields** this pass reads off a note and writes onto its
104
+ * document — `img`, `portrait`, whichever of them reaches the output.
105
+ *
106
+ * Empty by default, and every shipped pass states its own, for the reason
107
+ * {@link BasePackCompiler.readsPackOutputOf} does: the fact belongs to the
108
+ * class that does the writing, and a second list of it somewhere else is a
109
+ * list free to disagree with what is actually emitted.
110
+ *
111
+ * The reader is the frontmatter lint. `img` is a *shared top-level* field —
112
+ * legal on every note whatever its type, because
113
+ * `BLOCK_DOCUMENT_PROPERTIES` maps it onto `document.img` — so a note whose
114
+ * document has no such property authors it, validates, compiles, and loses
115
+ * the value with nothing said. That is #349: `Parrot` in `sohl-thalorna`
116
+ * had declared `img:` since long before the art rule existed and compiled
117
+ * `img: null` exactly as a note declaring nothing does. Naming the fields
118
+ * here is what lets the lint tell an inert key from a live one.
119
+ *
120
+ * A pass that emits art **anywhere** in its document declares it, not only
121
+ * one that writes a top-level `img`: the scenes pass puts the path on the
122
+ * scene's background rather than on a property called `img`, and the value
123
+ * is no less live for it. The question this answers is whether the authored
124
+ * path reaches the output at all.
125
+ *
126
+ * @type {readonly string[]}
127
+ */
128
+ static emitsArt: readonly string[];
102
129
  /**
103
130
  * @param {object} options
104
131
  * @param {string} options.contentBase - Root of the content tree.
105
132
  * @param {string} options.dest - Where this pass writes its JSON.
106
133
  * @param {readonly string[]} options.skipDirectories - Directories the walk
107
134
  * never descends into. Required: see {@link assertStatedScope}.
108
- * @param {(path: string|null) => string|null} [options.folderResolver] -
109
- * Resolves a `sohl.folder` id against this pack's folder hierarchy.
135
+ * @param {(address: string|null) => string|null} [options.folderResolver] -
136
+ * Resolves a `packFolder` a folder note's address — to the Foundry
137
+ * folder id it materialises as in this pack (#255, #257).
110
138
  * @param {string} [options.packName] - The pack this pass writes.
111
139
  * @param {string} [options.docType] - The Foundry document type it holds.
112
140
  * @param {{resolve: Function}} [options.router] - The pack router. Omit it
@@ -118,7 +146,7 @@ export class BasePackCompiler {
118
146
  contentBase: string;
119
147
  dest: string;
120
148
  skipDirectories: readonly string[];
121
- folderResolver?: ((path: string | null) => string | null) | undefined;
149
+ folderResolver?: ((address: string | null) => string | null) | undefined;
122
150
  packName?: string | undefined;
123
151
  docType?: string | undefined;
124
152
  router?: {
@@ -236,6 +264,29 @@ export class BasePackCompiler {
236
264
  * the note's own file can be read.
237
265
  */
238
266
  eligibleFor(fm: object): boolean;
267
+ /**
268
+ * A refusal only this pass can make, because its subject is the note's
269
+ * **type** (#330).
270
+ *
271
+ * The `assertNo*Field` family above it in the walk is type-agnostic by
272
+ * construction: it runs before `selects`, so that a note declaring a
273
+ * retired field is answered whichever pass would have claimed it. A rule
274
+ * about what a *`trauma`* may write cannot live there — it needs the type's
275
+ * field declaration, which only the pass that compiles the type can reach.
276
+ *
277
+ * So it is a hook, called once the note is known to be this pass's, and its
278
+ * throw is counted and located exactly as the family's is: the note is
279
+ * declined rather than skipped, and the build fails naming the line.
280
+ *
281
+ * The default refuses nothing, which is the honest position for a pass
282
+ * whose documents have no schema to have opinions about.
283
+ *
284
+ * @param {object} fm - The note's frontmatter.
285
+ * @returns {void}
286
+ * @throws {Error} When the note authors something its type forbids. The
287
+ * error may carry a `position` for the diagnostic.
288
+ */
289
+ assertAuthorable(fm: object): void;
239
290
  /**
240
291
  * Whether this pass claims a note. **Required.**
241
292
  *