@heroiclands/package-build 9.0.0 → 10.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 (78) hide show
  1. package/CHANGELOG.md +694 -0
  2. package/CONTENT.md +273 -13
  3. package/bin/content-build.mjs +437 -7
  4. package/content-config.mjs +259 -28
  5. package/docs/content-format.md +1418 -0
  6. package/engine/address-charset.mjs +62 -0
  7. package/engine/alias-index.mjs +153 -0
  8. package/engine/base-compiler.mjs +194 -4
  9. package/engine/content-address.mjs +4 -4
  10. package/engine/content-format-check.mjs +570 -0
  11. package/engine/content-format.mjs +253 -0
  12. package/engine/content-links.mjs +132 -56
  13. package/engine/content-lint.mjs +8 -1
  14. package/engine/diagnostics.mjs +33 -0
  15. package/engine/document-subtypes.mjs +440 -0
  16. package/engine/field-spec.mjs +49 -43
  17. package/engine/frontmatter-lint.mjs +343 -27
  18. package/engine/generate.mjs +32 -4
  19. package/engine/helpers.mjs +41 -29
  20. package/engine/ids.mjs +19 -1
  21. package/engine/index.mjs +15 -0
  22. package/engine/item-registry.mjs +72 -5
  23. package/engine/kb-manifest.mjs +36 -7
  24. package/engine/map-notes.mjs +34 -18
  25. package/engine/note-claims.mjs +383 -0
  26. package/engine/note-vocabulary.mjs +678 -0
  27. package/engine/pack-config.mjs +39 -22
  28. package/engine/pack-router.mjs +17 -6
  29. package/engine/prose-lint.mjs +55 -3
  30. package/engine/retired-fields.mjs +117 -3
  31. package/engine/scenes.mjs +19 -1
  32. package/engine/schema-check.mjs +347 -3
  33. package/engine/site-build.mjs +1 -1
  34. package/engine/site-index.mjs +38 -21
  35. package/engine/system-block.mjs +513 -0
  36. package/engine/web-wikilinks.mjs +112 -80
  37. package/engine/wikilink-syntax.mjs +30 -0
  38. package/engine/wikilinks.mjs +67 -51
  39. package/package.json +6 -2
  40. package/sohl/actors.mjs +249 -36
  41. package/sohl/document-subtypes.mjs +82 -0
  42. package/sohl/index.mjs +3 -0
  43. package/sohl/items.mjs +110 -14
  44. package/sohl/note-schemas.mjs +11 -7
  45. package/types/content-config.d.mts +48 -4
  46. package/types/engine/address-charset.d.mts +45 -0
  47. package/types/engine/alias-index.d.mts +122 -0
  48. package/types/engine/base-compiler.d.mts +132 -4
  49. package/types/engine/content-address.d.mts +2 -2
  50. package/types/engine/content-format-check.d.mts +163 -0
  51. package/types/engine/content-format.d.mts +101 -0
  52. package/types/engine/content-links.d.mts +16 -1
  53. package/types/engine/content-lint.d.mts +6 -0
  54. package/types/engine/diagnostics.d.mts +29 -0
  55. package/types/engine/document-subtypes.d.mts +233 -0
  56. package/types/engine/field-spec.d.mts +76 -23
  57. package/types/engine/frontmatter-lint.d.mts +47 -2
  58. package/types/engine/generate.d.mts +14 -1
  59. package/types/engine/helpers.d.mts +21 -13
  60. package/types/engine/ids.d.mts +10 -0
  61. package/types/engine/index.d.mts +5 -0
  62. package/types/engine/item-registry.d.mts +21 -2
  63. package/types/engine/kb-manifest.d.mts +35 -8
  64. package/types/engine/map-notes.d.mts +21 -11
  65. package/types/engine/note-claims.d.mts +113 -0
  66. package/types/engine/note-vocabulary.d.mts +251 -0
  67. package/types/engine/pack-config.d.mts +4 -3
  68. package/types/engine/pack-router.d.mts +4 -4
  69. package/types/engine/prose-lint.d.mts +6 -2
  70. package/types/engine/retired-fields.d.mts +73 -2
  71. package/types/engine/schema-check.d.mts +182 -0
  72. package/types/engine/system-block.d.mts +281 -0
  73. package/types/engine/web-wikilinks.d.mts +23 -12
  74. package/types/engine/wikilink-syntax.d.mts +29 -0
  75. package/types/sohl/actors.d.mts +62 -6
  76. package/types/sohl/document-subtypes.d.mts +14 -0
  77. package/types/sohl/index.d.mts +1 -0
  78. package/types/sohl/items.d.mts +21 -0
@@ -56,10 +56,18 @@
56
56
  */
57
57
 
58
58
  import { authoredFields } from "./field-spec.mjs";
