@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
@@ -46,7 +46,8 @@ import { replaceOutsideCode } from "./code-fences.mjs";
46
46
  // The canonical `package-system-type-shortcode` key, so a package-qualified
47
47
  // address is matched the way a vendored manifest publishes it — by the
48
48
  // segments the target supplies, with the system wildcarded unless stated (#59).
49
- import { canonicalKey, readCanonicalKey } from "./content-address.mjs";
49
+ import { canonicalKey, expandAddress, readCanonicalKey } from "./content-address.mjs";
50
+ import { NO_SYSTEM } from "./systems.mjs";
50
51
  // The one rule about a link's shape both builds share: it carries a label, and
51
52
  // {@link unlabelledLinkMessage} is the one place that says so (#180).
52
53
  import { unlabelledLinkMessage } from "./wikilink-syntax.mjs";
@@ -85,32 +86,33 @@ import { authoredLabel, WIKILINK, isSamePage, parseWikilink } from "./wikilink-s
85
86
  * @param {object|null} read - From {@link readQualifier}.
86
87
  * @returns {string | null} The index key, or `null` when not an address.
87
88
  */
88
- function lookupRead(index, read) {
89
+ function lookupRead(index, read, contentPackage) {
89
90
  if (!read || read.reason) return undefined;
90
- // An unqualified address stays the system-blind short key, which is the
91
- // wildcard an author writing `[[skill-melee]]` means, and resolves within
92
- // this package only.
93
- if (!read.package) return index.get(`${read.type}/${read.shortcode}`.toLowerCase());
91
+ // Every omitted segment defaults from where the link is written (#336)
92
+ // package from the citing package, system from the block, which on a page
93
+ // body is `none` — so the target expands to exactly one canonical address
94
+ // and this is a plain lookup. No filter, no single-hit rule, and no
95
+ // ambiguity: one key names one entry.
96
+ //
97
+ // It replaced a system-blind short key for the unqualified form, which
98
+ // could be silently overwritten by a second note of the same
99
+ // `(type, shortcode)` under another system.
100
+ const key = expandAddress(read, { package: contentPackage, system: NO_SYSTEM });
101
+ const found = index.get(key);
102
+ if (found || read.package) return found;
94
103
 
95
- // A package-qualified one is matched by the segments it supplies, with the
96
- // system wildcarded unless stated (#59) an exact `get` cannot express
97
- // that, and would silently miss every address whose system it did not
98
- // guess. Exactly one hit resolves; two are an ambiguity for the caller to
99
- // report rather than a pick to make here.
100
- const type = String(read.type).toLowerCase();
101
- const shortcode = String(read.shortcode).toLowerCase();
102
- const pkg = String(read.package).toLowerCase();
103
- let found;
104
- for (const [key, value] of index) {
105
- const parts = readCanonicalKey(key);
106
- if (!parts) continue;
107
- if (parts.package !== pkg) continue;
108
- if (read.system && parts.system !== String(read.system).toLowerCase()) continue;
109
- if (parts.type !== type || parts.shortcode !== shortcode) continue;
110
- if (found) return undefined;
111
- found = value;
112
- }
113
- return found;
104
+ // A local target may also be keyed by its short form. On the KB that is not
105
+ // a second answer: an item note renders as **one page** which is its own
106
+ // documentation, so `skill/climb`, `docskill/climb` and both canonical
107
+ // addresses are all the same value (#1362) the fallback cannot pick
108
+ // differently, only earlier. It stays because an index built before the
109
+ // canonical documentation key was added still carries the short one, and
110
+ // because a page collision here is caught by the site index's own
111
+ // `ambiguous` set rather than by silent overwrite as in the pack index.
112
+ return (
113
+ index.get(`${read.itemDoc ? "doc" : ""}${read.type}/${read.shortcode}`.toLowerCase()) ??
114
+ undefined
115
+ );
114
116
  }
115
117
 
116
118
  /**
@@ -366,7 +368,7 @@ export function resolveWebWikilinks(body, ctx) {
366
368
  const read = readQualifier(target, ctx.contentTypes ?? new Set(), ctx.packages);
367
369
  const rawKey = target.toLowerCase();
368
370
  const hit =
369
- lookupRead(ctx.index, read) ??
371
+ lookupRead(ctx.index, read, ctx.contentPackage) ??
370
372
  // `section/slug` is the site's own address for a page, and it is in
371
373
  // the same map. Admitted only when the target carries a slash, so
372
374
  // a page's bare slug cannot answer for an address.
@@ -375,7 +377,11 @@ export function resolveWebWikilinks(body, ctx) {
375
377
  // local one (#1446), so a cross-package hit needs no special case
376
378
  // below. Local wins: a live build is authoritative and a vendored
377
379
  // manifest can only be staler.
378
- (ctx.foreign ? lookupRead(ctx.foreign, read) : undefined);
380
+ // A short form names *this* package (#336), so it never reaches a
381
+ // vendored manifest; only a fully qualified address does.
382
+ (ctx.foreign && read?.package ?
383
+ lookupRead(ctx.foreign, read, ctx.contentPackage)
384
+ : undefined);
379
385
  if (hit) {
380
386
  // An address with an *empty* label has no prose to show (a
381
387
  // shortcode is not display text), so the document's **current**
@@ -182,6 +182,7 @@ export const LINK_FINDING_REASONS = Object.freeze(
182
182
  new Set([
183
183
  "unlabelled",
184
184
  "not-an-address",
185
+ "not-lowercase",
185
186
  "unknown-type",
186
187
  "unresolved",
187
188
  "ambiguous",
@@ -276,6 +277,12 @@ export function linkFindingMessage({ reason, target, packages, anchor }) {
276
277
  `[[package-system-type-shortcode|Text]] for a note in another ` +
277
278
  `package`
278
279
  );
280
+ case "not-lowercase":
281
+ return (
282
+ `address [[${target}]] capitalises a package, system or type ` +
283
+ `segment — those three are lowercase, and only the shortcode ` +
284
+ `keeps its case`
285
+ );
279
286
  case "unknown-type":
280
287
  return `address [[${target}]] names no known content type`;
281
288
  case "ambiguous":
@@ -87,7 +87,9 @@ import crypto from "crypto";
87
87
 
88
88
  import { compendiumUuid, ITEM_PACK, packForType, pageUuid, PACK_BY_TYPE } from "./ids.mjs";
89
89
  import { readCanonicalKey } from "./content-address.mjs";
90
- import { isSystemSegment } from "./systems.mjs";
90
+ import { isSystemSegment, NO_SYSTEM } from "./systems.mjs";
91
+ import { systemOf } from "./document-subtypes.mjs";
92
+ import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./subtype-registry.mjs";
91
93
  import { hasDocEntry, itemDocEntryId } from "./item-docs.mjs";
92
94
  import { replaceOutsideCode } from "./code-fences.mjs";
93
95
  // The syntax lives in `./wikilink-syntax.mjs`, so the web resolver and this
@@ -207,6 +209,48 @@ export function resolveItemDocType(qualifier, types) {
207
209
  * but names no known type; or `null` when it is not an address at all.
208
210
  */
209
211
  export function readQualifier(target, types, packages) {
212
+ // **Package, system and type are lowercase; the shortcode is not.** A
213
+ // shortcode is case-sensitive and routinely mixed — `Clb`, `LtShoe`,
214
+ // `HsTunic` — so it is written as the note declares it. The three segments
215
+ // in front of it are closed vocabularies with one spelling each, and
216
+ // accepting `Skill` beside `skill` would bless two ways of writing one
217
+ // address. Reported rather than folded, so the corpus has one form.
218
+ //
219
+ // Tested only once the target *parses*: a note name is full of capitals
220
+ // (`[[Shock State]]`), and calling that a badly-cased address rather than
221
+ // not an address would name the wrong mistake. Neither tree carries a
222
+ // violation — 10,538 authored targets — so this pins a rule already kept.
223
+ const read = readQualifierCased(target, types, packages);
224
+ if (read && !read.reason && qualifyingSegments(target).some((s) => /[A-Z]/.test(s))) {
225
+ return { reason: "not-lowercase" };
226
+ }
227
+ return read;
228
+ }
229
+
230
+ /**
231
+ * The segments of a target that must be lowercase — everything but the
232
+ * shortcode, which is case-sensitive and keeps whatever the note declares.
233
+ *
234
+ * @param {string} target - The link target, anchor already removed.
235
+ * @returns {string[]} The package / system / type segments, as written.
236
+ */
237
+ function qualifyingSegments(target) {
238
+ const slash = target.lastIndexOf("/");
239
+ // The legacy `type/shortcode` form states only a type.
240
+ if (slash > 0) return [target.slice(0, slash)];
241
+ const parts = target.split("-");
242
+ return parts.slice(0, -1);
243
+ }
244
+
245
+ /**
246
+ * {@link readQualifier} without the lowercase rule — the grammar alone.
247
+ *
248
+ * @param {string} target
249
+ * @param {Set<string>} types
250
+ * @param {Set<string>} [packages]
251
+ * @returns {object|null}
252
+ */
253
+ function readQualifierCased(target, types, packages) {
210
254
  // The slash form is legacy and states neither package nor system, so it is
211
255
  // read first and separately. A slash is unconditionally a qualifier —
212
256
  // nothing else uses one — which is why an unknown type before it is
@@ -440,25 +484,24 @@ function foreignHits(index, read) {
440
484
  const wanted = norm(read.itemDoc ? `doc${read.type}` : read.type);
441
485
  const shortcode = norm(read.shortcode);
442
486
 
443
- // One filter for every reading, because an address is matched **by the
444
- // segments it supplies** and wildcarded on the ones it does not (#59). A
445
- // package-qualified target used to take a separate exact-`get` path, which
446
- // is what let this function carry its own copy of the key grammar — a
447
- // hand-built `${package}-${type}-${shortcode}` and a literal segment count
448
- // — and drift from `readCanonicalKey` the moment the grammar gained a
449
- // system segment. There is one reader now.
487
+ // **An omitted package means this package** (#336), so a short form
488
+ // addresses nothing foreign and never reaches a dependency's index. A link
489
+ // that resolved into another package only because no local note claimed the
490
+ // address was resolving by accident, and would have retargeted silently the
491
+ // day one did. Reaching another package is the fully qualified form's job.
450
492
  //
451
- // The system is a wildcard unless the target states one: most authored
452
- // links name none, and defaulting it to `none` would exclude every link to
453
- // an item, which is the majority 1,632 of `sohl`'s own resolve into the
454
- // items pack. Ambiguity is caught by the caller's single-hit rule rather
455
- // than pre-empted by a guess here.
493
+ // The system likewise comes from where the link is written `none` in a
494
+ // body so both segments are known here and this is an exact lookup rather
495
+ // than a filter. That is what makes a cross-package `ambiguous` impossible:
496
+ // one key, one entry.
497
+ if (!read.package) return [];
498
+ const wantedSystem = norm(read.system ?? NO_SYSTEM);
456
499
  const hits = [];
457
500
  for (const [key, entry] of index.foreign) {
458
501
  const parts = readCanonicalKey(key);
459
502
  if (!parts) continue;
460
- if (read.package && parts.package !== norm(read.package)) continue;
461
- if (read.system && parts.system !== norm(read.system)) continue;
503
+ if (parts.package !== norm(read.package)) continue;
504
+ if (parts.system !== wantedSystem) continue;
462
505
  if (parts.type !== wanted || parts.shortcode !== shortcode) continue;
463
506
  hits.push(entry);
464
507
  }
@@ -616,7 +659,22 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index }) {
616
659
  });
