@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.
- package/CHANGELOG.md +33 -0
- package/CONTENT.md +192 -1
- package/bin/content-build.mjs +26 -4
- package/content-config.mjs +8 -3
- package/docs/api.md +99 -26
- package/docs/configuration.md +20 -1
- package/docs/content-format.md +238 -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/frontmatter-lint.mjs +2 -0
- package/engine/holdings.mjs +416 -0
- package/engine/index-records.mjs +25 -0
- package/engine/index.mjs +16 -0
- package/engine/map-graphviz.mjs +12 -3
- package/engine/map-places.mjs +33 -3
- package/engine/market-class.mjs +178 -0
- package/engine/metadata-index.mjs +7 -4
- package/engine/note-state.mjs +213 -0
- package/engine/note-vocabulary.mjs +59 -2
- package/engine/pdf-build.mjs +4 -2
- package/engine/populations.mjs +557 -0
- package/engine/site-build.mjs +96 -10
- package/engine/site-maps.mjs +165 -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 +157 -0
- package/types/engine/index-records.d.mts +22 -0
- package/types/engine/index.d.mts +6 -0
- package/types/engine/map-graphviz.d.mts +8 -1
- package/types/engine/map-places.d.mts +26 -0
- package/types/engine/market-class.d.mts +62 -0
- package/types/engine/note-state.d.mts +110 -0
- package/types/engine/note-vocabulary.d.mts +25 -0
- package/types/engine/populations.d.mts +121 -0
- package/types/engine/site-build.d.mts +37 -6
- package/types/engine/site-maps.d.mts +81 -0
- package/types/engine/stub-lint.d.mts +51 -0
- 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,
|
|
390
|
-
// so a consumer's lint
|
|
391
|
-
//
|
|
392
|
-
// dependency's places beside its
|
|
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"]),
|
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 })));
|