59
- import { positionInFrontmatter } from "./diagnostics.mjs";
59
+ import { resolveFieldValue, SYSTEM_BLOCK_KEYS, unknownBlockKeys } from "./system-block.mjs";
60
+ import { positionInFrontmatter, positionOfFrontmatterPath } from "./diagnostics.mjs";
60
61
  import { checkHomepageAddressFields } from "./homepage.mjs";
61
62
  import { RETIRED_TYPES } from "./ids.mjs";
62
- import { draftRetiredMessage } from "./retired-fields.mjs";
63
+ import { declaredTags } from "./note-vocabulary.mjs";
64
+ import {
65
+ RETIRED_FIELD_ALIASES,
66
+ declaresRetiredAlias,
67
+ draftRetiredMessage,
68
+ readAliasedField,
69
+ retiredAliasMessage,
70
+ } from "./retired-fields.mjs";
63
71
 
64
72
  /**
65
73
  * `sohl:` keys every type accepts, whatever its schema says.
@@ -91,6 +99,28 @@ import { draftRetiredMessage } from "./retired-fields.mjs";
91
99
  */
92
100
  export const UNIVERSAL_KEYS = Object.freeze(new Set(["folder", "pack", "archetype", "kbcat"]));
93
101
 
102
+ /**
103
+ * The system blocks a build checks, and what each accepts beyond the shared
104
+ * vocabulary.
105
+ *
106
+ * One entry, because one system is what every existing tree declares — and the
107
+ * default is a *declaration*, not a hard-coded assumption: a build that ships
108
+ * content for two systems passes both, and each block is then checked against
109
+ * its own vocabulary rather than against the other's (#58). A block nothing
110
+ * declares is not checked, because nothing can say what it may carry, and
111
+ * inventing a rule for it would report a correct tree red.
112
+ *
113
+ * `fieldVocabulary` says the note type's own declared field names are keys of
114
+ * this block. True for `sohl` and untrue in general: those names come from the
115
+ * `itemBuilders` registry that this system declares, and a second system's
116
+ * notes write a second system's fields.
117
+ *
118
+ * @type {Readonly<Record<string, {known?: readonly string[], fieldVocabulary?: boolean}>>}
119
+ */
120
+ export const DEFAULT_SYSTEM_BLOCKS = Object.freeze({
121
+ sohl: Object.freeze({ fieldVocabulary: true }),
122
+ });
123
+
94
124
  /**
95
125
  * Edit distance, capped — enough to answer "did you mean".
96
126
  *
@@ -182,14 +212,208 @@ export function matchesKind(value, kind) {
182
212
  }
183
213
 
184
214
  /**
185
- * The `sohl:` block a note authored, or an empty one.
215
+ * The `data:` container a note authored.
216
+ *
217
+ * An emptied map arrives from the property editor as `[]` and means the same
218
+ * thing `{}` does — this note authors no entries (#8) — so both read as an
219
+ * empty container rather than as a malformed one.
186
220
  *
187
221
  * @param {object} fm - The note's frontmatter.
188
- * @returns {object} The block.
222
+ * @returns {{present: boolean, entries: object, malformed: boolean}} What was
223
+ * authored, and whether it is a container at all.
224
+ */
225
+ function dataBlock(fm) {
226
+ if (!Object.hasOwn(fm ?? {}, "data")) {
227
+ return { present: false, entries: {}, malformed: false };
228
+ }
229
+ const value = fm.data;
230
+ if (value == null || (Array.isArray(value) && value.length === 0)) {
231
+ return { present: true, entries: {}, malformed: false };
232
+ }
233
+ if (typeof value !== "object" || Array.isArray(value)) {
234
+ return { present: true, entries: {}, malformed: true };
235
+ }
236
+ return { present: true, entries: value, malformed: false };
237
+ }
238
+
239
+ /**
240
+ * Check a note's `data:` container against the closed vocabulary its type
241
+ * declares (#128).
242
+ *
243
+ * Unlike the top level, which is passed through to the published page and so
244
+ * cannot be refused, `data:` holds the type-specific facts about the subject
245
+ * and every key of it is declared. An unrecognised key is therefore a finding
246
+ * naming the note, with the key it was most likely meant to be — the same
247
+ * capped edit distance {@link nearest} applies to a `sohl:` key, drawn from
248
+ * this type's own vocabulary rather than from every type's.
249
+ *
250
+ * @param {object} note - The note.
251
+ * @param {object} opts
252
+ * @param {string} opts.type - The note's type, for the message.
253
+ * @param {readonly object[]} opts.fields - The type's `data:` declaration.
254
+ * @returns {object[]} Findings.
255
+ */
256
+ function checkDataContainer(note, { type, fields }) {
257
+ const findings = [];
258
+ const { present, entries, malformed } = dataBlock(note.fm ?? {});
259
+ if (!present) return findings;
260
+
261
+ const raw = note.raw ?? "";
262
+ if (malformed) {
263
+ findings.push({
264
+ file: note.file,
265
+ ...positionOfFrontmatterPath(raw, ["data"], { key: true }),
266
+ severity: "error",
267
+ message:
268
+ "`data:` must be a map of the note's own type-specific " +
269
+ `properties, but reads ${JSON.stringify(note.fm.data)}`,
270
+ });
271
+ return findings;
272
+ }
273
+
274
+ /** First segment of each declared name — `charges.value` is authored as `charges`. */
275
+ const declared = new Set(fields.map((f) => f.name.split(".")[0]));
276
+
277
+ for (const key of Object.keys(entries)) {
278
+ if (declared.has(key)) continue;
279
+ const guess = nearest(key, declared);
280
+ findings.push({
281
+ file: note.file,
282
+ ...positionOfFrontmatterPath(raw, ["data", key], { key: true }),
283
+ severity: "error",
284
+ message:
285
+ `"${key}" is not a \`data:\` property declared by ${type}; ` +
286
+ `the container is closed, so unlike a top-level key it is ` +
287
+ `not passed through to the page` +
288
+ (guess ? `. Did you mean "${guess}"?` : ""),
289
+ });
290
+ }
291
+
292
+ for (const field of fields) {
293
+ if (!field.kind) continue;
294
+ const segments = field.name.split(".");
295
+ let value = entries;
296
+ for (const segment of segments) {
297
+ value = value && typeof value === "object" ? value[segment] : undefined;
298
+ }
299
+ if (value === undefined || value === null) continue;
300
+ if (matchesKind(value, field.kind)) continue;
301
+ findings.push({
302
+ file: note.file,
303
+ ...positionOfFrontmatterPath(raw, ["data", ...segments]),
304
+ severity: "error",
305
+ message:
306
+ `\`data.${field.name}\` should be ${field.shape ?? field.kind}, ` +
307
+ `but reads ${JSON.stringify(value)}`,
308
+ });
309
+ }
310
+
311
+ return findings;
312
+ }
313
+
314
+ /**
315
+ * Check a note's top-level `subType` against the values its type declares
316
+ * (#128).
317
+ *
318
+ * `subType` stays at the top level — it is what each system's map reads to
319
+ * derive a document type, so it describes the note rather than the subject —
320
+ * but it is not open like the rest of that region: a type either declares a
321
+ * `subType` or does not, and a type that does declares its values.
322
+ *
323
+ * @param {object} note - The note.
324
+ * @param {object} opts
325
+ * @param {string} opts.type - The note's type, for the message.
326
+ * @param {object} opts.entry - The type's vocabulary entry.
327
+ * @returns {object[]} Findings.
189
328
  */
