@heroiclands/package-build 22.17.1 → 22.17.2

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.
@@ -22,7 +22,7 @@
22
22
  * disagree without anything detecting it, which the canonical-separator
23
23
  * handling already did once on each side.
24
24
  *
25
- * Five rules, all about a note's identity:
25
+ * Four rules, all about a note's identity:
26
26
  *
27
27
  * 1. **Shape** — a `shortcode` is strictly lowercase ASCII-alphanumeric. It is
28
28
  * the identity key referenced from saved world data, and it is half of the
@@ -34,12 +34,7 @@
34
34
  * reason the other two do: it is a statement about which note holds which
35
35
  * address, it needs no `site:` configuration to decide, and a package with
36
36
  * no front page is misconfigured whether or not anyone runs a site build.
37
- * 4. **Vacated addresses** — a `renamedFrom:` entry names an address this note
38
- * once held and nothing holds now. It is the same statement as
39
- * rule 2 read backwards, and it needs the same whole-tree view: an entry can
40
- * only be checked against every *other* note's address, and two notes
41
- * claiming one predecessor is the uniqueness rule applied to the past.
42
- * 5. **The vocabulary a package's kind leaves it** — a package compiling no
37
+ * 4. **The vocabulary a package's kind leaves it** — a package compiling no
43
38
  * Foundry documents publishes `doc` and `homepage` notes and nothing else.
44
39
  * It is here rather than with the claim check in `note-claims.mjs` because
45
40
  * that check runs at compile, and this is the case where no compile runs.
@@ -72,7 +67,6 @@ import { assertStatedScope } from "./helpers.mjs";
72
67
  // The corpus, read from the one pass that derives it.
73
68
  import { authoredFrontmatter, indexRecordsFor, isNoteRecord, noteFile } from "./content-index.mjs";
74
69
  import { checkHomepageCount, isHomepage } from "./homepage.mjs";
75
- import { declaresRenamedFrom, renamedFrom, renamedFromEntries } from "./note-renames.mjs";
76
70
 
