@heroiclands/package-build 22.4.7 → 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.
- package/CHANGELOG.md +23 -0
- package/CONTENT.md +83 -1
- package/bin/content-build.mjs +16 -2
- package/docs/api.md +49 -12
- package/docs/content-format.md +167 -0
- package/docs/diagnostics.md +44 -0
- package/engine/content-index.mjs +63 -21
- package/engine/content-links.mjs +60 -6
- package/engine/content-tables.mjs +29 -2
- package/engine/holdings.mjs +42 -17
- package/engine/index-records.mjs +25 -0
- package/engine/index.mjs +7 -0
- package/engine/note-state.mjs +213 -0
- package/engine/note-vocabulary.mjs +40 -2
- package/engine/pdf-build.mjs +4 -2
- package/engine/populations.mjs +557 -0
- package/engine/site-build.mjs +8 -0
- package/engine/sql-tables.mjs +157 -4
- package/engine/stub-lint.mjs +237 -0
- package/engine/wikilink-syntax.mjs +27 -1
- package/package.json +1 -1
- package/types/engine/content-index.d.mts +19 -5
- package/types/engine/content-links.d.mts +3 -3
- package/types/engine/content-tables.d.mts +2 -1
- package/types/engine/holdings.d.mts +17 -6
- package/types/engine/index-records.d.mts +22 -0
- package/types/engine/index.d.mts +3 -0
- package/types/engine/note-state.d.mts +110 -0
- package/types/engine/note-vocabulary.d.mts +15 -0
- package/types/engine/populations.d.mts +121 -0
- package/types/engine/stub-lint.d.mts +51 -0
- package/types/engine/wikilink-syntax.d.mts +19 -1
|
@@ -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
|
+
}
|
|
@@ -86,6 +86,7 @@ import { currentType } from "./ids.mjs";
|
|
|
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
88
|
import { checkHeld } from "./holdings.mjs";
|
|
89
|
+
import { checkCitedPopulations, checkPopulation } from "./populations.mjs";
|
|
89
90
|
// What trade a settlement supports — the scale and the check that holds a
|
|
90
91
|
// value to it, read rather than restated, so the reference below and the
|
|
91
92
|
// finding an author meets state one list.
|
|
@@ -142,10 +143,22 @@ import { MARKET_CLASSES, checkMarket } from "./market-class.mjs";
|
|
|
142
143
|
* not yet enumerate. Presence is permitted and the value is unchecked.
|
|
143
144
|
* - **a list** — the closed set of values, and anything else is a finding.
|
|
144
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
|
+
*
|
|
145
156
|
* @typedef {object} TypeVocabulary
|
|
146
157
|
* @property {readonly DataFieldSpec[]} data - The `data:` keys, closed.
|
|
147
158
|
* @property {readonly string[]|null} [subTypes] - The top-level `subType`
|
|
148
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}.
|
|
149
162
|
* @property {(note: object, opts: {index?: object}) => object[]} [check] - A
|
|
150
163
|
* check of the whole note, run by the frontmatter lint beside the field
|
|
151
164
|
* checks with the same index. For a rule that is about the note rather than
|
|
@@ -547,6 +560,7 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
547
560
|
/* ----- actors --------------------------------------------------- */
|
|
548
561
|
|
|
549
562
|
being: Object.freeze({
|
|
563
|
+
stubbable: true,
|
|
550
564
|
// Derived from the note's `(type, subType)` by each system's map, which
|
|
551
565
|
// lands with. Declared open until it does, because inventing the
|
|
552
566
|
// values here would put a second, weaker answer beside the real one.
|
|
@@ -594,6 +608,7 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
594
608
|
}),
|
|
595
609
|
|
|
596
610
|
vehicle: Object.freeze({
|
|
611
|
+
stubbable: true,
|
|
597
612
|
subTypes: null,
|
|
598
613
|
data: Object.freeze([TOKEN_ICON, TEMPLATE_PRIORITY]),
|
|
599
614
|
}),
|
|
@@ -601,6 +616,7 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
601
616
|
/* ----- items ---------------------------------------------------- */
|
|
602
617
|
|
|
603
618
|
affiliation: Object.freeze({
|
|
619
|
+
stubbable: true,
|
|
604
620
|
subTypes: Object.freeze([
|
|
605
621
|
"guild",
|
|
606
622
|
"order",
|
|
@@ -683,6 +699,7 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
683
699
|
}),
|
|
684
700
|
|
|
685
701
|
affliction: Object.freeze({
|
|
702
|
+
stubbable: true,
|
|
686
703
|
subTypes: Object.freeze(["disease", "poisontoxin", "maladiction"]),
|
|
687
704
|
data: Object.freeze([
|
|
688
705
|
TEMPLATE_PRIORITY,
|
|
@@ -733,21 +750,25 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
733
750
|
}),
|
|
734
751
|
|
|
735
752
|
armorgear: Object.freeze({
|
|
753
|
+
stubbable: true,
|
|
736
754
|
// Quantity is always one, so the specification refuses the key rather
|
|
737
755
|
// than defaulting it.
|
|
738
756
|
data: Object.freeze([TEMPLATE_PRIORITY, ...GEAR]),
|
|
739
757
|
}),
|
|
740
758
|
|
|
741
759
|
armorlocation: Object.freeze({
|
|
760
|
+
stubbable: true,
|
|
742
761
|
subTypes: null,
|
|
743
762
|
data: Object.freeze([TEMPLATE_PRIORITY]),
|
|
744
763
|
}),
|
|
745
764
|
|
|
746
765
|
attribute: Object.freeze({
|
|
766
|
+
stubbable: true,
|
|
747
767
|
data: Object.freeze([TEMPLATE_PRIORITY]),
|
|
748
768
|
}),
|
|
749
769
|
|
|
750
770
|
concoctiongear: Object.freeze({
|
|
771
|
+
stubbable: true,
|
|
751
772
|
subTypes: Object.freeze(["mundane", "exotic", "elixir"]),
|
|
752
773
|
data: Object.freeze([
|
|
753
774
|
TEMPLATE_PRIORITY,
|
|
@@ -763,6 +784,7 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
763
784
|
}),
|
|
764
785
|
|
|
765
786
|
containergear: Object.freeze({
|
|
787
|
+
stubbable: true,
|
|
766
788
|
data: Object.freeze([
|
|
767
789
|
TEMPLATE_PRIORITY,
|
|
768
790
|
...GEAR,
|
|
@@ -771,10 +793,12 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
771
793
|
}),
|
|
772
794
|
|
|
773
795
|
miscgear: Object.freeze({
|
|
796
|
+
stubbable: true,
|
|
774
797
|
data: Object.freeze([TEMPLATE_PRIORITY, ...GEAR, QUANTITY]),
|
|
775
798
|
}),
|
|
776
799
|
|
|
777
800
|
mystery: Object.freeze({
|
|
801
|
+
stubbable: true,
|
|
778
802
|
subTypes: Object.freeze(["boon", "boost", "fate", "grace", "birthsign", "other", "piety"]),
|
|
779
803
|
data: Object.freeze([
|
|
780
804
|
TEMPLATE_PRIORITY,
|
|
@@ -796,6 +820,7 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
796
820
|
}),
|
|
797
821
|
|
|
798
822
|
mysticalability: Object.freeze({
|
|
823
|
+
stubbable: true,
|
|
799
824
|
subTypes: Object.freeze([
|
|
800
825
|
"spiritrite",
|
|
801
826
|
"spiritaction",
|
|
@@ -823,11 +848,13 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
823
848
|
}),
|
|
824
849
|
|
|
825
850
|
projectilegear: Object.freeze({
|
|
851
|
+
stubbable: true,
|
|
826
852
|
subTypes: Object.freeze(["none", "arrow", "bolt", "bullet", "dart", "other"]),
|
|
827
853
|
data: Object.freeze([TEMPLATE_PRIORITY, ...GEAR, QUANTITY]),
|
|
828
854
|
}),
|
|
829
855
|
|
|
830
856
|
skill: Object.freeze({
|
|
857
|
+
stubbable: true,
|
|
831
858
|
subTypes: Object.freeze([
|
|
832
859
|
"social",
|
|
833
860
|
"nature",
|
|
@@ -848,6 +875,7 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
848
875
|
}),
|
|
849
876
|
|
|
850
877
|
trauma: Object.freeze({
|
|
878
|
+
stubbable: true,
|
|
851
879
|
subTypes: Object.freeze([
|
|
852
880
|
"injury",
|
|
853
881
|
"fear",
|
|
@@ -897,6 +925,7 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
897
925
|
}),
|
|
898
926
|
|
|
899
927
|
weapongear: Object.freeze({
|
|
928
|
+
stubbable: true,
|
|
900
929
|
// No `subTypes`, deliberately: SoHL distinguishes a weapon's uses with
|
|
901
930
|
// strike modes rather than by kind, and HM3's document type follows
|
|
902
931
|
// from which of those a note describes.
|
|
@@ -906,6 +935,7 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
906
935
|
/* ----- core documents ------------------------------------------- */
|
|
907
936
|
|
|
908
937
|
doc: Object.freeze({
|
|
938
|
+
stubbable: true,
|
|
909
939
|
// Five genres, and a genre is all this field carries: what kind of page
|
|
910
940
|
// it is, never who reads it. An audience term alongside them would give
|
|
911
941
|
// a developer how-to two valid values and no rule for choosing.
|
|
@@ -913,10 +943,11 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
913
943
|
// `userguide` and `howto`, not `user-guide` and `how-to`: a subType is
|
|
914
944
|
// held to the address charset, and a segment carries no hyphen.
|
|
915
945
|
subTypes: Object.freeze(["rules", "userguide", "reference", "howto", "concept"]),
|
|
946
|
+
check: checkCitedPopulations,
|
|
916
947
|
data: Object.freeze([]),
|
|
917
948
|
}),
|
|
918
949
|
|
|
919
|
-
macro: Object.freeze({ data: Object.freeze([]) }),
|
|
950
|
+
macro: Object.freeze({ stubbable: true, data: Object.freeze([]) }),
|
|
920
951
|
|
|
921
952
|
// Foundry's `Folder`, and the last document this package compiled from
|
|
922
953
|
// bespoke configuration rather than from a note. It declares no
|
|
@@ -928,6 +959,7 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
928
959
|
// is structure, not content, so it wants no documentation journal and takes
|
|
929
960
|
// no part in `docEntryTypes`.
|
|
930
961
|
folder: Object.freeze({
|
|
962
|
+
stubbable: false,
|
|
931
963
|
data: Object.freeze([
|
|
932
964
|
{
|
|
933
965
|
name: "parent",
|
|
@@ -957,6 +989,7 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
957
989
|
// `system` field, so a bundle spanning two systems is two documents, and
|
|
958
990
|
// the pack each is written to is what carries the system.
|
|
959
991
|
bundle: Object.freeze({
|
|
992
|
+
stubbable: true,
|
|
960
993
|
data: Object.freeze([
|
|
961
994
|
{
|
|
962
995
|
name: "contents",
|
|
@@ -970,6 +1003,7 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
970
1003
|
}),
|
|
971
1004
|
|
|
972
1005
|
lore: Object.freeze({
|
|
1006
|
+
stubbable: true,
|
|
973
1007
|
subTypes: Object.freeze([
|
|
974
1008
|
"cosmology",
|
|
975
1009
|
"deity",
|
|
@@ -993,6 +1027,7 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
993
1027
|
}),
|
|
994
1028
|
|
|
995
1029
|
place: Object.freeze({
|
|
1030
|
+
stubbable: true,
|
|
996
1031
|
subTypes: Object.freeze(["world", "region", "settlement", "site", "structure", "feature"]),
|
|
997
1032
|
check: checkHeld,
|
|
998
1033
|
data: Object.freeze([
|
|
@@ -1016,6 +1051,7 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
1016
1051
|
{
|
|
1017
1052
|
name: "population",
|
|
1018
1053
|
...NUM,
|
|
1054
|
+
check: checkPopulation,
|
|
1019
1055
|
describe: "Approximate population, to two significant digits.",
|
|
1020
1056
|
},
|
|
1021
1057
|
{
|
|
@@ -1048,6 +1084,7 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
1048
1084
|
}),
|
|
1049
1085
|
|
|
1050
1086
|
scenario: Object.freeze({
|
|
1087
|
+
stubbable: true,
|
|
1051
1088
|
subTypes: Object.freeze(["campaign", "adventure", "encounter"]),
|
|
1052
1089
|
data: Object.freeze([
|
|
1053
1090
|
{ name: "parents", ...LINKS, describe: "Scenarios this one sits within." },
|
|
@@ -1077,9 +1114,10 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
1077
1114
|
]),
|
|
1078
1115
|
}),
|
|
1079
1116
|
|
|
1080
|
-
homepage: Object.freeze({ data: Object.freeze([]) }),
|
|
1117
|
+
homepage: Object.freeze({ stubbable: false, data: Object.freeze([]) }),
|
|
1081
1118
|
|
|
1082
1119
|
map: Object.freeze({
|
|
1120
|
+
stubbable: true,
|
|
1083
1121
|
// One type, three subTypes: they differ only in the canvas defaults
|
|
1084
1122
|
// derived for them, which is precisely what a subType decides.
|
|
1085
1123
|
subTypes: Object.freeze(["battlemap", "localmap", "regionalmap"]),
|
package/engine/pdf-build.mjs
CHANGED
|
@@ -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
|
-
|
|
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 })));
|