@heroiclands/package-build 22.17.1 → 22.18.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.
@@ -12,75 +12,14 @@
12
12
  */
13
13
 
14
14
  /**
15
- * Diffing a package's published item addresses against a released one.
15
+ * Compare published Item addresses between a released package and its current
16
+ * build. The `(type, shortcode)` pair is the address consumers resolve from
17
+ * package indexes.
16
18
  *
17
- * A package's `(type, shortcode)` addresses are a **published interface**.
18
- * Every satellite that declares `itemCatalog: true` assembles its beings out of
19
- * them — `attribute:str`, `skill:awar`, `weapongear:Tabri` — resolving each one
20
- * against the Item packs of the release its `compatibility.verified` pins. So
21
- * renaming a shortcode is a breaking change to something other repositories
22
- * consume, and until this module there was nothing that noticed: the check that
23
- * got made was a repository-local grep, which cannot see the other
24
- * repositories and reports the reassuring answer.
25
- *
26
- * `sohl` renamed one weapon's shortcode from `Tabri` to `Taburi` two days after
27
- * the `v0.8.2` tag, on the stated ground that "nothing referenced the old
28
- * value, so the rename is self-contained". True of that repository. Both
29
- * satellites pin `v0.8.2` and address `weapongear:Tabri` on their copy of the
30
- * same character — five lookups that resolve today and fail the moment either
31
- * pin moves, with an error reading like a missing item.
32
- *
33
- * **The comparison is release-to-release, in the repository doing the
34
- * renaming.** The alternative — checking a consumer's addresses against its
35
- * pinned release — already exists and already fails the build (`no predefined
36
- * item for "weapongear:Taburi"`); what it lacks is an explanation, and it
37
- * cannot honestly produce one, because at the point of the miss all it holds is
38
- * the address string. It has no document id and no name to match a candidate
39
- * against, so any successor it named would be a guess at a similar-looking
40
- * string. Here both sides are whole documents, so the question is decidable.
41
- *
42
- * **A rename is told from a removal by the document id, and that is an identity
43
- * match rather than an inference.** An address that disappeared while its
44
- * document is still published elsewhere *is* a rename — not "probably" one.
45
- * When the id is published under no address at all, that is all this can say:
46
- * **withdrawn**, with no successor named. A split, a deletion and a merge are
47
- * indistinguishable from one another at that point, and inventing a "did you
48
- * mean" from string similarity would be worse than saying nothing, because a
49
- * wrong one sends the reader to the wrong fix.
50
- *
51
- * **That match is narrow, and a declaration is what makes up the
52
- * difference.** The join rested on the id being independent of the shortcode: a
53
- * note authored its `_id`, so the `Tabri` → `Taburi` commit changed the
54
- * shortcode alone and left the id to join the two sides. An id is
55
- * *derived from the canonical address*, which carries the shortcode — so
56
- * renaming a shortcode moves the id too, both sides of the join move together,
57
- * and the match finds nothing. It stays exact for a note that **pins** an `id`,
58
- * and pinning is still how a document keeps its identity across a rename; what
59
- * it cannot do is help the author who did not pin, because pinning has to
60
- * happen before the rename, by someone who does not yet know they will make
61
- * one.
62
- *
63
- * So an author who has just renamed a shortcode **says so**, with
64
- * `renamedFrom:` on the note that made the change (and see
65
- * `engine/note-renames.mjs`). That is neither a guess nor an identity match but
66
- * testimony from the only party that knows, and the diagnostic reports which of
67
- * the two it had rather than blending them — a reader can verify a matched id
68
- * in both artefacts, and can only take a declaration on its author's word.
69
- *
70
- * **Three joins, tried in that order of authority**: the document id, then a
71
- * declaration, then nothing — which remains **withdrawn**. Nothing here
72
- * infers, so a rename that is neither pinned nor declared is still reported as
73
- * a withdrawal; that is the honest answer, not a gap.
74
- *
75
- * **Severity is decided per case.** A withdrawal is legitimate — content is
76
- * allowed to be retired — so it is reported and does not fail a build. A rename
77
- * is equally legitimate as a decision (the charset rule forces some), which
78
- * is why it does not fail one either; what it must not do is happen in silence.
79
- * A caller that wants a gate passes `error` and treats any finding as one.
80
- *
81
- * Item packs only, because that is the address space consumers resolve
82
- * against: {@link foreignItemCatalogDirs} extracts nothing else, and a being's
83
- * embedded items are the only cross-package resolution by `(type, shortcode)`.
19
+ * A departed address is a rename when its document id is still published under
20
+ * another address. Otherwise it is reported as withdrawn. Since an id derived
21
+ * from a note's canonical address changes with its shortcode, that kind of
22
+ * rename produces a withdrawal finding; the added address has no finding.
84
23
  *
85
24
  * @module
86
25
  */