77
71
  /**
78
72
  * The shape every `shortcode` must match: lowercase ASCII letters and digits
@@ -175,110 +169,6 @@ function collectNotes(contentBase, { skipDirectories, config, records, problems
175
169
  return notes;
176
170
  }
177
171
 
178
- /**
179
- * What one note's `renamedFrom:` says, checked against itself.
180
- *
181
- * The entries a note can be wrong about on its own: a value that is not a
182
- * shortcode, one naming the address the note holds *now*, one written twice.
183
- * Every one of them is silent without a check — a malformed entry is skipped by
184
- * the diff, so the author who wrote it sees the rename they were trying to
185
- * announce reported as a withdrawal anyway, with nothing saying why.
186
- *
187
- * The cross-note questions are not here, because one note cannot answer them:
188
- * whether an entry names an address some *other* note still publishes, and
189
- * whether two notes claim one predecessor, both need the whole tree and are
190
- * asked in {@link lintContentTree} once it has one.
191
- *
192
- * @param {object} note - The note, as {@link collectNotes} yields it.
193
- * @param {() => string} raw - Reads the file, deferred so a clean note costs
194
- * nothing.
195
- * @returns {Array<object>} The findings.
196
- */
197
- function checkRenamedFrom({ fm, file }, raw) {
198
- if (!declaresRenamedFrom(fm)) return [];
199
- const findings = [];
200
- const at = (value) => positionInFrontmatter(raw(), "renamedFrom", value);
201
- const shortcode = typeof fm.shortcode === "string" ? fm.shortcode.trim() : "";
202
-
203
- // A rename is a statement about where this note's address moved *to*, so a
204
- // note with no address of its own has made no such statement. Reported
205
- // before the entries: telling the author their entries are fine would be
206
- // the less useful half of the answer.
207
- if (!shortcode) {
208
- return [
209
- {
210
- file,
211
- ...at(undefined),
212
- severity: "error",
213
- message:
214
- "`renamedFrom` names the address this note used to hold, " +
215
- "but the note declares no `shortcode`, so it holds none now " +
216
- "and nothing was renamed",
217
- },
218
- ];
219
- }
220
-
221
- const seen = new Set();
222
- for (const entry of renamedFromEntries(fm)) {
223
- // Not `String(entry)`: the point is that the author wrote something
224
- // that is not a shortcode, and rendering a list as `a,b` would show
225
- // them a string they never typed.
226
- if (typeof entry !== "string" || !entry.trim()) {
227
- findings.push({
228
- file,
229
- ...at(undefined),
230
- severity: "error",
231
- message:
232
- `\`renamedFrom\` takes shortcodes, and one entry is ` +
233
- `${entry === "" || (typeof entry === "string" && !entry.trim()) ? "blank" : `a ${typeof entry}`}; ` +
234
- `it is skipped, so the rename it was meant to announce is ` +
235
- `still reported as a withdrawal`,
236
- });
237
- continue;
238
- }
239
- const value = entry.trim();
240
- if (!isValidShortcode(value)) {
241
- findings.push({
242
- file,
243
- ...at(value),
244
- severity: "error",
245
- message:
246
- `\`renamedFrom: ${value}\` is not strictly alphanumeric — ` +
247
- `lowercase letters and digits only (${ADDRESS_SEGMENT_PATTERN.source}) — ` +
248
- `so it is not an address this package ever published — a ` +
249
- `shortcode is held to one charset whether it is current or past`,
250
- });
251
- continue;
252
- }
253
- if (value === shortcode) {
254
- findings.push({
255
- file,
256
- ...at(value),
257
- severity: "error",
258
- message:
259
- `\`renamedFrom: ${value}\` is this note's own shortcode, so ` +
260
- `it declares a rename from itself; name the shortcode it ` +
261
- `was published under before, or drop the key`,
262
- });
263
- continue;
264
- }
265
- if (seen.has(value)) {
266
- findings.push({
267
- file,
268
- ...at(value),
269
- // The declaration still works — the reader de-duplicates — so
270
- // this is tidiness, and failing a build over it would red a
271
- // tree whose renames are all correctly announced.
272
- severity: "warning",
273
- message: `\`renamedFrom: ${value}\` is listed twice; the repeat says nothing new`,
274
- });
275
- continue;
276
- }
277
- seen.add(value);
278
- }
279
- return findings;
280
- }
281
-
282
172
  /**
283
173
  * Lint every address in a content tree.
284
174
  *
@@ -313,9 +203,6 @@ export function lintContentTree(
313
203
 
314
204
  /** @type {Map<string, Array<{file: string, absPath: string}>>} */
315
205
  const byKey = new Map();
316
- /** @type {Map<string, Array<{file: string, absPath: string, shortcode: string}>>} */
317
- const claimedPredecessors = new Map();
318
-
319
206
  for (const note of notes) {
320
207
  const { fm, absPath, file } = note;
321
208
  const shortcode = fm.shortcode;
@@ -324,7 +211,7 @@ export function lintContentTree(
324
211
  // tree costs one pass rather than two.
325
212
  const raw = () => fs.readFileSync(absPath, "utf8");
326
213
 
327
- // Rule 5, and it is the whole of the check for a package that compiles
214
+ // Rule 4, and it is the whole of the check for a package that compiles
328
215
  // nothing: no pass downstream would report the note, because the pass
329
216
  // that reports an unclaimed type is a compile pass and none runs.
330
217
  const type = typeof fm.type === "string" ? fm.type.trim() : "";
@@ -341,25 +228,6 @@ export function lintContentTree(
341
228
  });
342
229
  }
343
230
 
