@heroiclands/package-build 17.1.0 → 18.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 (153) hide show
  1. package/CHANGELOG.md +1773 -0
  2. package/CONTENT.md +288 -72
  3. package/README.md +1 -1
  4. package/bin/content-build.mjs +218 -137
  5. package/bin/package-build.mjs +43 -0
  6. package/content-config.mjs +62 -67
  7. package/docs/content-format.md +768 -58
  8. package/engine/actor-compiler.mjs +586 -0
  9. package/engine/address-charset.mjs +11 -8
  10. package/engine/address-diff.mjs +266 -22
  11. package/engine/anchored-sections.mjs +83 -0
  12. package/engine/anchors.mjs +83 -0
  13. package/engine/base-compiler.mjs +148 -18
  14. package/engine/bundle-notes.mjs +276 -0
  15. package/engine/bundles.mjs +307 -0
  16. package/engine/code-fences.mjs +103 -0
  17. package/engine/compile-corpus.mjs +89 -0
  18. package/engine/content-address.mjs +267 -5
  19. package/engine/content-format-check.mjs +54 -11
  20. package/engine/content-format.mjs +37 -5
  21. package/engine/content-index.mjs +394 -111
  22. package/engine/content-links.mjs +185 -77
  23. package/engine/content-lint.mjs +232 -14
  24. package/engine/content-package.mjs +2 -1
  25. package/engine/content-tables.mjs +99 -3
  26. package/engine/document-subtypes.mjs +49 -6
  27. package/engine/field-reference.mjs +1 -1
  28. package/engine/field-spec.mjs +56 -6
  29. package/engine/folder-notes.mjs +470 -0
  30. package/engine/foreign-catalog.mjs +195 -4
  31. package/engine/{manifest-emit.mjs → foundry-entries.mjs} +78 -81
  32. package/engine/frontmatter-lint.mjs +245 -19
  33. package/engine/frontmatter.mjs +88 -8
  34. package/engine/generate.mjs +304 -13
  35. package/engine/helpers.mjs +293 -64
  36. package/engine/ids.mjs +137 -2
  37. package/engine/index-records.mjs +126 -0
  38. package/engine/index.mjs +23 -5
  39. package/engine/item-compiler.mjs +349 -0
  40. package/engine/item-docs.mjs +7 -1
  41. package/engine/item-registry.mjs +6 -0
  42. package/engine/journals.mjs +93 -27
  43. package/engine/macros.mjs +4 -2
  44. package/engine/metadata-index.mjs +495 -0
  45. package/engine/note-claims.mjs +146 -25
  46. package/engine/note-ids.mjs +112 -0
  47. package/engine/note-renames.mjs +134 -0
  48. package/engine/note-schemas.mjs +25 -0
  49. package/engine/note-vocabulary.mjs +110 -27
  50. package/engine/pack-config.mjs +4 -0
  51. package/engine/pack-router.mjs +23 -0
  52. package/engine/retired-fields.mjs +137 -3
  53. package/engine/scenes.mjs +66 -34
  54. package/engine/schema-check.mjs +58 -17
  55. package/engine/site-build.mjs +71 -12
  56. package/engine/site-index.mjs +20 -7
  57. package/engine/sql-tables.mjs +485 -0
  58. package/engine/subtype-registry.mjs +102 -0
  59. package/engine/system-block.mjs +96 -12
  60. package/engine/systems.mjs +201 -0
  61. package/engine/web-wikilinks.mjs +50 -20
  62. package/engine/wikilink-syntax.mjs +17 -8
  63. package/engine/wikilinks.mjs +124 -51
  64. package/engine/yaml-lint.mjs +282 -0
  65. package/hm3/actors.mjs +352 -0
  66. package/hm3/default-item-art.mjs +75 -0
  67. package/hm3/document-subtypes.mjs +134 -0
  68. package/hm3/index.mjs +56 -0
  69. package/hm3/item-builders.mjs +84 -0
  70. package/hm3/item-fields.mjs +180 -0
  71. package/hm3/items.mjs +96 -0
  72. package/hm3/template-priority.mjs +85 -0
  73. package/labels.mjs +191 -0
  74. package/manifest.mjs +43 -2
  75. package/package.json +19 -4
  76. package/release.mjs +62 -7
  77. package/sohl/actors.mjs +33 -487
  78. package/sohl/being-info.mjs +16 -7
  79. package/sohl/default-item-art.mjs +14 -3
  80. package/sohl/document-subtypes.mjs +16 -10
  81. package/sohl/item-builders.mjs +14 -5
  82. package/sohl/item-fields.mjs +68 -7
  83. package/sohl/items.mjs +44 -258
  84. package/sohl/note-schemas.mjs +2 -2
  85. package/types/content-config.d.mts +21 -42
  86. package/types/engine/actor-compiler.d.mts +204 -0
  87. package/types/engine/address-charset.d.mts +11 -8
  88. package/types/engine/address-diff.d.mts +53 -5
  89. package/types/engine/anchored-sections.d.mts +21 -0
  90. package/types/engine/anchors.d.mts +20 -0
  91. package/types/engine/base-compiler.d.mts +17 -17
  92. package/types/engine/bundle-notes.d.mts +173 -0
  93. package/types/engine/bundles.d.mts +60 -0
  94. package/types/engine/code-fences.d.mts +43 -0
  95. package/types/engine/compile-corpus.d.mts +32 -0
  96. package/types/engine/content-address.d.mts +205 -5
  97. package/types/engine/content-format-check.d.mts +6 -2
  98. package/types/engine/content-format.d.mts +57 -1
  99. package/types/engine/content-index.d.mts +64 -80
  100. package/types/engine/content-links.d.mts +52 -7
  101. package/types/engine/content-lint.d.mts +10 -1
  102. package/types/engine/content-package.d.mts +2 -1
  103. package/types/engine/content-tables.d.mts +21 -39
  104. package/types/engine/document-subtypes.d.mts +37 -3
  105. package/types/engine/field-spec.d.mts +76 -5
  106. package/types/engine/folder-notes.d.mts +159 -0
  107. package/types/engine/foreign-catalog.d.mts +53 -0
  108. package/types/engine/{manifest-emit.d.mts → foundry-entries.d.mts} +16 -38
  109. package/types/engine/frontmatter-lint.d.mts +10 -2
  110. package/types/engine/frontmatter.d.mts +64 -0
  111. package/types/engine/generate.d.mts +38 -0
  112. package/types/engine/helpers.d.mts +94 -30
  113. package/types/engine/ids.d.mts +96 -0
  114. package/types/engine/index-records.d.mts +68 -0
  115. package/types/engine/index.d.mts +9 -3
  116. package/types/engine/item-compiler.d.mts +131 -0
  117. package/types/engine/journals.d.mts +47 -9
  118. package/types/engine/metadata-index.d.mts +226 -0
  119. package/types/engine/note-claims.d.mts +57 -11
  120. package/types/engine/note-ids.d.mts +38 -0
  121. package/types/engine/note-renames.d.mts +102 -0
  122. package/types/engine/note-vocabulary.d.mts +44 -8
  123. package/types/engine/retired-fields.d.mts +75 -0
  124. package/types/engine/scenes.d.mts +3 -2
  125. package/types/engine/schema-check.d.mts +25 -4
  126. package/types/engine/site-build.d.mts +4 -4
  127. package/types/engine/site-index.d.mts +1 -1
  128. package/types/engine/sql-tables.d.mts +185 -0
  129. package/types/engine/subtype-registry.d.mts +49 -0
  130. package/types/engine/system-block.d.mts +40 -1
  131. package/types/engine/systems.d.mts +106 -0
  132. package/types/engine/web-wikilinks.d.mts +4 -2
  133. package/types/engine/wikilink-syntax.d.mts +10 -3
  134. package/types/engine/wikilinks.d.mts +41 -13
  135. package/types/engine/yaml-lint.d.mts +107 -0
  136. package/types/hm3/actors.d.mts +48 -0
  137. package/types/hm3/default-item-art.d.mts +42 -0
  138. package/types/hm3/document-subtypes.d.mts +24 -0
  139. package/types/hm3/index.d.mts +7 -0
  140. package/types/hm3/item-builders.d.mts +11 -0
  141. package/types/hm3/item-fields.d.mts +12 -0
  142. package/types/hm3/items.d.mts +23 -0
  143. package/types/hm3/template-priority.d.mts +21 -0
  144. package/types/manifest.d.mts +22 -1
  145. package/types/release.d.mts +9 -4
  146. package/types/sohl/actors.d.mts +7 -74
  147. package/types/sohl/being-info.d.mts +8 -27
  148. package/types/sohl/default-item-art.d.mts +5 -3
  149. package/types/sohl/items.d.mts +17 -32
  150. package/engine/foreign-manifests.mjs +0 -126
  151. package/engine/kb-manifest.mjs +0 -490
  152. package/types/engine/foreign-manifests.d.mts +0 -43
  153. package/types/engine/kb-manifest.d.mts +0 -241
