@heroiclands/package-build 22.5.0 → 22.6.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.
@@ -65,10 +65,13 @@ import {
65
65
  NEVER_PACKED_TYPES,
66
66
  DERIVED_PACKED_TYPES,
67
67
  } from "./note-claims.mjs";
68
+ import { DEFAULT_DOCUMENT_SUBTYPES, SYSTEM_DOCUMENT_CLASSES } from "./subtype-registry.mjs";
69
+ import { carriesSystemBlock } from "./system-block.mjs";
68
70
  import { walkMarkdownTree } from "./helpers.mjs";
69
71
  import { resolveNoteId } from "./note-ids.mjs";
70
72
  import { compendiumUuid, currentType, packForType, pageUuid } from "./ids.mjs";
71
73
  import { hasDocEntry, itemDocEntryId } from "./item-docs.mjs";
74
+ import { journalHasContent } from "./note-state.mjs";
72
75
  import { isHomepage } from "./homepage.mjs";
73
76
  import { declaresNoPack } from "./pack-router.mjs";
74
77
  import { assertNoDeclaredPackage } from "./note-package.mjs";
@@ -163,7 +166,19 @@ export function entriesForNote(fm, name, address, body, ctx) {
163
166
  // A published address must name the pack the document actually shipped in:
164
167
  // a consumer resolves the UUID verbatim, and a repository may ship several
165
168
  // packs of one type.
166
- const uuidFor = (type, id, routeFm) =>
169
+ const uuidFor = (type, id, routeFm) => {
170
+ // **A document that would be empty is not created, so nothing names
171
+ // one.** A JournalEntry's content is the note's prose, and an empty
172
+ // body compiles no pages, so the pass emits no entry — and a manifest
173
+ // naming one would send every consumer to a document that is not
174
+ // there. Asked of the **document type**, which is what makes one
175
+ // statement cover both journals a note can produce: its own, and the
176
+ // documentation standing beside an item. Every other document is
177
+ // derived from `data:` and is emitted whatever the body holds.
178
+ if (packForType(type)?.docType === "JournalEntry" && !journalHasContent(body)) {
179
+ return undefined;
180
+ }
181
+
167
182
  // A type this cannot name a single compendium document for has no UUID
168
183
  // to publish, whatever id it derives. That used to follow from such a
169
184
  // note authoring no `id:`; every addressable note derives
@@ -185,21 +200,40 @@ export function entriesForNote(fm, name, address, body, ctx) {
185
200
  // statement: it publishes a page and compiles into nothing, so it has
186
201
  // an address and no UUID. The shared declaration is what is read,
187
202
  // because an entry names one document and this pass routes no system.
188
- (
189
- id &&
190
- !NEVER_PACKED_TYPES.has(String(type)) &&
191
- !DERIVED_PACKED_TYPES.has(String(type)) &&
192
- !(routeFm && declaresNoPack(routeFm))
193
- ) ?
194
- compendiumUuid(
195
- foundryPackageId,
196
- type,
197
- id,
198
- routeFm ?
199
- packRouter.resolveOrNull(routeFm, packForType(type).docType)
200
- : packRouter.defaultOf("JournalEntry"),
201
- )
202
- : undefined;
203
+ if (
204
+ !id ||
205
+ NEVER_PACKED_TYPES.has(String(type)) ||
206
+ DERIVED_PACKED_TYPES.has(String(type)) ||
207
+ (routeFm && declaresNoPack(routeFm))
208
+ ) {
209
+ return undefined;
210
+ }
211
+
212
+ const docType = routeFm ? packForType(type).docType : "JournalEntry";
213
+ const pack =
214
+ routeFm ?
215
+ packRouter.resolveOrNull(routeFm, docType)
216
+ : packRouter.defaultOf("JournalEntry");
217
+
218
+ // **A system block is what makes a game document**, so a note carrying
219
+ // none compiles into no Actor and no Item — and an address naming one
220
+ // would send every consumer to a document that is not there. The same
221
+ // statement `pack: none` makes about a pack, made about a system: the
222
+ // note keeps its address, its page and its prose journal, and publishes
223
+ // no UUID for the document it does not produce.
224
+ //
225
+ // Which block is asked for is the pack's, because the pack decides the
226
+ // pass: one declaring a `system:` is compiled by that system's pass,
227
+ // and one declaring none by the fallback, which reads a single block of
228
+ // its own.
229
+ if (SYSTEM_DOCUMENT_CLASSES.has(docType)) {
230
+ const system =
231
+ (pack && packRouter.systemOf?.(pack)) || DEFAULT_DOCUMENT_SUBTYPES.system;
232
+ if (!carriesSystemBlock(routeFm, system)) return undefined;
233
+ }
234
+
235
+ return compendiumUuid(foundryPackageId, type, id, pack);
236
+ };
203
237
 
204
238
  const carriesDoc =
205
239
  ctx.docEntryTypes ?
@@ -102,7 +102,22 @@ const COMPILERS = {
102
102
  };
103
103
 
104
104
  /**
105
- * The system-specific compilers, by the system a pack declares.
105
+ * Every pass this toolchain ships that writes one system's data.
106
+ *
107
+ * The list a system joins, and the only place it is named. Each class already
108
+ * declares which system it is for (`documentSubtypes`) and which document it
109
+ * writes (`documentClass`), so the pairing is read off the passes rather than
110
+ * spelled a second time beside them — a table keyed by system id is a table
111
+ * free to disagree with the classes it names, and the way it disagrees is
112
+ * silent: a system absent from it took the fallback pass and compiled its packs
113
+ * out of a neighbour's blocks.
114
+ *
115
+ * @type {readonly Function[]}
116
+ */
117
+ const SYSTEM_PASSES = Object.freeze([Items, Actors, Hm3Items, Hm3Actors]);
118
+
119
+ /**
120
+ * The system-specific compilers, derived from the passes themselves.
106
121
  *
107
122
  * A repository feeding two systems declares one Item pack and one Actor pack
108
123
  * per system — `harn-ensemble` has `actors-hm3` and `actors-sohl` — and each
@@ -111,28 +126,84 @@ const COMPILERS = {
111
126
  * `itemBuilders` lookup already read, so nothing new is declared to make the
112
127
  * compiler follow it.
113
128
  *
114
- * A system with no entry — or a pack that declares none — falls back to
115
- * {@link COMPILERS}. That keeps every single-system configuration meaning
116
- * exactly what it did: SoHL's passes were the only ones, so they stay the
117
- * answer where nothing says otherwise.
129
+ * @param {readonly Function[]} passes - The system passes to table.
130
+ * @returns {Readonly<Record<string, Readonly<Record<string, Function>>>>}
131
+ * System id → document class → pass.
132
+ * @throws {Error} When a pass declares no system or no document class, which is
133
+ * a pass that cannot be addressed by either question this table answers.
134
+ */
135
+ export function systemCompilers(passes) {
136
+ /** @type {Record<string, Record<string, Function>>} */
137
+ const table = {};
138
+ for (const pass of passes) {
139
+ const declared =
140
+ /** @type {{documentSubtypes?: {system?: string},
141
+ * documentClass?: string, name: string}} */ (pass);
142
+ const system = declared.documentSubtypes?.system;
143
+ const docType = declared.documentClass;
144
+ if (!system || !docType) {
145
+ throw new Error(
146
+ `${declared.name} is registered as a system pass but declares ` +
147
+ `${system ? "no `documentClass`" : "no `documentSubtypes`"} — a pass ` +
148
+ `writing a system's data states both, so which system and which ` +
149
+ `document it answers for is read from the class itself.`,
150
+ );
151
+ }
152
+ (table[system] ??= {})[docType] = pass;
153
+ }
154
+ return Object.freeze(
155
+ Object.fromEntries(
156
+ Object.entries(table).map(([system, byType]) => [system, Object.freeze(byType)]),
157
+ ),
158
+ );
159
+ }
160
+
161
+ /** @type {Readonly<Record<string, Readonly<Record<string, Function>>>>} */
162
+ const SYSTEM_COMPILERS = systemCompilers(SYSTEM_PASSES);
163
+
164
+ /**
165
+ * The document classes some system writes its own data into.
166
+ *
167
+ * Derived from {@link SYSTEM_COMPILERS}, so it is the set of classes for which
168
+ * "which system?" is a question with consequences. Every other class has one
169
+ * implementation whatever a pack declares.
118
170
  *
119
- * @type {Readonly<Record<string, Readonly<Record<string, Function>>>>}
171
+ * @type {ReadonlySet<string>}
120
172
  */
121
- const SYSTEM_COMPILERS = Object.freeze({
122
- sohl: Object.freeze({ Item: Items, Actor: Actors }),
123
- hm3: Object.freeze({ Item: Hm3Items, Actor: Hm3Actors }),
124
- });
173
+ const SYSTEM_DOCUMENT_CLASSES = Object.freeze(
174
+ new Set(Object.values(SYSTEM_COMPILERS).flatMap((byType) => Object.keys(byType))),
175
+ );
125
176
 
126
177
  /**
127
178
  * The compiler class a pack of one document type and one system gets.
128
179
  *
180
+ * A pack declaring **no** system takes the fallback pass for its document
181
+ * class, which reads
182
+ * {@link module:engine/subtype-registry.DEFAULT_DOCUMENT_SUBTYPES} — the
183
+ * single-system convenience every tree shipping for one system is built on.
184
+ *
185
+ * A pack declaring a system this toolchain has no pass for is **refused by
186
+ * name**. Falling through to the fallback would compile that pack out of
187
+ * another system's blocks and ship it stamped with the system it asked for,
188
+ * which is the quiet failure the refusal exists to replace.
189
+ *
129
190
  * @param {string} docType - The Foundry document type the pack holds.
130
191
  * @param {string|null} [system] - The system the pack declares, if any.
131
192
  * @returns {Function|undefined} The compiler class, or `undefined` for a
132
193
  * document type nothing here compiles — which {@link generatePack} reports
133
194
  * rather than defaulting past.
195
+ * @throws {Error} When a system-bearing document class is asked for a system
196
+ * this toolchain ships no pass for.
134
197
  */
135
198
  export function compilerFor(docType, system = null) {
199
+ if (system && SYSTEM_DOCUMENT_CLASSES.has(docType) && !SYSTEM_COMPILERS[system]?.[docType]) {
200
+ throw new Error(
201
+ `no ${docType} compiler for system "${system}" — this toolchain ships ` +
202
+ `passes for ${Object.keys(SYSTEM_COMPILERS).sort().join(", ")}. Declare a ` +
203
+ `system one of those names, or add the pass that reads a ` +
204
+ `\`${system}:\` block.`,
205
+ );
206
+ }
136
207
  return (system && SYSTEM_COMPILERS[system]?.[docType]) || COMPILERS[docType];
137
208
  }
138
209
 
@@ -58,24 +58,23 @@ export function infoboxFor(system) {
58
58
  * Whether one system compiles a document for one note.
59
59
  *
60
60
  * This is what a system box's _available_ asserts, and it is the compile's own
61
- * question rather than a reading of the frontmatter. A note carrying no block
62
- * is not a note a system has nothing for: where the pack compiling its document
63
- * declares no `system:`, the document is built from `data:` and the field
64
- * defaults and ships exactly like any other.
61
+ * question rather than a reading of the frontmatter.
65
62
  *
66
63
  * Three statements answer it, and they are the three the compile itself
67
64
  * follows:
68
65
  *
69
- * 1. **The map** says which document class this system makes of the note's
66
+ * 1. **The note's block.** A system block is what makes a game document, so a
67
+ * note carrying nothing for this system compiles into no document of it
68
+ * anywhere — the rule
69
+ * {@link module:engine/base-compiler.BasePackCompiler#eligibleFor} applies,
70
+ * asked here from outside.
71
+ * 2. **The map** says which document class this system makes of the note's
70
72
  * type. No row, no document — and no box either, which is why a caller
71
73
  * reaching here already has one.
72
- * 2. **The router** says which pack that document goes to, read from the pack
73
- * list this build is driven by.
74
- * 3. **That pack's `system:`** decides the rest. Declaring one, it writes that
75
- * system's data and takes only notes that say something about it — the rule
76
- * {@link module:engine/base-compiler.BasePackCompiler#eligibleFor} applies,
77
- * asked here from outside. Declaring none, it is compiled by the fallback
78
- * pass, which needs no block and answers for
74
+ * 3. **The router** says which pack that document goes to, read from the pack
75
+ * list this build is driven by, and **that pack's `system:`** decides whose
76
+ * it is. Declaring one, it writes that system's data. Declaring none, it is
77
+ * compiled by the fallback pass, which reads
79
78
  * {@link module:engine/subtype-registry.DEFAULT_DOCUMENT_SUBTYPES} alone —
80
79
  * so a tree with no HM3 pack ships no HM3 document however a note is
81
80
  * written.
@@ -86,6 +85,8 @@ export function infoboxFor(system) {
86
85
  * @returns {boolean} True when this system compiles a document for this note.
87
86
  */
88
87
  export function compilesSystemDocument(fm, map, router) {
88
+ if (!carriesSystemBlock(fm, map.block)) return false;
89
+
89
90
  const row = subtypeRow(map, fm?.type);
90
91
  if (!row?.document) return false;
91
92
 
@@ -94,7 +95,7 @@ export function compilesSystemDocument(fm, map, router) {
94
95
 
95
96
  const packSystem = router.systemOf(packName);
96
97
  if (!packSystem) return map.system === DEFAULT_DOCUMENT_SUBTYPES.system;
97
- return packSystem === map.system && carriesSystemBlock(fm, map.block);
98
+ return packSystem === map.system;
98
99
  }
99
100
 
100
101
  /**
@@ -116,6 +116,18 @@ export class SystemItemCompiler extends BasePackCompiler {
116
116
  */
117
117
  static requiresSystemBlock = true;
118
118
 
119
+ /**
120
+ * The Foundry document class this pass writes.
121
+ *
122
+ * Declared by the class that does the writing, so the table pairing a
123
+ * system with its passes is derived from the passes themselves rather than
124
+ * written out a second time — see
125
+ * {@link module:engine/generate.systemCompilers}.
126
+ *
127
+ * @type {string}
128
+ */
129
+ static documentClass = "Item";
130
+
119
131
  /**
120
132
  * An Item carries one piece of art — its icon — which this pass writes from
121
133
  * the address the note names, falling back to the type's default art.
@@ -55,6 +55,7 @@ import { infoboxesToHtml, linkToUuid } from "./infobox-render.mjs";
55
55
  import { noteInfoboxes } from "./infobox-registry.mjs";
56
56
  import { hasDocEntry, itemDocEntryId } from "./item-docs.mjs";
57
57
  import { JOURNAL_TYPES } from "./ids.mjs";
58
+ import { journalHasContent } from "./note-state.mjs";
58
59
 
59
60
  /**
60
61
  * Splits a markdown body into pages by top-level H1 headings. Fenced
@@ -382,17 +383,24 @@ export class Journals extends BasePackCompiler {
382
383
  }
383
384
 
384
385
  /**
385
- * An item with no prose gets no doc, and the items pass leaves its
386
- * description empty rather than pointing at nothing; a map with no prose
387
- * gets no entry and no pin target. The two passes apply the same rule to
388
- * the same body, so they agree.
386
+ * **A journal that would be empty is not created**, and that is the whole
387
+ * rule — one test, asked of every note this pass claims, whether its
388
+ * journal is its own document or the documentation standing beside an item.
389
+ * A note with no prose gets no entry: the items pass leaves its description
390
+ * empty rather than pointing at nothing, a map gets no pin target, and
391
+ * {@link module:engine/foundry-entries} publishes no UUID, so nothing names
392
+ * a document that was not made.
393
+ *
394
+ * An entry holding only its infobox is empty by this test. The panel is a
395
+ * rendering of `data:` that the content index already carries, so a
396
+ * compendium entry of nothing else tells a reader what the index told them.
389
397
  *
390
398
  * @param {object} fm - The note's frontmatter.
391
399
  * @param {string} body - The note body, frontmatter stripped.
392
400
  * @returns {boolean} True to skip the note.
393
401
  */
394
402
  skipNote(fm, body) {
395
- return hasDocEntry(fm.type) && !String(body).trim();
403
+ return !journalHasContent(body);
396
404
  }
397
405
 
398
406
  /**
@@ -94,7 +94,13 @@ import { loadPackConfig } from "./pack-config.mjs";
94
94
  import { declaresNoPack } from "./pack-router.mjs";
95
95
  import { locateFrontmatterKey } from "./retired-fields.mjs";
96
96
  import { noteTypesFor, subtypeRow } from "./document-subtypes.mjs";
97
- import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./subtype-registry.mjs";
97
+ import {
98
+ DEFAULT_DOCUMENT_SUBTYPES,
99
+ KNOWN_DOCUMENT_SUBTYPE_MAPS,
100
+ SHIPPED_SYSTEMS,
101
+ subtypeMapFor,
102
+ } from "./subtype-registry.mjs";
103
+ import { carriesSystemBlock } from "./system-block.mjs";
98
104
  import { HOMEPAGE_TYPE } from "./homepage.mjs";
99
105
  import { FOLDER_TYPE } from "./folder-notes.mjs";
100
106
  import { BUNDLE_TYPE } from "./bundle-notes.mjs";
@@ -615,7 +621,92 @@ function authoringMessage(type) {
615
621
  }
616
622
 
617
623
  /**
618
- * Every note in the content tree that no configured pack would compile.
624
+ * Which pass would read one system's block, given a configuration's packs.
625
+ *
626
+ * A pack declaring `system: hm3` is compiled by the HM3 pass and reads `hm3:`.
627
+ * A pack declaring **no** system is compiled by the fallback pass, which reads
628
+ * {@link module:engine/subtype-registry.DEFAULT_DOCUMENT_SUBTYPES} and nothing
629
+ * else — so a systemless pack answers for one system, not for every system a
630
+ * note might carry. That asymmetry is the whole of this question: the router
631
+ * will happily route an `hm3:` document into a systemless pack, and the pass
632
+ * waiting there reads a different block.
633
+ *
634
+ * A **prebuilt** pack compiles nothing — its JSON is checked in — so it reads
635
+ * no block either.
636
+ *
637
+ * @param {object} config - The resolved build configuration.
638
+ * @param {string} docType - The document class the block would compile into.
639
+ * @param {string} system - The system whose block is in question.
640
+ * @returns {{packs: number, serving: number}} How many non-prebuilt packs of
641
+ * that class the configuration has, and how many of them compile that
642
+ * system's documents.
643
+ */
644
+ function packsServing(config, docType, system) {
645
+ const packs = (config?.packs ?? []).filter((pack) => !pack?.prebuilt && pack?.type === docType);
646
+ const serving = packs.filter((pack) =>
647
+ pack.system ? pack.system === system : system === DEFAULT_DOCUMENT_SUBTYPES.system,
648
+ );
649
+ return { packs: packs.length, serving: serving.length };
650
+ }
651
+
652
+ /**
653
+ * Every system block on one note that no pack in this package will read.
654
+ *
655
+ * **A discarded field is the quiet-failure class the diagnostics exist to
656
+ * close**, and a system block is the largest field a note can write: a whole
657
+ * document's worth of authored data, compiled by nobody and reported by
658
+ * nobody. The claim check beside this one asks whether a note's *type* is
659
+ * claimed; this asks whether its *systems* are.
660
+ *
661
+ * `error`, because the data is lost either way and the author cannot see it
662
+ * happen. It says nothing about a note carrying **no** block — that note is
663
+ * making a statement, and the statement is that nobody rolls dice against this
664
+ * person.
665
+ *
666
+ * Silent, too, where the class has no pack at all: the note's whole document is
667
+ * missing, which {@link partialMessage} already reports with the remedy.
668
+ *
669
+ * @param {object} fm - The note's frontmatter.
670
+ * @param {string} absPath - The note's path, for the finding's position.
671
+ * @param {string} type - The note's current type.
672
+ * @param {object} config - The resolved build configuration.
673
+ * @returns {Array<{file: string, line?: number, column?: number,
674
+ * severity: "error", message: string, type: string}>} One finding per
675
+ * discarded block.
676
+ */
677
+ function discardedSystemBlockFindings(fm, absPath, type, config) {
678
+ const findings = [];
679
+ for (const system of SHIPPED_SYSTEMS) {
680
+ if (!carriesSystemBlock(fm, system)) continue;
681
+ const docType = subtypeRow(subtypeMapFor(system), type)?.document;
682
+ // This system makes no document of this note type, so its block says
683
+ // nothing that could be discarded — it is read for the note's fields
684
+ // wherever another pass wants them.
685
+ if (!docType) continue;
686
+ const { packs, serving } = packsServing(config, docType, system);
687
+ if (!packs || serving) continue;
688
+ findings.push({
689
+ file: absPath,
690
+ ...locateFrontmatterKey(absPath, system, undefined, { topLevel: true }),
691
+ severity: /** @type {"error"} */ ("error"),
692
+ type,
693
+ message:
694
+ `carries a \`${system}:\` block, and no ${docType} pack in this ` +
695
+ `package compiles ${system} documents, so the block is discarded — ` +
696
+ `declare \`system: ${system}\` on one of the \`packs:\`, or remove ` +
697
+ `the block.`,
698
+ });
699
+ }
700
+ return findings;
701
+ }
702
+
703
+ /**
704
+ * Every note in the content tree that no configured pack would compile, and
705
+ * every system block no configured pack would read.
706
+ *
707
+ * Two questions of one walk, because they are the same question asked of a
708
+ * note's type and of a note's systems, and a note can fail either while passing
709
+ * the other — see {@link discardedSystemBlockFindings}.
619
710
  *
620
711
  * Read-only: it walks the tree and reports, and writes nothing. Three kinds of
621
712
  * note are passed over, each for a stated reason rather than by omission — a
@@ -672,6 +763,11 @@ export function unclaimedNoteFindings(config = loadPackConfig(), sources, { reco
672
763
  // lint, which can say what to write instead.
673
764
  const current = currentType(type);
674
765
 
766
+ // Asked first, and asked whatever the type's claim turns out to be: a
767
+ // note whose documents all have packs may still carry a block none of
768
+ // them reads, and that is invisible to every question below.
769
+ findings.push(...discardedSystemBlockFindings(fm, absPath, current, config));
770
+
675
771
  // Every document this note produces, against the classes this
676
772
  // configuration has a pack for. Three outcomes, and the middle one is
677
773
  // the half-claimed one — invisible while the question is only "is anything
@@ -114,6 +114,35 @@ export function isStubNote(frontmatter, body, vocabulary = NOTE_VOCABULARY) {
114
114
  return isEmptyBody(body) && isStubbableType(frontmatter?.type, vocabulary);
115
115
  }
116
116
 
117
+ /**
118
+ * Whether a note's body compiles into a JournalEntry that holds anything.
119
+ *
120
+ * > **A document that would be empty is not created.** Nothing names one
121
+ * > either: the index records the note with every field that names an artefact
122
+ * > set to `null`.
123
+ *
124
+ * The question is about the **output**, not the input, and that is what makes
125
+ * one rule serve every caller: a note's own journal, and the documentation
126
+ * journal an item's prose compiles into beside it, are the same document asked
127
+ * the same question.
128
+ *
129
+ * A JournalEntry's content is its pages, and a page comes from
130
+ * {@link module:engine/journals.splitPages} — a lead page of the prose before
131
+ * the first heading, and one page per heading after it. A body of whitespace
132
+ * yields neither, so an empty body is exactly an empty journal.
133
+ *
134
+ * **An infobox does not count.** The panel is a rendering of `data:`, which the
135
+ * index already publishes; an entry holding nothing else gives a consumer
136
+ * nothing it cannot read from the record.
137
+ *
138
+ * @param {string|null|undefined} body - The note body, frontmatter stripped.
139
+ * @returns {boolean} Whether a JournalEntry compiled from this body holds
140
+ * anything.
141
+ */
142
+ export function journalHasContent(body) {
143
+ return !isEmptyBody(body);
144
+ }
145
+
117
146
  /**
118
147
  * The phrases that stand in for prose nobody has written.
119
148
  *
@@ -68,12 +68,15 @@ export const KNOWN_DOCUMENT_SUBTYPE_MAPS = Object.freeze([
68
68
  /**
69
69
  * The map a pack declaring no `system:` compiles its documents against.
70
70
  *
71
- * A pack names the system its documents are shaped for, and one declaring none
72
- * is compiled by the fallback pass for its document type — a single-system
71
+ * **The single-system fallback, and not a default any system inherits.** A pack
72
+ * names the system its documents are shaped for, and one declaring none is
73
+ * compiled by the fallback pass for its document type — a single-system
73
74
  * package's ordinary arrangement, and the one `sohl-thalorna` ships. That pass
74
- * follows one map whatever a note carries, which makes this the answer to "a
75
- * document compiled here belongs to *which* system?" wherever a pack has not
76
- * said.
75
+ * reads this map's block and no other, so a note carrying nothing for this
76
+ * system compiles into no document there; a note carrying another system's
77
+ * block reaches that system's pass or nothing at all, never this one. This is
78
+ * the answer to "a document compiled here belongs to *which* system?" wherever
79
+ * a pack has not said, and it answers nothing about a system that has said.
77
80
  *
78
81
  * Stated here rather than read off the compiler classes because those are not
79
82
  * reachable from a leaf: `engine/item-compiler.mjs` imports the journals pass,
@@ -86,6 +89,40 @@ export const KNOWN_DOCUMENT_SUBTYPE_MAPS = Object.freeze([
86
89
  */
87
90
  export const DEFAULT_DOCUMENT_SUBTYPES = SOHL_DOCUMENT_SUBTYPES;
88
91
 
92
+ /**
93
+ * Every system this toolchain ships a map for, by id.
94
+ *
95
+ * Derived from {@link KNOWN_DOCUMENT_SUBTYPE_MAPS}, so registering a map is the
96
+ * whole of adding a system to every reader that asks "which systems are there?"
97
+ * — the per-system findings among them.
98
+ *
99
+ * @type {readonly string[]}
100
+ */
101
+ export const SHIPPED_SYSTEMS = Object.freeze(KNOWN_DOCUMENT_SUBTYPE_MAPS.map((map) => map.system));
102
+
103
+ /**
104
+ * The Foundry document classes a system compiles its own data into.
105
+ *
106
+ * Derived from the shipped maps' rows, so it is the set of classes for which
107
+ * "which system?" is a question with consequences: a pack of one of these
108
+ * classes is compiled by one system's pass reading one system's block, and a
109
+ * note carrying no such block produces nothing there. Every other class — a
110
+ * JournalEntry, a Macro, a Scene, an Adventure — has one implementation
111
+ * whatever a pack declares, because it is Foundry's document rather than any
112
+ * system's.
113
+ *
114
+ * @type {ReadonlySet<string>}
115
+ */
116
+ export const SYSTEM_DOCUMENT_CLASSES = Object.freeze(
117
+ new Set(
118
+ KNOWN_DOCUMENT_SUBTYPE_MAPS.flatMap((map) =>
119
+ Object.values(map.types)
120
+ .map((row) => row?.document)
121
+ .filter(Boolean),
122
+ ),
123
+ ),
124
+ );
125
+
89
126
  /**
90
127
  * Every note type any shipped map compiles into an **Actor**.
91
128
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "22.5.0",
3
+ "version": "22.6.0",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -199,6 +199,17 @@ export function loadItemsMap(itemsSourceDirs: readonly string[], foreignSourceDi
199
199
  * becomes embedded items. Nothing above that line is a system's business.
200
200
  */
201
201
  export class SystemActorCompiler extends BasePackCompiler {
202
+ /**
203
+ * The Foundry document class this pass writes.
204
+ *
205
+ * Declared by the class that does the writing, so the table pairing a
206
+ * system with its passes is derived from the passes themselves rather than
207
+ * written out a second time — see
208
+ * {@link module:engine/generate.systemCompilers}.
209
+ *
210
+ * @type {string}
211
+ */
212
+ static documentClass: string;
202
213
  /**
203
214
  * The note-type → document-subtype map this pass compiles against.
204
215
  *