@@ -95,19 +34,13 @@ import { assertStatedScope } from "./helpers.mjs";
95
34
  // index's import graph reaches this module — only `bin/` imports it — so this
96
35
  // is a plain static import, as in the link checker.
97
36
  import { indexRecordsFor, isNoteRecord, noteFile } from "./content-index.mjs";
98
- import { renamedFrom } from "./note-renames.mjs";
99
- import { referencedSubtype } from "./document-subtypes.mjs";
100
- import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./subtype-registry.mjs";
101
37
 
102
38
  /**
103
39
  * The one spelling of an address in this space: `type:shortcode`.
104
40
  *
105
- * Written by both readers — the compiled packs on each side of the diff, and
106
- * the declarations read out of the tree — so a predecessor a note names and an
107
- * address a pack publishes cannot come apart over punctuation or case. The
108
- * shortcode is used **verbatim**, not lowercased: `readItemAddresses` reads it
109
- * off the compiled document, where `Tabri` is stored as authored, and folding
110
- * case here would join two addresses the packs keep apart.
41
+ * Used for addresses read from compiled documents. The shortcode is used
42
+ * **verbatim**, not lowercased: folding case would join two addresses the packs
43
+ * keep apart.
111
44
  *
112
45
  * @param {string} type - The Foundry document subtype, not the note type.
113
46
  * @param {string} shortcode - The address's `system.shortcode`.
@@ -174,40 +107,14 @@ export function readItemAddresses(dirs) {
174
107
  }
175
108
 
176
109
  /**
177
- * The corpus both reads below share, as content-index records.
178
- *
179
- * **One walk, not two.** `addresses diff` reads the tree twice — once for the
180
- * declarations and once to place its findings — and those would otherwise be
181
- * two
182
- * independent walks that each parsed every note. They are now one derivation,
183
- * shared: the caller derives the records and hands them to both, so the two
184
- * halves of a single command cannot disagree about which files the corpus is,
185
- * or about the ids in it.
186
- *
187
- * **The id is why it matters, and not only tidiness.** `noteFilesById` joins
188
- * tree-side ids against ids read out of the *compiled packs*. An id
189
- * is derived from the canonical address, whose first segment is the content
190
- * package — and the tree side used to derive it through `resolveNoteId(fm)`
191
- * with no package, which falls back to `contentPackage()` and so to whichever
192
- * configuration the working directory answers with. The compiled side is
193
- * produced by a compiler running on the configuration the *build* resolved. Let
194
- * those differ — under `PACKAGE_BUILD_CONFIG`, in a worktree, in a test — and
195
- * every id fails to join, so every rename degrades to a withdrawal and every
196
- * finding loses the note it should have been reported against. Reading the
197
- * index derives both sides from the one resolved configuration.
198
- *
199
- * A tree that is not there yields no records rather than throwing, which is
200
- * what the walk this replaces did: an absent tree is a caller's business, and
201
- * these two functions have never been the ones to report it.
110
+ * Read the scoped content corpus as content-index records.
202
111
  *
203
112
  * @param {string} contentBase - Root of the content tree.
204
113
  * @param {object} opts - Options.
205
- * @param {readonly string[]} [opts.skipDirectories] - The scope, required
206
- * unless `records` supplies the corpus outright.
207
- * @param {object} [opts.config] - The resolved configuration.
114
+ * @param {readonly string[]} [opts.skipDirectories] - The stated corpus scope.
115
+ * @param {object} [opts.config] - The resolved build configuration.
208
116
  * @param {readonly object[]} [opts.records] - Records the caller derived.
209
- * @param {object[]} [opts.problems] - Collects the notes the index cannot
210
- * record, so one of them does not abort the diff before it reports.
117
+ * @param {object[]} [opts.problems] - Collects notes the index cannot record.
211
118
  * @returns {readonly object[]} The index records.
212
119
  */
