@heroiclands/package-build 22.9.2 → 22.11.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 (97) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/CONTENT.md +54 -0
  3. package/bin/content-build.mjs +3 -1
  4. package/docs/api.md +11 -6
  5. package/docs/commands.md +1 -1
  6. package/docs/content-format.md +411 -201
  7. package/engine/actor-compiler.mjs +20 -7
  8. package/engine/address-fields.mjs +426 -0
  9. package/engine/address-values.mjs +80 -0
  10. package/engine/address.mjs +769 -0
  11. package/engine/art-fields.mjs +112 -97
  12. package/engine/art-slots.mjs +81 -0
  13. package/engine/base-compiler.mjs +61 -7
  14. package/engine/bundle-notes.mjs +5 -2
  15. package/engine/bundles.mjs +17 -10
  16. package/engine/content-address.mjs +23 -186
  17. package/engine/content-embeds.mjs +18 -4
  18. package/engine/content-format-check.mjs +50 -22
  19. package/engine/content-index.mjs +85 -43
  20. package/engine/content-links.mjs +105 -104
  21. package/engine/content-lint.mjs +11 -1
  22. package/engine/content-tables.mjs +9 -1
  23. package/engine/field-spec.mjs +21 -4
  24. package/engine/folder-notes.mjs +44 -15
  25. package/engine/foundry-entries.mjs +3 -0
  26. package/engine/frontmatter-lint.mjs +150 -47
  27. package/engine/frontmatter.mjs +55 -10
  28. package/engine/generate.mjs +7 -1
  29. package/engine/helpers.mjs +37 -9
  30. package/engine/holdings.mjs +41 -12
  31. package/engine/index-records.mjs +2 -0
  32. package/engine/infobox-render.mjs +2 -1
  33. package/engine/infobox.mjs +26 -6
  34. package/engine/item-compiler.mjs +5 -0
  35. package/engine/item-docs.mjs +7 -44
  36. package/engine/map-dot.mjs +2 -0
  37. package/engine/map-notes.mjs +7 -2
  38. package/engine/map-places.mjs +42 -10
  39. package/engine/metadata-index.mjs +60 -5
  40. package/engine/note-addresses.mjs +325 -0
  41. package/engine/note-vocabulary.mjs +140 -28
  42. package/engine/pdf-build.mjs +1 -1
  43. package/engine/pdf-toc.mjs +17 -2
  44. package/engine/place-relations.mjs +160 -50
  45. package/engine/populations.mjs +17 -9
  46. package/engine/reference-targets.mjs +54 -0
  47. package/engine/scenes.mjs +5 -2
  48. package/engine/shortcode-references.mjs +68 -0
  49. package/engine/site-build.mjs +39 -15
  50. package/engine/site-index.mjs +57 -75
  51. package/engine/sql-tables.mjs +43 -9
  52. package/engine/system-block.mjs +21 -0
  53. package/engine/system-vocabulary.mjs +46 -0
  54. package/engine/web-wikilinks.mjs +11 -9
  55. package/engine/wikilink-syntax.mjs +21 -0
  56. package/engine/wikilinks.mjs +92 -306
  57. package/hm3/actors.mjs +8 -24
  58. package/hm3/item-fields.mjs +15 -5
  59. package/package.json +1 -1
  60. package/sohl/infobox.mjs +111 -17
  61. package/sohl/item-fields.mjs +131 -41
  62. package/types/engine/address-fields.d.mts +95 -0
  63. package/types/engine/address-values.d.mts +34 -0
  64. package/types/engine/address.d.mts +452 -0
  65. package/types/engine/art-fields.d.mts +59 -53
  66. package/types/engine/art-slots.d.mts +40 -0
  67. package/types/engine/base-compiler.d.mts +24 -2
  68. package/types/engine/bundle-notes.d.mts +2 -2
  69. package/types/engine/content-address.d.mts +3 -139
  70. package/types/engine/content-embeds.d.mts +1 -1
  71. package/types/engine/content-format-check.d.mts +25 -0
  72. package/types/engine/content-index.d.mts +2 -2
  73. package/types/engine/content-links.d.mts +6 -5
  74. package/types/engine/field-spec.d.mts +46 -9
  75. package/types/engine/folder-notes.d.mts +11 -10
  76. package/types/engine/frontmatter-lint.d.mts +7 -97
  77. package/types/engine/frontmatter.d.mts +16 -1
  78. package/types/engine/helpers.d.mts +2 -2
  79. package/types/engine/infobox.d.mts +6 -0
  80. package/types/engine/item-docs.d.mts +1 -35
  81. package/types/engine/map-notes.d.mts +2 -2
  82. package/types/engine/note-addresses.d.mts +41 -0
  83. package/types/engine/note-vocabulary.d.mts +20 -3
  84. package/types/engine/place-relations.d.mts +12 -1
  85. package/types/engine/populations.d.mts +17 -2
  86. package/types/engine/reference-targets.d.mts +9 -0
  87. package/types/engine/shortcode-references.d.mts +14 -0
  88. package/types/engine/site-build.d.mts +3 -3
  89. package/types/engine/site-index.d.mts +9 -30
  90. package/types/engine/sql-tables.d.mts +2 -2
  91. package/types/engine/system-block.d.mts +17 -0
  92. package/types/engine/system-vocabulary.d.mts +16 -0
  93. package/types/engine/wikilink-syntax.d.mts +7 -0
  94. package/types/engine/wikilinks.d.mts +6 -109
  95. package/types/hm3/item-fields.d.mts +5 -0
  96. package/types/sohl/infobox.d.mts +6 -5
  97. package/types/sohl/item-fields.d.mts +8 -0
