@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
@@ -68,6 +68,9 @@ import path from "node:path";
68
68
  import { cachedSchemaPath, SCHEMA_ARTIFACT_FILE } from "./foreign-catalog.mjs";
69
69
  import { loadPackConfig } from "./pack-config.mjs";
70
70
  import { systemData, systemDataPaths, undeclaredPaths } from "./system-block.mjs";
71
+ // A field the document writes for itself in play: declared by the schema,
72
+ // emitted by no builder, and authored by no note (#330).
73
+ import { runtimeOnlyFields } from "./field-spec.mjs";
71
74
 
72
75
  /**
73
76
  * The artifact version this module reads.
@@ -133,12 +136,21 @@ export function declaredFields(artifact, documentType, subtype) {
133
136
  * the path beneath it separately, so a comparison that knew only the leaf would
134
137
  * report the container as unemitted and the leaf as undeclared.
135
138
  *
136
- * @param {readonly {to: string}[]} fields - A type's field declaration.
139
+ * **A runtime-only field is not in it** (#330). It declares a `to` in order to
140
+ * *claim* the path — so the verbatim passthrough leaves it alone and the
141
+ * refusal has something to name — and `buildFromFields` deliberately skips it,
142
+ * because the document writes that field in play. Counting it here would make
143
+ * the check assert the builder writes a key it never writes; the *unemitted*
144
+ * direction handles it instead, in {@link compareFields}.
145
+ *
146
+ * @param {readonly {to: string, runtimeOnly?: string}[]} fields - A type's
147
+ * field declaration.
137
148
  * @returns {Set<string>} The paths, parents included.
138
149
  */