190
- function sohlBlock(fm) {
191
- const block = fm?.sohl;
192
- return block && typeof block === "object" && !Array.isArray(block) ? block : {};
329
+ function checkSubType(note, { type, entry }) {
330
+ const fm = note.fm ?? {};
331
+ if (!Object.hasOwn(fm, "subType") || fm.subType == null || fm.subType === "") return [];
332
+
333
+ const value = String(fm.subType);
334
+ const at = positionInFrontmatter(note.raw ?? "", "subType");
335
+
336
+ if (!Object.hasOwn(entry, "subTypes")) {
337
+ return [
338
+ {
339
+ file: note.file,
340
+ ...at,
341
+ severity: "error",
342
+ message:
343
+ `\`subType\` is not a property declared by ${type}; it ` +
344
+ `declares no subtypes, so nothing reads this value`,
345
+ },
346
+ ];
347
+ }
348
+
349
+ const values = entry.subTypes;
350
+ // `null` is "declared, values not yet enumerated" — presence is legal and
351
+ // the value is nobody's to check yet.
352
+ if (values == null || values.includes(value)) return [];
353
+
354
+ const guess = nearest(value, values);
355
+ return [
356
+ {
357
+ file: note.file,
358
+ ...at,
359
+ severity: "error",
360
+ message:
361
+ `\`subType\` "${value}" is not one of the subtypes ` +
362
+ `${type} declares (${values.join(", ")})` +
363
+ (guess ? `. Did you mean "${guess}"?` : ""),
364
+ },
365
+ ];
366
+ }
367
+
368
+ /**
369
+ * Check a note's `tags` for near misses against the tags that classify (#172).
370
+ *
371
+ * `tags:` is top-level and the top level is open, so an unrecognised tag is
372
+ * **not** a finding: a theme, a region or a working state is the author's own
373
+ * vocabulary and this build has no standing to refuse it.
374
+ *
375
+ * **Distance alone is not enough either**, which the corpus settles rather than
376
+ * argues: `azravan` on a faith, `barter` on an economy note and `secret` on
377
+ * three lore notes are all within a typo's distance of `caravan`, `border` and
378
+ * `sacred`, and not one is a mistake. Checked against every declared tag at
379
+ * once, the rule was wrong on every note it touched.
380
+ *
381
+ * **The scope is what makes it sound.** Each group names the types it applies
382
+ * to, so a place's kinds are only ever checked on a place, and the eight
383
+ * findings above become none while a settlement tagged `vilage` is still
384
+ * caught.
385
+ *
386
+ * @param {object} note - The note.
387
+ * @param {object} opts
388
+ * @param {readonly string[]} opts.tags - Every declared tag, flattened.
389
+ * @returns {object[]} Findings.
390
+ */
391
+ function checkTags(note, { type }) {
392
+ const authored = (note.fm ?? {}).tags;
393
+ if (!Array.isArray(authored)) return [];
394
+
395
+ const tags = declaredTags(type);
396
+ if (!tags.length) return [];
397
+ const declared = new Set(tags);
398
+ const findings = [];
399
+ for (const raw of authored) {
400
+ if (typeof raw !== "string" || !raw.trim()) continue;
401
+ const value = raw.trim();
402
+ if (declared.has(value)) continue;
403
+ const guess = nearest(value, tags);
404
+ if (!guess) continue;
405
+ findings.push({
406
+ file: note.file,
407
+ ...positionInFrontmatter(note.raw ?? "", "tags"),
408
+ severity: "error",
409
+ message:
410
+ `tag "${value}" is not declared, and is a near miss for the declared ` +
411
+ `tag "${guess}". A classifying tag is queried, so a misspelt one drops ` +
412
+ `this note out of an index without failing anything. Write "${guess}", ` +
413
+ `or rename the tag so it is plainly the author's own`,
414
+ });
415
+ }
416
+ return findings;
193
417
  }
194
418
 
195
419
  /**
@@ -200,9 +424,18 @@ function sohlBlock(fm) {
200
424
  * @param {Record<string, readonly object[]>} opts.schemas - Type → declaration.
201
425
  * @param {object} [opts.index] - The link index, for the reference check. Its
202
426
  * absence skips that check rather than reporting every reference as dead.
427
+ * @param {Record<string, object>} [opts.vocabulary] - Type → the closed regions
428
+ * it declares, as `engine/note-vocabulary.mjs` states them (#128). Supplied
429
+ * by the caller for the same reason `schemas` is: this module validates a
430
+ * note against whatever its type declares and knows no type names of its
431
+ * own. Its absence skips the `data:` and `subType` checks rather than
432
+ * reporting every key as unknown.
433
+ * @param {Readonly<Record<string, {known?: readonly string[], fieldVocabulary?: boolean}>>} [opts.systems]
434
+ * The system blocks to check, and what each accepts. See
435
+ * {@link DEFAULT_SYSTEM_BLOCKS}.
203
436
  * @returns {object[]} Findings, each with a locator where one is obtainable.
204
437
  */
205
- export function lintNote(note, { schemas, index }) {
438
+ export function lintNote(note, { schemas, index, vocabulary, systems = DEFAULT_SYSTEM_BLOCKS }) {
206
439
  const findings = [];
207
440
  const fm = note.fm ?? {};
208
441
  const type = String(fm.type ?? "");
@@ -239,6 +472,11 @@ export function lintNote(note, { schemas, index }) {
239
472
  // fields above because it is the same kind of statement — a top-level key
240
473
  // this note may not write — and, like them, it must survive the two early
241
474
  // returns below: the finding stands whatever else the type is.
475
+ // Tags are checked here for the same reason: a classifying tag is not a
476
+ // type's property — `draft` belongs to any note and `village` to a place —
477
+ // so the finding must survive the early returns below.
478
+ findings.push(...checkTags(note, { type }));
479
+
242
480
  for (const { key, message } of checkHomepageAddressFields(fm)) {
243
481
  findings.push({
244
482
  file: note.file,
@@ -275,42 +513,114 @@ export function lintNote(note, { schemas, index }) {
275
513
  return findings;
276
514
  }
277
515
 
516
+ // The closed frontmatter regions (#128), checked beside the `sohl:` block
517
+ // because they are the same statement about the same note: this key is not
518
+ // one this type may write. Skipped entirely when the caller declares no
519
+ // vocabulary — reporting every key as unknown because nothing was loaded
520
+ // to recognise it would be worse than not checking.
521
+ const entry = vocabulary?.[type];
522
+ if (entry) {
523
+ findings.push(...checkDataContainer(note, { type, fields: entry.data ?? [] }));
524
+ findings.push(...checkSubType(note, { type, entry }));
525
+ }
526
+
278
527
  const fields = authoredFields(schema);
279
- const block = sohlBlock(fm);
280
528
  /** First segment of each declared name — `impact.die` is authored as `impact`. */
281
529
  const declared = new Set(fields.map((f) => f.name.split(".")[0]));
282
530
 
283
- for (const key of Object.keys(block)) {
284
- if (declared.has(key) || UNIVERSAL_KEYS.has(key)) continue;
285
- const guess = nearest(key, declared);
531
+ // The retired spelling of a field this type declares → what to write now.
532
+ // Built from the type's own vocabulary, so a renamed field is retired
533
+ // exactly where its replacement exists and the old name stays an unknown
534
+ // key everywhere else (#142).
535
+ const renamed = new Map();
536
+ for (const name of declared) {
537
+ const retired = RETIRED_FIELD_ALIASES[name];
538
+ if (retired) renamed.set(retired, name);
539
+ }
540
+ for (const [retired, current] of renamed) {
541
+ // `declaresRetiredAlias` searches both regions, because both are read:
542
+ // a note that moved the key to the top level without renaming it has
543
+ // done half the migration. It is the same predicate the compile-time
544
+ // report asks, so the two cannot disagree about what a note declares.
545
+ if (!declaresRetiredAlias(fm, current)) continue;
286
546
  findings.push({
287
547
  file: note.file,
288
- ...at(key),
289
- severity: "error",
290
- message:
291
- `"${key}" is not a property of a ${type}; it is discarded at ` +
292
- `compile with no warning` +
293
- (guess ? `. Did you mean "${guess}"?` : ""),
548
+ ...at(retired),
549
+ // A warning, not an error: the note compiles to the correct
550
+ // document, so failing a build over it would red a tree that has
551
+ // done nothing wrong yet. The refusal comes after the sweep, as
552
+ // `package:`'s did (#56).
553
+ severity: "warning",
554
+ message: retiredAliasMessage(retired, current),
294
555
  });
295
556
  }
296
557
 
558
+ // Every declared system's block, each against its own vocabulary (#58). A
559
+ // block carries the shared keys any system's does — `system`, `type`,
560
+ // `img`, `effects`, `flags`, `pack` — plus whatever that system declares:
561
+ // for `sohl`, the note type's own field names, which are still the position
562
+ // the corpus authors them at until #126 moves them.
563
+ for (const [blockName, spec] of Object.entries(systems ?? {})) {
564
+ const accepted = new Set([
565
+ ...UNIVERSAL_KEYS,
566
+ ...(spec?.known ?? []),
567
+ ...(spec?.fieldVocabulary ? declared : []),
568
+ ]);
569
+ for (const key of unknownBlockKeys(fm, blockName, { known: accepted })) {
570
+ // Reported above, with what to write instead — a retired spelling
571
+ // is a rename to schedule, not a key nobody recognises. Only where
572
+ // the block's vocabulary is this type's: the alias renames *this*
573
+ // system's field, and another system's like-spelled key is not it.
574
+ if (spec?.fieldVocabulary && renamed.has(key)) continue;
575
+ const guess = nearest(key, [...accepted, ...SYSTEM_BLOCK_KEYS]);
576
+ findings.push({
577
+ file: note.file,
578
+ ...at(key),
579
+ severity: "error",
580
+ message:
581
+ `"${key}" is not a property of a ${type}` +
582
+ (blockName === "sohl" ? "" : ` under \`${blockName}\``) +
583
+ `; it is discarded at compile with no warning` +
584
+ (guess ? `. Did you mean "${guess}"?` : ""),
585
+ });
586
+ }
587
+ }
588
+
297
589
  for (const field of fields) {
298
590
  // Only top-level names are read here: a nested one (`impact.die`) is
299
591
  // reached through its parent, and reporting the parent twice — once as
300
592
  // itself and once as its child — helps nobody.
301
- const [head, ...rest] = field.name.split(".");
302
- let value = block[head];
303
- for (const segment of rest) {
304
- value = value && typeof value === "object" ? value[segment] : undefined;
593
+ const [head] = field.name.split(".");
594
+ // Resolved exactly as the compiler resolves it (#58): the system path
595
+ // first, then the block, then the declared shared source. A lint that
596
+ // read only one of the three would report a note's own field as missing
597
+ // the moment it moved to another of them. A **shared** field needs
598
+ // nothing extra here — the note's top level *is* the third step — so
599
+ // `shared` says only where the field's home is, for the message below.
600
+ let { value, from } = resolveFieldValue(field, fm, { block: "sohl" });
601
+ // A **renamed** field may still be written under its retired spelling,
602
+ // which that order knows nothing about. It resolves through the reader
603
+ // the compiler uses, so the lint cannot disagree with the build about
604
+ // which value a note carries (#142).
605
+ if ((from === "default" || value == null) && RETIRED_FIELD_ALIASES[field.name]) {
606
+ const aliased = readAliasedField(fm, field.name);
607
+ if (aliased !== undefined) {
608
+ value = aliased;
609
+ from = "block";
610
+ }
305
611
  }
306
- const absent = value === undefined || value === null;
612
+ const absent = from === "default" || value === undefined || value === null;
613
+ // Where the field belongs, as a message names it: a shared field is not
614
+ // under `sohl:`, so telling an author to write `sohl.img` would send
615
+ // them to the wrong region.
616
+ const label = field.shared ? `\`${field.name}\`` : `\`sohl.${field.name}\``;
307
617
 
308
618
  if (field.required && absent) {
309
619
  findings.push({
310
620
  file: note.file,
311
621
  ...at("type", type),
312
622
  severity: "error",
313
- message: `a ${type} must declare \`sohl.${field.name}\` — ${field.describe}`,
623
+ message: `a ${type} must declare ${label} — ${field.describe}`,
314
624
  });
315
625
  continue;
316
626
  }
@@ -322,7 +632,7 @@ export function lintNote(note, { schemas, index }) {
322
632
  ...at(head),
323
633
  severity: "error",
324
634
  message:
325
- `\`sohl.${field.name}\` should be ${field.shape ?? field.kind}, ` +
635
+ `${label} should be ${field.shape ?? field.kind}, ` +
326
636
  `but reads ${JSON.stringify(value)}`,
327
637
  });
328
638
  continue;
@@ -340,7 +650,7 @@ export function lintNote(note, { schemas, index }) {
340
650
  ...at(head, value),
341
651
  severity: "error",
342
652
  message:
343
- `\`sohl.${field.name}\` names ${field.ref} ` +
653
+ `${label} names ${field.ref} ` +
344
654
  `"${value}", and no note or vendored manifest declares it`,
345
655
  });
346
656
  }
@@ -356,11 +666,15 @@ export function lintNote(note, { schemas, index }) {
356
666
  * @param {object} index - From `buildLinkIndex`.
357
667
  * @param {object} opts
358
668
  * @param {Record<string, readonly object[]>} opts.schemas - Type → declaration.
669
+ * @param {Record<string, object>} [opts.vocabulary] - Type → the closed regions
670
+ * it declares (#128); see {@link lintNote}.
359
671
  * @param {boolean} [opts.references=true] - Whether to check references.
672
+ * @param {Readonly<Record<string, {known?: readonly string[], fieldVocabulary?: boolean}>>} [opts.systems]
673
+ * The system blocks to check. See {@link DEFAULT_SYSTEM_BLOCKS}.
360
674
  * @returns {{findings: object[], notes: number}} The findings, and how many
361
675
  * notes were inspected.
362
676
  */
363
- export function lintFrontmatter(index, { schemas, references = true }) {
677
+ export function lintFrontmatter(index, { schemas, vocabulary, references = true, systems }) {
364
678
  const findings = [];
365
679
  const notes = [...index.notes].sort((a, b) =>
366
680
  a.file < b.file ? -1
@@ -371,7 +685,9 @@ export function lintFrontmatter(index, { schemas, references = true }) {
371
685
  findings.push(
372
686
  ...lintNote(note, {
373
687
  schemas,
688
+ vocabulary,
374
689
  index: references ? index : undefined,
690
+ ...(systems ? { systems } : {}),
375
691
  }),
376
692
  );
377
693
  }
@@ -53,6 +53,7 @@ import { countContentNotes } from "./content-tree.mjs";
53
53
  import { emitDiagnostic } from "./diagnostics.mjs";
54
54
  import { loadPackConfig } from "./pack-config.mjs";
55
55
  import { routerFor } from "./pack-router.mjs";
56
+ import { unclaimedNoteFindings } from "./note-claims.mjs";
56
57
 
57
58
  /**
58
59
  * The compiler class for each Foundry document type a pack may hold.
@@ -94,16 +95,30 @@ export const packJsonDir = (name, config = loadPackConfig()) =>
94
95
  * order among packs of one type — and every one of them is written before the
95
96
  * actors pass that reads them.
96
97
  *
98
+ * **Scoped to one system when the caller has one (#58).** A being addresses an
99
+ * item by `(type, shortcode)`, and that address is unique within one system and
100
+ * not across two: `skill:sword` is an HM3 skill *and* a SoHL skill, with
101
+ * different data models behind them. The reference itself is unambiguous — it
102
+ * sits inside a system block, so position says which it means — but the
103
+ * resolver has to know which catalogue it is searching, or it resolves the pair
104
+ * by whichever pack was read first. So an Actor pass reads the Item packs of
105
+ * **its own** system plus the system-neutral ones, which belong to every
106
+ * system. Asking for no system reads them all, which is every single-system
107
+ * build and the behaviour this always had.
108
+ *
97
109
  * @param {object} [config] - The resolved build configuration. Defaults to this
98
110
  * repository's.
111
+ * @param {string|null} [system] - The system whose catalogue is wanted. Omitted
112
+ * or `null`, every Item pack is read.
99
113
  * @returns {string[]} Each Item pack's JSON directory. Empty when the
100
114
  * repository ships no items at all, which is a legitimate package: the actors
101
115
  * pass accepts an empty list and reports an item it cannot resolve per
102
116
  * `(type, shortcode)` instead, naming the being (#49).
103
117
  */
104
- export function itemPackJsonDirs(config = loadPackConfig()) {
118
+ export function itemPackJsonDirs(config = loadPackConfig(), system = null) {
105
119
  return config.packs
106
120
  .filter((pack) => pack.type === "Item")
121
+ .filter((pack) => system == null || !pack.system || pack.system === system)
107
122
  .map((pack) => packJsonDir(pack.name, config));
108
123
  }
109
124
 
@@ -296,7 +311,9 @@ async function generatePack(
296
311
  // the Item packs, so the dependency is stated rather than assumed
297
312
  // (#1508) — and it is every Item pack, since a repository may ship more
298
313
  // than one (#1566).
299
- itemsSourceDirs: itemPackJsonDirs(config),
314
+ // Scoped to this pack's system, so a being resolves `(type, shortcode)`
315
+ // against its own system's catalogue and the neutral one (#58).
316
+ itemsSourceDirs: itemPackJsonDirs(config, system ?? null),
300
317
  // The catalogue of a package this repository depends on but does
301
318
  // not contain, for a repository that authors beings without
302
319
  // holding the items they are assembled from. Cache-only: a cold
@@ -381,6 +398,17 @@ export async function generatePacksJson({ only, config = loadPackConfig() } = {}
381
398
  return 1;
382
399
  }
383
400
  log.info(`Content tree: ${noteCount} note(s) at ${contentBase}`);
401
+
402
+ // A note whose `type:` no configured pack claims compiles into nothing, and
403
+ // used to say nothing (#146) — no pass got far enough to reject it, so the
404
+ // silence had no owner. Asked once, of the whole configuration, because
405
+ // that is the only place it can be answered: a per-pass check would report
406
+ // every type a system deliberately does not map, which is exactly the
407
+ // silence #79 requires. Independent of `only`, since it is a fact about the
408
+ // configured pack list rather than about which passes this run executes.
409
+ const unclaimed = unclaimedNoteFindings(config);
410
+ for (const finding of unclaimed) emitDiagnostic(finding);
411
+
384
412
  fs.mkdirSync(config.paths.packJson, { recursive: true });
385
413
 
386
414
  // A companion pack has no pass of its own — naming it selects the pass that
@@ -419,10 +447,10 @@ export async function generatePacksJson({ only, config = loadPackConfig() } = {}
419
447
  for (const message of unsatisfied) {
420
448
  emitDiagnostic({ severity: "error", message });
421
449
  }
422
- return unsatisfied.length;
450
+ return unsatisfied.length + unclaimed.length;
423
451
  }
424
452
 
425
- let totalErrors = 0;
453
+ let totalErrors = unclaimed.length;
426
454
  const passes = [];
427
455
  for (const pack of ordered) {
428
456
  const { errors, compiled } = await generatePack(
@@ -39,6 +39,8 @@ import { contentPackage, foundryPackageId } from "./content-package.mjs";
39
39
  import { searchableFrontmatter } from "./note-package.mjs";
40
40
  import { loadForeignManifests, PACKAGE_BASE } from "./kb-manifest.mjs";
41
41
  import { buildWikilinkIndex, convertWikilinks } from "./wikilinks.mjs";
42
+ // The alias sources every index shares (#131).
43
+ import { aliasesOf } from "./alias-index.mjs";
42
44
  import { expandContentTables } from "./content-tables.mjs";
43
45
  import { emitDiagnostic, positionInBody } from "./diagnostics.mjs";
44
46
  // The pure `sohl:` frontmatter readers live in a leaf module so the item-type
@@ -147,8 +149,8 @@ export function* walkMarkdownTree(
147
149
 
148
150
  /**
149
151
  * Resolve the required `sohl.archetype` frontmatter for an Item/Actor entry
150
- * (see the archetype contract, #604 — `flags.sohl.docArchetype`). The property
151
- * is a nullable number that authors must state explicitly:
152
+ * (the archetype contract, #604). The property is a nullable number that
153
+ * authors must state explicitly:
152
154
  * - a number → the document is an archetype of that priority.
153
155
  * - `null` → the document is not an archetype.
154
156
  * - absent → an authoring error (throws), so "not an archetype" is never
@@ -182,30 +184,29 @@ export function resolveArchetype(fm, label) {
182
184
  }
183
185
 
184
186
  /**
185
- * Merge the required `sohl.archetype` frontmatter into a document's `flags`,
186
- * returning a new object (the input is never mutated). A numeric archetype
187
- * seeds `flags.sohl.docArchetype`; `null` omits the flag (and clears any stale
188
- * `docArchetype` while preserving sibling `sohl` flags); an absent value
189
- * throws. See {@link resolveArchetype}.
190
- *
191
- * @param {object} fm Parsed frontmatter.
192
- * @param {object} [flags] The entry's existing flags (e.g. `fm.flags`).
193
- * @param {string} label Human-readable context for error messages.
194
- * @returns {object} The flags object with the archetype applied.
187
+ * The value a document's `system.archetype` carries, from the required
188
+ * `sohl.archetype` frontmatter (#126, sohl#1780).
189
+ *
190
+ * A **schema field**, so the tri-state is written out in full rather than
191
+ * expressed by a key's presence: a number is an archetype at that priority,
192
+ * and `null` is not an archetype. This is where {@link resolveArchetype}'s
193
+ * `undefined` becomes the field's `null` — an emitted `undefined` would be
194
+ * dropped by `JSON.stringify`, leaving the compiled document with no
195
+ * `archetype` at all and the tri-state readable as two.
196
+ *
197
+ * **`0` is an archetype.** It is the priority SoHL's own archetypes ship at,
198
+ * and it is falsy, so this returns it unchanged and every caller must ask
199
+ * `typeof v === "number"` rather than testing truthiness.
200
+ *
201
+ * @param {object} fm Parsed frontmatter.
202
+ * @param {string} label Human-readable context for error messages.
203
+ * @returns {number|null} The archetype priority, or `null` for a document
204
+ * that is not an archetype.
195
205
  * @throws {Error} When `sohl.archetype` is absent or invalid.
196
206
  */
197
- export function withArchetypeFlag(fm, flags, label) {
207
+ export function systemArchetype(fm, label) {
198
208
  const archetype = resolveArchetype(fm, label);
199
- const out = { ...(flags || {}) };
200
- const sohl = { ...(out.sohl || {}) };
201
- if (archetype === undefined) {
202
- delete sohl.docArchetype;
203
- } else {
204
- sohl.docArchetype = archetype;
205
- }
206
- if (Object.keys(sohl).length > 0) out.sohl = sohl;
207
- else delete out.sohl;
208
- return out;
209
+ return archetype === undefined ? null : archetype;
209
210
  }
210
211
 
211
212
  /**
@@ -449,12 +450,10 @@ export function buildContentLinkIndex(contentBase, router = packRouter()) {
449
450
  docPack: router.resolveOrNull(fm, "JournalEntry"),
450
451
  shortcode: fm.shortcode ?? null,
451
452
  name: fm.name?.full ?? base,
452
- aliases: [
453
- ...(Array.isArray(fm.aliases) ? fm.aliases : []),
454
- ...(fm.name?.full ? [fm.name.full] : []),
455
- ...(Array.isArray(fm.name?.aliases) ? fm.name.aliases : []),
456
- base,
457
- ].filter(Boolean),
453
+ // The shared alias sources (#131): `aliases`, `name.aliases` and
454
+ // `name.full`, and deliberately not the filename — see
455
+ // {@link aliasesOf} for what that admitted and why it went.
456
+ aliases: aliasesOf(fm),
458
457
  });
459
458
  }
460
459
  // Packages this build links *into* but does not publish. Their manifests
@@ -567,6 +566,19 @@ export function convertNoteWikilinks(
567
566
  `as [[type-shortcode|Text]].`,
568
567
  );
569
568
  }
569
+ // The author wrote a pipe, so they meant an address — and this target
570
+ // is not one. Its own message, because the correction is its own: a
571
+ // note *name* has to become an address, which is not the same job as
572
+ // fixing a shortcode that resolves nowhere (#131).
573
+ if (u.reason === "not-an-address") {
574
+ fail(
575
+ u,
576
+ `wikilink ${u.link} in "${name}" is written as an address — ` +
577
+ `the "|" says so — but "${u.target}" is not one. Write ` +
578
+ `[[type-shortcode|Text]], or drop the "|" to name it as ` +
579
+ `an alias within this note's own type.`,
580
+ );
581
+ }
570
582
  // A qualified address resolving nowhere is a typo, now that every
571
583
  // linkable package is either built here or vendored (#1499) — so it
572
584
  // fails the note rather than degrading to text. A bare alias stays a