@heroiclands/package-build 8.1.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 (122) hide show
  1. package/CHANGELOG.md +748 -0
  2. package/CONTENT.md +273 -13
  3. package/bin/content-build.mjs +479 -123
  4. package/bin/package-build.mjs +27 -69
  5. package/bin/report.mjs +1 -2
  6. package/bundle.mjs +2 -10
  7. package/config.mjs +31 -106
  8. package/container.mjs +13 -57
  9. package/content-config.mjs +300 -188
  10. package/coverage.mjs +14 -55
  11. package/deploy.mjs +4 -13
  12. package/docs/content-format.md +1418 -0
  13. package/e2e.mjs +16 -55
  14. package/engine/address-charset.mjs +62 -0
  15. package/engine/address-diff.mjs +1 -4
  16. package/engine/alias-index.mjs +153 -0
  17. package/engine/base-compiler.mjs +203 -31
  18. package/engine/code-fences.mjs +4 -13
  19. package/engine/compendiums.mjs +13 -37
  20. package/engine/content-address.mjs +6 -10
  21. package/engine/content-format-check.mjs +570 -0
  22. package/engine/content-format.mjs +253 -0
  23. package/engine/content-links.mjs +144 -99
  24. package/engine/content-lint.mjs +12 -16
  25. package/engine/content-slug.mjs +2 -6
  26. package/engine/content-tables.mjs +26 -79
  27. package/engine/diagnostics.mjs +37 -15
  28. package/engine/document-subtypes.mjs +440 -0
  29. package/engine/field-reference.mjs +6 -20
  30. package/engine/field-spec.mjs +49 -45
  31. package/engine/foreign-catalog.mjs +7 -22
  32. package/engine/foreign-manifests.mjs +1 -4
  33. package/engine/frontmatter-lint.mjs +347 -43
  34. package/engine/frontmatter.mjs +3 -8
  35. package/engine/generate.mjs +36 -20
  36. package/engine/helpers.mjs +54 -81
  37. package/engine/homepage.mjs +4 -15
  38. package/engine/ids.mjs +21 -12
  39. package/engine/index.mjs +15 -0
  40. package/engine/item-registry.mjs +72 -9
  41. package/engine/journals.mjs +4 -14
  42. package/engine/kb-manifest.mjs +41 -24
  43. package/engine/macros.mjs +2 -10
  44. package/engine/manifest-emit.mjs +6 -17
  45. package/engine/map-notes.mjs +53 -87
  46. package/engine/note-claims.mjs +383 -0
  47. package/engine/note-package.mjs +1 -4
  48. package/engine/note-vocabulary.mjs +678 -0
  49. package/engine/pack-config.mjs +56 -60
  50. package/engine/pack-router.mjs +18 -8
  51. package/engine/prose-config.mjs +20 -4
  52. package/engine/prose-lint.mjs +61 -17
  53. package/engine/region-events.mjs +1 -3
  54. package/engine/retired-fields.mjs +117 -3
  55. package/engine/scene-levels.mjs +8 -22
  56. package/engine/scenes.mjs +31 -47
  57. package/engine/schema-check.mjs +348 -7
  58. package/engine/schema-extract.mjs +11 -39
  59. package/engine/site-build.mjs +13 -38
  60. package/engine/site-index.mjs +40 -35
  61. package/engine/system-block.mjs +513 -0
  62. package/engine/web-wikilinks.mjs +115 -92
  63. package/engine/wikilink-syntax.mjs +30 -0
  64. package/engine/wikilinks.mjs +147 -183
  65. package/index.mjs +1 -5
  66. package/lang.mjs +1 -3
  67. package/manifest.mjs +10 -37
  68. package/markdownlint-config.mjs +1 -5
  69. package/package.json +6 -2
  70. package/sohl/actors.mjs +251 -68
  71. package/sohl/being-info.mjs +3 -6
  72. package/sohl/document-subtypes.mjs +82 -0
  73. package/sohl/index.mjs +4 -6
  74. package/sohl/item-builders.mjs +1 -3
  75. package/sohl/item-fields.mjs +16 -34
  76. package/sohl/items.mjs +111 -17
  77. package/sohl/kb-passes.mjs +29 -39
  78. package/sohl/note-schemas.mjs +11 -7
  79. package/sohl/skill-base.mjs +7 -23
  80. package/stage.mjs +3 -13
  81. package/templates.mjs +4 -15
  82. package/types/bundle.d.mts +1 -1
  83. package/types/container.d.mts +2 -2
  84. package/types/content-config.d.mts +48 -4
  85. package/types/coverage.d.mts +1 -1
  86. package/types/e2e.d.mts +4 -4
  87. package/types/engine/address-charset.d.mts +45 -0
  88. package/types/engine/alias-index.d.mts +122 -0
  89. package/types/engine/base-compiler.d.mts +132 -4
  90. package/types/engine/content-address.d.mts +2 -2
  91. package/types/engine/content-format-check.d.mts +163 -0
  92. package/types/engine/content-format.d.mts +101 -0
  93. package/types/engine/content-links.d.mts +16 -1
  94. package/types/engine/content-lint.d.mts +6 -0
  95. package/types/engine/diagnostics.d.mts +29 -0
  96. package/types/engine/document-subtypes.d.mts +233 -0
  97. package/types/engine/field-spec.d.mts +76 -23
  98. package/types/engine/frontmatter-lint.d.mts +47 -2
  99. package/types/engine/generate.d.mts +15 -2
  100. package/types/engine/helpers.d.mts +22 -14
  101. package/types/engine/ids.d.mts +10 -0
  102. package/types/engine/index.d.mts +5 -0
  103. package/types/engine/item-registry.d.mts +21 -2
  104. package/types/engine/kb-manifest.d.mts +35 -8
  105. package/types/engine/map-notes.d.mts +21 -11
  106. package/types/engine/note-claims.d.mts +113 -0
  107. package/types/engine/note-vocabulary.d.mts +251 -0
  108. package/types/engine/pack-config.d.mts +4 -3
  109. package/types/engine/pack-router.d.mts +4 -4
  110. package/types/engine/prose-lint.d.mts +6 -2
  111. package/types/engine/retired-fields.d.mts +73 -2
  112. package/types/engine/schema-check.d.mts +182 -0
  113. package/types/engine/schema-extract.d.mts +1 -1
  114. package/types/engine/site-index.d.mts +1 -1
  115. package/types/engine/system-block.d.mts +281 -0
  116. package/types/engine/web-wikilinks.d.mts +23 -12
  117. package/types/engine/wikilink-syntax.d.mts +29 -0
  118. package/types/manifest.d.mts +1 -1
  119. package/types/sohl/actors.d.mts +62 -6
  120. package/types/sohl/document-subtypes.d.mts +14 -0
  121. package/types/sohl/index.d.mts +1 -0
  122. package/types/sohl/items.d.mts +21 -0