213
120
  function addressCorpus(contentBase, { skipDirectories, config, records, problems } = {}) {
@@ -217,118 +124,20 @@ function addressCorpus(contentBase, { skipDirectories, config, records, problems
217
124
  return indexRecordsFor({ contentBase, config, skipDirectories, problems });
218
125
  }
219
126
 
220
- /**
221
- * Every rename the tree's notes **declare**, as old address → where it went.
222
- *
223
- * Read from the content tree rather than from compiled output, because a
224
- * declaration is authored and the compiled document does not carry it: nothing
225
- * downstream consumes `renamedFrom:`, so emitting it into every pack to let one
226
- * diagnostic read it back would put a build-time note in shipped data forever.
227
- * The tree is already read by this module for the same reason
228
- * ({@link noteFilesById}) — to place a finding where its author can fix it.
229
- *
230
- * **A declaration is keyed by document subtype, not by note type.** The address
231
- * space is the one consumers resolve against, and it is spelled in compiled
232
- * documents: `hm3` compiles a `projectile` note into a `missilegear` item,
233
- * so that is the address a rename of it moves. {@link referencedSubtype} is the
234
- * function that already answers this for a being's embedded `(type, shortcode)`
235
- * references, so both sides read the same rule rather than a second copy of it.
236
- *
237
- * **An entry is emitted for every system that maps the type**, whether or not
238
- * the note declares that system's block. Over-emitting is inert — the diff uses
239
- * an entry only when the baseline published the old address *and* this build
240
- * publishes the new one, and a system the note does not compile for satisfies
241
- * neither — while asking which blocks a note declares would put a second,
242
- * subtly different answer to that question in a third place.
243
- *
244
- * **First claim wins on a collision.** Two notes naming one predecessor is a
245
- * contradiction — an address has one successor — and it is reported as an error
246
- * by `engine/content-lint.mjs`, where both notes are in hand and can both be
247
- * named. Picking one here keeps this a map; it is not a resolution, and nothing
248
- * rests on which one it picked.
249
- *
250
- * @param {string} contentBase - Root of the content tree.
251
- * @param {object} opts
252
- * @param {readonly string[]} [opts.skipDirectories] - The corpus scope. Stated
253
- * by the caller, never defaulted — see {@link addressCorpus}.
254
- * @param {readonly object[]} [opts.maps] - The document-subtype maps.
255
- * @param {object} [opts.config] - The resolved build configuration.
256
- * @param {readonly object[]} [opts.records] - Index records the caller already
257
- * derived, shared with {@link noteFilesById} so one command reads one corpus.
258
- * @param {object[]} [opts.problems] - Collects the notes the index cannot
259
- * record, so one of them does not abort the diff before it reports.
260
- * @returns {Map<string, {to: string, file: string, shortcode: string}>} Old
261
- * address → the address the declaring note publishes at now, and that note.
262
- */
263
- export function declaredPredecessors(
264
- contentBase,
265
- { skipDirectories, maps = KNOWN_DOCUMENT_SUBTYPE_MAPS, config, records, problems } = {},
266
- ) {
267
- const byOldAddress = new Map();
268
- for (const record of addressCorpus(contentBase, {
269
- skipDirectories,
270
- config,
271
- records,
272
- problems,
273
- })) {
274
- // A documentation journal is a document this tree emits, not a note in
275
- // it: it has no file and declares nothing.
276
- if (!isNoteRecord(record)) continue;
277
- // The record carries the note's frontmatter, so a declaration is read
278
- // off it exactly as it was read off the parse.
279
- const declared = renamedFrom(record);
280
- if (!declared.length) continue;
281
- const shortcode = typeof record.shortcode === "string" ? record.shortcode.trim() : "";
282
- // A note with no address of its own has nowhere for a predecessor to
283
- // have gone, so it declares a rename to nothing. Reported by the lint;
284
- // silently skipped here rather than indexed as a rename to `type:`.
285
- if (!shortcode) continue;
286
- const absPath = noteFile(contentBase, record);
287
- for (const map of maps) {
288
- const { subType } = referencedSubtype(map, record.type, "Item");
289
- if (!subType) continue;
290
- const to = itemAddressKey(subType, shortcode);
291
- for (const old of declared) {
292
- const from = itemAddressKey(subType, old);
293
- // A note naming its own current address declares nothing, and
294
- // indexing it would make every such address look renamed to
295
- // itself. The lint reports it.
296
- if (from === to) continue;
297
- if (!byOldAddress.has(from)) {
298
- byOldAddress.set(from, { to, file: absPath, shortcode: old });
299
- }
300
- }
301
- }
302
- }
303
- return byOldAddress;
304
- }
305
-
306
127
  /**
307
128
  * Every address the baseline published that this build does not.
308
129
  *
309
- * An address that merely *arrived* is not a finding: adding one breaks nobody.
310
- * The arrivals are read only to answer the one question that matters about a
311
- * departure — is the document still here under another name?
130
+ * An address that arrived is not a finding. A departed address is a rename
131
+ * only when its document id appears at another current address; otherwise it
132
+ * is a withdrawal.
312
133
  *
313
134
  * @param {Map<string, object>} baseline - The released address space.
314
135
  * @param {Map<string, object>} current - This build's address space.
315
136
  * @param {object} opts
316
- * @param {string} opts.baseline - What the baseline is, for the message —
317
- * conventionally `<package>@<version>`.
318
- * @param {Map<string, {to: string, file: string}>} [opts.predecessors] - The
319
- * declared renames, from {@link declaredPredecessors}. Omitted, the diff
320
- * falls back to the id join alone and reports an unpinned rename as a
321
- * withdrawal, which is what it did before.
322
- * @returns {Array<object>} One finding per departed address, in address order
323
- * so two runs read the same. `kind` is `"renamed"` (with `to`, and `declared`
324
- * when it was the note's word rather than an id match) or `"withdrawn"`.
137
+ * @param {string} opts.baseline - What the baseline is, for the message.
138
+ * @returns {Array<object>} One finding per departed address, in address order.
325
139
  */
326
- export function diffItemAddresses(baseline, current, { baseline: label, predecessors }) {
327
- // A baseline that yields no address at all cannot produce a finding, so it
328
- // reports a clean result for every possible input — the one failure a check
329
- // like this can never catch, and the same one `foreign-manifests.mjs` exists
330
- // to stop. It is a real state, not a hypothetical: `sohl-kethira-basic@0.5.3`
331
- // shipped 307 items carrying no `system.shortcode` between them.
140
+ export function diffItemAddresses(baseline, current, { baseline: label }) {
332
141
  if (!baseline.size) {
333
142
  throw new Error(
334
143
  `${label} publishes no addressable item — no document in its Item ` +
@@ -337,8 +146,6 @@ export function diffItemAddresses(baseline, current, { baseline: label, predeces
337
146
  `so it is refused rather than passed`,
338
147
  );
339
148
  }
340
- // Where each still-published document lives now. Built once: a rename is
341
- // decided by identity, so this is the whole evidence base.
342
149
  const currentById = new Map();
343
150
  for (const [address, entry] of current) {
344
151
  if (!currentById.has(entry.id)) currentById.set(entry.id, address);
@@ -347,28 +154,11 @@ export function diffItemAddresses(baseline, current, { baseline: label, predeces
347
154
  const findings = [];
348
155
  for (const [address, entry] of baseline) {
349
156
  if (current.has(address)) continue;
350
-
351
- // The id first, because it is the strongest answer available: a match
352
- // is an identity, and a reader can check it in both artefacts.
353
- const matched = currentById.get(entry.id);
354
- // Then the author's word, and only where it checks out — the note that
355
- // claims this address as a predecessor must itself be publishing now.
356
- // A declaration pointing at an address this build does not publish
357
- // describes a rename that did not survive to the packs, and naming it
358
- // as the successor would send the reader somewhere nothing is.
359
- const claim = matched ? undefined : predecessors?.get(address);
360
- const declared = claim && current.has(claim.to) ? claim : undefined;
361
- const to = matched ?? declared?.to;
362
-
157
+ const to = currentById.get(entry.id);
363
158
  findings.push({
364
159
  kind: to ? "renamed" : "withdrawn",
365
160
  address,
366
161
  ...(to ? { to } : {}),
367
- // Set only on a declared rename, so a reader of a finding can tell
368
- // testimony from an identity match without re-deriving which it was.
369
- ...(matched ? {}
370
- : declared ? { declared: true, noteFile: declared.file }
371
- : {}),
372
162
  id: entry.id,
373
163
  name: entry.name,
374
164
  shortcode: entry.shortcode,
@@ -381,7 +171,7 @@ export function diffItemAddresses(baseline, current, { baseline: label, predeces
381
171
  }
382
172
 
383
173
  /**
384
- * Every content note in a tree, indexed by the document id it authors.
174
+ * Every content note in a tree, indexed by the document id it compiles under.
385
175
  *
386
176
  * The address space is read from compiled output because that is what actually
387
177
  * ships; the tree is read only to place a finding somewhere a reader can open
@@ -395,7 +185,7 @@ export function diffItemAddresses(baseline, current, { baseline: label, predeces
395
185
  * @param {object} [opts.config] - The resolved build configuration, which the
396
186
  * id is derived against. See {@link addressCorpus} for why that matters.
397
187
  * @param {readonly object[]} [opts.records] - Index records the caller already
398
- * derived, shared with {@link declaredPredecessors}.
188
+ * derived by the caller.
399
189
  * @param {object[]} [opts.problems] - Collects the notes the index cannot
400
190
  * record, so one of them does not abort the diff before it reports.
401
191
  * @returns {Map<string, string>} Document id → the note's absolute path.
@@ -426,35 +216,15 @@ export function noteFilesById(contentBase, { skipDirectories, config, records, p
426
216
  /**
427
217
  * Where to send the reader for one finding.
428
218
  *
429
- * A rename is fixed in the note that made it, so a finding whose id is still in
430
- * this tree is reported at that note's `shortcode:` line — the line the author
431
- * just edited. A withdrawal has no such note by definition, so it degrades to
432
- * the baseline document, which is the only artefact left that records the
433
- * address existing. When neither is readable the position is **dropped**, never
434
- * defaulted to `1:1`.
435
- *
436
- * A **declared** rename knows its note without any lookup — the declaration is
437
- * how it was found — and is reported at the `renamedFrom:` line rather than the
438
- * `shortcode:` line, because that is the line the finding is about and the one
439
- * the author deletes once the declaration has done its work.
219
+ * A matched document is located at its current `shortcode:` line. A withdrawal
220
+ * is located in the baseline artifact, the remaining evidence for its address.
221
+ * When neither source has a usable position, the location is dropped.
440
222
  *
441
223
  * @param {object} finding - One finding from {@link diffItemAddresses}.
442
224
  * @param {Map<string, string>} noteFiles - From {@link noteFilesById}.
443
- * @returns {{file?: string, line?: number, column?: number}} Spreadable
444
- * position fields for {@link formatDiagnostic}.
225
+ * @returns {{file?: string, line?: number, column?: number}} Position fields.
445
226
  */
446
227
  export function locateAddressFinding(finding, noteFiles) {
447
- if (finding.declared && finding.noteFile) {
448
- try {
449
- const raw = fs.readFileSync(finding.noteFile, "utf8");
450
- return {
451
- file: finding.noteFile,
452
- ...positionInFrontmatter(raw, "renamedFrom", finding.shortcode),
453
- };
454
- } catch {
455
- return { file: finding.noteFile };
456
- }
457
- }
458
228
  const note = noteFiles?.get(finding.id);
459
229
  if (note) {
460
230
  try {
@@ -479,36 +249,19 @@ export function locateAddressFinding(finding, noteFiles) {
479
249
  /**
480
250
  * What one finding says, without a locator or a severity.
481
251
  *
482
- * The rename message names the identity it matched on, because that is what
483
- * separates this from a spelling suggestion: the reader can check the id in
484
- * both artefacts. The withdrawal message names no successor, because none is
485
- * known — and says so, rather than leaving the reader to wonder whether one was
486
- * looked for.
487
- *
488
- * **A declared rename says it is declared**. The two claims are not
489
- * equally checkable: an id match is a fact in the artefacts, while a
490
- * declaration is an author's word, and a reader deciding whether to trust the
491
- * successor needs to know which one they have. Saying "the same document" of a
492
- * declared rename would spend the id match's credibility on it.
493
- *
494
- * The consequence sentence is the same for both, because it is the same
495
- * consequence: the old address stops resolving either way, and that is what
496
- * the reader has to act on.
252
+ * A matched document names the address where it now appears. A withdrawal
253
+ * names no successor because the current build publishes no document with
254
+ * that identity.
497
255
  *
498
256
  * @param {object} finding - One finding from {@link diffItemAddresses}.
499
257
  * @returns {string} The message.
500
258
  */
501
259
  export function addressFindingMessage(finding) {
502
260
  if (finding.kind === "renamed") {
503
- const how =
504
- finding.declared ?
505
- `the note now published as ${finding.to} declares it was ` +
506
- `renamed from ${finding.shortcode}`
507
- : `the same document (${finding.id}) is now published as ${finding.to}`;
508
261
  return (
509
262
  `since ${finding.baseline}, ${finding.address} is no longer ` +
510
- `published; ${how}. Every package that resolves ${finding.address} ` +
511
- `breaks when it moves past ${finding.baseline}`
263
+ `published; the same document (${finding.id}) is now published as ${finding.to}. ` +
264
+ `Every package that resolves ${finding.address} breaks when it moves past ${finding.baseline}`
512
265
  );
513
266
  }
514
267
  return (
@@ -45,7 +45,6 @@
45
45
  */
46
46
 
47
47
  import { encodeAddresses } from "./address-values.mjs";
48
- import { hasTag } from "./note-vocabulary.mjs";
49
48
  import { ASSET_SYSTEM, isAssetType } from "./asset-types.mjs";
50
49
  import { ASSETS_SEGMENT } from "./pathnames.mjs";
51
50
  import { isAssetRecord } from "./index-records.mjs";
@@ -231,7 +230,7 @@ export function artPathname(index, value, defaultType, accepts) {
231
230
  }
232
231
 
233
232
  /**
234
- * The art a being falls back to, by the kind it is tagged.
233
+ * The art a being falls back to, by its subtype.
235
234
  *
236
235
  * Both files ship in `sohl`, under `assets/icons/other/`, and both are named
237
236
  * here as addresses rather than as paths for the reason every art reference is:
@@ -239,8 +238,8 @@ export function artPathname(index, value, defaultType, accepts) {
239
238
  * package borrowing the default gets the same file the system ships.
240
239
  *
241
240
  * **Only the compiler can choose between them**, because only the compiler
242
- * reads the note's tags. A schema default is the last resort beneath this one,
243
- * and covers a world document created by hand, which no note describes.
241
+ * reads the note's subtype. A schema default is the last resort beneath this
242
+ * one, and covers a world document created by hand, which no note describes.
244
243
  *
245
244
  * @type {Readonly<Record<string, string>>}
246
245
  */
@@ -250,19 +249,18 @@ export const BEING_DEFAULT_ART = Object.freeze({
250
249
  });
251
250
 
252
251
  /**
253
- * The default art address a being's own tags choose, or `null`.
252
+ * The default art address a being's subtype chooses, or `null`.
254
253
  *
255
- * `character` means a **person**, not a human, and `creature` everything else;
256
- * a being carries exactly one of the two, which is what makes the choice a
257
- * lookup rather than a precedence rule.
254
+ * An NPC and a character use person art; a creature uses creature art.
258
255
  *
259
256
  * @param {object} fm - The note's frontmatter.
260
- * @returns {string|null} The address, or `null` for a note carrying neither tag.
257
+ * @returns {string|null} The address, or `null` when no subtype selects one.
261
258
  */
262
259
  export function beingDefaultArt(fm) {
263
- for (const [tag, address] of Object.entries(BEING_DEFAULT_ART)) {
264
- if (hasTag(fm, tag)) return address;
260
+ if (fm?.subType === "npc" || fm?.subType === "character") {
261
+ return BEING_DEFAULT_ART.character;
265
262
  }
263
+ if (fm?.subType === "creature") return BEING_DEFAULT_ART.creature;
266
264
  return null;
267
265
  }
268
266
 
@@ -29,7 +29,10 @@ types:
29
29
  - appearance.skin_color
30
30
  - appearance.complexion
31
31
  - appearance.extra_features
32
- subTypes: []
32
+ subTypes:
33
+ - npc
34
+ - character
35
+ - creature
33
36
  homepage:
34
37
  data: []
35
38
  subTypes: []
@@ -676,9 +679,6 @@ vocabularies:
676
679
  - link
677
680
  - links
678
681
  - list
679
- beingKind:
680
- - character
681
- - creature
682
682
  class:
683
683
  - .full-width
684
684
  size:
@@ -696,14 +696,39 @@ export function collectContentIndex(
696
696
  // `walkMarkdownTree` is a generator — a single-use one, exhausted the
697
697
  // moment anything else iterates it first.
698
698
  const notes = fs.existsSync(contentBase) ? [...walkMarkdownTree(contentBase, walkOpts)] : [];
699
+ const parsedNotes = [];
700
+ for (const note of notes) {
701
+ if (!note.parseError) {
702
+ parsedNotes.push(note);
703
+ continue;
704
+ }
705
+ const problem = {
706
+ file: note.absPath,
707
+ ...(note.parseError.line === undefined ? {} : { line: note.parseError.line }),
708
+ ...(note.parseError.column === undefined ? {} : { column: note.parseError.column }),
709
+ severity: "error",
710
+ message: note.parseError.message,
711
+ };
712
+ if (!problems) {
713
+ const error = new Error(problem.message);
714
+ error.file = problem.file;
715
+ error.position = {
716
+ ...(problem.line === undefined ? {} : { line: problem.line }),
717
+ ...(problem.column === undefined ? {} : { column: problem.column }),
718
+ };
719
+ error.keyPath = [];
720
+ throw error;
721
+ }
722
+ problems.push(problem);
723
+ }
699
724
 
700
725
  // The package's declared present, read once from whichever `place` note in
701
726
  // this same walk states one — the whole tree is already in memory, so no
702
727
  // second read is needed to answer a question about all of it.
703
- const present = presentAmongFrontmatters(notes.map((n) => n.frontmatter));
704
- const dates = reckoningContext({ notes: notes.map((n) => n.frontmatter) });
728
+ const present = presentAmongFrontmatters(parsedNotes.map((n) => n.frontmatter));
729
+ const dates = reckoningContext({ notes: parsedNotes.map((n) => n.frontmatter) });
705
730
 
706
- for (const { frontmatter, body, bodyLine, absPath } of notes) {
731
+ for (const { frontmatter, body, bodyLine, absPath } of parsedNotes) {
707
732
  const fm = frontmatter ?? {};
708
733
  applyComputedBeingAge(fm, present, dates);
709
734
  const relPath = path.relative(contentBase, absPath);