344
- // Before the keyless `continue` below, because a note declaring a
345
- // rename while carrying no address of its own is exactly one of the
346
- // things this reports — and reaching it after the skip would mean it
347
- // never ran on the case that needs it most.
348
- findings.push(...checkRenamedFrom(note, raw));
349
- if (shortcode) {
350
- // The de-duplicated reader, not the raw entries: a note that listed
351
- // one predecessor twice has made one claim, and indexing it twice
352
- // would make the note collide with itself and be reported as two
353
- // notes claiming one address.
354
- for (const value of renamedFrom(fm)) {
355
- if (value === shortcode) continue;
356
- const claim = `${fm.type}:${value}`;
357
- const seen = claimedPredecessors.get(claim);
358
- if (seen) seen.push({ file, absPath, shortcode });
359
- else claimedPredecessors.set(claim, [{ file, absPath, shortcode }]);
360
- }
361
- }
362
-
363
231
  // Folder documents and keyless entries carry no address at all.
364
232
  if (!shortcode) continue;
365
233
 
@@ -436,52 +304,5 @@ export function lintContentTree(
436
304
  }
437
305
  }
438
306
 
439
- // The two questions about a declared rename that need the whole tree.
440
- // Both are the uniqueness rule above, applied to the past: an
441
- // address has one holder, so it has one successor and it cannot be both
442
- // vacated and occupied.
443
- for (const [claim, claimants] of claimedPredecessors) {
444
- const live = byKey.get(claim);
445
- if (live) {
446
- for (const { file, absPath } of claimants) {
447
- findings.push({
448
- file,
449
- ...positionInFrontmatter(
450
- fs.readFileSync(absPath, "utf8"),
451
- "renamedFrom",
452
- claim.slice(claim.indexOf(":") + 1),
453
- ),
454
- severity: "error",
455
- message:
456
- `\`renamedFrom\` claims "${claim}", which ` +
457
- `${live.map((f) => f.file).join(", ")} still publishes; ` +
458
- `that address was never vacated, so nothing was renamed ` +
459
- `away from it`,
460
- });
461
- }
462
- }
463
- if (claimants.length < 2) continue;
464
- // Named on every claimant rather than once on the address, for the same
465
- // reason a duplicate address is: each is a file an author has to open,
466
- // and a finding naming only the address sends them hunting for the rest.
467
- for (const { file, absPath, shortcode } of claimants) {
468
- const others = claimants.filter((c) => c.file !== file);
469
- findings.push({
470
- file,
471
- ...positionInFrontmatter(
472
- fs.readFileSync(absPath, "utf8"),
473
- "renamedFrom",
474
- claim.slice(claim.indexOf(":") + 1),
475
- ),
476
- severity: "error",
477
- message:
478
- `"${claim}" is claimed as a predecessor by more than one ` +
479
- `note — this one (now "${shortcode}") and ` +
480
- `${others.map((c) => `${c.file} (now "${c.shortcode}")`).join(", ")}; ` +
481
- `an address had one holder, so it has one successor`,
482
- });
483
- }
484
- }
485
-
486
307
  return { findings, notes: notes.length, keys: byKey.size };
487
308
  }
@@ -57,6 +57,7 @@
57
57
  import { isAddressTuple } from "./address.mjs";
58
58
  import { authoredNoteKeys, NOTE_TOP_LEVEL_KEY_SET } from "./note-frontmatter.mjs";
59
59
  import { AddressEntries } from "./address-values.mjs";
60
+ import { addressPositions } from "./note-addresses.mjs";
60
61
  import { authoredFields, readsLegacyKey } from "./field-spec.mjs";