@@ -203,9 +203,7 @@ export function writeZipEntries(files, dest) {
203
203
  * @returns {Promise<void>}
204
204
  */
205
205
  async function extractItemPacks(id, version, manifest, root, dir) {
206
- const itemPacks = (manifest.packs ?? []).filter(
207
- (pack) => pack.type === "Item",
208
- );
206
+ const itemPacks = (manifest.packs ?? []).filter((pack) => pack.type === "Item");
209
207
  if (!itemPacks.length) {
210
208
  throw new Error(
211
209
  `${id}@${version}: its manifest declares no Item packs, so it ` +
@@ -239,9 +237,7 @@ async function extractItemPacks(id, version, manifest, root, dir) {
239
237
  async function downloadAndUnzip(url, dest) {
240
238
  const res = await fetch(url, { redirect: "follow" });
241
239
  if (!res.ok) {
242
- throw new Error(
243
- `could not download ${url}: HTTP ${res.status} ${res.statusText}`,
244
- );
240
+ throw new Error(`could not download ${url}: HTTP ${res.status} ${res.statusText}`);
245
241
  }
246
242
  writeZipEntries(unzipSync(new Uint8Array(await res.arrayBuffer())), dest);
247
243
  }
@@ -272,10 +268,7 @@ export function pinnedManifestUrl(url, verified) {
272
268
  if (at === -1) return { url, pinned: false };
273
269
  const tag = verified.startsWith("v") ? verified : `v${verified}`;
274
270
  return {
275
- url:
276
- url.slice(0, at) +
277
- `/releases/download/${tag}/` +
278
- url.slice(at + marker.length),
271
+ url: url.slice(0, at) + `/releases/download/${tag}/` + url.slice(at + marker.length),
279
272
  pinned: true,
280
273
  };
281
274
  }
@@ -318,9 +311,7 @@ export async function fetchCatalog(config, rel) {
318
311
 
319
312
  const download = manifest.download;
320
313
  if (!download) {
321
- throw new Error(
322
- `${rel.id}@${version}: its manifest declares no \`download\``,
323
- );
314
+ throw new Error(`${rel.id}@${version}: its manifest declares no \`download\``);
324
315
  }
325
316
 
326
317
  // Rebuild from empty: a previous run may have died partway, and a stale
@@ -398,9 +389,7 @@ export async function fetchCatalogFromPath(config, rel, source) {
398
389
  throw new Error(`${rel.id}: nothing at ${source}`);
399
390
  }
400
391
 
401
- const staging = fs.mkdtempSync(
402
- path.join(os.tmpdir(), `content-build-${rel.id}-`),
403
- );
392
+ const staging = fs.mkdtempSync(path.join(os.tmpdir(), `content-build-${rel.id}-`));
404
393
  try {
405
394
  let root;
406
395
  if (fs.statSync(source).isDirectory()) {
@@ -419,9 +408,7 @@ export async function fetchCatalogFromPath(config, rel, source) {
419
408
  );
420
409
  }
421
410
  if (manifest.id && manifest.id !== rel.id) {
422
- throw new Error(
423
- `${rel.id}: ${source} is package "${manifest.id}", not "${rel.id}"`,
424
- );
411
+ throw new Error(`${rel.id}: ${source} is package "${manifest.id}", not "${rel.id}"`);
425
412
  }
426
413
  const version = manifest.version;
427
414
  if (!version) {
@@ -448,9 +435,7 @@ export async function fetchCatalogFromPath(config, rel, source) {
448
435
  export async function fetchAllCatalogs(config) {
449
436
  const rels = itemCatalogRelationships(config);
450
437
  if (!rels.length) {
451
- log.info(
452
- "No relationship declares `itemCatalog: true`; nothing to fetch.",
453
- );
438
+ log.info("No relationship declares `itemCatalog: true`; nothing to fetch.");
454
439
  return 0;
455
440
  }
456
441
  for (const rel of rels) await fetchCatalog(config, rel);
@@ -106,10 +106,7 @@ export function formatUnaddressableFinding(finding, manifestDir) {
106
106
  const file = path.join(manifestDir, `${finding.package}.json`);
107
107
  let at = {};
108
108
  try {
109
- at = positionOfLiteral(
110
- fs.readFileSync(file, "utf8"),
111
- `"${finding.sampleKey}"`,
112
- );
109
+ at = positionOfLiteral(fs.readFileSync(file, "utf8"), `"${finding.sampleKey}"`);
113
110
  } catch {
114
111
  // Unreadable here is not itself the finding — `loadForeignManifests`
115
112
  // already reports that as a stale manifest. The file is simply all that
@@ -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.
@@ -89,9 +97,29 @@ import { draftRetiredMessage } from "./retired-fields.mjs";
89
97
  *
90
98
  * @type {ReadonlySet<string>}
91
99
  */
92
- export const UNIVERSAL_KEYS = Object.freeze(
93
- new Set(["folder", "pack", "archetype", "kbcat"]),
94
- );
100
+ export const UNIVERSAL_KEYS = Object.freeze(new Set(["folder", "pack", "archetype", "kbcat"]));
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
+ });
95
123
 
96
124
  /**
97
125
  * Edit distance, capped — enough to answer "did you mean".
@@ -166,11 +194,7 @@ export function matchesKind(value, kind) {
166
194
  value.trim() !== "" &&
167
195
  Number.isFinite(Number(value));
168
196
  case "boolean":
169
- return (
170
- typeof value === "boolean" ||
171
- value === "true" ||
172
- value === "false"
173
- );
197
+ return typeof value === "boolean" || value === "true" || value === "false";
174
198
  case "string":
175
199
  return typeof value !== "object" || value === null;
176
200
  case "list":
@@ -179,9 +203,7 @@ export function matchesKind(value, kind) {
179
203
  // An emptied map arrives as `[]` from the property editor, and
180
204
  // means "this note authors no entries" — the same thing `{}` means.
181
205
  return (
182
- (typeof value === "object" &&
183
- value !== null &&
184
- !Array.isArray(value)) ||
206
+ (typeof value === "object" && value !== null && !Array.isArray(value)) ||
185
207
  (Array.isArray(value) && value.length === 0)
186
208
  );
187
209
  default:
@@ -190,16 +212,208 @@ export function matchesKind(value, kind) {
190
212
  }
191
213
 
192
214
  /**
193
- * 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.
194
220
  *
195
221
  * @param {object} fm - The note's frontmatter.
196
- * @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.
197
328
  */
198
- function sohlBlock(fm) {
199
- const block = fm?.sohl;
200
- return block && typeof block === "object" && !Array.isArray(block) ?
201
- block
202
- : {};
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;
203
417
  }
204
418
 
205
419
  /**
@@ -210,15 +424,23 @@ function sohlBlock(fm) {
210
424
  * @param {Record<string, readonly object[]>} opts.schemas - Type → declaration.
211
425
  * @param {object} [opts.index] - The link index, for the reference check. Its
212
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}.
213
436
  * @returns {object[]} Findings, each with a locator where one is obtainable.
214
437
  */
215
- export function lintNote(note, { schemas, index }) {
438
+ export function lintNote(note, { schemas, index, vocabulary, systems = DEFAULT_SYSTEM_BLOCKS }) {
216
439
  const findings = [];
217
440
  const fm = note.fm ?? {};
218
441
  const type = String(fm.type ?? "");
219
442
  const raw = () => note.raw ?? "";
220
- const at = (key, literal) =>
221
- positionInFrontmatter(raw(), key, literal ?? undefined);
443
+ const at = (key, literal) => positionInFrontmatter(raw(), key, literal ?? undefined);
222
444
 
223
445
  // The retired top-level fields, checked before the type: a note may carry
224
446
  // one whatever its type is, and each finding stands on its own. Reported
@@ -250,6 +472,11 @@ export function lintNote(note, { schemas, index }) {
250
472
  // fields above because it is the same kind of statement — a top-level key
251
473
  // this note may not write — and, like them, it must survive the two early
252
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
+
253
480
  for (const { key, message } of checkHomepageAddressFields(fm)) {
254
481
  findings.push({
255
482
  file: note.file,
@@ -286,43 +513,114 @@ export function lintNote(note, { schemas, index }) {
286
513
  return findings;
287
514
  }
288
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
+
289
527
  const fields = authoredFields(schema);
290
- const block = sohlBlock(fm);
291
528
  /** First segment of each declared name — `impact.die` is authored as `impact`. */
292
529
  const declared = new Set(fields.map((f) => f.name.split(".")[0]));
293
530
 
294
- for (const key of Object.keys(block)) {
295
- if (declared.has(key) || UNIVERSAL_KEYS.has(key)) continue;
296
- 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;
297
546
  findings.push({
298
547
  file: note.file,
299
- ...at(key),
300
- severity: "error",
301
- message:
302
- `"${key}" is not a property of a ${type}; it is discarded at ` +
303
- `compile with no warning` +
304
- (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),
305
555
  });
306
556
  }
307
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
+
308
589
  for (const field of fields) {
309
590
  // Only top-level names are read here: a nested one (`impact.die`) is
310
591
  // reached through its parent, and reporting the parent twice — once as
311
592
  // itself and once as its child — helps nobody.
312
- const [head, ...rest] = field.name.split(".");
313
- let value = block[head];
314
- for (const segment of rest) {
315
- value =
316
- 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
+ }
317
611
  }
318
- 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}\``;
319
617
 
320
618
  if (field.required && absent) {
321
619
  findings.push({
322
620
  file: note.file,
323
621
  ...at("type", type),
324
622
  severity: "error",
325
- message: `a ${type} must declare \`sohl.${field.name}\` — ${field.describe}`,
623
+ message: `a ${type} must declare ${label} — ${field.describe}`,
326
624
  });
327
625
  continue;
328
626
  }
@@ -334,7 +632,7 @@ export function lintNote(note, { schemas, index }) {
334
632
  ...at(head),
335
633
  severity: "error",
336
634
  message:
337
- `\`sohl.${field.name}\` should be ${field.shape ?? field.kind}, ` +
635
+ `${label} should be ${field.shape ?? field.kind}, ` +
338
636
  `but reads ${JSON.stringify(value)}`,
339
637
  });
340
638
  continue;
@@ -352,7 +650,7 @@ export function lintNote(note, { schemas, index }) {
352
650
  ...at(head, value),
353
651
  severity: "error",
354
652
  message:
355
- `\`sohl.${field.name}\` names ${field.ref} ` +
653
+ `${label} names ${field.ref} ` +
356
654
  `"${value}", and no note or vendored manifest declares it`,
357
655
  });
358
656
  }
@@ -368,11 +666,15 @@ export function lintNote(note, { schemas, index }) {
368
666
  * @param {object} index - From `buildLinkIndex`.
369
667
  * @param {object} opts
370
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}.
371
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}.
372
674
  * @returns {{findings: object[], notes: number}} The findings, and how many
373
675
  * notes were inspected.
374
676
  */
375
- export function lintFrontmatter(index, { schemas, references = true }) {
677
+ export function lintFrontmatter(index, { schemas, vocabulary, references = true, systems }) {
376
678
  const findings = [];
377
679
  const notes = [...index.notes].sort((a, b) =>
378
680
  a.file < b.file ? -1
@@ -383,7 +685,9 @@ export function lintFrontmatter(index, { schemas, references = true }) {
383
685
  findings.push(
384
686
  ...lintNote(note, {
385
687
  schemas,
688
+ vocabulary,
386
689
  index: references ? index : undefined,
690
+ ...(systems ? { systems } : {}),
387
691
  }),
388
692
  );
389
693
  }
@@ -119,8 +119,7 @@ export function resolveCharges(fm) {
119
119
  // A blank maximum means "does not use charges" — a stray current count
120
120
  // cannot outlive it, since the logic layer disables both modifiers.
121
121
  return {
122
- value:
123
- max === null ? null : toCount(sohlField(fm, "charges.value", null)),
122
+ value: max === null ? null : toCount(sohlField(fm, "charges.value", null)),
124
123
  max,
125
124
  };
126
125
  }
@@ -149,9 +148,7 @@ export function resolveCharges(fm) {
149
148
  export function resolveSkillAptitudes(fm, ctx = "item") {
150
149
  const entries = readMapEntries(fm, "skillAptitudes");
151
150
  if (entries === null) {
152
- throw new Error(
153
- `${ctx}: skillAptitudes must be a map of selector → number`,
154
- );
151
+ throw new Error(`${ctx}: skillAptitudes must be a map of selector → number`);
155
152
  }
156
153
  const out = {};
157
154
  for (const [selector, value] of entries) {
@@ -187,9 +184,7 @@ export function resolveSkillAptitudes(fm, ctx = "item") {
187
184
  export function resolveRelation(fm, ctx = "item") {
188
185
  const entries = readMapEntries(fm, "relation");
189
186
  if (entries === null) {
190
- throw new Error(
191
- `${ctx}: relation must be a map of shortcode → standing`,
192
- );
187
+ throw new Error(`${ctx}: relation must be a map of shortcode → standing`);
193
188
  }
194
189
  const out = {};
195
190
  for (const [code, value] of entries) {