617
660
  return unresolvedLink(text || target, target);
618
661
  }
619
- itemDoc = qualified.itemDoc;
662
+ // An omitted system defaults from where the link is written (#336),
663
+ // and a body is under no system block, so it is `none`. Under
664
+ // `none` a system-bearing type addresses its *documentation* — a
665
+ // note's `none` address IS its `doc<type>` entry — which is what a
666
+ // prose link almost always means. Stating the system is how prose
667
+ // reaches the Item instead.
668
+ //
669
+ // Only a type whose *own* document carries a system is redirected.
670
+ // A `macro` and the map types have documentation journals too, but
671
+ // their own documents are core ones already at `none`, so
672
+ // `macro-autoattack` names the Macro and `docmacro-autoattack` its
673
+ // journal — two live addresses the redirect would collapse.
674
+ itemDoc =
675
+ qualified.itemDoc ||
676
+ ((qualified.system ?? NO_SYSTEM) === NO_SYSTEM &&
677
+ systemOf(qualified.type, KNOWN_DOCUMENT_SUBTYPE_MAPS) !== NO_SYSTEM);
620
678
  doc = index.byShortcode.get(`${qualified.type}/${qualified.shortcode}`);
621
679
  }
622
680
  if (!doc) {
package/hm3/actors.mjs CHANGED
@@ -29,10 +29,11 @@
29
29
  * **What is emitted, and what is deliberately not.** Four rows of the content
30
30
  * format's `being` mapping table give HM3 a destination — `data.portrait` →
31
31
  * `system.bioImage`, `data.species`, `data.gender`, `data.occupation`, and
32
- * `data.templatePriority` → `flags.hm3.templatePriority` — and, since #305,
33
- * three of them are *declared as those sources* rather than as the bare key the
34
- * corpus writes, so the specification's mapping is executable rather than
35
- * aspirational. Plus the two anchored prose sections: `{#appearance}` is HM3's `description` and `{#dossier}` its
32
+ * `data.templatePriority` → `flags.hm3.templatePriority` — and every one of
33
+ * them is *declared as that source* rather than as the bare key the corpus
34
+ * writes, so the specification's mapping is executable rather than
35
+ * aspirational. Three were declared by #305; `data.portrait` was still read by
36
+ * hand until #332, and so was the one row that did not work. Plus the two anchored prose sections: `{#appearance}` is HM3's `description` and `{#dossier}` its
36
37
  * `biography`. Everything else an HM3 actor carries — the thirteen abilities,
37
38
  * the sunsign, `move`, `fatigue`, `shockIndex`, a creature's `loadRating` — has
38
39
  * no shared source stated anywhere, so it is authored at its own path under
@@ -49,15 +50,19 @@
49
50
  */
50
51
 
51
52
  import { resolveName, resolveImg } from "../engine/helpers.mjs";
52
- import { buildFromFields, STRING } from "../engine/field-spec.mjs";
53
+ import { buildFromFields, readField, retiredTopLevelKey, STRING } from "../engine/field-spec.mjs";
53
54
  import { SystemActorCompiler } from "../engine/actor-compiler.mjs";
54
55
  import { renderSection } from "../engine/anchored-sections.mjs";
55
56
  import { documentSubtype } from "../engine/document-subtypes.mjs";
56
57
  import { HM3_DOCUMENT_SUBTYPES } from "./document-subtypes.mjs";
57
58
  import { templateFlags } from "./template-priority.mjs";
58
- // The retirement window's report, shared with the frontmatter lint so the
59
- // two cannot say different things about the same key (#305).
60
- import { legacyKeyMessage, locateFrontmatterKey } from "../engine/retired-fields.mjs";
59
+ // The retirement window's reports, shared with the frontmatter lint so the
60
+ // two cannot say different things about the same key (#305, #332).
61
+ import {
62
+ legacyKeyMessage,
63
+ locateFrontmatterKey,
64
+ retiredTopLevelMessage,
65
+ } from "../engine/retired-fields.mjs";
61
66
  // The note-level `hm3:` block: `hm3.system` onto the document's `system`
62
67
  // verbatim, and `hm3.img` / `hm3.items` / `hm3.effects` / `hm3.flags`
63
68
  // overriding their shared top-level forms for this system alone (#58).
@@ -94,6 +99,33 @@ const ACTOR_FIELDS = Object.freeze([
94
99
  },
95
100
  ]);
