@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.
@@ -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"]),
@@ -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 })));