@heroiclands/package-build 22.4.6 → 22.5.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 (45) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/CONTENT.md +192 -1
  3. package/bin/content-build.mjs +26 -4
  4. package/content-config.mjs +8 -3
  5. package/docs/api.md +99 -26
  6. package/docs/configuration.md +20 -1
  7. package/docs/content-format.md +238 -0
  8. package/docs/diagnostics.md +44 -0
  9. package/engine/content-index.mjs +63 -21
  10. package/engine/content-links.mjs +60 -6
  11. package/engine/content-tables.mjs +29 -2
  12. package/engine/frontmatter-lint.mjs +2 -0
  13. package/engine/holdings.mjs +416 -0
  14. package/engine/index-records.mjs +25 -0
  15. package/engine/index.mjs +16 -0
  16. package/engine/map-graphviz.mjs +12 -3
  17. package/engine/map-places.mjs +33 -3
  18. package/engine/market-class.mjs +178 -0
  19. package/engine/metadata-index.mjs +7 -4
  20. package/engine/note-state.mjs +213 -0
  21. package/engine/note-vocabulary.mjs +59 -2
  22. package/engine/pdf-build.mjs +4 -2
  23. package/engine/populations.mjs +557 -0
  24. package/engine/site-build.mjs +96 -10
  25. package/engine/site-maps.mjs +165 -0
  26. package/engine/sql-tables.mjs +157 -4
  27. package/engine/stub-lint.mjs +237 -0
  28. package/engine/wikilink-syntax.mjs +27 -1
  29. package/package.json +1 -1
  30. package/types/engine/content-index.d.mts +19 -5
  31. package/types/engine/content-links.d.mts +3 -3
  32. package/types/engine/content-tables.d.mts +2 -1
  33. package/types/engine/holdings.d.mts +157 -0
  34. package/types/engine/index-records.d.mts +22 -0
  35. package/types/engine/index.d.mts +6 -0
  36. package/types/engine/map-graphviz.d.mts +8 -1
  37. package/types/engine/map-places.d.mts +26 -0
  38. package/types/engine/market-class.d.mts +62 -0
  39. package/types/engine/note-state.d.mts +110 -0
  40. package/types/engine/note-vocabulary.d.mts +25 -0
  41. package/types/engine/populations.d.mts +121 -0
  42. package/types/engine/site-build.d.mts +37 -6
  43. package/types/engine/site-maps.d.mts +81 -0
  44. package/types/engine/stub-lint.d.mts +51 -0
  45. package/types/engine/wikilink-syntax.d.mts +19 -1