96
101
 
102
+ /**
103
+ * The actor's bio image — the fourth row of the specification's `being` table,
104
+ * and the last one that was still read by hand.
105
+ *
106
+ * Declared for the reason {@link ACTOR_FIELDS} is, and fixed for the reason
107
+ * `data.species` was: read with `blockProperty(fm, block, "portrait")` it saw
108
+ * the block and the note's top level and nothing else, so the `data.portrait`
109
+ * the specification names never reached the document and the `?? defaultImg`
110
+ * beside it dressed the miss up as "this note names no art" (#332).
111
+ *
112
+ * It is **not** in `ACTOR_FIELDS`, because `buildFromFields` has no seam for
113
+ * the subtype default that has to follow it — the `?? defaultImg` is the whole
114
+ * of what distinguishes an unnamed portrait from a deliberately blank one
115
+ * (#218), and it needs a subtype the coercion is not handed.
116
+ *
117
+ * @type {import("../engine/field-spec.mjs").FieldSpec}
118
+ */
119
+ const BIO_IMAGE_FIELD = Object.freeze({
120
+ name: "data.portrait",
121
+ legacyKey: "portrait",
122
+ to: "bioImage",
123
+ shape: "path",
124
+ read: (raw) => resolveImg(raw),
125
+ default: null,
126
+ describe: "Path to the portrait image.",
127
+ });
128
+
97
129
  /**
98
130
  * The two `data:` facts HM3 declares on a `character` and not on a `creature`.
99
131
  *
@@ -259,29 +291,44 @@ export class Hm3Actors extends SystemActorCompiler {
259
291
  locateFrontmatterKey(this.currentNote?.absPath, field.legacyKey),
260
292
  );
261
293
 
262
- // Both spellings, as everywhere else: `packFolder` is a folder note's
263
- // address and `folder` a Foundry id, and which one applies comes from
264
- // the field it was written in rather than from the string (#251, #255).
265
- // This pass read only the id, so an HM3 tree could not file an actor by
266
- // address at allwhich its own sweep needs.
267
- const packFolderAddress = blockField(fm, block, "packFolder", null);
268
- const folder =
269
- packFolderAddress ?
270
- this.folderResolver(packFolderAddress, { isAddress: true })
271
- : this.folderResolver(blockField(fm, block, "folder", null));
294
+ // The shared level's own retiring position the top-level key `data:`
295
+ // gathered the field off (#332). Same signal, same severity, separate
296
+ // callback: a note may have moved one position and not the other.
297
+ // Anchored at column 1, because the two spellings coincide `portrait`
298
+ // names a block key and a top-level one and a locator that took the
299
+ // first match would point at the wrong line.
300
+ const onRetiredTopLevel = (field) =>
301
+ this.noteWarn(
302
+ retiredTopLevelMessage(field),
303
+ locateFrontmatterKey(
304
+ this.currentNote?.absPath,
305
+ retiredTopLevelKey(field),
306
+ undefined,
307
+ { topLevel: true },
308
+ ),
309
+ );
310
+ const reports = { block, onLegacyKey, onRetiredTopLevel };
311
+
312
+ // One spelling, as everywhere else: `packFolder` names a folder note
313
+ // by its address. This pass once read only the Foundry id, so an HM3
314
+ // tree could not file an actor by address at all — which its own sweep
315
+ // needs; the id spelling is retired outright (#251, #255, #260).
316
+ const folder = this.folderResolver(blockField(fm, block, "packFolder", null), {
317
+ isAddress: true,
318
+ });
272
319
 
273
320
  const system = {
274
321
  // Nullish, not `||` (#218): a note that names no portrait gets the
275
322
  // subtype's default, one that writes `""` ships blank on purpose.
276
- bioImage: resolveImg(blockProperty(fm, block, "portrait")) ?? defaultImg,
323
+ // Resolved through the declaration so `data.portrait` is reached at
324
+ // all — see {@link BIO_IMAGE_FIELD} (#332).
325
+ bioImage: readField(BIO_IMAGE_FIELD, fm, reports) ?? defaultImg,
277
326
  description: renderSection(body || "", "appearance"),
278
327
  biography: renderSection(body || "", "dossier"),
279
- ...buildFromFields(ACTOR_FIELDS, { block, onLegacyKey })(fm),
328
+ ...buildFromFields(ACTOR_FIELDS, reports)(fm),
280
329
  // Declared on `character` alone, so written there alone — see the
281
330
  // module note.
282
- ...(subType === "character" ?
283
- buildFromFields(CHARACTER_FIELDS, { block, onLegacyKey })(fm)
284
- : {}),
331
+ ...(subType === "character" ? buildFromFields(CHARACTER_FIELDS, reports)(fm) : {}),
285
332
  };
286
333
 
287
334
  // Whatever the note authors under `hm3.system`, at the DataModel's own
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "18.2.0",
3
+ "version": "20.0.0",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -155,7 +155,7 @@
155
155
  "devDependencies": {
156
156
  "@changesets/cli": "^3.0.0",
157
157
  "@types/node": "^26.2.0",
158
- "vitest": "^4.1.10"
158
+ "vitest": "^5.0.0"
159
159
  },
160
160
  "scripts": {
161
161
  "test": "vitest run",
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
  };