@@ -55,8 +55,9 @@
55
55
  * @module
56
56
  */
57
57
 
58
- import { authoredFields } from "./field-spec.mjs";
58
+ import { authoredFields, readsLegacyKey } from "./field-spec.mjs";
59
59
  import {
60
+ legacyKeyOf,
60
61
  resolveFieldValue,
61
62
  systemBlock,
62
63
  SYSTEM_BLOCK_KEYS,
@@ -64,8 +65,12 @@ import {
64
65
  } from "./system-block.mjs";
65
66
  import { positionInFrontmatter, positionOfFrontmatterPath } from "./diagnostics.mjs";
66
67
  import { checkHomepageAddressFields } from "./homepage.mjs";
67
- import { RETIRED_TYPES } from "./ids.mjs";
68
+ import { RETIRED_TYPES, RENAMED_TYPES, currentType, renamedTypeMessage } from "./ids.mjs";
68
69
  import { isAddressSegment } from "./address-charset.mjs";
70
+ // The one place the "every pack not named" key is spelled. Imported rather
71
+ // than repeated, because a linter holding its own copy of what the compiler
72
+ // reads is exactly the disagreement #288 was.
73
+ import { DEFAULT_PARENT } from "./folder-notes.mjs";
69
74
  import { declaredTags, subTypeCharsetMessage, typeCharsetMessage } from "./note-vocabulary.mjs";
70
75
  import {
71
76
  RETIRED_FIELD_ALIASES,
@@ -73,9 +78,11 @@ import {
73
78
  aliasesRetiredMessage,
74
79
  declaresRetiredAliasesField,
75
80
  draftRetiredMessage,
81
+ legacyKeyMessage,
76
82
  readAliasedField,
77
83
  retiredAliasMessage,
78
84
  sectionRetiredMessage,
85
+ traitsRetiredMessage,
79
86
  } from "./retired-fields.mjs";
80
87
 
81
88
  /**
@@ -106,7 +113,9 @@ import {
106
113
  *
107
114
  * @type {ReadonlySet<string>}
108
115
  */
109
- export const UNIVERSAL_KEYS = Object.freeze(new Set(["folder", "pack", "archetype", "kbcat"]));
116
+ export const UNIVERSAL_KEYS = Object.freeze(
117
+ new Set(["folder", "packFolder", "pack", "archetype", "templatePriority", "kbcat"]),
118
+ );
110
119
 
111
120
  /**
112
121
  * The system blocks a build checks, and what each accepts beyond the shared
@@ -215,11 +224,33 @@ export function matchesKind(value, kind) {
215
224
  (typeof value === "object" && value !== null && !Array.isArray(value)) ||
216
225
  (Array.isArray(value) && value.length === 0)
217
226
  );
227
+ case "scalar-or-map":
228
+ // A scalar, or a map of them. The map's *entries* are checked
229
+ // separately, by the caller that can name the key at fault; all
230
+ // this answers is whether the value has one of the two shapes the
231
+ // field admits. A list has neither.
232
+ return matchesKind(value, "string") || matchesKind(value, "map");
218
233
  default:
219
234
  return true;
220
235
  }
221
236
  }
222
237
 
238
+ /**
239
+ * The entries of a `scalar-or-map` value written in its map form, or `null`
240
+ * where it was written as the scalar.
241
+ *
242
+ * The empty-list spelling of an emptied map ({@link matchesKind}) has no
243
+ * entries, so it reads the same as `{}` here too.
244
+ *
245
+ * @param {unknown} value - The authored value.
246
+ * @returns {Record<string, unknown>|null} Its entries, or `null` for a scalar.
247
+ */
248
+ function mapEntries(value) {
249
+ if (Array.isArray(value)) return value.length === 0 ? {} : null;
250
+ if (typeof value !== "object" || value === null) return null;
251
+ return /** @type {Record<string, unknown>} */ (value);
252
+ }
253
+
223
254
  /**
224
255
  * The `data:` container a note authored.
225
256
  *
@@ -260,9 +291,14 @@ function dataBlock(fm) {
260
291
  * @param {object} opts
261
292
  * @param {string} opts.type - The note's type, for the message.
262
293
  * @param {readonly object[]} opts.fields - The type's `data:` declaration.
294
+ * @param {readonly string[]} [opts.packs] - The pack names this package
295
+ * declares, against which a `keys: "pack"` map's keys are checked. Absent,
296
+ * no claim is made about them: a caller that cannot see the configuration
297
+ * knows no pack names, and reporting every key as unknown because nothing
298
+ * was loaded to recognise it would be worse than not checking.
263
299
  * @returns {object[]} Findings.
264
300
  */
265
- function checkDataContainer(note, { type, fields }) {
301
+ function checkDataContainer(note, { type, fields, packs }) {
266
302
  const findings = [];
267
303
  const { present, entries, malformed } = dataBlock(note.fm ?? {});
268
304
  if (!present) return findings;
@@ -306,13 +342,91 @@ function checkDataContainer(note, { type, fields }) {
306
342
  value = value && typeof value === "object" ? value[segment] : undefined;
307
343
  }
308
344
  if (value === undefined || value === null) continue;
309
- if (matchesKind(value, field.kind)) continue;
345
+ if (!matchesKind(value, field.kind)) {
346
+ findings.push({
347
+ file: note.file,
348
+ ...positionOfFrontmatterPath(raw, ["data", ...segments]),
349
+ severity: "error",
350
+ message:
351
+ `\`data.${field.name}\` should be ${field.shape ?? field.kind}, ` +
352
+ `but reads ${JSON.stringify(value)}`,
353
+ });
354
+ continue;
355
+ }
356
+ // A `scalar-or-map` written in its map form is checked entry by entry,
357
+ // because that is the correction an author has to make: one key's
358
+ // value, not the whole map. Quoting the map back would name every
359
+ // entry that is right alongside the one that is not.
360
+ const written = field.kind === "scalar-or-map" ? mapEntries(value) : null;
361
+ if (written) {
362
+ findings.push(
363
+ ...checkKeyedMap(note, { field, segments, entries: written, raw, packs }),
364
+ );
365
+ }
366
+ }
367
+
368
+ return findings;
369
+ }
370
+
371
+ /**
372
+ * Check one `scalar-or-map` field written in its map form, entry by entry.
373
+ *
374
+ * Two separate statements are checked, and they fail independently: whether a
375
+ * key names something — a pack, for `keys: "pack"` — and whether the value
376
+ * under it has the shape one entry is declared to have. A key nobody declares
377
+ * is not a harmless surplus: the compiler asks the map for the pack it is
378
+ * compiling and takes `default` when there is no such key, so a mistyped
379
+ * `journal:` silently files the folder wherever the default puts it, which is
380
+ * exactly the hierarchy the author wrote the key to override.
381
+ *
382
+ * @param {object} note - The note.
383
+ * @param {object} opts
384
+ * @param {object} opts.field - The field's declaration.
385
+ * @param {readonly string[]} opts.segments - Its path under `data:`.
386
+ * @param {Record<string, unknown>} opts.entries - The map's entries.
387
+ * @param {string} opts.raw - The note's raw text, for positions.
388
+ * @param {readonly string[]} [opts.packs] - The declared pack names, if known.
389
+ * @returns {object[]} Findings, one per offending entry.
390
+ */
391
+ function checkKeyedMap(note, { field, segments, entries, raw, packs }) {
392
+ const findings = [];
393
+ const known = field.keys === "pack" && packs?.length ? new Set(packs) : undefined;
394
+
395
+ for (const [key, value] of Object.entries(entries)) {
396
+ const path = ["data", ...segments, key];
397
+ const named = `data.${field.name}.${key}`;
398
+
399
+ // `default` is the map's own key for "every pack not named", not a
400
+ // pack — spelled out rather than left as an absent key, so a map
401
+ // stating only exceptions still reads as a complete answer (#276).
402
+ if (known && key !== DEFAULT_PARENT && !known.has(key)) {
403
+ const guess = nearest(key, known);
404
+ findings.push({
405
+ file: note.file,
406
+ ...positionOfFrontmatterPath(raw, path, { key: true }),
407
+ severity: "error",
408
+ message:
409
+ `"${key}" is not a pack this package declares, so ` +
410
+ `\`${named}\` states a hierarchy nothing reads` +
411
+ (guess ? `. Did you mean "${guess}"?` : ""),
412
+ });
413
+ continue;
414
+ }
415
+
416
+ // An explicit `~` under a key is a statement, not an omission: it says
417
+ // "at the root there", which is different from saying nothing.
418
+ if (value === undefined || value === null) continue;
419
+ if (matchesKind(value, "string")) continue;
310
420
  findings.push({
311
421
  file: note.file,
312
- ...positionOfFrontmatterPath(raw, ["data", ...segments]),
422
+ // On the key, not the value: an entry whose value is itself a map
423
+ // begins on the *next* line, so pointing at the value lands a
424
+ // reader inside the thing that is wrong rather than on the entry
425
+ // the message names.
426
+ ...positionOfFrontmatterPath(raw, path, { key: true }),
313
427
  severity: "error",
314
428
  message:
315
- `\`data.${field.name}\` should be ${field.shape ?? field.kind}, ` +
429
+ `\`${named}\` should be ${field.entryShape ?? "a scalar"}, ` +
316
430
  `but reads ${JSON.stringify(value)}`,
317
431
  });
318
432
  }
@@ -554,7 +668,7 @@ function checkEmbeddedShortcodes(note, blockName) {
554
668
  : entry.shortcode;
555
669
  if (!type || !key) return;
556
670
 
557
- const address = `${type}${key}`;
671
+ const address = `${type}/${key}`;
558
672
  const first = claimed.get(address);
559
673
  if (first === undefined) {
560
674
  claimed.set(address, index);
@@ -598,9 +712,16 @@ function checkEmbeddedShortcodes(note, blockName) {
598
712
  * @param {Readonly<Record<string, {known?: readonly string[], fieldVocabulary?: boolean}>>} [opts.systems]
599
713
  * The system blocks to check, and what each accepts. See
600
714
  * {@link DEFAULT_SYSTEM_BLOCKS}.
715
+ * @param {readonly string[]} [opts.packs] - The pack names this package
716
+ * declares, for a `data:` field whose map is keyed by pack. Supplied by the
717
+ * caller like `schemas` and `vocabulary`, and absent it no claim is made
718
+ * about those keys.
601
719
  * @returns {object[]} Findings, each with a locator where one is obtainable.
602
720
  */
603
- export function lintNote(note, { schemas, index, vocabulary, systems = DEFAULT_SYSTEM_BLOCKS }) {
721
+ export function lintNote(
722
+ note,
723
+ { schemas, index, vocabulary, packs, systems = DEFAULT_SYSTEM_BLOCKS },
724
+ ) {
604
725
  const findings = [];
605
726
  const fm = note.fm ?? {};
606
727
  const type = String(fm.type ?? "");
@@ -657,6 +778,29 @@ export function lintNote(note, { schemas, index, vocabulary, systems = DEFAULT_S
657
778
  // harmless. Whether `title: ""` deserves a warning of its own is a separate
658
779
  // question about the *page's* heading, still open on #218, and not settled
659
780
  // by extending an art-path check to it.
781
+ // The template priority is a *shared source* — the specification states it
782
+ // once for every type, as it does `pack` — so its retirement is reported
783
+ // here rather than by the per-type loop below, which only reaches a field
784
+ // some type's vocabulary declares (#266).
785
+ if (declaresRetiredAlias(fm, "templatePriority")) {
786
+ findings.push({
787
+ file: note.file,
788
+ ...at(RETIRED_FIELD_ALIASES.templatePriority),
789
+ // An error, unlike the other retired alias. `archetype` is not
790
+ // a field of its own — it is `templatePriority` under its prior
791
+ // name, and both sit one letter from `archetypes`, which means
792
+ // something else entirely. A tree still on it is one where a
793
+ // priority and a taxonomy are told apart by a plural `s`, which
794
+ // is worth stopping rather than mentioning. This reds every
795
+ // tree until each is swept; that is the point.
796
+ severity: "error",
797
+ message: retiredAliasMessage(
798
+ RETIRED_FIELD_ALIASES.templatePriority,
799
+ "templatePriority",
800
+ ),
801
+ });
802
+ }
803
+
660
804
  for (const key of ART_FIELDS) {
661
805
  if (authoredValue(fm, key) !== "") continue;
662
806
  findings.push({
@@ -670,6 +814,29 @@ export function lintNote(note, { schemas, index, vocabulary, systems = DEFAULT_S
670
814
  "meant to have no image",
671
815
  });
672
816
  }
817
+ // `title: ""` publishes a blank heading (#218). The rule the art fields
818
+ // follow — `null` falls back, `""` is blank on purpose — reads the same way
819
+ // here, and for a *page heading* the deliberate blank is almost never what
820
+ // anyone wants: the emitter is `fm.title ?? name`, so `""` survives, the
821
+ // page publishes with no name, and it sorts to the front of its section
822
+ // landing ahead of every named page. Fifteen notes in `sohl-thalorna` are
823
+ // in exactly that state.
824
+ //
825
+ // A warning rather than an error: the value is legal under the rule, and a
826
+ // note that genuinely wants no heading may keep it — it just has to mean it.
827
+ if (authoredValue(fm, "title") === "") {
828
+ findings.push({
829
+ file: note.file,
830
+ ...at("title"),
831
+ severity: "warning",
832
+ message:
833
+ '`title: ""` publishes a page with no heading, which sorts to ' +
834
+ "the front of its section ahead of every named page. Write " +
835
+ "`title: null` to fall back to `name.full`, or give the page a " +
836
+ 'heading; keep `""` only where the blank is meant',
837
+ });
838
+ }
839
+
673
840
  if (Object.hasOwn(fm, "draft")) {
674
841
  findings.push({
675
842
  file: note.file,
@@ -689,6 +856,18 @@ export function lintNote(note, { schemas, index, vocabulary, systems = DEFAULT_S
689
856
  message: sectionRetiredMessage(),
690
857
  });
691
858
  }
859
+ // Anchored at column 1 for the same reason `section` is, and with more at
860
+ // stake: `sohl.traits` is a *different field that shares the name* —
861
+ // `projectilegear` declares one and the theme's gear sidebar reads it — so
862
+ // a finding about the retired top-level block must never open on it (#291).
863
+ if (Object.hasOwn(fm, "traits")) {
864
+ findings.push({
865
+ file: note.file,
866
+ ...positionInFrontmatter(raw(), "traits", undefined, { topLevel: true }),
867
+ severity: "error",
868
+ message: traitsRetiredMessage(),
869
+ });
870
+ }
692
871
  // Only the top-level `aliases` is retired. `name.aliases` writes the same
693
872
  // key indented under `name:` and is **permitted** — reserved and unread —
694
873
  // so both the test and the locator are anchored at column 1 (#180).
@@ -753,7 +932,27 @@ export function lintNote(note, { schemas, index, vocabulary, systems = DEFAULT_S
753
932
  return findings;
754
933
  }
755
934
 
756
- const schema = schemas[type];
935
+ // A **renamed** type is the opposite case, and the opposite answer: the
936
+ // note compiles into exactly the document it always did, so refusing it
937
+ // would fail a build over a note that is not wrong. It is reported, and
938
+ // every lookup below reads the current spelling (#78).
939
+ const renamedTo = RENAMED_TYPES[type];
940
+ if (renamedTo) {
941
+ findings.push({
942
+ file: note.file,
943
+ ...at("type", type),
944
+ // A warning, for the reason the retired *field* alias below is one:
945
+ // the sweep is the content trees' work and the refusal comes after
946
+ // it, as `package:`'s did (#56).
947
+ severity: "warning",
948
+ message: renamedTypeMessage(type, renamedTo),
949
+ });
950
+ }
951
+ // What every type-keyed table is keyed by. The authored spelling is still
952
+ // what a message quotes — it is what the reader has in front of them.
953
+ const current = currentType(type);
954
+
955
+ const schema = schemas[current];
757
956
  if (!schema) {
758
957
  findings.push({
759
958
  file: note.file,
@@ -771,15 +970,22 @@ export function lintNote(note, { schemas, index, vocabulary, systems = DEFAULT_S
771
970
  // one this type may write. Skipped entirely when the caller declares no
772
971
  // vocabulary — reporting every key as unknown because nothing was loaded
773
972
  // to recognise it would be worse than not checking.
774
- const entry = vocabulary?.[type];
973
+ const entry = vocabulary?.[current];
775
974
  if (entry) {
776
- findings.push(...checkDataContainer(note, { type, fields: entry.data ?? [] }));
975
+ findings.push(...checkDataContainer(note, { type, fields: entry.data ?? [], packs }));
777
976
  findings.push(...checkSubType(note, { type, entry }));
778
977
  }
779
978
 
780
979
  const fields = authoredFields(schema);
781
- /** First segment of each declared name — `impact.die` is authored as `impact`. */
782
- const declared = new Set(fields.map((f) => f.name.split(".")[0]));
980
+ /**
981
+ * First segment of the key each field is authored at **inside the block** —
982
+ * `impact.die` is authored as `impact`, and a field whose shared source
983
+ * moved under `data:` is authored at the `legacyKey` it declares rather
984
+ * than at its dotted name (#305). Keying this on the name would report
985
+ * `sohl.species` as a property no `being` has, against exactly the notes
986
+ * the sweep has not reached yet.
987
+ */
988
+ const declared = new Set(fields.map((f) => legacyKeyOf(f).split(".")[0]));
783
989
 
784
990
  // The retired spelling of a field this type declares → what to write now.
785
991
  // Built from the type's own vocabulary, so a renamed field is retired
@@ -865,11 +1071,28 @@ export function lintNote(note, { schemas, index, vocabulary, systems = DEFAULT_S
865
1071
  from = "block";
866
1072
  }
867
1073
  }
1074
+ // The sweep's progress signal (#305). A **warning**, for the reason a
1075
+ // retired spelling is one: the note compiles to the correct document,
1076
+ // so failing a build over it would red a tree that has done nothing
1077
+ // wrong yet. The refusal comes once no tree writes the position.
1078
+ if (readsLegacyKey(field, from)) {
1079
+ findings.push({
1080
+ file: note.file,
1081
+ ...at(legacyKeyOf(field)),
1082
+ severity: "warning",
1083
+ message: legacyKeyMessage("sohl", field),
1084
+ });
1085
+ }
868
1086
  const absent = from === "default" || value === undefined || value === null;
869
1087
  // Where the field belongs, as a message names it: a shared field is not
870
1088
  // under `sohl:`, so telling an author to write `sohl.img` would send
871
- // them to the wrong region.
872
- const label = field.shared ? `\`${field.name}\`` : `\`sohl.${field.name}\``;
1089
+ // them to the wrong region. Nor is a field whose shared source is a
1090
+ // path into `data:` `sohl.data.species` is a region that does not
1091
+ // exist, and the home of that field is the container it names (#305).
1092
+ const label =
1093
+ field.shared || (field.name.includes(".") && field.legacyKey !== undefined) ?
1094
+ `\`${field.name}\``
1095
+ : `\`sohl.${field.name}\``;
873
1096
 
874
1097
  if (field.required && absent) {
875
1098
  findings.push({
@@ -896,7 +1119,7 @@ export function lintNote(note, { schemas, index, vocabulary, systems = DEFAULT_S
896
1119
 
897
1120
  // A reference names another note by shortcode. Resolved through the
898
1121
  // link index's own resolver, so a cross-package reference answered by a
899
- // vendored manifest lands exactly as the same address in a wikilink
1122
+ // fetched index lands exactly as the same address in a wikilink
900
1123
  // would — rather than through a second, subtly different rule.
901
1124
  //
902
1125
  // **As an address, always** — which is now the only namespace there
@@ -914,7 +1137,7 @@ export function lintNote(note, { schemas, index, vocabulary, systems = DEFAULT_S
914
1137
  severity: "error",
915
1138
  message:
916
1139
  `${label} names ${field.ref} ` +
917
- `"${value}", and no note or vendored manifest declares it`,
1140
+ `"${value}", and no note or fetched index declares it`,
918
1141
  });
919
1142
  }
920
1143
  }
@@ -934,10 +1157,12 @@ export function lintNote(note, { schemas, index, vocabulary, systems = DEFAULT_S
934
1157
  * @param {boolean} [opts.references=true] - Whether to check references.
935
1158
  * @param {Readonly<Record<string, {known?: readonly string[], fieldVocabulary?: boolean}>>} [opts.systems]
936
1159
  * The system blocks to check. See {@link DEFAULT_SYSTEM_BLOCKS}.
1160
+ * @param {readonly string[]} [opts.packs] - The declared pack names; see
1161
+ * {@link lintNote}.
937
1162
  * @returns {{findings: object[], notes: number}} The findings, and how many
938
1163
  * notes were inspected.
939
1164
  */
940
- export function lintFrontmatter(index, { schemas, vocabulary, references = true, systems }) {
1165
+ export function lintFrontmatter(index, { schemas, vocabulary, packs, references = true, systems }) {
941
1166
  const findings = [];
942
1167
  const notes = [...index.notes].sort((a, b) =>
943
1168
  a.file < b.file ? -1
@@ -949,6 +1174,7 @@ export function lintFrontmatter(index, { schemas, vocabulary, references = true,
949
1174
  ...lintNote(note, {
950
1175
  schemas,
951
1176
  vocabulary,
1177
+ packs,
952
1178
  index: references ? index : undefined,
953
1179
  ...(systems ? { systems } : {}),
954
1180
  }),
@@ -64,6 +64,46 @@ export function sohlField(fm, key, defaultValue = undefined) {
64
64
  return getFrontmatter(fm, key, defaultValue);
65
65
  }
66
66
 
67
+ /**
68
+ * Read a `sohl:` field, seeing the **destination** position as well (#126).
69
+ *
70
+ * Five declarations resolve their value by re-reading the note rather than by
71
+ * taking the one {@link module:engine/system-block.resolveFieldValue} handed
72
+ * them — `subType`, `charges`, a mystery's `skillAptitudes`, an affiliation's
73
+ * `relations` and a projectile's impact die — because each validates a *shape
74
+ * spread over several keys* rather than coercing one scalar.
75
+ *
76
+ * That was equivalent while every note authored inside the block, and stopped
77
+ * being so once a note may author at `sohl.system.<to>` instead: {@link
78
+ * sohlField} sees `sohl.<key>` and the top level, never inside `sohl.system`.
79
+ * So those five read as unset and shipped their empty value — a missing
80
+ * `subType` is a thrown build error, and the other four ship empty in silence,
81
+ * which is the failure class the passthrough exists to prevent.
82
+ *
83
+ * The **destination wins**, matching every other field's resolution order.
84
+ *
85
+ * `legacyKey` is for the one pair whose two positions are spelled differently:
86
+ * a projectile authors `impact.die` and stores `impactBase.die`, so the reader
87
+ * has to be told both. It is the same split `FieldSpec.name`/`legacyKey` makes
88
+ * (#305), for the same reason — one name cannot key two positions.
89
+ *
90
+ * @param {object} fm - The note's frontmatter.
91
+ * @param {string} to - The key at the destination, dotted for a nested one.
92
+ * @param {any} [defaultValue] - What an unauthored field reads as.
93
+ * @param {object} [options] - Options.
94
+ * @param {string} [options.legacyKey] - The key the block still carries, when
95
+ * it is not spelled `to`. Defaults to `to`.
96
+ * @returns {any} The value.
97
+ */
98
+ export function sohlSystemField(fm, to, defaultValue = undefined, { legacyKey = to } = {}) {
99
+ const system = fm?.sohl?.system;
100
+ if (system && typeof system === "object" && !Array.isArray(system)) {
101
+ const found = getFrontmatter(system, to, undefined);
102
+ if (found !== undefined) return found;
103
+ }
104
+ return sohlField(fm, legacyKey, defaultValue);
105
+ }
106
+
67
107
  /**
68
108
  * Read a frontmatter property that is authored as a **map**, returning its
69
109
  * entries — or `null` when the note authors none.
@@ -81,12 +121,12 @@ export function sohlField(fm, key, defaultValue = undefined) {
81
121
  * silent data loss these readers exist to prevent.
82
122
  *
83
123
  * @param {object} fm - The item frontmatter.
84
- * @param {string} key - The property name, read via {@link sohlField}.
124
+ * @param {string} key - The property name, read via {@link sohlSystemField}.
85
125
  * @returns {[string, unknown][] | null} The property's entries — empty when the
86
126
  * note authors none — or `null` when the value is not a map.
87
127
  */
88
128
  function readMapEntries(fm, key) {
89
- const raw = sohlField(fm, key, undefined);
129
+ const raw = sohlSystemField(fm, key, undefined);
90
130
  if (raw == null) return [];
91
131
  if (Array.isArray(raw)) return raw.length === 0 ? [] : null;
92
132
  if (typeof raw !== "object") return null;
@@ -115,11 +155,11 @@ export function resolveCharges(fm) {
115
155
  const num = Number(raw);
116
156
  return Number.isFinite(num) ? Math.trunc(num) : null;
117
157
  };
118
- const max = toCount(sohlField(fm, "charges.max", null));
158
+ const max = toCount(sohlSystemField(fm, "charges.max", null));
119
159
  // A blank maximum means "does not use charges" — a stray current count
120
160
  // cannot outlive it, since the logic layer disables both modifiers.
121
161
  return {
122
- value: max === null ? null : toCount(sohlField(fm, "charges.value", null)),
162
+ value: max === null ? null : toCount(sohlSystemField(fm, "charges.value", null)),
123
163
  max,
124
164
  };
125
165
  }
@@ -182,16 +222,24 @@ export function resolveSkillAptitudes(fm, ctx = "item") {
182
222
  * @throws {Error} When the map is malformed or names an unknown standing.
183
223
  */
184
224
  export function resolveRelation(fm, ctx = "item") {
185
- const entries = readMapEntries(fm, "relation");
225
+ // `relations` is the field's name; `relation` is the retired spelling, read
226
+ // underneath it so a tree converts on its own schedule (SoHL#1781). The
227
+ // current name wins wherever a note writes both, and the lint reports the
228
+ // old one through {@link RETIRED_FIELD_ALIASES}.
229
+ // Probed at the destination too: a note that has moved to
230
+ // `sohl.system.relations` carries the current spelling, and a probe that
231
+ // 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);
186
234
  if (entries === null) {
187
- throw new Error(`${ctx}: relation must be a map of shortcode → standing`);
235
+ throw new Error(`${ctx}: ${key} must be a map of shortcode → standing`);
188
236
  }
189
237
  const out = {};
190
238
  for (const [code, value] of entries) {
191
239
  const standing = String(value);
192
240
  if (!AFFILIATION_STANDINGS.includes(standing)) {
193
241
  throw new Error(
194
- `${ctx}: relation["${code}"] must be one of ${AFFILIATION_STANDINGS.join(", ")}, got "${value}"`,
242
+ `${ctx}: ${key}["${code}"] must be one of ${AFFILIATION_STANDINGS.join(", ")}, got "${value}"`,
195
243
  );
196
244
  }
197
245
  out[code] = standing;
@@ -216,7 +264,7 @@ export function resolveRelation(fm, ctx = "item") {
216
264
  * @throws {Error} When `subType` is missing or blank.
217
265
  */
218
266
  export function requireSubType(fm, ctx) {
219
- const subType = sohlField(fm, "subType", undefined);
267
+ const subType = sohlSystemField(fm, "subType", undefined);
220
268
  if (subType == null || subType === "") {
221
269
  const label = ctx || fm?.title || fm?.name || "item";
222
270
  throw new Error(
@@ -251,3 +299,35 @@ export function parseValueDesc(raw) {
251
299
  return { label: String(entry), maxValue: 0 };
252
300
  });
253
301
  }
302
+
303
+ /**
304
+ * The compendium folder a note names, and how it named it.
305
+ *
306
+ * Two spellings, deliberately not merged into one value: `packFolder:` is a
307
+ * folder note's **address** (`folder-poisonsandtoxins`) and `folder:` is a
308
+ * Foundry **id** (`ONXsqZAIZr2qzxTb`). Which one a value is cannot be told from
309
+ * the string — both are alphanumeric — so the field it was written in is what
310
+ * says, and that answer is carried rather than re-derived (#251).
311
+ *
312
+ * **`packFolder` was a path for one release** (`Possessions/Misc_Gear/Cooking`)
313
+ * and is an address now (#255). A path encoded the hierarchy *in the value*, so
314
+ * reparenting a folder made every note naming it wrong — a structural edit
315
+ * became a corpus-wide rewrite. An address is stable under reparenting, which
316
+ * is exactly why a note is addressed by `(type, shortcode)` and never by
317
+ * `file.path`. The path form is **removed rather than deprecated**: it had no
318
+ * authors to migrate, which is the whole reason the change was cheap enough to
319
+ * make.
320
+ *
321
+ * `packFolder` wins where both are present. Nothing about `folder` changes: a
322
+ * note that names one is read, resolved and emitted exactly as before, until
323
+ * #260 retires it.
324
+ *
325
+ * @param {object|null|undefined} fm - Parsed frontmatter.
326
+ * @returns {{value: string|null, isAddress: boolean}} The authored value, and
327
+ * whether it is a folder note's address.
328
+ */
329
+ export function folderField(fm) {
330
+ const asAddress = sohlField(fm, "packFolder", null);
331
+ if (asAddress != null && asAddress !== "") return { value: asAddress, isAddress: true };
332
+ return { value: sohlField(fm, "folder", null), isAddress: false };
333
+ }