@@ -0,0 +1,178 @@
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
+ * What trade a settlement supports — a place note's `data.market`.
16
+ *
17
+ * `data.population` says how many people a settlement holds, which is not the
18
+ * same question as what a traveller can buy there. Two settlements of eight
19
+ * hundred are a different proposition when one has a weekly market and the
20
+ * other a chartered fair with a moneylender, and `market` is where a note says
21
+ * which.
22
+ *
23
+ * **The scale is six steps and is stated once, here.** The lint refuses a
24
+ * value against it, the vocabulary's `describe` reads its ends, and
25
+ * `docs/content-format.md` states it as a table the suite compares against
26
+ * this list. A second copy of the scale is one more list free to disagree
27
+ * about what a `4` buys.
28
+ *
29
+ * **A class is a step, not a measurement.** The numbers order the steps and
30
+ * nothing more: a `5` is not a market two fifths larger than a `3`, it is a
31
+ * city rather than a town. So a value off the scale is refused rather than
32
+ * clamped — a reader of the scale has to be able to trust that every number
33
+ * means the same thing wherever it appears.
34
+ *
35
+ * **The key is meaningful on a settlement**, and no subType condition is
36
+ * checked. `population` states none either: which places a key says something
37
+ * about is the author's judgement, and the `data:` container is closed per
38
+ * type rather than per subType.
39
+ *
40
+ * @module
41
+ */
42
+
43
+ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
44
+
45
+ /**
46
+ * One step of the market scale.
47
+ *
48
+ * @typedef {object} MarketClass
49
+ * @property {number} value - The integer a note writes.
50
+ * @property {string} name - What a settlement of this class is called.
51
+ * @property {string} trade - What can be had there.
52
+ */
53
+
54
+ /**
55
+ * The market scale, in order, from the settlement with no market to the one
56
+ * other markets buy from.
57
+ *
58
+ * @type {readonly MarketClass[]}
59
+ */
60
+ export const MARKET_CLASSES = Object.freeze([
61
+ Object.freeze({
62
+ value: 1,
63
+ name: "hamlet",
64
+ trade: "no market — what neighbours trade among themselves",
65
+ }),
66
+ Object.freeze({
67
+ value: 2,
68
+ name: "village",
69
+ trade: "a weekly market: staples, a smith, what a household cannot make",
70
+ }),
71
+ Object.freeze({
72
+ value: 3,
73
+ name: "town",
74
+ trade: "a regular market, most common goods, several trades working full time",
75
+ }),
76
+ Object.freeze({
77
+ value: 4,
78
+ name: "market town",
79
+ trade: "a chartered fair, goods carried from a week away, a moneylender",
80
+ }),
81
+ Object.freeze({
82
+ value: 5,
83
+ name: "city",
84
+ trade: "anything ordinary, in quantity; foreign goods; a guild structure",
85
+ }),
86
+ Object.freeze({
87
+ value: 6,
88
+ name: "great city",
89
+ trade:
90
+ "the rare and the imported as a matter of course; banking; " +
91
+ "the market from which other markets buy",
92
+ }),
93
+ ]);
94
+
95
+ /** The lowest class the scale states. @type {MarketClass} */
96
+ const LOWEST = MARKET_CLASSES[0];
97
+
98
+ /** The highest class the scale states. @type {MarketClass} */
99
+ const HIGHEST = MARKET_CLASSES[MARKET_CLASSES.length - 1];
100
+
101
+ /**
102
+ * The scale's ends, as a finding states them: `1 (hamlet) to 6 (great city)`.
103
+ *
104
+ * @returns {string} The phrase.
105
+ */
106
+ function scale() {
107
+ return `${LOWEST.value} (${LOWEST.name}) to ${HIGHEST.value} (${HIGHEST.name})`;
108
+ }
109
+
110
+ /**
111
+ * Whether a value is one of the classes the scale states.
112
+ *
113
+ * A note may write the number quoted, which YAML hands over as a string, so
114
+ * the value is read as a number before it is compared — the same reading
115
+ * `matchesKind` gives a `number` field.
116
+ *
117
+ * @param {unknown} value - The authored value.
118
+ * @returns {boolean} Whether it names a class.
119
+ */
120
+ export function isMarketClass(value) {
121
+ const n = numberOf(value);
122
+ return n !== undefined && MARKET_CLASSES.some((entry) => entry.value === n);
123
+ }
124
+
125
+ /**
126
+ * The class a value names, or `undefined` for a value that names none.
127
+ *
128
+ * @param {unknown} value - The authored value.
129
+ * @returns {MarketClass|undefined} The class.
130
+ */
131
+ export function marketClass(value) {
132
+ const n = numberOf(value);
133
+ return MARKET_CLASSES.find((entry) => entry.value === n);
134
+ }
135
+
136
+ /**
137
+ * A value as the finite number it reads as, or `undefined`.
138
+ *
139
+ * @param {unknown} value - The authored value.
140
+ * @returns {number|undefined} The number.
141
+ */
142
+ function numberOf(value) {
143
+ if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
144
+ if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) {
145
+ return Number(value);
146
+ }
147
+ return undefined;
148
+ }
149
+
150
+ /**
151
+ * Check a place note's `data.market`.
152
+ *
153
+ * The value is one of the classes the scale states, or it is a finding located
154
+ * at the key's own line. A value that is not a number at all is left alone:
155
+ * the container's own shape check reports it, and saying so twice would make
156
+ * one slip read as two.
157
+ *
158
+ * @param {object} note - The note, as the link index hands it over.
159
+ * @returns {object[]} Findings.
160
+ */
161
+ export function checkMarket(note) {
162
+ const fm = note?.fm ?? {};
163
+ const data = fm.data && typeof fm.data === "object" && !Array.isArray(fm.data) ? fm.data : {};
164
+ const value = data.market;
165
+ if (value === undefined || value === null) return [];
166
+ if (numberOf(value) === undefined) return [];
167
+ if (isMarketClass(value)) return [];
168
+ return [
169
+ {
170
+ file: note.file,
171
+ ...positionOfFrontmatterPath(note.raw ?? "", ["data", "market"]),
172
+ severity: "error",
173
+ message:
174
+ `\`data.market\` is a market class — a whole number from ${scale()} — ` +
175
+ `but reads ${JSON.stringify(value)}`,
176
+ },
177
+ ];
178
+ }
@@ -386,13 +386,16 @@ export function loadForeignIndexes(config, localPackages, bases = PACKAGE_BASE)
386
386
  // root onto it and resolves in one step — which is the point of