@@ -54,6 +54,8 @@
54
54
  * @module
55
55
  */
56
56
 
57
+ import { isAddressTuple } from "./address.mjs";
58
+ import { AddressEntries } from "./address-values.mjs";
57
59
  import { authoredFields, readsLegacyKey } from "./field-spec.mjs";
58
60
  import {
59
61
  legacyKeyOf,
@@ -66,6 +68,8 @@ import { positionInFrontmatter, positionOfFrontmatterPath } from "./diagnostics.
66
68
  import { pathnameProblem } from "./pathnames.mjs";
67
69
  import { checkHomepageAddressFields } from "./homepage.mjs";
68
70
  import { RETIRED_TYPES, RENAMED_TYPES, currentType, renamedTypeMessage } from "./ids.mjs";
71
+ import { parseAddress, acceptsType } from "./address.mjs";
72
+ import { ASSET_TYPE_NAMES } from "./asset-types.mjs";
69
73
  import { isAddressSegment } from "./address-charset.mjs";
70
74
  // The one place the "every pack not named" key is spelled. Imported rather
71
75
  // than repeated, because a linter holding its own copy of what the compiler
@@ -196,19 +200,7 @@ export const DEFAULT_SYSTEM_BLOCKS = Object.freeze({
196
200
  * @param {object} [config] - A resolved configuration from `defineConfig`.
197
201
  * @returns {string[]} The system ids, deduplicated, in declared order.
198
202
  */
199
- export function declaredSystems(config) {
200
- const out = [];
201
- for (const system of Object.keys(config?.systems ?? {})) {
202
- if (!out.includes(system)) out.push(system);
203
- }
204
- for (const pack of config?.packs ?? []) {
205
- const system = pack?.system;
206
- if (typeof system === "string" && system && !out.includes(system)) out.push(system);
207
- }
208
- if (out.length) return out;
209
- const packageWide = config?.stats?.systemId;
210
- return typeof packageWide === "string" && packageWide ? [packageWide] : [];
211
- }
203
+ export { declaredSystems } from "./system-vocabulary.mjs";
212
204
 
213
205
  /**
214
206
  * The system blocks a configuration says its tree carries, and what each
@@ -261,19 +253,7 @@ export function declaredSystems(config) {
261
253
  * @returns {Readonly<Record<string, SystemBlockSpec>>} The blocks to check, in
262
254
  * declared order. A system nothing states the vocabulary of is absent.
263
255
  */
264
- export function systemBlocksFor(config, { schemaSystem } = {}) {
265
- const byName = config?.itemFieldsBySystem ?? {};
266
- /** @type {Record<string, SystemBlockSpec>} */
267
- const blocks = {};
268
- for (const system of declaredSystems(config)) {
269
- /** @type {SystemBlockSpec} */
270
- const spec = {};
271
- if (system === schemaSystem) spec.fieldVocabulary = true;
272
- if (byName[system]) spec.fields = byName[system];
273
- if (Object.keys(spec).length) blocks[system] = Object.freeze(spec);
274
- }
275
- return Object.freeze(blocks);
276
- }
256
+ export { systemBlocksFor } from "./system-vocabulary.mjs";
277
257
 
278
258
  /**
279
259
  * Edit distance, capped — enough to answer "did you mean".
@@ -337,10 +317,15 @@ function nearest(key, candidates) {
337
317
  *
338
318
  * @param {unknown} value - The authored value.
339
319
  * @param {string} kind - The declared kind.
320
+ * @param {object} [context] - Complete Address defaults and known type vocabulary.
340
321
  * @returns {boolean} Whether it is acceptable.
341
322
  */
342
- export function matchesKind(value, kind) {
323
+ export function matchesKind(value, kind, context = {}) {
343
324
  switch (kind) {
325
+ case "address":
326
+ return !parseAddress(value, context, { declared: true }).reason;
327
+ case "shortcode":
328
+ return isAddressSegment(value);
344
329
  case "number":
345
330
  return typeof value === "number" ?
346
331
  Number.isFinite(value)
@@ -382,6 +367,7 @@ export function matchesKind(value, kind) {
382
367
  * @returns {Record<string, unknown>|null} Its entries, or `null` for a scalar.
383
368
  */
384
369
  function mapEntries(value) {
370
+ if (isAddressTuple(value)) return null;
385
371
  if (Array.isArray(value)) return value.length === 0 ? {} : null;
386
372
  if (typeof value !== "object" || value === null) return null;
387
373
  return /** @type {Record<string, unknown>} */ (value);
@@ -427,6 +413,7 @@ function dataBlock(fm) {
427
413
  * @param {object} opts
428
414
  * @param {string} opts.type - The note's type, for the message.
429
415
  * @param {readonly object[]} opts.fields - The type's `data:` declaration.
416
+ * @param {object} [opts.addressContext] - Builder package and Address type vocabulary.
430
417
  * @param {readonly string[]} [opts.packs] - The pack names this package
431
418
  * declares, against which a `keys: "pack"` map's keys are checked. Absent,
432
419
  * no claim is made about them: a caller that cannot see the configuration
@@ -434,7 +421,7 @@ function dataBlock(fm) {
434
421
  * was loaded to recognise it would be worse than not checking.
435
422
  * @returns {object[]} Findings.
436
423
  */
437
- function checkDataContainer(note, { type, fields, packs }) {
424
+ function checkDataContainer(note, { type, fields, packs, addressContext }) {
438
425
  const findings = [];
439
426
  const { present, entries, malformed } = dataBlock(note.fm ?? {});
440
427
  if (!present) return findings;
@@ -489,10 +476,12 @@ function checkDataContainer(note, { type, fields, packs }) {
489
476
  ...positionOfFrontmatterPath(raw, ["data", key], { key: true }),
490
477
  severity: "error",
491
478
  message:
492
- `"${key}" is not a \`data:\` property declared by ${type}; ` +
493
- `the container is closed, so unlike a top-level key it is ` +
494
- `not passed through to the page` +
495
- (guess ? `. Did you mean "${guess}"?` : ""),
479
+ type === "affiliation" && key === "commonSkills" ?
480
+ "`data.commonSkills` is not an affiliation field; write skill Addresses at `sohl.system.commonSkills`"
481
+ : `"${key}" is not a \`data:\` property declared by ${type}; ` +
482
+ `the container is closed, so unlike a top-level key it is ` +
483
+ `not passed through to the page` +
484
+ (guess ? `. Did you mean "${guess}"?` : ""),
496
485
  });
497
486
  }
498
487
 
@@ -504,7 +493,7 @@ function checkDataContainer(note, { type, fields, packs }) {
504
493
  value = value && typeof value === "object" ? value[segment] : undefined;
505
494
  }
506
495
  if (value === undefined || value === null) continue;
507
- if (!matchesKind(value, field.kind)) {
496
+ if (!matchesKind(value, field.kind, { ...addressContext, type: field.ref })) {
508
497
  findings.push({
509
498
  file: note.file,
510
499
  ...positionOfFrontmatterPath(raw, ["data", ...segments]),
@@ -515,6 +504,7 @@ function checkDataContainer(note, { type, fields, packs }) {
515
504
  });
516
505
  continue;
517
506
  }
507
+ findings.push(...checkDataReferences(note, field, value, segments, addressContext));
518
508
  // A `scalar-or-map` written in its map form is checked entry by entry,
519
509
  // because that is the correction an author has to make: one key's
520
510
  // value, not the whole map. Quoting the map back would name every
@@ -530,6 +520,76 @@ function checkDataContainer(note, { type, fields, packs }) {
530
520
  return findings;
531
521
  }
532
522
 
523
+ /**
524
+ * Validate typed entries without resolving their targets.
525
+ * @param {object} note - The authored note.
526
+ * @param {object} field - Its data field declaration.
527
+ * @param {unknown} value - The field value.
528
+ * @param {string[]} segments - The field's path under `data`.
529
+ * @param {object} context - The builder's Address context.
530
+ * @returns {object[]} Located findings.
531
+ */
532
+ function checkDataReferences(note, field, value, segments, context) {
533
+ const checks = [];
534
+ if (field.kind === "address") checks.push({ value, path: [], kind: "address" });
535
+ if (field.entryKind) {
536
+ if (Array.isArray(value)) {
537
+ value.forEach((entry, i) =>
538
+ checks.push({ value: entry, path: [i], kind: field.entryKind }),
539
+ );
540
+ } else if (value && typeof value === "object" && !isAddressTuple(value)) {
541
+ Object.entries(value).forEach(([key, entry]) => {
542
+ if (entry != null)
543
+ checks.push({ value: entry, path: [key], kind: field.entryKind, atKey: true });
544
+ });
545
+ } else checks.push({ value, path: [], kind: field.entryKind });
546
+ }
547
+ if (field.keyKind) {
548
+ if (value instanceof AddressEntries) {
549
+ for (const entry of value.entries)
550
+ checks.push({
551
+ value: entry.target,
552
+ path: [entry.sourceKey],
553
+ kind: field.keyKind,
554
+ key: true,
555
+ });
556
+ } else
557
+ Object.keys(value).forEach((key) =>
558
+ checks.push({ value: key, path: [key], kind: field.keyKind, key: true }),
559
+ );
560
+ }
561
+ const findings = [];
562
+ const defaults = { ...context, type: field.ref };
563
+ const accepted = field.accepts;
564
+ for (const check of checks) {
565
+ if (
566
+ check.key &&
567
+ field.keySelector === "subType" &&
568
+ typeof check.value === "string" &&
569
+ check.value.startsWith("subType:") &&
570
+ isAddressSegment(check.value.slice("subType:".length))
571
+ )
572
+ continue;
573
+ let reason;
574
+ if (!matchesKind(check.value, check.kind, defaults))
575
+ reason = `should be ${check.kind === "address" ? "an Address" : "a Shortcode"}`;
576
+ else if (check.kind === "address" && accepted) {
577
+ const tuple = parseAddress(check.value, defaults, { declared: true });
578
+ if (!acceptsType(tuple, accepted))
579
+ reason = `accepts ${accepted.join(" or ")}, not ${tuple.type}`;
580
+ }
581
+ if (!reason) continue;
582
+ const path = ["data", ...segments, ...check.path];
583
+ findings.push({
584
+ file: note.file,
585
+ ...positionOfFrontmatterPath(note.raw ?? "", path, { key: check.key || check.atKey }),
586
+ severity: "error",
587
+ message: `\`${path.join(".")}\` ${reason}, but reads ${JSON.stringify(check.value)}`,
588
+ });
589
+ }
590
+ return findings;
591
+ }
592
+
533
593
  /**
534
594
  * Check one `scalar-or-map` field written in its map form, entry by entry.
535
595
  *
@@ -578,7 +638,7 @@ function checkKeyedMap(note, { field, segments, entries, raw, packs }) {
578
638
  // An explicit `~` under a key is a statement, not an omission: it says
579
639
  // "at the root there", which is different from saying nothing.
580
640
  if (value === undefined || value === null) continue;
581
- if (matchesKind(value, "string")) continue;
641
+ if (field.entryKind || matchesKind(value, "string")) continue;
582
642
  findings.push({
583
643
  file: note.file,
584
644
  // On the key, not the value: an entry whose value is itself a map
@@ -1000,7 +1060,7 @@ function checkEmbeddedShortcodes(note, blockName) {
1000
1060
  * @param {object} opts
1001
1061
  * @param {Record<string, readonly object[]>} opts.schemas - Type → declaration.
1002
1062
  * @param {object} [opts.index] - The link index, for the reference check, which
1003
- * runs through its `referenceHit`. Its absence skips that check rather than
1063
+ * runs through its `shortcodeHit`. Its absence skips that check rather than
1004
1064
  * reporting every reference as dead.
1005
1065
  * @param {Record<string, object>} [opts.vocabulary] - Type → the closed regions
1006
1066
  * it declares, as `engine/note-vocabulary.mjs` states them. Supplied
@@ -1019,6 +1079,7 @@ function checkEmbeddedShortcodes(note, blockName) {
1019
1079
  * for the same reason `schemas` is — a build derives them from its
1020
1080
  * configuration through {@link systemBlocksFor}, and this module states no
1021
1081
  * system name of its own. See {@link DEFAULT_SYSTEM_BLOCKS} for the fallback.
1082
+ * @param {object} [opts.addressContext] - Builder package and Address type vocabulary.
1022
1083
  * @param {readonly string[]} [opts.packs] - The pack names this package
1023
1084
  * declares, for a `data:` field whose map is keyed by pack. Supplied by the
1024
1085
  * caller like `schemas` and `vocabulary`, and absent it no claim is made
@@ -1027,10 +1088,29 @@ function checkEmbeddedShortcodes(note, blockName) {
1027
1088
  */
1028
1089
  export function lintNote(
1029
1090
  note,
1030
- { schemas, index, vocabulary, packs, emittedArt, systems = DEFAULT_SYSTEM_BLOCKS },
1091
+ {
1092
+ schemas,
1093
+ index,
1094
+ vocabulary,
1095
+ packs,
1096
+ emittedArt,
1097
+ addressContext,
1098
+ systems = DEFAULT_SYSTEM_BLOCKS,
1099
+ },
1031
1100
  ) {
1032
1101
  const findings = [];
1033
1102
  const fm = note.fm ?? {};
1103
+ const referenceContext = {
1104
+ package: index?.contentPackage,
1105
+ system: "none",
1106
+ ...addressContext,
1107
+ types: new Set([
1108
+ ...Object.keys(vocabulary ?? {}),
1109
+ ...ASSET_TYPE_NAMES,
1110
+ ...(index?.types ?? []),
1111
+ ...(addressContext?.types ?? []),
1112
+ ]),
1113
+ };
1034
1114
  const type = String(fm.type ?? "");
1035
1115
  const raw = () => note.raw ?? "";
1036
1116
  const at = (key, literal) => positionInFrontmatter(raw(), key, literal ?? undefined);
@@ -1197,7 +1277,7 @@ export function lintNote(
1197
1277
  inData,
1198
1278
  blockCollides: blockCollisions.has(key),
1199
1279
  });
1200
- if (typeof authored !== "string") continue;
1280
+ if (typeof authored !== "string" && !isAddressTuple(authored)) continue;
1201
1281
  findings.push({
1202
1282
  file: note.file,
1203
1283
  ...at(key),
@@ -1225,7 +1305,7 @@ export function lintNote(
1225
1305
  inData,
1226
1306
  blockCollides: blockCollisions.has(key),
1227
1307
  });
1228
- if (typeof authored !== "string") continue;
1308
+ if (typeof authored !== "string" && !isAddressTuple(authored)) continue;
1229
1309
  const problem = pathnameProblem(authored);
1230
1310
  if (!problem) continue;
1231
1311
  findings.push({
@@ -1438,6 +1518,7 @@ export function lintNote(
1438
1518
  // the container check would have to be told about.
1439
1519
  fields,
1440
1520
  packs,
1521
+ addressContext: referenceContext,
1441
1522
  }),
1442
1523
  );
1443
1524
  findings.push(...checkSubType(note, { type, entry }));
@@ -1605,20 +1686,37 @@ export function lintNote(
1605
1686
  continue;
1606
1687
  }
1607
1688
 
1608
- // A reference names another note by shortcode, and the field supplies
1609
- // the type, so the resolver is handed the whole `type-shortcode` pair.
1610
- // It resolves in any reachable package: the value is persisted as
1611
- // written and looked up at runtime among one actor's embedded items,
1612
- // which come from every package the actor draws on.
1613
- if (field.ref && index && typeof value === "string" && value) {
1614
- const target = `${field.ref}-${value}`;
1615
- if (!index.referenceHit(target)) {
1689
+ // A `code:` field's value is a Shortcode, not an Address: one segment,
1690
+ // persisted verbatim and resolved at runtime among the items embedded
1691
+ // on one actor, where packages do not exist. The charset is checked
1692
+ // first, because a value carrying the separator cannot mean what this
1693
+ // position asks for, whatever the field supplies as the type — and the
1694
+ // message says so, rather than reporting a lookup failure for a value
1695
+ // that was never going to resolve. Once it passes, the field's type and
1696
+ // the value form the `(type, shortcode)` pair the resolver takes; it
1697
+ // resolves in any reachable package, because the value comes from
1698
+ // every package the actor draws on.
1699
+ const codeType = field.code;
1700
+ if (codeType && typeof value === "string" && value) {
1701
+ if (!isAddressSegment(value)) {
1702
+ findings.push({
1703
+ file: note.file,
1704
+ ...at(head, value),
1705
+ severity: "error",
1706
+ message:
1707
+ `${label} must be a shortcode — the ${codeType}'s ` +
1708
+ `\`shortcode\`, not an address — because this position ` +
1709
+ `resolves it at runtime among one actor's embedded ` +
1710
+ `items, where packages do not exist, but reads ` +
1711
+ `${JSON.stringify(value)}`,
1712
+ });
1713
+ } else if (index && !index.shortcodeHit(codeType, value)) {
1616
1714
  findings.push({
1617
1715
  file: note.file,
1618
1716
  ...at(head, value),
1619
1717
  severity: "error",
1620
1718
  message:
1621
- `${label} names ${field.ref} ` +
1719
+ `${label} names ${codeType} ` +
1622
1720
  `"${value}", and no note or fetched index declares it`,
1623
1721
  });
1624
1722
  }
@@ -1665,6 +1763,11 @@ export function lintFrontmatter(
1665
1763
  packs,
1666
1764
  emittedArt,
1667
1765
  index: references ? index : undefined,
1766
+ addressContext: {
1767
+ package: index.contentPackage,
1768
+ system: "none",
1769
+ types: index.types,
1770
+ },
1668
1771
  ...(systems ? { systems } : {}),
1669
1772
  }),
1670
1773
  );
@@ -30,6 +30,7 @@
30
30
  // build package rather than restated here, so the pipeline and the runtime
31
31
  // enum cannot drift apart — a value absent from the list is a build
32
32
  // error, never a silent ship.
33
+ import { AddressEntries } from "./address-values.mjs";
33
34
  import { AFFILIATION_STANDINGS } from "../sohl/affiliation-standings.mjs";
34
35
 
35
36
  /**
@@ -87,20 +88,48 @@ export function sohlField(fm, key, defaultValue = undefined) {
87
88
  * has to be told both. It is the same split `FieldSpec.name`/`legacyKey` makes,
88
89
  * for the same reason — one name cannot key two positions.
89
90
  *
91
+ * `shared` is the field's declared shared source, and it completes the order the
92
+ * three other positions are two thirds of. A field whose source is a path into
93
+ * `data:` is authored there by the specification's own mapping tables, and a
94
+ * reader that knew the destination, the block and the top level alone skipped
95
+ * straight past the container: every authored value read as unset, and the
96
+ * document shipped the field's default with nothing said. It sits **between**
97
+ * the block and the top level, exactly where
98
+ * {@link module:engine/system-block.resolveFieldValue} puts it — the block is the
99
+ * position a tree still writes and wins while it does, and the bare top-level
100
+ * key is the spelling `data:` gathered the fact off.
101
+ *
90
102
  * @param {object} fm - The note's frontmatter.
91
103
  * @param {string} to - The key at the destination, dotted for a nested one.
92
104
  * @param {any} [defaultValue] - What an unauthored field reads as.
93
105
  * @param {object} [options] - Options.
94
106
  * @param {string} [options.legacyKey] - The key the block still carries, when
95
107
  * it is not spelled `to`. Defaults to `to`.
108
+ * @param {string} [options.shared] - The field's declared shared source, when it
109
+ * is a path into a container (`data.relations`). Omitted by a field whose
110
+ * shared position is the note's top level, which `sohlField` already reads.
96
111
  * @returns {any} The value.
97
112
  */
98
- export function sohlSystemField(fm, to, defaultValue = undefined, { legacyKey = to } = {}) {
113
+ export function sohlSystemField(fm, to, defaultValue = undefined, { legacyKey = to, shared } = {}) {
99
114
  const system = fm?.sohl?.system;
100
115
  if (system && typeof system === "object" && !Array.isArray(system)) {
101
116
  const found = getFrontmatter(system, to, undefined);
102
117
  if (found !== undefined) return found;
103
118
  }
119
+ if (shared !== undefined) {
120
+ const block = fm?.sohl;
121
+ const inBlock =
122
+ block && typeof block === "object" ?
123
+ getFrontmatter(block, legacyKey, undefined)
124
+ : undefined;
125
+ // Consulted only where the block does not carry the key, so step 2 keeps
126
+ // winning; `sohlField` below then reads the block and the retiring
127
+ // top-level spelling exactly as it always has.
128
+ if (inBlock === undefined) {
129
+ const container = getFrontmatter(fm, shared, undefined);
130
+ if (container !== undefined) return container;
131
+ }
132
+ }
104
133
  return sohlField(fm, legacyKey, defaultValue);
105
134
  }
106
135
 
@@ -122,12 +151,16 @@ export function sohlSystemField(fm, to, defaultValue = undefined, { legacyKey =
122
151
  *
123
152
  * @param {object} fm - The item frontmatter.
124
153
  * @param {string} key - The property name, read via {@link sohlSystemField}.
154
+ * @param {object} [options] - Forwarded to {@link sohlSystemField}.
155
+ * @param {string} [options.shared] - The field's declared shared source, when it
156
+ * is a path into a container.
125
157
  * @returns {[string, unknown][] | null} The property's entries — empty when the
126
158
  * note authors none — or `null` when the value is not a map.
127
159
  */
128
- function readMapEntries(fm, key) {
129
- const raw = sohlSystemField(fm, key, undefined);
160
+ function readMapEntries(fm, key, { shared } = {}) {
161
+ const raw = sohlSystemField(fm, key, undefined, { shared });
130
162
  if (raw == null) return [];
163
+ if (raw instanceof AddressEntries) return raw.entries.map((e) => [e.target, e.value]);
131
164
  if (Array.isArray(raw)) return raw.length === 0 ? [] : null;
132
165
  if (typeof raw !== "object") return null;
133
166
  return Object.entries(raw);
@@ -155,11 +188,16 @@ export function resolveCharges(fm) {
155
188
  const num = Number(raw);
156
189
  return Number.isFinite(num) ? Math.trunc(num) : null;
157
190
  };
158
- const max = toCount(sohlSystemField(fm, "charges.max", null));
191
+ const max = toCount(sohlSystemField(fm, "charges.max", null, { shared: "data.charges.max" }));
159
192
  // A blank maximum means "does not use charges" — a stray current count
160
193
  // cannot outlive it, since the logic layer disables both modifiers.
161
194
  return {
162
- value: max === null ? null : toCount(sohlSystemField(fm, "charges.value", null)),
195
+ value:
196
+ max === null ? null : (
197
+ toCount(
198
+ sohlSystemField(fm, "charges.value", null, { shared: "data.charges.value" }),
199
+ )
200
+ ),
163
201
  max,
164
202
  };
165
203
  }
@@ -186,7 +224,7 @@ export function resolveCharges(fm) {
186
224
  * @throws {Error} When the value is not a map, or a value is not an integer.
187
225
  */
188
226
  export function resolveSkillAptitudes(fm, ctx = "item") {
189
- const entries = readMapEntries(fm, "skillAptitudes");
227
+ const entries = readMapEntries(fm, "skillAptitudes", { shared: "data.skillAptitudes" });
190
228
  if (entries === null) {
191
229
  throw new Error(`${ctx}: skillAptitudes must be a map of selector → number`);
192
230
  }
@@ -229,12 +267,18 @@ export function resolveRelation(fm, ctx = "item") {
229
267
  // Probed at the destination too: a note that has moved to
230
268
  // `sohl.system.relations` carries the current spelling, and a probe that
231
269
  // could not see it would fall through to the retired one and read `{}`.
232
- const key = sohlSystemField(fm, "relations", undefined) == null ? "relation" : "relations";
233
- const entries = readMapEntries(fm, key);
270
+ // Each spelling is probed at all four of its positions, the `data:` source
271
+ // the specification names included, so a retirement and a container move are
272
+ // independent of each other.
273
+ const current = sohlSystemField(fm, "relations", undefined, { shared: "data.relations" });
274
+ const key = current == null ? "relation" : "relations";
275
+ const entries = readMapEntries(fm, key, { shared: `data.${key}` });
234
276
  if (entries === null) {
235
277
  throw new Error(`${ctx}: ${key} must be a map of shortcode → standing`);
236
278
  }
279
+ const typed = entries.some(([code]) => typeof code === "object");
237
280
  const out = {};
281
+ const typedEntries = [];
238
282
  for (const [code, value] of entries) {
239
283
  const standing = String(value);
240
284
  if (!AFFILIATION_STANDINGS.includes(standing)) {
@@ -242,9 +286,10 @@ export function resolveRelation(fm, ctx = "item") {
242
286
  `${ctx}: ${key}["${code}"] must be one of ${AFFILIATION_STANDINGS.join(", ")}, got "${value}"`,
243
287
  );
244
288
  }
245
- out[code] = standing;
289
+ if (typed) typedEntries.push({ target: code, value: standing, sourceKey: "" });
290
+ else out[code] = standing;
246
291
  }
247
- return out;
292
+ return typed ? new AddressEntries(typedEntries) : out;
248
293
  }
249
294
 
250
295
  /**
@@ -39,6 +39,7 @@
39
39
  * HeroicLands vault is no longer a build input for SoHL content.
40
40
  */
41
41
 
42
+ import { noteAddressContext } from "./note-addresses.mjs";
42
43
  import fs from "fs";
43
44
  import path from "path";
44
45
  import log from "loglevel";
@@ -805,7 +806,12 @@ export async function generatePacksJson({ only, config = loadPackConfig() } = {}
805
806
  )
806
807
  .map((record) => {
807
808
  const absPath = noteFile(contentBase, record);
808
- return { frontmatter: parseMarkdownFile(absPath).frontmatter, absPath };
809
+ return {
810
+ frontmatter: parseMarkdownFile(absPath, {
811
+ addressContext: noteAddressContext(config),
812
+ }).frontmatter,
813
+ absPath,
814
+ };
809
815
  }),
810
816
  // The package this build resolved, not the ambient accessor:
811
817
  // they are the same value in a real repository and different
@@ -25,6 +25,12 @@
25
25
  * journals.mjs, actors.mjs).
26
26
  */
27
27
 
28
+ import { buildReferenceTargets } from "./reference-targets.mjs";
29
+
30
+ import { decodeNoteAddresses } from "./note-addresses.mjs";
31
+ import { positionOfYamlPath } from "./diagnostics.mjs";
32
+ import { completeAddress, renderAddress } from "./address.mjs";
33
+ import { encodeAddresses } from "./address-values.mjs";
28
34
  import fs from "fs";
29
35
  import crypto from "crypto";
30
36
  import path from "path";
@@ -131,7 +137,7 @@ export const md = markdownit({ html: true })
131
137
  * `{ frontmatter: null, body: "", description: "" }` with a warn log, and no
132
138
  * position: there is no body to have one.
133
139
  */
134
- export function parseMarkdownFile(filePath) {
140
+ export function parseMarkdownFile(filePath, { addressContext } = {}) {
135
141
  const content = fs.readFileSync(filePath, "utf8");
136
142
  const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
137
143
  if (!fmMatch) {
@@ -144,6 +150,19 @@ export function parseMarkdownFile(filePath) {
144
150
  log.warn(`YAML parse error in ${filePath}: ${err.message}`);
145
151
  return { frontmatter: null, body: "", description: "" };
146
152
  }
153
+ if (addressContext) {
154
+ try {
155
+ decodeNoteAddresses(frontmatter, addressContext);
156
+ } catch (error) {
157
+ const position = positionOfYamlPath(fmMatch[1], error.keyPath ?? [], {
158
+ key: error.addressKey,
159
+ });
160
+ if (position.line) position.line++;
161
+ error.file = filePath;
162
+ error.position = position;
163
+ throw error;
164
+ }
165
+ }
147
166
  const raw = fmMatch[2];
148
167
  const body = raw.trim();
149
168
  const description = body ? md.render(body) : "";
@@ -238,7 +257,7 @@ export function assertSuppliedCorpus(records, who) {
238
257
  * @throws {Error} When `skipDirectories` is not stated — see
239
258
  * {@link assertStatedScope}.
240
259
  */
241
- export function* walkMarkdownTree(rootDir, { skipDirectories } = {}) {
260
+ export function* walkMarkdownTree(rootDir, { skipDirectories, addressContext } = {}) {
242
261
  // Stated by the caller, never resolved here. A default here
243
262
  // — `loadPackConfig().skipDirectories` — read whichever configuration
244
263
  // resolved from the working directory rather than the one the caller was
@@ -269,7 +288,7 @@ export function* walkMarkdownTree(rootDir, { skipDirectories } = {}) {
269
288
  stack.push(absPath);
270
289
  } else if (entry.isFile() && entry.name.endsWith(".md")) {
271
290
  yield {
272
- ...parseMarkdownFile(absPath),
291
+ ...parseMarkdownFile(absPath, { addressContext }),
273
292
  file: entry.name,
274
293
  absPath,
275
294
  };
@@ -702,12 +721,16 @@ export function buildContentLinkIndex(
702
721
  const assets = new Map();
703
722
  const resolved = config ?? loadPackConfig();
704
723
  assertSuppliedCorpus(records, "buildContentLinkIndex");
724
+ const recordsByAddress = new Map(
725
+ records.map((record) => [encodeAddresses(record.address?.canonical), record]),
726
+ );
705
727
  for (const record of records) {
706
728
  // An asset's record addresses a file rather than a note, so it becomes
707
729
  // no `doc` and takes no part in link resolution — it is keyed for the
708
730
  // art fields, which name a file and never a document.
709
731
  if (isAssetRecord(record)) {
710
- if (record.address?.canonical) assets.set(record.address.canonical, record);
732
+ if (record.address?.canonical)
733
+ assets.set(encodeAddresses(record.address.canonical), record);
711
734
  continue;
712
735
  }
713
736
  // A documentation journal is a document this tree emits, not a note in
@@ -734,7 +757,9 @@ export function buildContentLinkIndex(
734
757
  // several frames deeper with nothing to go on.
735
758
  assertTypeNotRetired(fm.type, absPath);
736
759
  const base = String(record.file.name).replace(/_/g, " ");
760
+ const documentation = recordsByAddress.get(encodeAddresses(record.documentation));
737
761
  docs.push({
762
+ documentationUuid: documentation?.foundry?.none?.uuid ?? null,
738
763
  type: fm.type,
739
764
  id: fm.id,
740
765
  // Where this note's own document lands, and where the JournalEntry
@@ -770,11 +795,11 @@ export function buildContentLinkIndex(
770
795
  // a contributor without every repository checked out resolves the same
771
796
  // links CI does — from an artifact the producer shipped rather than a copy
772
797
  // this repository committed.
773
- const { index: foreign, stale } = loadForeignIndexes(
774
- resolved,
775
- [resolved.contentPackage],
776
- PACKAGE_BASE,
777
- );
798
+ const {
799
+ index: foreign,
800
+ references: foreignReferences,
801
+ stale,
802
+ } = loadForeignIndexes(resolved, [resolved.contentPackage], PACKAGE_BASE);
778
803
  if (stale.length) {
779
804
  for (const st of stale) {
780
805
  log.error(`Unusable content index for "${st.package}": ${st.reason}`);
@@ -788,7 +813,10 @@ export function buildContentLinkIndex(
788
813
  `Wikilink index: ${docs.length} local document(s), ` +
789
814
  `${foreign.size} foreign address(es)`,
790
815
  );
816
+ const referenceTargets = buildReferenceTargets(records);
791
817
  return buildWikilinkIndex(docs, resolved.foundryPackage, foreign, resolved.contentPackage, {
818
+ referenceTargets,
819
+ foreignReferences,
792
820
  assets,
793
821
  noIndexPackages: noContentIndexPackages(resolved),
794
822
  });