@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
@@ -0,0 +1,62 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * The one charset every segment of a canonical address is held to (#59).
16
+ *
17
+ * An address is a hyphen-joined tuple — `sohl-skill-clmb` — and it is read back
18
+ * by **counting segments**, with a fixed meaning per position. That is sound
19
+ * for exactly one reason: the hyphen is *purely* a separator, because no
20
+ * segment may contain one. Take that away and reading an address needs a
21
+ * vocabulary to match against, a longest-match rule, and an answer for every
22
+ * name that is a prefix of another — none of which exist.
23
+ *
24
+ * So the charset is not a tidiness rule. It is the premise the address grammar
25
+ * rests on, and the issue's word for how it should be held is **enforced rather
26
+ * than assumed**: a value that breaks it is refused where it is written, not
27
+ * discovered later as addresses that fail to parse and report nothing about
28
+ * why. `harn-adventures` was that case — its keys read as four segments and
29
+ * failed as a `null` return.
30
+ *
31
+ * This module is a **leaf with no local imports**, so the validator a
32
+ * consumer's `package-build.config.mjs` reaches (`content-config.mjs`) can name
33
+ * it without closing a cycle around that file.
34
+ *
35
+ * @module
36
+ */
37
+
38
+ /**
39
+ * The shape every address segment must match: ASCII letters and digits only.
40
+ *
41
+ * Case is deliberately **not** constrained. Hundreds of authored shortcodes are
42
+ * mixed-case and collide with nothing, and case has no bearing on the
43
+ * separator, which is the whole of what this pattern is protecting.
44
+ *
45
+ * @type {RegExp}
46
+ */
47
+ export const ADDRESS_SEGMENT_PATTERN = /^[A-Za-z0-9]+$/;
48
+
49
+ /**
50
+ * Whether a value is a well-formed address segment.
51
+ *
52
+ * A blank value is **not** valid: this answers "is this an acceptable segment",
53
+ * never "is a segment present". Presence is a separate question, asked
54
+ * wherever the value is required, and conflating the two would report a missing
55
+ * key as a charset violation.
56
+ *
57
+ * @param {unknown} value - The candidate segment.
58
+ * @returns {boolean} `true` when it matches {@link ADDRESS_SEGMENT_PATTERN}.
59
+ */
60
+ export function isAddressSegment(value) {
61
+ return typeof value === "string" && ADDRESS_SEGMENT_PATTERN.test(value);
62
+ }
@@ -0,0 +1,153 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * The **alias** namespace: what a note can be called, and who may claim a name.
16
+ *
17
+ * A wikilink resolves through one of two namespaces, and the pipe chooses
18
+ * which (#131): `[[x|…]]` is an *address*, parsed by the address grammar;
19
+ * `[[x]]` is an *alias*, looked up here. This module owns the second half —
20
+ * what goes into the index, how a key is spelled, and what happens when two
21
+ * notes claim one name.
22
+ *
23
+ * **An alias is scoped to the claiming note's own type.** The key is
24
+ * `(type, alias)`, so `Shock` may be a `skill` in one place and a `trauma` in
25
+ * another without the two ever meeting. A link resolves against the *source*
26
+ * note's type, which is why a bare name reaches a sibling and never a
27
+ * cross-type target — that one is written as an address.
28
+ *
29
+ * **Three sources, all authored.** `aliases`, `name.aliases`, and `name.full`.
30
+ * Each is something a person wrote down as a name for the note, which is
31
+ * exactly what a bare `[[…]]` cites.
32
+ *
33
+ * **The filename is deliberately not one of them**, and it used to be — every
34
+ * one of the three copies of this index added `basename(file, ".md")` with
35
+ * underscores turned to spaces. That admitted keys no author could ever cite
36
+ * and no author had ever written:
37
+ *
38
+ * - `_Introduction.md` yields the alias `" introduction"`, *with a leading
39
+ * space*. A wikilink target is trimmed, so nothing can ever match it. In one
40
+ * repository thirteen notes — one per documentation section — claimed that
41
+ * key, making it the largest alias collision in the corpus and every one of
42
+ * its claimants blameless.
43
+ * - `README.md` yields `readme`, claimed once per section for the same reason.
44
+ *
45
+ * Since a collision is now a build failure rather than a silent deletion, an
46
+ * index entry that cannot be cited can only ever *cause* one. Removing the
47
+ * source was measured first, across all five content trees: not one link that
48
+ * resolves today resolves through the filename alone, so nothing loses a
49
+ * target — while the collision count falls without a note being edited.
50
+ *
51
+ * @module
52
+ */
53
+
54
+ /**
55
+ * Every alias a note claims, in the order the sources are consulted.
56
+ *
57
+ * @param {object} fm - Parsed frontmatter.
58
+ * @returns {string[]} The claimed aliases, each a non-empty string.
59
+ */
60
+ export function aliasesOf(fm) {
61
+ return [
62
+ ...(Array.isArray(fm?.aliases) ? fm.aliases : []),
63
+ ...(Array.isArray(fm?.name?.aliases) ? fm.name.aliases : []),
64
+ fm?.name?.full,
65
+ ].filter((a) => typeof a === "string" && a);
66
+ }
67
+
68
+ /**
69
+ * The index key one note's claim on one alias is filed under.
70
+ *
71
+ * Stated here so the three indexes — the pack build's, the site build's and
72
+ * the link checker's — cannot spell it differently. All three already used
73
+ * `type|alias`, lowercased; the risk was never that they disagreed today.
74
+ *
75
+ * @param {string} type - The claiming note's content type.
76
+ * @param {string} alias - The alias, as authored.
77
+ * @returns {string} The key.
78
+ */
79
+ export function aliasKey(type, alias) {
80
+ return `${String(type).trim()}|${String(alias).trim()}`.toLowerCase();
81
+ }
82
+
83
+ /**
84
+ * One alias claimed by more than one note of a single type.
85
+ *
86
+ * @typedef {object} AliasCollision
87
+ * @property {string} key - The index key, `type|alias`.
88
+ * @property {string} type - The type both claimants share.
89
+ * @property {string} alias - The alias, as the first claimant wrote it.
90
+ * @property {unknown[]} claimants - Every note claiming it, in walk order.
91
+ */
92
+
93
+ /**
94
+ * Build the type-scoped alias index, and report every collision in it.
95
+ *
96
+ * **A collision resolves to nothing, and is reported naming every claimant.**
97
+ * Both halves matter. Resolving to whichever note happened to be walked first
98
+ * makes a link silently point at the wrong document, and which one it is
99
+ * depends on directory order. Reporting it at the *citing* note blames a file
100
+ * whose author did nothing wrong — whoever added the second claimant broke
101
+ * every existing citation (#13) — so the claimants are kept rather than
102
+ * discarded along with the entry.
103
+ *
104
+ * @template T
105
+ * @param {Iterable<{type: string, aliases: Iterable<string>, value: T}>} entries
106
+ * One per note: the type that scopes its claims, the aliases it claims, and
107
+ * whatever the caller wants an alias to resolve to.
108
+ * @param {object} [opts]
109
+ * @param {(a: T, b: T) => boolean} [opts.same] - Whether two values are the
110
+ * same note. Defaults to identity; a caller whose values are freshly built
111
+ * records supplies its own.
112
+ * @returns {{byKey: Map<string, T>, claims: Map<string, T[]>,
113
+ * collisions: AliasCollision[]}} `byKey` omits every colliding key, so a
114
+ * lookup in it can never resolve an ambiguous alias.
115
+ */
116
+ export function indexAliases(entries, { same = Object.is } = {}) {
117
+ const claims = new Map();
118
+ /** The alias as first written, per key, for a message that reads. */
119
+ const written = new Map();
120
+ const typeOf = new Map();
121
+
122
+ for (const { type, aliases, value } of entries) {
123
+ for (const alias of aliases ?? []) {
124
+ if (typeof alias !== "string" || !alias) continue;
125
+ const key = aliasKey(type, alias);
126
+ const claimants = claims.get(key);
127
+ if (!claimants) {
128
+ claims.set(key, [value]);
129
+ written.set(key, alias);
130
+ typeOf.set(key, String(type).toLowerCase());
131
+ } else if (!claimants.some((c) => same(c, value))) {
132
+ claimants.push(value);
133
+ }
134
+ }
135
+ }
136
+
137
+ const byKey = new Map();
138
+ const collisions = [];
139
+ for (const [key, claimants] of claims) {
140
+ if (claimants.length === 1) {
141
+ byKey.set(key, claimants[0]);
142
+ } else {
143
+ collisions.push({
144
+ key,
145
+ type: typeOf.get(key),
146
+ alias: written.get(key),
147
+ claimants,
148
+ });
149
+ }
150
+ }
151
+
152
+ return { byKey, claims, collisions };
153
+ }
@@ -79,6 +79,9 @@ import { emitDiagnostic } from "./diagnostics.mjs";
79
79
  import { assertNoDeclaredPackage } from "./note-package.mjs";
80
80
  import { assertNoDraftField } from "./retired-fields.mjs";
81
81
  import { assertTypeNotRetired, packForType } from "./ids.mjs";
82
+ import { carriesSystemBlock } from "./system-block.mjs";
83
+ import { checkAuthoredSystemData, checkEmittedSystemData } from "./schema-check.mjs";
84
+ import { locateFrontmatterKey } from "./retired-fields.mjs";
82
85
 
83
86
  /**
84
87
  * The tallies one pass accumulates while walking the tree.
@@ -93,8 +96,9 @@ import { assertTypeNotRetired, packForType } from "./ids.mjs";
93
96
  * @property {number} compiled - Notes that became a document.
94
97
  * @property {number} skippedNoId - Notes with no `id`, where that is tolerated.
95
98
  * @property {number} skippedOther - Notes this pass does not claim.
96
- * @property {number} declined - Notes refused because they declare a retired
97
- * frontmatter field. Counted as errors, never as skips.
99
+ * @property {number} declined - Notes this pack **refused** one declaring a
100
+ * retired frontmatter field, or one routed to a system pack whose system it
101
+ * says nothing about (#58). Counted as errors, never as skips.
98
102
  */
99
103
 
100
104
  /**
@@ -167,6 +171,26 @@ export class BasePackCompiler {
167
171
  */
168
172
  static readsPackOutputOf = Object.freeze([]);
169
173
 
174
+ /**
175
+ * Whether this pass's document **is** a system's data, and therefore takes
176
+ * only notes that carry that system's block (#58).
177
+ *
178
+ * A pack may declare a `system:` — `harn-ensemble` ships an `actors-hm3`
179
+ * and an `actors-sohl` from one tree — and the note-side half of that is
180
+ * the block named after the system. A note carrying no such block has
181
+ * nothing to say about it, so compiling it there would emit a **hollow
182
+ * document**: a subtype, and none of the fields the subtype exists for.
183
+ *
184
+ * False by default, because most passes write documents that are not
185
+ * system data at all. A JournalEntry of prose is the same document under
186
+ * either system, and a journals pack that declared one must not turn every
187
+ * doc note in the tree into a finding. The Item and Actor passes say so;
188
+ * anything else that genuinely writes a system's data says so too.
189
+ *
190
+ * @type {boolean}
191
+ */
192
+ static requiresSystemBlock = false;
193
+
170
194
  /** @type {string} */
171
195
  contentBase;
172
196
  /** @type {string} */
@@ -176,6 +200,19 @@ export class BasePackCompiler {
176
200
  /** @type {number} */
177
201
  errorCount = 0;
178
202
 
203
+ /**
204
+ * Emitted-`system` findings, one per `documentType|subtype|field` (#155).
205
+ *
206
+ * A key the compiler writes is on **every** document of a subtype, so
207
+ * reporting it where it is found would print the same sentence 3,126 times
208
+ * and bury the one that is not systemic. Collected here instead and flushed
209
+ * once at the end of the pass, keyed so the class of defect is reported
210
+ * once and the first document carrying it names a file a reader can open.
211
+ *
212
+ * @type {Map<string, {message: string, file: string|undefined}>}
213
+ */
214
+ emittedFindings = new Map();
215
+
179
216
  /**
180
217
  * The pack this pass writes, and the Foundry document type it holds.
181
218
  *
@@ -300,7 +337,36 @@ export class BasePackCompiler {
300
337
  */
301
338
  routesHere(fm) {
302
339
  if (!this.router || !this.packName || !this.docType) return true;
303
- return this.router.resolve(fm, this.docType) === this.packName;
340
+ return (
341
+ this.router.resolve(fm, this.docType, this.packSystem ?? undefined) === this.packName
342
+ );
343
+ }
344
+
345
+ /**
346
+ * Whether a claimed, routed note may become this pack's document at all.
347
+ *
348
+ * The pack-eligibility gate, and it fails rather than skipping: a note that
349
+ * routed *here* and carries nothing for this pack's system is an authoring
350
+ * mistake with a hollow document at the end of it, not a note that belongs
351
+ * to another pass. Skipping it quietly is how a whole tree compiles to
352
+ * documents nobody can use — the failure mode #1502 and #56 are both
353
+ * instances of.
354
+ *
355
+ * @param {object} fm - The note's frontmatter.
356
+ * @returns {boolean} True when the note may be compiled here.
357
+ * @throws {Error} When this pack's system is absent from the note. The
358
+ * error carries a `position` where the note's own file can be read.
359
+ */
360
+ eligibleFor(fm) {
361
+ if (!this.constructor.requiresSystemBlock || !this.packSystem) return true;
362
+ if (carriesSystemBlock(fm, this.packSystem)) return true;
363
+ const label = fm?.name?.full ?? fm?.shortcode ?? fm?.id ?? "this note";
364
+ throw new Error(
365
+ `${label} carries no \`${this.packSystem}:\` block, so it has no ` +
366
+ `${this.packSystem} data to compile — but it routes to pack ` +
367
+ `"${this.packName}", which declares \`system: ${this.packSystem}\`. ` +
368
+ `Add the block, or route the note to a pack of another system.`,
369
+ );
304
370
  }
305
371
 
306
372
  /**
@@ -442,6 +508,113 @@ export class BasePackCompiler {
442
508
  });
443
509
  }
444
510
 
511
+ /**
512
+ * Report every `<system>.system` key the receiving subtype does not declare
513
+ * (#58).
514
+ *
515
+ * An **error**, not a warning: Foundry drops an unknown `system` key at
516
+ * construction without a word, so the alternative is a document shipped
517
+ * with a field the author wrote and nobody will ever see. Each finding is
518
+ * located at the offending key where the file can be read, so it points at
519
+ * a line rather than at a note.
520
+ *
521
+ * Silent where nothing can answer — no published schema, or a subtype the
522
+ * artifact does not name. `content-build lint` says that out loud once for
523
+ * the whole build rather than once per note.
524
+ *
525
+ * @param {object} fm - The note's frontmatter.
526
+ * @param {string} block - The system block to read.
527
+ * @param {string} documentType - `Item`, `Actor`, …
528
+ * @param {string} subType - The subtype this note compiles into.
529
+ * @returns {number} How many findings were reported.
530
+ */
531
+ reportUndeclaredSystemData(fm, block, documentType, subType) {
532
+ const absPath = this.currentNote?.absPath;
533
+ const findings = checkAuthoredSystemData(fm, { block, documentType, subType });
534
+ for (const finding of findings) {
535
+ this.errorCount++;
536
+ const leaf = finding.path.split(".").pop();
537
+ this.noteError(finding.message, locateFrontmatterKey(absPath, leaf));
538
+ }
539
+ return findings.length;
540
+ }
541
+
542
+ /**
543
+ * Record every `system` key the *compiled document* carries that the
544
+ * receiving subtype does not declare (#155).
545
+ *
546
+ * The sibling of {@link BasePackCompiler#reportUndeclaredSystemData}, and
547
+ * the half that sees what no declaration states. A compiler writes keys of
548
+ * its own alongside the declared fields — `shortcode`, `actionDefs`,
549
+ * `notes`, `docHtml`, `archetype` — and neither the field-declaration check
550
+ * nor the authored-`system` check can see them, so until this nothing
551
+ * compared them at all. Foundry's discard is the same silent one either
552
+ * way.
553
+ *
554
+ * Called with the block **after** the builder, the authored merge and any
555
+ * conditional fields have all written into it, so what is checked is what
556
+ * the pack file receives.
557
+ *
558
+ * Recorded rather than reported: see {@link BasePackCompiler#emittedFindings}
559
+ * for why, and {@link BasePackCompiler#reportEmittedFindings} for where they
560
+ * come out.
561
+ *
562
+ * @param {object} system - The `system` block just assembled.
563
+ * @param {object} opts
564
+ * @param {object} opts.fm - The note's frontmatter.
565
+ * @param {string} opts.block - The system block the note writes.
566
+ * @param {string} opts.documentType - `Item`, `Actor`, …
567
+ * @param {string} opts.subType - The subtype this note compiles into.
568
+ * @param {string} opts.type - The note's content type.
569
+ * @param {readonly {to?: string}[]} [opts.fields] - The type's field
570
+ * declaration, which tells a builder emission from a compiler one.
571
+ * @param {object} [opts.config] - The resolved build configuration.
572
+ * @returns {number} How many findings were new to this pass.
573
+ */
574
+ reportEmittedSystemData(system, { fm, block, documentType, subType, type, fields, config }) {
575
+ const findings = checkEmittedSystemData(system, {
576
+ fm,
577
+ block,
578
+ documentType,
579
+ subType,
580
+ type,
581
+ fields,
582
+ ...(config ? { config } : {}),
583
+ });
584
+ let added = 0;
585
+ for (const finding of findings) {
586
+ const key = `${finding.documentType}|${finding.subtype}|${finding.field}`;
587
+ if (this.emittedFindings.has(key)) continue;
588
+ this.emittedFindings.set(key, {
589
+ message: finding.message,
590
+ file: this.currentNote?.absPath,
591
+ });
592
+ added++;
593
+ }
594
+ return added;
595
+ }
596
+
597
+ /**
598
+ * Emit the collected emitted-`system` findings, once each.
599
+ *
600
+ * An **error**, for the reason #60 made its sibling one: the value is gone
601
+ * at load and the build says nothing, and severity that varied by *which
602
+ * part of the build wrote the key* would make the less fixable half the
603
+ * quieter one. What varies is the message, which says whose fix it is —
604
+ * see {@link module:engine/schema-check.emittedUndeclaredMessage}.
605
+ *
606
+ * @returns {number} How many were reported.
607
+ */
608
+ reportEmittedFindings() {
609
+ const found = this.emittedFindings;
610
+ this.emittedFindings = new Map();
611
+ for (const finding of found.values()) {
612
+ this.errorCount++;
613
+ emitDiagnostic({ file: finding.file, severity: "error", message: finding.message });
614
+ }
615
+ return found.size;
616
+ }
617
+
445
618
  /**
446
619
  * One note → one document. **Required.**
447
620
  *
@@ -552,7 +725,8 @@ export class BasePackCompiler {
552
725
  // them in the skipped tally is the defect (#56). Each one has
553
726
  // already been named individually as a diagnostic.
554
727
  log.error(
555
- `Declined ${stats.declined} note(s) declaring a retired ` + `frontmatter field`,
728
+ `Declined ${stats.declined} note(s) each named above, with ` +
729
+ `the reason this pack would not compile it`,
556
730
  );
557
731
  }
558
732
  this.reportDetail(stats);
@@ -644,6 +818,18 @@ export class BasePackCompiler {
644
818
  }
645
819
  continue;
646
820
  }
821
+ // Whether this pack's system is one the note speaks for. Checked
822
+ // after routing — a note bound for another pack is none of this
823
+ // pass's business — and before `skipNote`, so a pass's own
824
+ // rejection rules never run on a note it may not compile.
825
+ try {
826
+ this.eligibleFor(fm);
827
+ } catch (err) {
828
+ stats.declined++;
829
+ this.errorCount++;
830
+ this.noteError(err.message, err.position);
831
+ continue;
832
+ }
647
833
  if (this.skipNote(fm, body)) {
648
834
  stats.skippedOther++;
649
835
  continue;
@@ -668,6 +854,10 @@ export class BasePackCompiler {
668
854
 
669
855
  this.compiledCount = stats.compiled;
670
856
  await this.finish(stats);
857
+ // After `finish`, because a pass that writes documents there (the
858
+ // scenes pass bundles an Adventure) has emitted them by now; before
859
+ // `report`, so the pass's summary counts them.
860
+ this.reportEmittedFindings();
671
861
  this.report(stats);
672
862
  }
673
863
  }
@@ -38,14 +38,14 @@ export const KB_PREFIX = "kb/";
38
38
  * The URL section a note routes to.
39
39
  *
40
40
  * A `doc` is narrative content whose only identity is its subtype label, so it
41
- * routes by `category`; every other type names its own section.
41
+ * routes by `subType`; every other type names its own section.
42
42
  *
43
43
  * @param {object} fm - Parsed frontmatter.
44
44
  * @returns {string|undefined} The section, or `undefined` when the note has
45
- * none — a `doc` with no category has no address and is not published.
45
+ * none — a `doc` with no subtype has no address and is not published.
46
46
  */
47
47
  export function sectionOf(fm) {
48
- return fm.type === "doc" ? fm.category : fm.type;
48
+ return fm.type === "doc" ? fm.subType : fm.type;
49
49
  }
50
50
 
51
51
  /**
@@ -85,7 +85,7 @@ function landingOf(fm, isReadme, landing) {
85
85
  // `collection`. The section is authored rather than derived: it is the
86
86
  // identity of the section being introduced, and the note's own title
87
87
  // ("Creatures") is presentation, which would slug to something else.
88
- if (fm.type === "doc" && fm.category === "collection") {
88
+ if (fm.type === "doc" && fm.subType === "collection") {
89
89
  return { landing: true, segment: fm.section || fm.slug };
90
90
  }
91
91
  return { landing: false };