387
387
  // the record naming a path at all.
388
388
  asset: record.asset ?? undefined,
389
- // What a place is next to, reachable from and within, carried
390
- // so a consumer's lint checks a border across the package
391
- // boundary from both ends, and a consumer's map draws the
392
- // dependency's places beside its own.
389
+ // What a place is next to, reachable from and within, and
390
+ // what an affiliation holds, carried so a consumer's lint
391
+ // checks a border across the package boundary from both ends,
392
+ // a consumer's map draws the dependency's places beside its
393
+ // own, and a consumer's pages list what a dependency's
394
+ // affiliation holds and what lies within its regions.
393
395
  borders: record.data?.borders ?? undefined,
394
396
  routes: record.data?.routes ?? undefined,
395
397
  parents: record.data?.parents ?? undefined,
398
+ domains: record.data?.domains ?? undefined,
396
399
  package: pkg,
397
400
  });
398
401
  }
@@ -0,0 +1,213 @@
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 three states of a note, and the one observable fact they rest on.
16
+ *
17
+ * A note whose body is empty is a **stub**: frontmatter only — identity and
18
+ * statistics — reaching the content index and queryable there, generating no
19
+ * page, no address and no link target. A note with a body and the declared
20
+ * `draft` tag is a **draft**: it publishes, compiles and resolves like any
21
+ * other note, and a link *into* it renders marked. A note with a body and no
22
+ * tag is **full**, which is the default and carries no marker at all.
23
+ *
24
+ * **Full is the absence of everything**, and that is right: the overwhelming
25
+ * majority of a finished corpus should say nothing about its own finishedness.
26
+ *
27
+ * **Nothing is stored.** The index gains no `state` key, {@link
28
+ * module:engine/index-records.DERIVED_KEYS} gains no entry, and no note can
29
+ * assert its own state. The only authored signal is the tag, which marks the
30
+ * one thing that is not observable — "I am not finished" is a judgement only
31
+ * the author holds. The observable thing, the body, is not duplicated by a
32
+ * field that could disagree with it.
33
+ *
34
+ * This module holds the rule; the index withholds an address by it, the
35
+ * `entries` view derives the ladder from that absence, and the lint keeps an
36
+ * empty body deliberate.
37
+ *
38
+ * @module
39
+ */
40
+
41
+ import { NOTE_VOCABULARY } from "./note-vocabulary.mjs";
42
+
43
+ /**
44
+ * The three states, in the order they read as a ladder.
45
+ *
46
+ * @type {ReadonlyArray<string>}
47
+ */
48
+ export const NOTE_STATES = Object.freeze(["stub", "draft", "full"]);
49
+
50
+ /**
51
+ * Whether a note's body is empty.
52
+ *
53
+ * > **The body is empty when everything after the closing frontmatter fence is
54
+ * > whitespace.** Nothing else is empty.
55
+ *
56
+ * The severity is the point: **the test must be one a person can apply by
57
+ * looking at the file.** Every softer rule makes two files that look different
58
+ * behave the same, and turns "why did my page disappear?" into an
59
+ * investigation. So an HTML comment, a horizontal rule, a lone heading and
60
+ * `_To be written._` are all bodies — each renders to little or nothing, and
61
+ * {@link module:engine/stub-lint} reports them rather than treating them as
62
+ * either state.
63
+ *
64
+ * An HTML comment is deliberately not empty. An invisible marker deciding
65
+ * whether a page exists is the failure the retired `draft:` field was removed
66
+ * for, and reintroducing it in comment form would be worse, because nothing
67
+ * would report it.
68
+ *
69
+ * @param {string|null|undefined} body - The note body, frontmatter stripped.
70
+ * @returns {boolean} Whether the body is empty.
71
+ */
72
+ export function isEmptyBody(body) {
73
+ return String(body ?? "").trim() === "";
74
+ }
75
+
76
+ /**
77
+ * Whether an empty body makes a note of this type a stub.
78
+ *
79
+ * Two types are **structural** and exempt, and the measurement forced the
80
+ * question: a `folder` note's page is a generated section index and the
81
+ * `homepage`'s is the site's front door, so both have no body by design. If an
82
+ * empty body meant "no page" for them, a tree would lose a page per folder plus
83
+ * its own front door, and the build would report nothing.
84
+ *
85
+ * Read from {@link module:engine/note-vocabulary.NOTE_VOCABULARY}, where every
86
+ * type declares `stubbable` — so a type added without answering the question
87
+ * fails the guard rather than being classified by a default nobody chose. A
88
+ * type the registry does not declare at all is stubbable: the rule is about the
89
+ * body, and an unknown type has no exemption to claim.
90
+ *
91
+ * @param {string|null|undefined} type - The note's `type`.
92
+ * @param {Readonly<Record<string, {stubbable?: boolean}>>} [vocabulary] - The
93
+ * registry to read.
94
+ * @returns {boolean} Whether an empty body suppresses this type's page.
95
+ */
96
+ export function isStubbableType(type, vocabulary = NOTE_VOCABULARY) {
97
+ const entry = vocabulary?.[String(type ?? "")];
98
+ return entry ? entry.stubbable !== false : true;
99
+ }
100
+
101
+ /**
102
+ * Whether a note is a stub: an empty body on a type an empty body suppresses.
103
+ *
104
+ * The one place the two halves are put together, so the index, the lint and the
105
+ * guard cannot answer it differently.
106
+ *
107
+ * @param {Record<string, any>|null|undefined} frontmatter - Parsed frontmatter.
108
+ * @param {string|null|undefined} body - The note body.
109
+ * @param {Readonly<Record<string, {stubbable?: boolean}>>} [vocabulary] - The
110
+ * registry to read.
111
+ * @returns {boolean} Whether the note is a stub.
112
+ */
113
+ export function isStubNote(frontmatter, body, vocabulary = NOTE_VOCABULARY) {
114
+ return isEmptyBody(body) && isStubbableType(frontmatter?.type, vocabulary);
115
+ }
116
+
117
+ /**
118
+ * The phrases that stand in for prose nobody has written.
119
+ *
120
+ * A body consisting of nothing but these is an abandoned draft, not a stub:
121
+ * it looks written, publishes a page, and says nothing. The list is matched
122
+ * whole-line, case-insensitively, after trailing punctuation is dropped — a
123
+ * line reading `TBD.` and one reading `tbd` are the same line.
124
+ *
125
+ * @type {ReadonlyArray<string>}
126
+ */
127
+ export const PLACEHOLDER_PHRASES = Object.freeze([
128
+ "tbd",
129
+ "tba",
130
+ "to be written",
131
+ "to be done",
132
+ "to come",
133
+ "coming soon",
134
+ "placeholder",
135
+ "n/a",
136
+ "todo",
137
+ "more to come",
138
+ "not yet written",
139
+ ]);
140
+
141
+ /** A line that carries no content of its own: a heading, a rule, a comment. */
142
+ const STRUCTURE_LINE = /^(?:#{1,6}\s|(?:-\s*){3,}$|(?:\*\s*){3,}$|(?:_\s*){3,}$|<!--)/;
143
+
144
+ /**
145
+ * One line reduced to the words it says.
146
+ *
147
+ * A placeholder is written dressed: as a list item, a blockquote, italic, bold,
148
+ * with or without a full stop, and in either order — `_To be written._` puts
149
+ * the stop inside the emphasis and `*TBD*.` puts it outside. Stripping every
150
+ * emphasis mark and then the trailing punctuation reads all of them the same,
151
+ * and the phrases themselves carry no such character.
152
+ *
153
+ * @param {string} line - One trimmed line.
154
+ * @returns {string} The words, lower case.
155
+ */
156
+ function bareWords(line) {
157
+ return line
158
+ .replace(/^[-*+>\s]+/, "")
159
+ .replace(/[_*`]/g, "")
160
+ .replace(/[.!:,;\s]+$/, "")
161
+ .trim()
162
+ .toLowerCase();
163
+ }
164
+
165
+ /**
166
+ * The placeholder phrases a body reduces to, or an empty list.
167
+ *
168
+ * A body is reduced by dropping every line that carries no content of its own —
169
+ * a heading, a thematic break, an HTML comment, a blank line — and then asking
170
+ * whether every line that is left is a placeholder. A body with some real prose
171
+ * beside its unwritten sections is **not** reduced: a `TBD` under a heading in
172
+ * a note that is honestly a draft is not a defect, and this fires only when a
173
+ * body reduces to nothing but such phrases.
174
+ *
175
+ * The phrases are returned with the 1-based offset of the line each sat on
176
+ * within the body, so a finding can be located rather than merely stated.
177
+ *
178
+ * @param {string|null|undefined} body - The note body.
179
+ * @returns {Array<{phrase: string, line: number}>} The phrases, in body order.
180
+ * Empty for a body that is empty, or that carries prose of its own.
181
+ */
182
+ export function placeholderBody(body) {
183
+ const lines = String(body ?? "").split("\n");
184
+ const found = [];
185
+ for (let i = 0; i < lines.length; i += 1) {
186
+ const line = lines[i].trim();
187
+ if (!line || STRUCTURE_LINE.test(line)) continue;
188
+ if (!PLACEHOLDER_PHRASES.includes(bareWords(line))) return [];
189
+ found.push({ phrase: line, line: i + 1 });
190
+ }
191
+ return found;
192
+ }
193
+
194
+ /**
195
+ * How many words a body holds, counting only the prose a reader meets.
196
+ *
197
+ * Fence blocks, headings, thematic breaks and HTML comments are dropped, and a
198
+ * wikilink counts as the one word it renders — `[[affiliation-meivor|Mëivōr]]`
199
+ * is a word, not three. What is left is split on whitespace.
200
+ *
201
+ * @param {string|null|undefined} body - The note body.
202
+ * @returns {number} The word count.
203
+ */
204
+ export function bodyWordCount(body) {
205
+ const text = String(body ?? "")
206
+ .replace(/```[\s\S]*?(?:```|$)/g, " ")
207
+ .replace(/<!--[\s\S]*?(?:-->|$)/g, " ")
208
+ .replace(/\[\[[^\]|]*\|?([^\]]*)\]\]/g, "$1")
209
+ .split("\n")
210
+ .filter((line) => !STRUCTURE_LINE.test(line.trim()))
211
+ .join(" ");
212
+ return text.split(/\s+/).filter((word) => /[\p{L}\p{N}]/u.test(word)).length;
213
+ }
@@ -85,6 +85,12 @@ import { currentType } from "./ids.mjs";
85
85
  // What a place is next to and reachable from — the two relation lists and
86
86
  // the checks that hold them to their closed sets and to each other.
87
87
  import { checkBorders, checkRoutes } from "./place-relations.mjs";
88
+ import { checkHeld } from "./holdings.mjs";
89
+ import { checkCitedPopulations, checkPopulation } from "./populations.mjs";
90
+ // What trade a settlement supports — the scale and the check that holds a
91
+ // value to it, read rather than restated, so the reference below and the
92
+ // finding an author meets state one list.
93
+ import { MARKET_CLASSES, checkMarket } from "./market-class.mjs";
88
94
 
89
95
  /**
90
96
  * One `data:` key a note type may carry.
@@ -137,10 +143,27 @@ import { checkBorders, checkRoutes } from "./place-relations.mjs";
137
143
  * not yet enumerate. Presence is permitted and the value is unchecked.
138
144
  * - **a list** — the closed set of values, and anything else is a finding.
139
145
  *
146
+ * `stubbable` says whether an empty body suppresses this type's page. Every
147
+ * type declares it, so adding one means answering the question rather than
148
+ * inheriting a default nobody chose — and a guard derives the check from this
149
+ * registry's own key list, so an omission is a red test.
150
+ *
151
+ * Two types answer **false**, and both are structural: a `folder` note's page
152
+ * is a generated section index and a `homepage`'s is the site's front door, so
153
+ * each is complete with no body at all. An empty body on one of them keeps its
154
+ * address and resolves as `full`.
155
+ *
140
156
  * @typedef {object} TypeVocabulary
141
157
  * @property {readonly DataFieldSpec[]} data - The `data:` keys, closed.
142
158
  * @property {readonly string[]|null} [subTypes] - The top-level `subType`
143
159
  * values, as above.
160
+ * @property {boolean} stubbable - Whether an empty body makes a note of this
161
+ * type a stub — see {@link module:engine/note-state.isStubbableType}.
162
+ * @property {(note: object, opts: {index?: object}) => object[]} [check] - A
163
+ * check of the whole note, run by the frontmatter lint beside the field
164
+ * checks with the same index. For a rule that is about the note rather than
165
+ * one of its fields — a place's tenure is a fact about every affiliation's
166
+ * `domains`, and is located at the note's `type:` line.
144
167
  */
145
168
 
146
169
  /* --------------------------------------------------------------------- */
@@ -537,6 +560,7 @@ export const NOTE_VOCABULARY = Object.freeze({
537
560
  /* ----- actors --------------------------------------------------- */
538
561
 
539
562
  being: Object.freeze({
563
+ stubbable: true,
540
564
  // Derived from the note's `(type, subType)` by each system's map, which
541
565
  // lands with. Declared open until it does, because inventing the
542
566
  // values here would put a second, weaker answer beside the real one.
@@ -584,6 +608,7 @@ export const NOTE_VOCABULARY = Object.freeze({
584
608
  }),
585
609
 
586
610
  vehicle: Object.freeze({
611
+ stubbable: true,
587
612
  subTypes: null,
588
613
  data: Object.freeze([TOKEN_ICON, TEMPLATE_PRIORITY]),
589
614
  }),
@@ -591,6 +616,7 @@ export const NOTE_VOCABULARY = Object.freeze({
591
616
  /* ----- items ---------------------------------------------------- */
592
617
 
593
618
  affiliation: Object.freeze({
619
+ stubbable: true,
594
620
  subTypes: Object.freeze([
595
621
  "guild",
596
622
  "order",
@@ -673,6 +699,7 @@ export const NOTE_VOCABULARY = Object.freeze({
673
699
  }),
674
700
 
675
701
  affliction: Object.freeze({
702
+ stubbable: true,
676
703
  subTypes: Object.freeze(["disease", "poisontoxin", "maladiction"]),
677
704
  data: Object.freeze([
678
705
  TEMPLATE_PRIORITY,
@@ -723,21 +750,25 @@ export const NOTE_VOCABULARY = Object.freeze({
723
750
  }),
724
751
 
725
752
  armorgear: Object.freeze({
753
+ stubbable: true,
726
754
  // Quantity is always one, so the specification refuses the key rather
727
755
  // than defaulting it.
728
756
  data: Object.freeze([TEMPLATE_PRIORITY, ...GEAR]),
729
757
  }),
730
758
 
731
759
  armorlocation: Object.freeze({
760
+ stubbable: true,
732
761
  subTypes: null,
733
762
  data: Object.freeze([TEMPLATE_PRIORITY]),
734
763
  }),
735
764
 
736
765
  attribute: Object.freeze({
766
+ stubbable: true,
737
767
  data: Object.freeze([TEMPLATE_PRIORITY]),
738
768
  }),
739
769
 
740
770
  concoctiongear: Object.freeze({
771
+ stubbable: true,
741
772
  subTypes: Object.freeze(["mundane", "exotic", "elixir"]),
742
773
  data: Object.freeze([
743
774
  TEMPLATE_PRIORITY,
@@ -753,6 +784,7 @@ export const NOTE_VOCABULARY = Object.freeze({
753
784
  }),
754
785
 
755
786
  containergear: Object.freeze({
787
+ stubbable: true,
756
788
  data: Object.freeze([
757
789
  TEMPLATE_PRIORITY,
758
790
  ...GEAR,
@@ -761,10 +793,12 @@ export const NOTE_VOCABULARY = Object.freeze({
761
793
  }),
762
794
 
763
795
  miscgear: Object.freeze({
796
+ stubbable: true,
764
797
  data: Object.freeze([TEMPLATE_PRIORITY, ...GEAR, QUANTITY]),
765
798
  }),
766
799
 
767
800
  mystery: Object.freeze({
801
+ stubbable: true,
768
802
  subTypes: Object.freeze(["boon", "boost", "fate", "grace", "birthsign", "other", "piety"]),
769
803
  data: Object.freeze([
770
804
  TEMPLATE_PRIORITY,
@@ -786,6 +820,7 @@ export const NOTE_VOCABULARY = Object.freeze({
786
820
  }),
787
821
 
788
822
  mysticalability: Object.freeze({
823
+ stubbable: true,
789
824
  subTypes: Object.freeze([
790
825
  "spiritrite",
791
826
  "spiritaction",
@@ -813,11 +848,13 @@ export const NOTE_VOCABULARY = Object.freeze({
813
848
  }),
814
849
 
815
850
  projectilegear: Object.freeze({
851
+ stubbable: true,
816
852
  subTypes: Object.freeze(["none", "arrow", "bolt", "bullet", "dart", "other"]),
817
853
  data: Object.freeze([TEMPLATE_PRIORITY, ...GEAR, QUANTITY]),
818
854
  }),
819
855
 
820
856
  skill: Object.freeze({
857
+ stubbable: true,
821
858
  subTypes: Object.freeze([
822
859
  "social",
823
860
  "nature",
@@ -838,6 +875,7 @@ export const NOTE_VOCABULARY = Object.freeze({
838
875
  }),
839
876
 
840
877
  trauma: Object.freeze({
878
+ stubbable: true,
841
879
  subTypes: Object.freeze([
842
880
  "injury",
843
881
  "fear",
@@ -887,6 +925,7 @@ export const NOTE_VOCABULARY = Object.freeze({
887
925
  }),
888
926
 
889
927
  weapongear: Object.freeze({
928
+ stubbable: true,
890
929
  // No `subTypes`, deliberately: SoHL distinguishes a weapon's uses with
891
930
  // strike modes rather than by kind, and HM3's document type follows
892
931
  // from which of those a note describes.
@@ -896,6 +935,7 @@ export const NOTE_VOCABULARY = Object.freeze({
896
935
  /* ----- core documents ------------------------------------------- */
897
936
 
898
937
  doc: Object.freeze({
938
+ stubbable: true,
899
939
  // Five genres, and a genre is all this field carries: what kind of page
900
940
  // it is, never who reads it. An audience term alongside them would give
901
941
  // a developer how-to two valid values and no rule for choosing.
@@ -903,10 +943,11 @@ export const NOTE_VOCABULARY = Object.freeze({
903
943
  // `userguide` and `howto`, not `user-guide` and `how-to`: a subType is
904
944
  // held to the address charset, and a segment carries no hyphen.
905
945
  subTypes: Object.freeze(["rules", "userguide", "reference", "howto", "concept"]),
946
+ check: checkCitedPopulations,
906
947
  data: Object.freeze([]),
907
948
  }),
908
949
 
909
- macro: Object.freeze({ data: Object.freeze([]) }),
950
+ macro: Object.freeze({ stubbable: true, data: Object.freeze([]) }),
910
951
 
911
952
  // Foundry's `Folder`, and the last document this package compiled from
912
953
  // bespoke configuration rather than from a note. It declares no
@@ -918,6 +959,7 @@ export const NOTE_VOCABULARY = Object.freeze({
918
959
  // is structure, not content, so it wants no documentation journal and takes
919
960
  // no part in `docEntryTypes`.
920
961
  folder: Object.freeze({
962
+ stubbable: false,
921
963
  data: Object.freeze([
922
964
  {
923
965
  name: "parent",
@@ -947,6 +989,7 @@ export const NOTE_VOCABULARY = Object.freeze({
947
989
  // `system` field, so a bundle spanning two systems is two documents, and
948
990
  // the pack each is written to is what carries the system.
949
991
  bundle: Object.freeze({
992
+ stubbable: true,
950
993
  data: Object.freeze([
951
994
  {
952
995
  name: "contents",
@@ -960,6 +1003,7 @@ export const NOTE_VOCABULARY = Object.freeze({
960
1003
  }),
961
1004
 
962
1005
  lore: Object.freeze({
1006
+ stubbable: true,
963
1007
  subTypes: Object.freeze([
964
1008
  "cosmology",
965
1009
  "deity",
@@ -983,7 +1027,9 @@ export const NOTE_VOCABULARY = Object.freeze({
983
1027
  }),
984
1028
 
985
1029
  place: Object.freeze({
1030
+ stubbable: true,
986
1031
  subTypes: Object.freeze(["world", "region", "settlement", "site", "structure", "feature"]),
1032
+ check: checkHeld,
987
1033
  data: Object.freeze([
988
1034
  {
989
1035
  name: "demonym",
@@ -1005,8 +1051,17 @@ export const NOTE_VOCABULARY = Object.freeze({
1005
1051
  {
1006
1052
  name: "population",
1007
1053
  ...NUM,
1054
+ check: checkPopulation,
1008
1055
  describe: "Approximate population, to two significant digits.",
1009
1056
  },
1057
+ {
1058
+ name: "market",
1059
+ ...NUM,
1060
+ check: checkMarket,
1061
+ describe:
1062
+ "What trade the settlement supports, on a scale of six: " +
1063
+ `${MARKET_CLASSES.map((c) => `${c.value} ${c.name}`).join(", ")}.`,
1064
+ },
1010
1065
  {
1011
1066
  name: "borders",
1012
1067
  ...LIST,
@@ -1029,6 +1084,7 @@ export const NOTE_VOCABULARY = Object.freeze({
1029
1084
  }),
1030
1085
 
1031
1086
  scenario: Object.freeze({
1087
+ stubbable: true,
1032
1088
  subTypes: Object.freeze(["campaign", "adventure", "encounter"]),
1033
1089
  data: Object.freeze([
1034
1090
  { name: "parents", ...LINKS, describe: "Scenarios this one sits within." },
@@ -1058,9 +1114,10 @@ export const NOTE_VOCABULARY = Object.freeze({
1058
1114
  ]),
1059
1115
  }),
1060
1116
 
1061
- homepage: Object.freeze({ data: Object.freeze([]) }),
1117
+ homepage: Object.freeze({ stubbable: false, data: Object.freeze([]) }),
1062
1118
 
1063
1119
  map: Object.freeze({
1120
+ stubbable: true,
1064
1121
  // One type, three subTypes: they differ only in the canvas defaults
1065
1122
  // derived for them, which is precisely what a subType decides.
1066
1123
  subTypes: Object.freeze(["battlemap", "localmap", "regionalmap"]),
@@ -93,7 +93,7 @@ import { loadPackConfig } from "./pack-config.mjs";
93
93
  import { routerFor } from "./pack-router.mjs";
94
94
  import { publishesContentPages } from "../content-config.mjs";
95
95
  import { indexRecordsFor } from "./content-index.mjs";
96
- import { isNoteRecord, noteFile } from "./index-records.mjs";
96
+ import { isNoteRecord, isStub, noteFile } from "./index-records.mjs";
97
97
  import { openNotesDatabase, prepareTreeSqlTables } from "./sql-tables.mjs";
98
98
  import { parseDocumentTree, runTreeFilters, planDocument } from "./pdf-toc.mjs";
99
99
  import { collectContentPages, siteGates, tableUniverse, gatesFailed } from "./site-build.mjs";
@@ -351,7 +351,9 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
351
351
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "content-pdf-"));
352
352
  const db = await openNotesDatabase(records, { dir });
353
353
  const ran = await runTreeFilters(parsed.nodes, db, {
354
- keep: isNoteRecord,
354
+ // A stub is a note and is deliberately in the corpus, but it has no
355
+ // body — selecting one would print a blank page under its name.
356
+ keep: (record) => isNoteRecord(record) && !isStub(record),
355
357
  text: tree.text,
356
358
  });
357
359
  findings.push(...ran.findings.map((f) => ({ file: resolved.pdf.document, ...f })));