61
62
  import {
62
63
  legacyKeyOf,
@@ -79,7 +80,6 @@ import { DEFAULT_PARENT } from "./folder-notes.mjs";
79
80
  import {
80
81
  dataFields,
81
82
  declaredTags,
82
- exclusiveTagGroups,
83
83
  subTypeCharsetMessage,
84
84
  typeCharsetMessage,
85
85
  } from "./note-vocabulary.mjs";
@@ -597,6 +597,115 @@ function checkDataReferences(note, field, value, segments, context, index) {
597
597
  return findings;
598
598
  }
599
599
 
600
+ /**
601
+ * Read values at a schema-declared address path, expanding wildcard segments.
602
+ * @param {unknown} value - The value at the current path.
603
+ * @param {readonly string[]} path - Remaining declaration path.
604
+ * @param {readonly (string|number)[]} [actual] - Authored path so far.
605
+ * @returns {Array<{value: unknown, path: Array<string|number>}>} Values and their paths.
606
+ */
607
+ function valuesAtAddressPath(value, path, actual = []) {
608
+ if (!path.length) return [{ value, path: [...actual] }];
609
+ const [head, ...tail] = path;
610
+ if (head === "*") {
611
+ const entries =
612
+ Array.isArray(value) ? value.map((entry, index) => [index, entry])
613
+ : value && typeof value === "object" && !isAddressTuple(value) ? Object.entries(value)
614
+ : [];
615
+ return entries.flatMap(([key, entry]) =>
616
+ valuesAtAddressPath(entry, tail, [...actual, key]),
617
+ );
618
+ }
619
+ if (!value || typeof value !== "object" || isAddressTuple(value)) return [];
620
+ return valuesAtAddressPath(value[head], tail, [...actual, head]);
621
+ }
622
+
623
+ /**
624
+ * Resolve system-block addresses declared by the system's own field schema.
625
+ * @param {object} note - A note from the link index.
626
+ * @param {object} opts
627
+ * @param {object} opts.index - The address index.
628
+ * @param {Record<string, readonly object[]>} [opts.schemas] - Note schemas.
629
+ * @param {Readonly<Record<string, object>>} [opts.systems] - Declared system blocks.
630
+ * @returns {object[]} Located findings for unresolved addresses.
631
+ */
632
+ export function systemAddressFindings(note, { index, schemas, systems } = {}) {
633
+ if (!index?.addressHit) return [];
634
+ const findings = [];
635
+ const fm = note.fm ?? {};
636
+ for (const position of addressPositions(fm, { schemas, systemBlocks: systems })) {
637
+ const block = position.path[0];
638
+ if (!Object.hasOwn(systems ?? {}, block) || position.path[1] === "packFolder") continue;
639
+ const candidates = valuesAtAddressPath(fm, position.path);
640
+ for (const candidate of candidates) {
641
+ let values;
642
+ if (position.shape === "list") {
643
+ values =
644
+ Array.isArray(candidate.value) ?
645
+ candidate.value.map((value, index) => ({
646
+ value,
647
+ path: [...candidate.path, index],
648
+ }))
649
+ : [];
650
+ } else if (position.shape === "keys") {
651
+ values =
652
+ candidate.value instanceof AddressEntries ?
653
+ candidate.value.entries.map((entry) => ({
654
+ value: entry.target,
655
+ path: [...candidate.path, entry.sourceKey],
656
+ key: true,
657
+ }))
658
+ : candidate.value && typeof candidate.value === "object" ?
659
+ Object.keys(candidate.value).map((key) => ({
660
+ value: key,
661
+ path: [...candidate.path, key],
662
+ key: true,
663
+ }))
664
+ : [];
665
+ } else if (position.shape === "scalar-or-map" && mapEntries(candidate.value)) {
666
+ values = Object.entries(mapEntries(candidate.value)).map(([key, value]) => ({
667
+ value,
668
+ path: [...candidate.path, key],
669
+ }));
670
+ } else {
671
+ values = [candidate];
672
+ }
673
+
674
+ for (const item of values) {
675
+ if (item.value == null || item.value === "") continue;
676
+ const context = {
677
+ package: index.contentPackage,
678
+ system: position.system ?? block,
679
+ type: position.type,
680
+ types: index.types,
681
+ };
682
+ const address =
683
+ isAddressTuple(item.value) ? item.value
684
+ : typeof item.value === "string" ?
685
+ parseAddress(item.value, context, {
686
+ declared: true,
687
+ legacyShortcodeCase: position.legacyShortcodeCase,
688
+ })
689
+ : null;
690
+ if (!address || address.reason) continue;
691
+ if (position.accepts && !acceptsType(address, position.accepts)) continue;
692
+ const target = renderAddress(address);
693
+ if (index.addressHit(target)) continue;
694
+ const path = item.path;
695
+ findings.push({
696
+ file: note.file,
697
+ ...positionOfFrontmatterPath(note.raw ?? "", path, { key: item.key }),
698
+ severity: "error",
699
+ message:
700
+ `\`${path.map(String).join(".")}\` names ${target}, which does not ` +
701
+ `resolve in this package or its declared dependencies`,
702
+ });
703
+ }
704
+ }
705
+ }
706
+ return findings;
707
+ }
708
+
600
709
  /**
601
710
  * Check one `scalar-or-map` field written in its map form, entry by entry.
602
711
  *
@@ -774,14 +883,6 @@ function checkSubType(note, { type, entry }) {
774
883
  * findings above become none while a settlement tagged `vilage` is still
775
884
  * caught.
776
885
  *
777
- * **A group declared as a slot is checked twice over.** Its tags are the
778
- * alternative answers to one question — a being is a `character` or a
779
- * `creature` — so two of them on one note is refused outright, by
780
- * {@link checkExclusiveTags}, on top of the near miss every group gets. An
781
- * *unfilled* slot is not a finding: the kind is authored deliberately, and
782
- * nothing can tell a being nobody has classified yet from one the author means
783
- * to leave unclassified.
784
- *
785
886
  * @param {object} note - The note.
786
887
  * @param {object} opts
787
888
  * @param {string} opts.type - The note's declared `type`, which scopes the
@@ -816,55 +917,6 @@ function checkTags(note, { type }) {
816
917
  return findings;
817
918
  }
818
919
 
819
- /**
820
- * Refuse a note that fills one single-valued tag slot twice.
821
- *
822
- * This is the whole of what "a closed tag vocabulary" can mean while `tags:`
823
- * stays open. A slot's values are alternatives, not attributes: a being is a
824
- * `character` or a `creature`, so a note carrying both has answered the
825
- * question twice and every reader of the tag — an index, a query, a renderer —
826
- * gets to pick. That is the same silent wrongness a misspelt classifying tag
827
- * is, and it gets the same answer.
828
- *
829
- * **An error, not a warning**, because a warning does not change the build's
830
- * exit code and a contradiction that still ships is a contradiction nobody
831
- * fixes.
832
- *
833
- * **Located on the `tags` key**, because the fault is the list rather than
834
- * either entry in it — both values are correctly spelt, and pointing at one of
835
- * them would say the wrong one is the wrong one.
836
- *
837
- * @param {object} note - The note.
838
- * @param {object} opts
839
- * @param {string} opts.type - The note's declared `type`, which scopes the
840
- * slots checked.
841
- * @returns {object[]} Findings.
842
- */
843
- function checkExclusiveTags(note, { type }) {
844
- const authored = (note.fm ?? {}).tags;
845
- if (!Array.isArray(authored)) return [];
846
-
847
- const carried = new Set(
848
- authored.filter((t) => typeof t === "string" && t.trim()).map((t) => t.trim()),
849
- );
850
- const findings = [];
851
- for (const { slot, tags } of exclusiveTagGroups(type)) {
852
- const filled = tags.filter((t) => carried.has(t));
853
- if (filled.length < 2) continue;
854
- findings.push({
855
- file: note.file,
856
- ...positionInFrontmatter(note.raw ?? "", "tags"),
857
- severity: "error",
858
- message:
859
- `a ${type}'s ${slot} is one tag, and this note carries ` +
860
- `${filled.map((t) => `"${t}"`).join(" and ")}. Those are the alternative ` +
861
- `answers to one question (${tags.join(", ")}), so carrying two of them ` +
862
- `states no ${slot} at all. Keep the one that is true, or neither`,
863
- });
864
- }
865
- return findings;
866
- }
867
-
868
920
  /**
869
921
  * The frontmatter fields that name artwork.
870
922
  *
@@ -1421,7 +1473,6 @@ export function lintNote(
1421
1473
  // type's property — `draft` belongs to any note and `village` to a place —
1422
1474
  // so the finding must survive the early returns below.
1423
1475
  findings.push(...checkTags(note, { type }));
1424
- findings.push(...checkExclusiveTags(note, { type }));
1425
1476
 
1426
1477
  // A refused field must be one the note *wrote*: `resolveNoteId` fills
1427
1478
  // `fm.id` in place, so the parsed frontmatter carries a derived id the
@@ -1753,6 +1804,8 @@ export function lintNote(
1753
1804
  }
1754
1805
  }
1755
1806
 
1807
+ findings.push(...systemAddressFindings(note, { index, schemas, systems }));
1808
+
1756
1809
  return findings;
1757
1810
  }
1758
1811
 
@@ -177,7 +177,8 @@ export function renderFoundryMarkdown(body, captions, footnoteNumbers, docId) {
177
177
  * @param {string} filePath - Markdown file to read.
178
178
  * @param {{addressContext?: object}} [opts] - Address defaults for decoded fields.
179
179
  * @returns {{frontmatter: object|null, body: string, description: string,
180
- * bodyLine?: number, bodyColumn?: number}} Parsed note and body position.
180
+ * bodyLine?: number, bodyColumn?: number, parseError?: {message: string,
181
+ * line?: number, column?: number}}} Parsed note and body position.
181
182
  */
182
183
  export function parseMarkdownFile(filePath, { addressContext } = {}) {
183
184
  const content = fs.readFileSync(filePath, "utf8");
@@ -189,8 +190,19 @@ export function parseMarkdownFile(filePath, { addressContext } = {}) {
189
190
  try {
190
191
  frontmatter = yaml.parse(fmMatch[1]) || {};
191
192
  } catch (err) {
192
- log.warn(`YAML parse error in ${filePath}: ${err.message}`);
193
- return { frontmatter: null, body: "", description: "" };
193
+ const firstLine = String(err.message).split("\n", 1)[0];
194
+ const message = firstLine.replace(/ at line \d+, column \d+.*$/, "");
195
+ const location = err.linePos?.[0];
196
+ return {
197
+ frontmatter: null,
198
+ body: "",
199
+ description: "",
200
+ parseError: {
201
+ message: `YAML parse error: ${message}`,
202
+ ...(Number.isInteger(location?.line) ? { line: location.line + 1 } : {}),
203
+ ...(Number.isInteger(location?.col) ? { column: location.col } : {}),
204
+ },
205
+ };
194
206
  }
195
207
  if (addressContext) {
196
208
  try {
package/engine/index.mjs CHANGED
@@ -178,9 +178,6 @@ export * as infoboxRender from "./infobox-render.mjs";
178
178
  /** The id a note's document is filed under: its pin, or its address. */
179
179
  export * as noteIds from "./note-ids.mjs";
180
180
 
181
- /** The shortcodes a note declares it used to be published under. */
182
- export * as noteRenames from "./note-renames.mjs";
183
-
184
181
  /** The published content index packages exchange addresses through. */
185
182
  export * as metadataIndex from "./metadata-index.mjs";
186
183
 
@@ -55,7 +55,10 @@ export function renderDot(dotPath, outPath, { engine, format = "svg", nop, dpi =
55
55
  .filter(
56
56
  (error) =>
57
57
  error.level === "warning" &&
58
- !error.message.includes("no hard-coded metrics for 'Helvetica-Bold'"),
58
+ !error.message.includes("no hard-coded metrics for 'Helvetica-Bold'") &&
59
+ !/^Warning: no value for width of non-ASCII character \d+\. Falling back to width of space character$/.test(
60
+ error.message,
61
+ ),
59
62
  )
60
63
  .map((error) => error.message)
61
64
  .join("; "),
@@ -18,6 +18,7 @@ export const NOTE_TOP_LEVEL_KEYS = Object.freeze([
18
18
  "data",
19
19
  "hm3",
20
20
  "sohl",
21
+ "dnd5e",
21
22
  ]);
22
23
 
23
24
  /** Membership test for the top-level vocabulary. */
@@ -571,15 +571,6 @@ export const DECLARED_TAGS = Object.freeze({
571
571
  "unguilded",
572
572
  ]),
573
573
  }),
574
- /**
575
- * What kind of being this is — a person, or one of the beasts and made
576
- * things. A being is one or the other, so the group is a slot.
577
- */
578
- beingKind: Object.freeze({
579
- types: ["being"],
580
- exclusive: "kind",
581
- tags: Object.freeze(["character", "creature"]),
582
- }),
583
574
  /** A note's working state, which any note may carry. */
584
575
  state: Object.freeze({ types: null, tags: Object.freeze([DRAFT_TAG]) }),
585
576
  /** Who may read a completed note. */
@@ -692,10 +683,7 @@ export const NOTE_VOCABULARY = Object.freeze({
692
683
 
693
684
  being: Object.freeze({
694
685
  stubbable: true,
695
- // Derived from the note's `(type, subType)` by each system's map, which
696
- // lands with. Declared open until it does, because inventing the
697
- // values here would put a second, weaker answer beside the real one.
698
- subTypes: null,
686
+ subTypes: Object.freeze(["npc", "character", "creature"]),
699
687
  // Whether an authored `age` disagrees with what `born` and the
700
688
  // package's declared present compute — see `engine/being-age.mjs`.
701
689
  check: checkBeingAge,
@@ -43,10 +43,6 @@ import { HM3_DOCUMENT_SUBTYPES } from "../hm3/document-subtypes.mjs";
43
43
  /**
44
44
  * The note-type → document-subtype maps this toolchain ships.
45
45
  *
46
- * Two, since `hm3/` landed — and it joined this list rather than the
47
- * claim table growing a second copy of the same fact, which is what the list
48
- * was for.
49
- *
50
46
  * The union is what makes the vocabulary wider than any one repository's
51
47
  * configuration: `armorlocation` is a real content type because HM3 maps it,
52
48
  * however a given repository is configured, so a tree full of them is a
@@ -90,7 +86,7 @@ export const KNOWN_DOCUMENT_SUBTYPE_MAPS = Object.freeze([
90
86
  export const DEFAULT_DOCUMENT_SUBTYPES = SOHL_DOCUMENT_SUBTYPES;
91
87
 
92
88
  /**
93
- * Every system this toolchain ships a map for, by id.
89
+ * Every system identifier with declared system-specific document types.
94
90
  *
95
91
  * Derived from {@link KNOWN_DOCUMENT_SUBTYPE_MAPS}, so registering a map is the
96
92
  * whole of adding a system to every reader that asks "which systems are there?"
@@ -156,7 +152,7 @@ export const ACTOR_TYPES = Object.freeze(
156
152
  /**
157
153
  * The map one system ships, by its id.
158
154
  *
159
- * @param {string|undefined} system - The system id (`"sohl"`, `"hm3"`).
155
+ * @param {string|undefined} system - The system id.
160
156
  * @returns {import("./document-subtypes.mjs").DocumentSubtypeMap|undefined} Its
161
157
  * map, or `undefined` where this toolchain ships none for it.
162
158
  */
@@ -20,10 +20,8 @@
20
20
  * address grammar asks on every note, and the issue's answer is that it comes
21
21
  * from a **registry rather than from a hardcoded set**: known system ids are
22
22
  * declared here, once, an unknown value is an error, and _adding a system is a
23
- * data change_. Before this module the same fact was spelled in as many places
24
- * as needed it — a `NO_SYSTEM` constant in the content index, a `map.system`
25
- * per document-subtype map, a system-keyed table per compiler — none of which
26
- * could refuse a value none of the others had heard of.
23
+ * data change_. The same fact is read by address parsing, configuration and
24
+ * linting.
27
25
  *
28
26
  * **`none` is a word, deliberately.** It is not a YAML null (`null`, `~`, or an
29
27
  * empty value), because those spell "nobody filled this in", and a note that
@@ -38,8 +36,8 @@
38
36
  * the configuration loader, by a compiler, and by a lint without closing a
39
37
  * cycle around any of them. In particular the ids are *declared* here rather
40
38
  * than derived from `KNOWN_DOCUMENT_SUBTYPE_MAPS` — deriving them would drag
41
- * `sohl/` and `hm3/` behind every import of this file, for a list of two
42
- * words. `tests/systems.test.ts` holds the two in step instead, which is where
39
+ * system-specific modules behind every import of this file. `tests/systems.test.ts`
40
+ * holds the registry and note-key vocabulary in step, which is where
43
41
  * the agreement between a registry and its implementations belongs.
44
42
  *
45
43
  * @module
@@ -66,11 +64,11 @@ export const NO_SYSTEM = "none";
66
64
  export const NOTE_SYSTEM = "note";
67
65
 
68
66
  /**
69
- * Every game system this toolchain compiles for.
67
+ * Every game system identifier this toolchain recognizes.
70
68
  *
71
- * The registry, and the only place the list is written down. Adding a system
72
- * is an edit to this set plus the map and compilers that make it real — a data
73
- * change, not a search for every place a name was spelled.
69
+ * The registry, and the only place the list is written down. A recognized id
70
+ * can have no system-specific document types; adding one of those requires a
71
+ * subtype map and compiler as well.
74
72
  *
75
73
  * `none` is **not** a member: it marks the absence of a system, and a caller
76
74
  * asking "is this a system" and a caller asking "is this a well-formed
@@ -79,7 +77,7 @@ export const NOTE_SYSTEM = "note";
79
77
  *
80
78
  * @type {ReadonlySet<string>}
81
79
  */
82
- export const SYSTEM_IDS = Object.freeze(new Set(["sohl", "hm3"]));
80
+ export const SYSTEM_IDS = Object.freeze(new Set(["sohl", "hm3", "dnd5e"]));
83
81
 
84
82
  /**
85
83
  * Everything the `<system>` segment of an address may say.
@@ -127,8 +125,8 @@ export function isSystemSegment(value) {
127
125
  *
128
126
  * The nullish case is called out by name because it is the likely mistake and
129
127
  * the least legible failure: a YAML `null`, a `~`, or a key written with no
130
- * value at all reaches here as `undefined`, and "expected one of sohl, hm3,
131
- * none" would leave a reader hunting for the value they cannot see.
128
+ * value at all reaches here as `undefined`, and a list of accepted values
129
+ * would leave a reader hunting for the value they cannot see.
132
130
  *
133
131
  * @param {unknown} value - The offending value.
134
132
  * @param {string} [where] - What carried it, for the message — a note path, a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "22.17.1",
3
+ "version": "22.17.2",
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",
@@ -230,11 +230,11 @@ export class SystemActorCompiler extends BasePackCompiler {
230
230
  * The actor's two pieces of art, resolved, with the being default beneath
231
231
  * them.
232
232
  *
233
- * **The default is chosen from the note's tags**, which only a compiler can
234
- * read: a `character` falls back to one file and a `creature` to another,
235
- * and both are addresses in the package that ships them. A tree whose index
236
- * cannot answer that address — a dependency not yet fetched — falls through
237
- * to the subtype's own default, so the document is never left with no art.
233
+ * **The default follows the note's `subType`**, with tags as a compatibility
234
+ * fallback for content trees that have not migrated. Both addresses belong
235
+ * to the package that ships them. A tree whose index cannot answer an
236
+ * address — a dependency not yet fetched — falls through to the document
237
+ * subtype's own default, so the document is never left with no art.
238
238
  *
239
239
  * `tokenIcon` unset follows `icon`, and the fallback is applied after
240
240
  * resolution rather than before: a note naming an icon and no token icon