@heroiclands/package-build 23.0.0 → 23.1.1
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 +66 -0
- package/README.md +1 -1
- package/bin/content-commands.mjs +1 -1
- package/ci/content-format-reference.mjs +46 -0
- package/docs/api.md +124 -141
- package/docs/authoring/assets.md +1 -1
- package/docs/authoring/dates-and-calendars.md +12 -6
- package/docs/authoring/frontmatter.md +2 -2
- package/docs/authoring/links-and-markup.md +145 -29
- package/docs/commands.md +8 -10
- package/docs/configuration.md +13 -11
- package/docs/content-format.md +2 -2
- package/docs/guides/book.md +59 -4
- package/docs/guides/icons.md +4 -5
- package/docs/guides/packs.md +6 -0
- package/docs/guides/reference-pages.md +21 -1
- package/docs/guides/release-and-deploy.md +39 -7
- package/docs/guides/site.md +17 -18
- package/docs/reference/format-details.md +877 -276
- package/docs/reference/note-types.md +278 -91
- package/docs/reference/theme-pages.md +58 -97
- package/engine/address-values.mjs +124 -4
- package/engine/address.mjs +44 -3
- package/engine/anchors.mjs +103 -3
- package/engine/art-slots.mjs +2 -9
- package/engine/base-compiler.mjs +25 -6
- package/engine/calendar-artifacts.mjs +4 -1
- package/engine/calendar-notes.mjs +166 -102
- package/engine/content-format-check.mjs +2 -3
- package/engine/content-format.mjs +44 -1
- package/engine/content-format.yaml +79 -16
- package/engine/content-images.mjs +10 -1
- package/engine/content-index.mjs +37 -9
- package/engine/content-links.mjs +24 -5
- package/engine/content-tables.mjs +24 -3
- package/engine/data-keys.mjs +347 -0
- package/engine/derived-sections.mjs +322 -0
- package/engine/diagnostics.mjs +28 -0
- package/engine/event-fields.mjs +187 -0
- package/engine/event-rows.mjs +126 -0
- package/engine/event-views.mjs +305 -0
- package/engine/exported-scene.mjs +29 -53
- package/engine/frontmatter-lint.mjs +103 -77
- package/engine/frontmatter.mjs +7 -2
- package/engine/generate.mjs +53 -0
- package/engine/generated-sections.mjs +223 -0
- package/engine/heading-attributes.mjs +4 -2
- package/engine/helpers.mjs +12 -1
- package/engine/holdings.mjs +12 -33
- package/engine/ids.mjs +17 -11
- package/engine/index.mjs +4 -4
- package/engine/infobox-render.mjs +21 -6
- package/engine/infobox.mjs +217 -99
- package/engine/journals.mjs +104 -47
- package/engine/literature-notes.mjs +4 -1
- package/engine/literature-works.mjs +21 -13
- package/engine/map-graphviz.mjs +4 -18
- package/engine/markdown-expressions.mjs +116 -11
- package/engine/metadata-index.mjs +26 -0
- package/engine/near-miss.mjs +90 -0
- package/engine/note-addresses.mjs +75 -19
- package/engine/note-dates.mjs +5 -3
- package/engine/note-event-terms.mjs +364 -0
- package/engine/note-events.mjs +823 -0
- package/engine/note-vocabulary.mjs +184 -88
- package/engine/office-holders.mjs +20 -54
- package/engine/packagebuild-index.mjs +16 -1
- package/engine/pdf-build.mjs +97 -60
- package/engine/pdf-index.mjs +263 -0
- package/engine/pdf-render.mjs +47 -5
- package/engine/place-relation-terms.mjs +174 -0
- package/engine/place-relations.mjs +72 -160
- package/engine/rank-ladder.mjs +14 -41
- package/engine/reckoning-markers.mjs +2 -0
- package/engine/region-events.mjs +6 -12
- package/engine/scenes.mjs +73 -468
- package/engine/site-build.mjs +75 -101
- package/engine/site-index.mjs +3 -1
- package/engine/site-maps.mjs +43 -29
- package/engine/site-preview.mjs +40 -31
- package/engine/social-ties.mjs +2 -1
- package/engine/sql-tables.mjs +212 -9
- package/engine/standing-terms.mjs +145 -6
- package/engine/standings.mjs +37 -49
- package/engine/web-wikilinks.mjs +21 -1
- package/engine/wikilink-syntax.mjs +34 -4
- package/engine/wikilinks.mjs +27 -2
- package/engine/yaml-lint.mjs +7 -4
- package/hugo-theme/layouts/partials/infobox/entry.html +4 -1
- package/hugo-theme/layouts/partials/page-body.html +0 -3
- package/hugo-theme/static/css/style.css +25 -55
- package/package.json +1 -2
- package/packagebuild-metadata.jsonl +13 -13
- package/release.mjs +62 -4
- package/sohl/note-schemas.mjs +8 -73
- package/types/engine/address-values.d.mts +50 -3
- package/types/engine/address.d.mts +21 -0
- package/types/engine/anchors.d.mts +49 -2
- package/types/engine/art-slots.d.mts +1 -1
- package/types/engine/base-compiler.d.mts +13 -4
- package/types/engine/calendar-notes.d.mts +7 -0
- package/types/engine/content-format.d.mts +52 -0
- package/types/engine/content-images.d.mts +5 -1
- package/types/engine/content-tables.d.mts +9 -1
- package/types/engine/data-keys.d.mts +145 -0
- package/types/engine/derived-sections.d.mts +73 -0
- package/types/engine/diagnostics.d.mts +15 -0
- package/types/engine/event-fields.d.mts +99 -0
- package/types/engine/event-rows.d.mts +12 -0
- package/types/engine/event-views.d.mts +67 -0
- package/types/engine/exported-scene.d.mts +5 -2
- package/types/engine/frontmatter-lint.d.mts +3 -3
- package/types/engine/generated-sections.d.mts +143 -0
- package/types/engine/holdings.d.mts +6 -20
- package/types/engine/ids.d.mts +15 -12
- package/types/engine/index.d.mts +1 -1
- package/types/engine/infobox.d.mts +13 -3
- package/types/engine/journals.d.mts +24 -11
- package/types/engine/literature-works.d.mts +1 -2
- package/types/engine/map-graphviz.d.mts +2 -10
- package/types/engine/markdown-expressions.d.mts +12 -3
- package/types/engine/near-miss.d.mts +42 -0
- package/types/engine/note-addresses.d.mts +7 -1
- package/types/engine/note-event-terms.d.mts +68 -0
- package/types/engine/note-events.d.mts +71 -0
- package/types/engine/note-vocabulary.d.mts +37 -8
- package/types/engine/office-holders.d.mts +3 -0
- package/types/engine/pdf-index.d.mts +35 -0
- package/types/engine/pdf-render.d.mts +8 -2
- package/types/engine/place-relation-terms.d.mts +83 -0
- package/types/engine/place-relations.d.mts +11 -66
- package/types/engine/rank-ladder.d.mts +2 -0
- package/types/engine/region-events.d.mts +6 -12
- package/types/engine/scenes.d.mts +6 -32
- package/types/engine/site-build.d.mts +8 -22
- package/types/engine/site-maps.d.mts +15 -7
- package/types/engine/sql-tables.d.mts +77 -2
- package/types/engine/standing-terms.d.mts +37 -4
- package/types/engine/standings.d.mts +14 -9
- package/types/engine/wikilink-syntax.d.mts +20 -0
- package/types/engine/wikilinks.d.mts +3 -2
- package/types/engine/yaml-lint.d.mts +5 -4
- package/types/release.d.mts +4 -0
- package/docs/guides/government-migration.md +0 -52
- package/engine/itinerary-scenes.mjs +0 -166
- package/engine/lore-events.mjs +0 -197
- package/engine/map-notes.mjs +0 -1309
- package/engine/map-raster.mjs +0 -51
- package/hugo-theme/layouts/partials/holdings/entry.html +0 -21
- package/hugo-theme/layouts/partials/holdings/kind-groups.html +0 -71
- package/hugo-theme/layouts/partials/holdings.html +0 -61
- package/hugo-theme/layouts/partials/literature-works.html +0 -29
- package/hugo-theme/layouts/partials/place-map.html +0 -9
- package/types/engine/itinerary-scenes.d.mts +0 -29
- package/types/engine/lore-events.d.mts +0 -14
- package/types/engine/map-notes.d.mts +0 -297
- package/types/engine/map-raster.d.mts +0 -8
|
@@ -0,0 +1,347 @@
|
|
|
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 inner keys of a `data:` field**, and the one check that holds every
|
|
16
|
+
* field's value to them.
|
|
17
|
+
*
|
|
18
|
+
* `data:` is closed at its own level by the frontmatter lint. A field whose value
|
|
19
|
+
* holds maps is closed the same way one level down and at every level below: its
|
|
20
|
+
* declaration states what the value holds, and a key the declaration does not
|
|
21
|
+
* name is a finding at that key's own `file:line:column`.
|
|
22
|
+
*
|
|
23
|
+
* A declaration states its contents with the same descriptor vocabulary a
|
|
24
|
+
* field uses — `name`, `kind`, `shape`, `describe` — plus three properties
|
|
25
|
+
* saying where the maps are:
|
|
26
|
+
*
|
|
27
|
+
* - **`fields`** — the value is a map with these keys and no others. Each is an
|
|
28
|
+
* {@link InnerKeySpec}, which may itself carry `fields`, `entries` or
|
|
29
|
+
* `values`.
|
|
30
|
+
* - **`entries`** — the value is a list, and this describes one entry. A list
|
|
31
|
+
* of Addresses states `entryKind` instead.
|
|
32
|
+
* - **`values`** — the value is a map whose keys are data (an Address, a
|
|
33
|
+
* Shortcode, a pack, a name), and this describes the value under each key.
|
|
34
|
+
* The keys themselves are checked as the field's `keyKind` says.
|
|
35
|
+
*
|
|
36
|
+
* Dotted field names (`appearance.eye_color`) declare a container's keys by
|
|
37
|
+
* their segments, and the container is closed to them just the same.
|
|
38
|
+
*
|
|
39
|
+
* **What the check owns is shape**: an undeclared key, a required key that is
|
|
40
|
+
* absent, and a present value of the wrong kind. What a value *means* — whether
|
|
41
|
+
* an Address resolves, a bearing is one of eight, a ladder repeats a level —
|
|
42
|
+
* stays with the field's own `check`, which skips a value of the wrong shape
|
|
43
|
+
* because this check has already reported it.
|
|
44
|
+
*
|
|
45
|
+
* @module
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { isAddressTuple } from "./address.mjs";
|
|
49
|
+
import { AddressEntries, AddressLink } from "./address-values.mjs";
|
|
50
|
+
import { positionOfFrontmatterPath } from "./diagnostics.mjs";
|
|
51
|
+
import { nearest } from "./near-miss.mjs";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* One key inside a field's value.
|
|
55
|
+
*
|
|
56
|
+
* @typedef {object} InnerKeySpec
|
|
57
|
+
* @property {string} name - The key as an author writes it.
|
|
58
|
+
* @property {string} [kind] - Its value's shape: `string`, `date`, `number`,
|
|
59
|
+
* `integer`, `boolean`, `address`, `list`, `map`, `scalar-or-map`,
|
|
60
|
+
* `list-or-map` or `string-or-list` — see {@link fitsKind}. Absent, the check
|
|
61
|
+
* makes no claim about the value.
|
|
62
|
+
* @property {readonly string[]} [allowed] - The closed set a present value is
|
|
63
|
+
* one of, where the key takes a fixed word.
|
|
64
|
+
* @property {boolean} [verbatim] - The value is a symbol, such as a unit, and
|
|
65
|
+
* every surface shows it exactly as written, never recased.
|
|
66
|
+
* @property {string} shape - The shape in words, for a finding and the reference.
|
|
67
|
+
* @property {boolean} [required] - The key must be stated, with a value.
|
|
68
|
+
* @property {boolean} [nullable] - An explicit `null` states the key, for a
|
|
69
|
+
* required key whose null has a meaning of its own.
|
|
70
|
+
* @property {string} [label] - The infobox row label, where humanising the key
|
|
71
|
+
* reads wrongly.
|
|
72
|
+
* @property {string} describe - One line, for the author-facing reference.
|
|
73
|
+
* @property {readonly InnerKeySpec[]} [fields] - Its own keys, where it is a map.
|
|
74
|
+
* @property {object} [entries] - One entry, where it is a list of maps.
|
|
75
|
+
* @property {object} [values] - One value, where it is a map keyed by data.
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
/** Whether a value is a plain map rather than a list, an Address or a scalar. */
|
|
79
|
+
export function isPlainMap(value) {
|
|
80
|
+
return (
|
|
81
|
+
value !== null &&
|
|
82
|
+
typeof value === "object" &&
|
|
83
|
+
!Array.isArray(value) &&
|
|
84
|
+
!isAddressTuple(value) &&
|
|
85
|
+
!(value instanceof AddressLink) &&
|
|
86
|
+
!(value instanceof AddressEntries)
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Whether a value is one value rather than a container of them. */
|
|
91
|
+
function isScalar(value) {
|
|
92
|
+
return (
|
|
93
|
+
typeof value !== "object" ||
|
|
94
|
+
value === null ||
|
|
95
|
+
isAddressTuple(value) ||
|
|
96
|
+
value instanceof AddressLink
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Whether a present value has a declared kind's shape.
|
|
102
|
+
*
|
|
103
|
+
* Shape alone: whether an Address resolves, or a word is in a closed set, is the
|
|
104
|
+
* field's own check's question. Lenient where YAML is ambiguous — `"12"` is a
|
|
105
|
+
* number, an emptied map arrives as `[]`, and a `date` may be the bare number
|
|
106
|
+
* YAML reads `720.1` as — and strict where it is not: a `string` is text, so a
|
|
107
|
+
* title or a description written as `5` is a finding rather than a value
|
|
108
|
+
* nothing reads as text. An `address` is one value or a parsed Address; what it
|
|
109
|
+
* names is the reference check's.
|
|
110
|
+
*
|
|
111
|
+
* @param {unknown} value - The authored value, not `undefined` or `null`.
|
|
112
|
+
* @param {string|undefined} kind - The declared kind.
|
|
113
|
+
* @returns {boolean} Whether it fits.
|
|
114
|
+
*/
|
|
115
|
+
export function fitsKind(value, kind) {
|
|
116
|
+
switch (kind) {
|
|
117
|
+
case "string":
|
|
118
|
+
return typeof value === "string";
|
|
119
|
+
case "date":
|
|
120
|
+
return (
|
|
121
|
+
typeof value === "string" || (typeof value === "number" && Number.isFinite(value))
|
|
122
|
+
);
|
|
123
|
+
case "address":
|
|
124
|
+
case "shortcode":
|
|
125
|
+
return isScalar(value) && typeof value !== "boolean";
|
|
126
|
+
case "number":
|
|
127
|
+
return typeof value === "number" ?
|
|
128
|
+
Number.isFinite(value)
|
|
129
|
+
: typeof value === "string" &&
|
|
130
|
+
value.trim() !== "" &&
|
|
131
|
+
Number.isFinite(Number(value));
|
|
132
|
+
case "integer":
|
|
133
|
+
return fitsKind(value, "number") && Number.isInteger(Number(value));
|
|
134
|
+
case "boolean":
|
|
135
|
+
return typeof value === "boolean";
|
|
136
|
+
case "list":
|
|
137
|
+
return Array.isArray(value);
|
|
138
|
+
case "map":
|
|
139
|
+
return (
|
|
140
|
+
isPlainMap(value) ||
|
|
141
|
+
value instanceof AddressEntries ||
|
|
142
|
+
(Array.isArray(value) && value.length === 0)
|
|
143
|
+
);
|
|
144
|
+
case "scalar-or-map":
|
|
145
|
+
return isScalar(value) || fitsKind(value, "map");
|
|
146
|
+
case "list-or-map":
|
|
147
|
+
return Array.isArray(value) || fitsKind(value, "map");
|
|
148
|
+
case "string-or-list":
|
|
149
|
+
return isScalar(value) || Array.isArray(value);
|
|
150
|
+
default:
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The entries of a map value, whatever form it reached the lint in.
|
|
157
|
+
*
|
|
158
|
+
* A map keyed by Address arrives as an {@link AddressEntries} once the note
|
|
159
|
+
* boundary has typed its keys; `sourceKey` is the text the file holds, so a
|
|
160
|
+
* finding locates on what the author wrote.
|
|
161
|
+
*
|
|
162
|
+
* @param {unknown} value - A map value.
|
|
163
|
+
* @returns {Array<[string, unknown]>} Its authored keys and values.
|
|
164
|
+
*/
|
|
165
|
+
export function mapEntriesOf(value) {
|
|
166
|
+
if (value instanceof AddressEntries)
|
|
167
|
+
return value.entries.map((entry) => [entry.sourceKey, entry.value]);
|
|
168
|
+
if (isPlainMap(value)) return Object.entries(value);
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* A field declaration as the tree its dotted names describe.
|
|
174
|
+
*
|
|
175
|
+
* `appearance.eye_color` and `appearance.hair_color` are two keys of one
|
|
176
|
+
* container, `appearance`, which is itself a map closed to them. Grouping them
|
|
177
|
+
* here is what lets one walk close the container and every field inside it.
|
|
178
|
+
*
|
|
179
|
+
* @param {readonly object[]} fields - The type's `data:` declaration.
|
|
180
|
+
* @returns {object[]} One spec per top-level key, a dotted group as a `map`
|
|
181
|
+
* whose `fields` are its members, with `grouped: true`.
|
|
182
|
+
*/
|
|
183
|
+
export function topLevelSpecs(fields) {
|
|
184
|
+
const out = [];
|
|
185
|
+
const groups = new Map();
|
|
186
|
+
for (const field of fields) {
|
|
187
|
+
const [head, ...rest] = field.name.split(".");
|
|
188
|
+
if (!rest.length) {
|
|
189
|
+
out.push(field);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
let group = groups.get(head);
|
|
193
|
+
if (!group) {
|
|
194
|
+
group = { name: head, kind: "map", shape: "a map", grouped: true, members: [] };
|
|
195
|
+
groups.set(head, group);
|
|
196
|
+
out.push(group);
|
|
197
|
+
}
|
|
198
|
+
group.members.push({ ...field, name: rest.join(".") });
|
|
199
|
+
}
|
|
200
|
+
for (const group of groups.values()) {
|
|
201
|
+
group.fields = topLevelSpecs(group.members);
|
|
202
|
+
delete group.members;
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* A path as a reader writes it: `data.routes[0].terrain`.
|
|
209
|
+
*
|
|
210
|
+
* @param {ReadonlyArray<string|number>} path - The path from the top of the frontmatter.
|
|
211
|
+
* @returns {string} The label.
|
|
212
|
+
*/
|
|
213
|
+
export function pathLabel(path) {
|
|
214
|
+
let out = "";
|
|
215
|
+
for (const segment of path) {
|
|
216
|
+
if (typeof segment === "number") out += `[${segment}]`;
|
|
217
|
+
else out += out ? `.${segment}` : segment;
|
|
218
|
+
}
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Whether a required key's value counts as stated. */
|
|
223
|
+
function stated(value, spec) {
|
|
224
|
+
if (value === undefined) return false;
|
|
225
|
+
if (value === null) return Boolean(spec.nullable);
|
|
226
|
+
if (typeof value === "string") return value.trim() !== "";
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Check every declared field's value against its stated inner keys.
|
|
232
|
+
*
|
|
233
|
+
* Run by the frontmatter lint after the container check has refused undeclared
|
|
234
|
+
* top-level keys and checked each top-level field's own kind, so neither is
|
|
235
|
+
* repeated here.
|
|
236
|
+
*
|
|
237
|
+
* @param {object} note - The note: `file`, `raw` and parsed `fm`.
|
|
238
|
+
* @param {readonly object[]} fields - The type's `data:` declaration, shared
|
|
239
|
+
* fields included.
|
|
240
|
+
* @returns {object[]} Findings, `file:line:column` located on the key at fault.
|
|
241
|
+
*/
|
|
242
|
+
export function checkDataKeys(note, fields) {
|
|
243
|
+
const data = note.fm?.data;
|
|
244
|
+
if (!isPlainMap(data)) return [];
|
|
245
|
+
const findings = [];
|
|
246
|
+
const raw = note.raw ?? "";
|
|
247
|
+
const finding = (path, message, key = false) => ({
|
|
248
|
+
file: note.file,
|
|
249
|
+
...positionOfFrontmatterPath(raw, path, { key }),
|
|
250
|
+
severity: "error",
|
|
251
|
+
message,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Walk one value against its declaration.
|
|
256
|
+
*
|
|
257
|
+
* @param {object} spec - The declaration.
|
|
258
|
+
* @param {unknown} value - The value, present and non-null.
|
|
259
|
+
* @param {Array<string|number>} path - Where it sits.
|
|
260
|
+
*/
|
|
261
|
+
const walk = (spec, value, path) => {
|
|
262
|
+
if (spec.fields && isPlainMap(value)) {
|
|
263
|
+
closeMap(spec.fields, value, path);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (Array.isArray(value)) {
|
|
267
|
+
const entry = spec.entries;
|
|
268
|
+
if (!entry) return;
|
|
269
|
+
value.forEach((item, i) => visit(entry, item, [...path, i]));
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (spec.values) {
|
|
273
|
+
for (const [key, item] of mapEntriesOf(value)) visit(spec.values, item, [...path, key]);
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
/** Check one present value's kind, then walk it. */
|
|
278
|
+
const visit = (spec, value, path) => {
|
|
279
|
+
if (value === undefined || value === null) return;
|
|
280
|
+
if (!fitsKind(value, spec.kind)) {
|
|
281
|
+
findings.push(
|
|
282
|
+
finding(
|
|
283
|
+
path,
|
|
284
|
+
`\`${pathLabel(path)}\` should be ${spec.shape ?? spec.kind}, but reads ` +
|
|
285
|
+
JSON.stringify(value),
|
|
286
|
+
),
|
|
287
|
+
);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (spec.allowed && !spec.allowed.includes(value)) {
|
|
291
|
+
const guess = typeof value === "string" ? nearest(value, spec.allowed) : undefined;
|
|
292
|
+
findings.push(
|
|
293
|
+
finding(
|
|
294
|
+
path,
|
|
295
|
+
`\`${pathLabel(path)}\` should be ${spec.allowed.map((word) => `\`${word}\``).join(" or ")}, ` +
|
|
296
|
+
`but reads ${JSON.stringify(value)}` +
|
|
297
|
+
(guess ? `. Did you mean "${guess}"?` : ""),
|
|
298
|
+
),
|
|
299
|
+
);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
walk(spec, value, path);
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
/** Close one map to its declared keys, and walk each that is present. */
|
|
306
|
+
const closeMap = (inner, value, path) => {
|
|
307
|
+
const names = inner.map((spec) => spec.name);
|
|
308
|
+
for (const key of Object.keys(value)) {
|
|
309
|
+
if (names.includes(key)) continue;
|
|
310
|
+
const guess = nearest(key, names);
|
|
311
|
+
findings.push(
|
|
312
|
+
finding(
|
|
313
|
+
[...path, key],
|
|
314
|
+
`"${key}" is not a key of \`${pathLabel(path)}\`; it takes only ` +
|
|
315
|
+
names.map((name) => `\`${name}\``).join(", ") +
|
|
316
|
+
(guess ? `. Did you mean "${guess}"?` : ""),
|
|
317
|
+
true,
|
|
318
|
+
),
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
for (const spec of inner) {
|
|
322
|
+
const item = value[spec.name];
|
|
323
|
+
if (spec.required && !stated(item, spec)) {
|
|
324
|
+
findings.push(
|
|
325
|
+
finding(
|
|
326
|
+
path,
|
|
327
|
+
`\`${pathLabel(path)}\` must state \`${spec.name}\` — ${spec.shape}`,
|
|
328
|
+
typeof path.at(-1) !== "number",
|
|
329
|
+
),
|
|
330
|
+
);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
visit(spec, item, [...path, spec.name]);
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
for (const spec of topLevelSpecs(fields)) {
|
|
338
|
+
const value = data[spec.name];
|
|
339
|
+
if (value === undefined || value === null) continue;
|
|
340
|
+
const path = ["data", spec.name];
|
|
341
|
+
// A dotted group's own shape is checked here: the container check
|
|
342
|
+
// reads its members' leaves and has nothing to say about the group.
|
|
343
|
+
if (spec.grouped) visit(spec, value, path);
|
|
344
|
+
else if (fitsKind(value, spec.kind)) walk(spec, value, path);
|
|
345
|
+
}
|
|
346
|
+
return findings;
|
|
347
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
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 **derived sections** a note is given after its text, written as the
|
|
16
|
+
* Markdown every surface receives — see {@link module:engine/generated-sections}
|
|
17
|
+
* for the rules all generated sections share.
|
|
18
|
+
*
|
|
19
|
+
* | Section | Anchor | On | Lists |
|
|
20
|
+
* | ----------------- | ---------------- | -------------------- | ----------------------------------------------------------- |
|
|
21
|
+
* | Within | `within` | a place | the places whose `parents` names it, grouped by kind |
|
|
22
|
+
* | Governed by | `governedby` | a place | the affiliation its `data.government` names |
|
|
23
|
+
* | Governed places | `governedplaces` | an affiliation | the places naming it as government, grouped by kind |
|
|
24
|
+
* | In song and story | `insongandstory` | any note | the works naming it, or one of its events, in `subjects` |
|
|
25
|
+
* | From here | `fromhere` | a place on a border or a route | the map from it, as an image |
|
|
26
|
+
*
|
|
27
|
+
* The lists are the derivations {@link module:engine/holdings} and
|
|
28
|
+
* {@link module:engine/literature-works} make, over this package's records and
|
|
29
|
+
* every fetched index. An entry naming a note that publishes a page is a
|
|
30
|
+
* wikilink with an empty label, so each surface resolves it its own way and
|
|
31
|
+
* prints the note's current name; an entry naming a stub is its name as plain
|
|
32
|
+
* text.
|
|
33
|
+
*
|
|
34
|
+
* The map is the drawing `package-build map --from` makes. The section names
|
|
35
|
+
* it by {@link module:engine/generated-sections.fromHereSource}, and each
|
|
36
|
+
* surface draws and serves it.
|
|
37
|
+
*
|
|
38
|
+
* @module
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import fs from "node:fs";
|
|
42
|
+
import path from "node:path";
|
|
43
|
+
|
|
44
|
+
import {
|
|
45
|
+
authoredSection,
|
|
46
|
+
fromHereFile,
|
|
47
|
+
fromHereSource,
|
|
48
|
+
sectionHeading,
|
|
49
|
+
} from "./generated-sections.mjs";
|
|
50
|
+
import { foreignHoldingsNodes, holdingsNode, holdingsPages } from "./holdings.mjs";
|
|
51
|
+
import { isNoteRecord } from "./index-records.mjs";
|
|
52
|
+
import { foreignWorksNodes, worksNode, worksPages } from "./literature-works.mjs";
|
|
53
|
+
import { buildMaps, relatedPlaces } from "./map-build.mjs";
|
|
54
|
+
import { mapWorld } from "./map-places.mjs";
|
|
55
|
+
import { NOTE_VOCABULARY } from "./note-vocabulary.mjs";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The heading each kind of place is grouped under, in the order the place
|
|
59
|
+
* vocabulary declares its kinds. A kind the vocabulary does not declare is
|
|
60
|
+
* grouped after these by its own name, and a place stating no kind last, under
|
|
61
|
+
* **Other**.
|
|
62
|
+
*
|
|
63
|
+
* @type {Readonly<Record<string, string>>}
|
|
64
|
+
*/
|
|
65
|
+
export const PLACE_KIND_LABELS = Object.freeze({
|
|
66
|
+
world: "Worlds",
|
|
67
|
+
region: "Regions",
|
|
68
|
+
settlement: "Settlements",
|
|
69
|
+
site: "Sites",
|
|
70
|
+
structure: "Structures",
|
|
71
|
+
feature: "Features",
|
|
72
|
+
celestial: "Celestial places",
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/** The place kinds, in vocabulary order. */
|
|
76
|
+
const PLACE_KINDS = NOTE_VOCABULARY.place.subTypes ?? [];
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A name set as plain Markdown text, its inline markup characters escaped.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} text - A note's name.
|
|
82
|
+
* @returns {string} Text no surface reads as markup.
|
|
83
|
+
*/
|
|
84
|
+
function plain(text) {
|
|
85
|
+
return String(text ?? "").replace(/([\\`*_[\]<>{}|#!~])/g, "\\$1");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* One entry: a wikilink to a note with a page, its name as text otherwise.
|
|
90
|
+
*
|
|
91
|
+
* @param {{title: string, url?: string, address?: string}} entry - From a
|
|
92
|
+
* derivation.
|
|
93
|
+
* @returns {string} Markdown.
|
|
94
|
+
*/
|
|
95
|
+
function entryText(entry) {
|
|
96
|
+
return entry.url && entry.address ? `[[${entry.address}|]]` : plain(entry.title);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A kind's words as a reader meets them: `polity`, `noble house`.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} kind - A `subType`.
|
|
103
|
+
* @returns {string} The words.
|
|
104
|
+
*/
|
|
105
|
+
function kindWords(kind) {
|
|
106
|
+
return String(kind)
|
|
107
|
+
.replace(/[-_]+/g, " ")
|
|
108
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
109
|
+
.toLowerCase();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Places grouped by kind, a paragraph to each kind: `**Settlements:** A, B`.
|
|
114
|
+
*
|
|
115
|
+
* @param {Array<{title: string, url?: string, address?: string, subType?: string}>} entries -
|
|
116
|
+
* Sorted by kind, then title.
|
|
117
|
+
* @returns {string} Markdown.
|
|
118
|
+
*/
|
|
119
|
+
function groupedPlaces(entries) {
|
|
120
|
+
const byKind = new Map();
|
|
121
|
+
for (const entry of entries) {
|
|
122
|
+
const kind = entry.subType ?? "";
|
|
123
|
+
if (!byKind.has(kind)) byKind.set(kind, []);
|
|
124
|
+
byKind.get(kind).push(entry);
|
|
125
|
+
}
|
|
126
|
+
const label = (kind) => {
|
|
127
|
+
if (!kind) return "Other";
|
|
128
|
+
if (PLACE_KIND_LABELS[kind]) return PLACE_KIND_LABELS[kind];
|
|
129
|
+
const words = kindWords(kind);
|
|
130
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
131
|
+
};
|
|
132
|
+
const known = PLACE_KINDS.filter((kind) => byKind.has(kind));
|
|
133
|
+
const others = [...byKind.keys()]
|
|
134
|
+
.filter((kind) => kind && !PLACE_KINDS.includes(kind))
|
|
135
|
+
.sort((a, b) => label(a).localeCompare(label(b), "en"));
|
|
136
|
+
const order = [...known, ...others, ...(byKind.has("") ? [""] : [])];
|
|
137
|
+
return order
|
|
138
|
+
.map((kind) => `**${label(kind)}:** ${byKind.get(kind).map(entryText).join(", ")}`)
|
|
139
|
+
.join("\n\n");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A list, one entry to a line, with its kind or form after it in parentheses.
|
|
144
|
+
*
|
|
145
|
+
* @param {Array<{title: string, url?: string, address?: string}>} entries
|
|
146
|
+
* @param {(entry: object) => string|undefined} detail - What follows the name.
|
|
147
|
+
* @returns {string} Markdown.
|
|
148
|
+
*/
|
|
149
|
+
function listed(entries, detail) {
|
|
150
|
+
return entries
|
|
151
|
+
.map((entry) => {
|
|
152
|
+
const after = detail(entry);
|
|
153
|
+
return `- ${entryText(entry)}${after ? ` (${plain(after)})` : ""}`;
|
|
154
|
+
})
|
|
155
|
+
.join("\n");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A section's Markdown: its heading, with its anchor, and its content.
|
|
160
|
+
*
|
|
161
|
+
* @param {string} slug - The section.
|
|
162
|
+
* @param {string} content - What it shows.
|
|
163
|
+
* @returns {string} The section.
|
|
164
|
+
*/
|
|
165
|
+
function section(slug, content) {
|
|
166
|
+
return `${sectionHeading(slug)}\n\n${content}\n`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The note's name, as the derivations title an entry.
|
|
171
|
+
*
|
|
172
|
+
* @param {object} record - An index record.
|
|
173
|
+
* @returns {string} The name.
|
|
174
|
+
*/
|
|
175
|
+
function titleOf(record) {
|
|
176
|
+
return String(record.name?.full ?? record.shortcode ?? "");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The places of this package that are given the map from them: each one that
|
|
181
|
+
* states, or is named in, a border or a route, and publishes a page.
|
|
182
|
+
*
|
|
183
|
+
* @param {object[]} records - The content-index records.
|
|
184
|
+
* @param {object} [opts]
|
|
185
|
+
* @param {Map<string, object>} [opts.foreignIndex] - The fetched indexes, whose
|
|
186
|
+
* places a local place's border or route may name.
|
|
187
|
+
* @param {object} [opts.config] - The resolved configuration. `site.maps:
|
|
188
|
+
* false` gives no place a map.
|
|
189
|
+
* @returns {string[]} The places' shortcodes, sorted.
|
|
190
|
+
*/
|
|
191
|
+
export function placesWithMaps(records, { foreignIndex = new Map(), config } = {}) {
|
|
192
|
+
if (config?.site?.maps === false) return [];
|
|
193
|
+
const world = mapWorld({
|
|
194
|
+
records: records.filter((record) => isNoteRecord(record)),
|
|
195
|
+
foreignIndex,
|
|
196
|
+
contentBase: config?.paths?.content ?? "",
|
|
197
|
+
config,
|
|
198
|
+
});
|
|
199
|
+
const published = new Set(
|
|
200
|
+
records
|
|
201
|
+
.filter((record) => isNoteRecord(record) && record.type === "place" && record.address)
|
|
202
|
+
.map((record) => String(record.shortcode).toLowerCase()),
|
|
203
|
+
);
|
|
204
|
+
return relatedPlaces(world.places).filter(
|
|
205
|
+
(shortcode) => world.places.get(shortcode)?.local && published.has(shortcode),
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Draw the map from each place given one, for a surface that serves the
|
|
211
|
+
* drawing as a file: the Foundry compile and the book. Each drawing is the
|
|
212
|
+
* standalone SVG `package-build map --from` writes, with no links, since
|
|
213
|
+
* neither surface follows a link inside a picture.
|
|
214
|
+
*
|
|
215
|
+
* @param {object[]} records - The content-index records.
|
|
216
|
+
* @param {object} opts
|
|
217
|
+
* @param {string} opts.outDir - Where to draw. Emptied first, so a place that
|
|
218
|
+
* lost its relations keeps no drawing from an earlier run.
|
|
219
|
+
* @param {Map<string, object>} [opts.foreignIndex] - The fetched indexes.
|
|
220
|
+
* @param {object} [opts.config] - The resolved configuration.
|
|
221
|
+
* @param {Iterable<string>} [opts.only] - The places to draw, of those given a
|
|
222
|
+
* map; absent, every one.
|
|
223
|
+
* @returns {{maps: Map<string, string>, findings: object[]}} Each drawing's
|
|
224
|
+
* file, keyed by the file name a **From here** section names, and what
|
|
225
|
+
* drawing found.
|
|
226
|
+
*/
|
|
227
|
+
export function drawMapsFrom(records, { outDir, foreignIndex = new Map(), config, only } = {}) {
|
|
228
|
+
fs.rmSync(outDir, { recursive: true, force: true });
|
|
229
|
+
const wanted = only ? new Set([...only].map((one) => String(one).toLowerCase())) : null;
|
|
230
|
+
const centres = placesWithMaps(records, { foreignIndex, config }).filter(
|
|
231
|
+
(shortcode) => !wanted || wanted.has(shortcode),
|
|
232
|
+
);
|
|
233
|
+
/** @type {Map<string, string>} */
|
|
234
|
+
const maps = new Map();
|
|
235
|
+
if (!centres.length) return { maps, findings: [] };
|
|
236
|
+
const world = mapWorld({
|
|
237
|
+
records: records.filter((record) => isNoteRecord(record)),
|
|
238
|
+
foreignIndex,
|
|
239
|
+
contentBase: config?.paths?.content ?? "",
|
|
240
|
+
config,
|
|
241
|
+
});
|
|
242
|
+
const { findings } = buildMaps({ world, outDir, from: centres });
|
|
243
|
+
for (const centre of centres) {
|
|
244
|
+
const name = fromHereFile(centre);
|
|
245
|
+
maps.set(name, path.join(outDir, name));
|
|
246
|
+
}
|
|
247
|
+
return { maps, findings };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The derived sections each note is given.
|
|
252
|
+
*
|
|
253
|
+
* @param {object[]} records - The content-index records, of the audience the
|
|
254
|
+
* surface publishes to.
|
|
255
|
+
* @param {object} opts
|
|
256
|
+
* @param {(record: object) => string} opts.fileOf - A record's note file, as
|
|
257
|
+
* prepared results are keyed.
|
|
258
|
+
* @param {Map<string, object>} [opts.foreignIndex] - The fetched indexes.
|
|
259
|
+
* @param {object} [opts.config] - The resolved configuration.
|
|
260
|
+
* @param {string} [opts.only] - One note file to give sections to; absent,
|
|
261
|
+
* every note.
|
|
262
|
+
* @returns {Map<string, Map<string, string>>} Note file to its sections, each
|
|
263
|
+
* keyed by its slug, for every note given at least one.
|
|
264
|
+
*/
|
|
265
|
+
export function derivedSections(records, { fileOf, foreignIndex = new Map(), config, only }) {
|
|
266
|
+
const notes = records.filter((record) => isNoteRecord(record) && record.shortcode);
|
|
267
|
+
const pages = notes.filter((record) => record.address);
|
|
268
|
+
const nodeOf = (record) => ({
|
|
269
|
+
title: titleOf(record),
|
|
270
|
+
...(record.address ? { url: record.address.canonical } : {}),
|
|
271
|
+
package: String(record.package ?? config?.contentPackage ?? ""),
|
|
272
|
+
...(record.address ? { canonical: record.address.canonical } : {}),
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// A stub publishes no page and is listed as plain text, so it takes part
|
|
276
|
+
// in every list without being given one.
|
|
277
|
+
const holdings = holdingsPages([
|
|
278
|
+
...notes.map((record) => holdingsNode(record, nodeOf(record))),
|
|
279
|
+
...foreignHoldingsNodes(foreignIndex),
|
|
280
|
+
]);
|
|
281
|
+
const works = worksPages(
|
|
282
|
+
[
|
|
283
|
+
...notes.map((record) => worksNode(record, nodeOf(record))),
|
|
284
|
+
...foreignWorksNodes(foreignIndex),
|
|
285
|
+
],
|
|
286
|
+
{ types: new Set(Object.keys(NOTE_VOCABULARY)) },
|
|
287
|
+
);
|
|
288
|
+
const mapped = new Set(placesWithMaps(notes, { foreignIndex, config }));
|
|
289
|
+
|
|
290
|
+
const out = new Map();
|
|
291
|
+
for (const record of pages) {
|
|
292
|
+
if (only !== undefined && fileOf(record) !== only) continue;
|
|
293
|
+
const key = record.address.canonical;
|
|
294
|
+
const lists = holdings.get(key) ?? {};
|
|
295
|
+
const sections = new Map();
|
|
296
|
+
const add = (slug, content) => {
|
|
297
|
+
if (content && !authoredSection(record, slug))
|
|
298
|
+
sections.set(slug, section(slug, content));
|
|
299
|
+
};
|
|
300
|
+
if (lists.contains?.length) add("within", groupedPlaces(lists.contains));
|
|
301
|
+
if (lists.governed_by?.length)
|
|
302
|
+
add(
|
|
303
|
+
"governedby",
|
|
304
|
+
listed(lists.governed_by, (entry) => entry.subType && kindWords(entry.subType)),
|
|
305
|
+
);
|
|
306
|
+
if (lists.governed_places?.length)
|
|
307
|
+
add("governedplaces", groupedPlaces(lists.governed_places));
|
|
308
|
+
const written = works.get(key)?.works ?? [];
|
|
309
|
+
if (written.length)
|
|
310
|
+
add(
|
|
311
|
+
"insongandstory",
|
|
312
|
+
listed(written, (entry) => entry.form),
|
|
313
|
+
);
|
|
314
|
+
const shortcode = String(record.shortcode).toLowerCase();
|
|
315
|
+
if (record.type === "place" && mapped.has(shortcode)) {
|
|
316
|
+
const alt = `Map from ${titleOf(record)}`.replace(/[[\]]/g, "");
|
|
317
|
+
add("fromhere", `}){.full-width}`);
|
|
318
|
+
}
|
|
319
|
+
if (sections.size) out.set(fileOf(record), sections);
|
|
320
|
+
}
|
|
321
|
+
return out;
|
|
322
|
+
}
|