139
150
  export function emittedFields(fields) {
140
151
  const out = new Set();
141
152
  for (const field of fields ?? []) {
153
+ if (field?.runtimeOnly) continue;
142
154
  if (typeof field?.to !== "string" || !field.to) continue;
143
155
  const parts = field.to.split(".");
144
156
  for (let i = 1; i <= parts.length; i++) {
@@ -221,6 +233,15 @@ export function compareFields({
221
233
  }
222
234
 
223
235
  const emitted = emittedFields(fields);
236
+ // Paths the declaration says the *document* writes in play (#330). They
237
+ // are neither emitted nor a defect, so they answer the unemitted
238
+ // question below rather than appearing in it: "every compiled document
239
+ // will carry the field's initial value" is exactly what a runtime-only
240
+ // field is for, and reporting it would leave a permanent warning that
241
+ // the correct declaration cannot clear.
242
+ const runtimeOnly = new Set(
243
+ runtimeOnlyFields(/** @type {never} */ (fields)).map((field) => field.to),
244
+ );
224
245
  for (const path of emitted) {
225
246
  if (declared.all.has(path)) continue;
226
247
  undeclared.push({
@@ -238,6 +259,7 @@ export function compareFields({
238
259
  // on a type that populates them correctly — two findings, both
239
260
  // false, on the first real schema this was run against.
240
261
  if (coveredByAncestor(path, emitted)) continue;
262
+ if (runtimeOnly.has(path)) continue;
241
263
  unemitted.push({
242
264
  type,
243
265
  subtype,
@@ -52,6 +52,7 @@
52
52
  import path from "node:path";
53
53
 
54
54
  import { canonicalKey, readCanonicalKey } from "./content-address.mjs";
55
+ import { NO_SYSTEM } from "./systems.mjs";
55
56
  import { systemOf } from "./document-subtypes.mjs";
56
57
  import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./note-claims.mjs";
57
58
  import { hasDocEntry } from "./item-docs.mjs";
@@ -266,6 +267,17 @@ export function buildSiteIndex(entries, { foreignIndex = new Map() } = {}) {
266
267
  if (hasDocEntry(type)) {
267
268
  contentTypes.add(`doc${type}`);
268
269
  index.set(`doc${type}/${shortcode}`.toLowerCase(), value);
270
+ // The canonical documentation address too, so the page answers
271
+ // to the address a bare prose link expands to (#336): body
272
+ // prose is under no system block, so it defaults to `none`, and
273
+ // a system-bearing type's `none` address is its `doc<type>`
274
+ // one. In Foundry that names a second document; here it names
275
+ // this same page, which is what makes one authored link correct
276
+ // in both builds.
277
+ index.set(
278
+ canonicalKey(e.pkg ?? ownPackage, NO_SYSTEM, `doc${type}`, shortcode),
279
+ value,
280
+ );
269
281
  }
270
282
  }
271
283
  }
@@ -316,6 +328,11 @@ export function wikiContext(built, { src, file, type = null, errors, foreignInde
316
328
  sections: built.sections,
317
329
  contentTypes: built.contentTypes,
318
330
  packages: built.packages,
331
+ // The package a link written on this page defaults to when it names
332
+ // none (#336). Taken from the resolved configuration, the same source
333
+ // the index's own addresses are built from, so a bare link cannot
334
+ // resolve against a package the index never keyed.
335
+ contentPackage: contentPackage(),
319
336
  type,
320
337
  errors,
321
338
  src,
@@ -64,6 +64,36 @@ export const KNOWN_DOCUMENT_SUBTYPE_MAPS = Object.freeze([
64
64
  HM3_DOCUMENT_SUBTYPES,
65
65
  ]);
66
66
 
67
+ /**
68
+ * Every note type any shipped map compiles into an **Actor**.
69
+ *
70
+ * Derived from the maps rather than written out, so a system that adds an actor
71
+ * type is covered without a second list to keep in step — the same reason
72
+ * {@link KNOWN_DOCUMENT_SUBTYPE_MAPS} exists rather than a hand-kept table.
73
+ *
74
+ * It exists because an actor note publishes documentation like every other
75
+ * system-bearing note (#337). `docEntryTypes` was `itemTypes` plus `macro` and
76
+ * the map types, which left a being as the one system-bearing note with no
77
+ * `none` address — nothing a prose link could land on, since its only address
78
+ * named the Actor. Composing that set needs to know which types are actors, and
79
+ * this is where the maps that know already live.
80
+ *
81
+ * **Not an item type.** This widens what carries *documentation*; it must never
82
+ * widen what the items pass compiles, or a being note would be compiled into an
83
+ * Item beside its Actor.
84
+ *
85
+ * @type {ReadonlySet<string>}
86
+ */
87
+ export const ACTOR_TYPES = Object.freeze(
88
+ new Set(
89
+ KNOWN_DOCUMENT_SUBTYPE_MAPS.flatMap((map) =>
90
+ Object.entries(map.types)
91
+ .filter(([, row]) => row?.document === "Actor")
92
+ .map(([noteType]) => noteType),
93
+ ),
94
+ ),
95
+ );
96
+
67
97
  /**
68
98
  * The map one system ships, by its id.
69
99
  *
@@ -50,6 +50,8 @@
50
50
  * kept until #126 moves it;
51
51
  * 3. the shared top-level property the field **declares** as its source, which
52
52
  * may be a dotted path (`data.portrait`) rather than a sibling key;
53
+ * 3b. for a `data.` source, the bare top-level key that container gathered it
54
+ * off — the retiring *shared* position, derived rather than declared;
53
55
  * 4. the field's own default.
54
56
  *
55
57
  * ## Steps 2 and 3 are two declarations, because they are two positions
@@ -75,6 +77,29 @@
75
77
  * that declares one is mid-sweep by construction, which is what
76
78
  * {@link module:engine/field-spec.readsLegacyKey} reports on.
77
79
  *
80
+ * ## Step 3 has a retiring position too, and it is derived (#332)
81
+ *
82
+ * `legacyKey` retires the *in-block* position, and for a while that looked like
83
+ * the whole of what `data:` left behind. It is not. The facts `data:` holds
84
+ * were not invented by it — #128 **gathered** them out of the note's open top
85
+ * level, where `portrait:` sat beside `img:` and `shortcode:` — so a field that
86
+ * declares `data.portrait` has two shared spellings to read, not one, and
87
+ * reading only the current one is the same silent miss `legacyKey` exists to
88
+ * prevent. It is worse here, because the caller's `?? default` cannot tell a
89
+ * value that is absent from one that is merely unreachable: 646 `sohl-thalorna`
90
+ * beings shipped the generic person icon over an authored path, and no tree
91
+ * that still writes the top-level spelling — `sohl`'s own bestiary does —
92
+ * looked any different from one that names no art at all.
93
+ *
94
+ * So step 3b reads it, and unlike `legacyKey` it is **derived**: the retiring
95
+ * spelling of `data.<key>` is `<key>`, mechanically, because that is precisely
96
+ * what the move did. A second declaration would be a second place for the same
97
+ * fact to be stated, and the retirement it describes is one rule rather than a
98
+ * per-field decision. {@link retiredTopLevelKey} is that derivation, and it
99
+ * answers for `data.` sources alone — `protection.blunt` is a path into a
100
+ * container notes have always written at the top level, never a `blunt:` that
101
+ * moved.
102
+ *
78
103
  * ## A name that collides across the two vocabularies skips step 3
79
104
  *
80
105
  * A field's `name` is both its identity and the shared property it draws from,
@@ -90,8 +115,10 @@
90
115
  * `"null"` in fifteen documents (#218).
91
116
  *
92
117
  * So a field may declare `topLevelMeans`: what the top-level key of that name
93
- * means *instead*. Declaring it removes step 3 for that field, leaving the two
94
- * positions that describe the document rather than the note. It is deliberately
118
+ * means *instead*. Declaring it removes the whole shared level step 3 and the
119
+ * retiring 3b alike, since both read the note's top level and the objection is
120
+ * to that level, not to a spelling — leaving the two positions that describe
121
+ * the document rather than the note. It is deliberately
95
122
  * a per-field opt-out rather than a change to the order — step 3 is right
96
123
  * wherever the two levels state the same quantity, which is nearly everywhere —
97
124
  * and its value is the reason rather than a bare flag, so the collision is
@@ -356,6 +383,43 @@ export function legacyKeyOf(field) {
356
383
  return field?.legacyKey ?? field?.name;
357
384
  }
358
385
 
386
+ /**
387
+ * The `data:` container's prefix, as a shared source spells it.
388
+ *
389
+ * @type {string}
390
+ */
391
+ const DATA_PREFIX = "data.";
392
+
393
+ /**
394
+ * The bare top-level key a `data:`-sourced field is being swept off — step 3b.
395
+ *
396
+ * `data:` (#128) did not invent the facts it holds; it *gathered* them, out of
397
+ * the note's open top level where each was a sibling of `img` and `shortcode`.
398
+ * So the retiring spelling of `data.portrait` is not a second declaration
399
+ * anyone has to write — it is `portrait`, mechanically, and the same holds for
400
+ * every other key that move relocated. Deriving it is what keeps the two
401
+ * spellings of one field from disagreeing the way two declarations would.
402
+ *
403
+ * **Only a `data.` source has one.** `protection.blunt` and `impact.die` are
404
+ * paths into containers a note has always written at the top level; they were
405
+ * never `blunt:` or `die:`, and reading those would invent a position rather
406
+ * than remember one.
407
+ *
408
+ * A field declaring {@link module:engine/field-spec.FieldSpec `topLevelMeans`}
409
+ * has no shared position at all, retiring or otherwise — the resolver checks
410
+ * that before asking.
411
+ *
412
+ * @param {{name?: string}} field - The declaration.
413
+ * @returns {string|undefined} The retiring top-level path, or `undefined` for a
414
+ * field whose shared source never lived there.
415
+ */
416
+ export function retiredTopLevelKey(field) {
417
+ const name = field?.name;
418
+ if (typeof name !== "string" || !name.startsWith(DATA_PREFIX)) return undefined;
419
+ const rest = name.slice(DATA_PREFIX.length);
420
+ return rest === "" ? undefined : rest;
421
+ }
422
+
359
423
  /**
360
424
  * Where a declared field's value came from.
361
425
  *
@@ -363,7 +427,7 @@ export function legacyKeyOf(field) {
363
427
  * can distinguish a value an author wrote from one a default supplied, which
364
428
  * the value alone never says.
365
429
  *
366
- * @typedef {"system"|"block"|"shared"|"default"|"value"} FieldSource
430
+ * @typedef {"system"|"block"|"shared"|"topLevel"|"default"|"value"} FieldSource
367
431
  */
368
432
 
369
433
  /**
@@ -422,6 +486,20 @@ export function resolveFieldValue(field, fm, { block = "sohl" } = {}) {
422
486
  if (field.topLevelMeans === undefined) {
423
487
  const shared = getFrontmatter(fm, field.name, undefined);
424
488
  if (shared !== undefined) return { value: shared, from: "shared" };
489
+
490
+ // 3b. The bare top-level key the `data:` source was gathered off — the
491
+ // retiring *shared* position, exactly as `legacyKey` is the
492
+ // retiring *in-block* one (#332). Without it a field declaring
493
+ // `data.portrait` cannot see the `portrait:` every tree still
494
+ // writes, and the miss arrives at the caller's `?? default` as an
495
+ // ordinary absence: 646 `sohl-thalorna` beings compiled the generic
496
+ // person icon over an authored path, deterministically and with
497
+ // nothing said.
498
+ const retiring = retiredTopLevelKey(field);
499
+ if (retiring !== undefined) {
500
+ const legacy = getFrontmatter(fm, retiring, undefined);
501
+ if (legacy !== undefined) return { value: legacy, from: "topLevel" };
502
+ }
425
503
  }
426
504
 
427
505
  // 4. The field's own default.
@@ -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,6 +291,24 @@ export class Hm3Actors extends SystemActorCompiler {
259
291
  locateFrontmatterKey(this.currentNote?.absPath, field.legacyKey),
260
292
  );
261
293
 
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
+
262
312
  // One spelling, as everywhere else: `packFolder` names a folder note
263
313
  // by its address. This pass once read only the Foundry id, so an HM3
264
314
  // tree could not file an actor by address at all — which its own sweep
@@ -270,15 +320,15 @@ export class Hm3Actors extends SystemActorCompiler {
270
320
  const system = {
271
321
  // Nullish, not `||` (#218): a note that names no portrait gets the
272
322
  // subtype's default, one that writes `""` ships blank on purpose.
273
- 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,
274
326
  description: renderSection(body || "", "appearance"),
275
327
  biography: renderSection(body || "", "dossier"),
276
- ...buildFromFields(ACTOR_FIELDS, { block, onLegacyKey })(fm),
328
+ ...buildFromFields(ACTOR_FIELDS, reports)(fm),
277
329
  // Declared on `character` alone, so written there alone — see the
278
330
  // module note.
279
- ...(subType === "character" ?
280
- buildFromFields(CHARACTER_FIELDS, { block, onLegacyKey })(fm)
281
- : {}),
331
+ ...(subType === "character" ? buildFromFields(CHARACTER_FIELDS, reports)(fm) : {}),
282
332
  };
283
333
 
284
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": "19.0.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",