@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.
Files changed (157) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +1 -1
  3. package/bin/content-commands.mjs +1 -1
  4. package/ci/content-format-reference.mjs +46 -0
  5. package/docs/api.md +124 -141
  6. package/docs/authoring/assets.md +1 -1
  7. package/docs/authoring/dates-and-calendars.md +12 -6
  8. package/docs/authoring/frontmatter.md +2 -2
  9. package/docs/authoring/links-and-markup.md +145 -29
  10. package/docs/commands.md +8 -10
  11. package/docs/configuration.md +13 -11
  12. package/docs/content-format.md +2 -2
  13. package/docs/guides/book.md +59 -4
  14. package/docs/guides/icons.md +4 -5
  15. package/docs/guides/packs.md +6 -0
  16. package/docs/guides/reference-pages.md +21 -1
  17. package/docs/guides/release-and-deploy.md +39 -7
  18. package/docs/guides/site.md +17 -18
  19. package/docs/reference/format-details.md +877 -276
  20. package/docs/reference/note-types.md +278 -91
  21. package/docs/reference/theme-pages.md +58 -97
  22. package/engine/address-values.mjs +124 -4
  23. package/engine/address.mjs +44 -3
  24. package/engine/anchors.mjs +103 -3
  25. package/engine/art-slots.mjs +2 -9
  26. package/engine/base-compiler.mjs +25 -6
  27. package/engine/calendar-artifacts.mjs +4 -1
  28. package/engine/calendar-notes.mjs +166 -102
  29. package/engine/content-format-check.mjs +2 -3
  30. package/engine/content-format.mjs +44 -1
  31. package/engine/content-format.yaml +79 -16
  32. package/engine/content-images.mjs +10 -1
  33. package/engine/content-index.mjs +37 -9
  34. package/engine/content-links.mjs +24 -5
  35. package/engine/content-tables.mjs +24 -3
  36. package/engine/data-keys.mjs +347 -0
  37. package/engine/derived-sections.mjs +322 -0
  38. package/engine/diagnostics.mjs +28 -0
  39. package/engine/event-fields.mjs +187 -0
  40. package/engine/event-rows.mjs +126 -0
  41. package/engine/event-views.mjs +305 -0
  42. package/engine/exported-scene.mjs +29 -53
  43. package/engine/frontmatter-lint.mjs +103 -77
  44. package/engine/frontmatter.mjs +7 -2
  45. package/engine/generate.mjs +53 -0
  46. package/engine/generated-sections.mjs +223 -0
  47. package/engine/heading-attributes.mjs +4 -2
  48. package/engine/helpers.mjs +12 -1
  49. package/engine/holdings.mjs +12 -33
  50. package/engine/ids.mjs +17 -11
  51. package/engine/index.mjs +4 -4
  52. package/engine/infobox-render.mjs +21 -6
  53. package/engine/infobox.mjs +217 -99
  54. package/engine/journals.mjs +104 -47
  55. package/engine/literature-notes.mjs +4 -1
  56. package/engine/literature-works.mjs +21 -13
  57. package/engine/map-graphviz.mjs +4 -18
  58. package/engine/markdown-expressions.mjs +116 -11
  59. package/engine/metadata-index.mjs +26 -0
  60. package/engine/near-miss.mjs +90 -0
  61. package/engine/note-addresses.mjs +75 -19
  62. package/engine/note-dates.mjs +5 -3
  63. package/engine/note-event-terms.mjs +364 -0
  64. package/engine/note-events.mjs +823 -0
  65. package/engine/note-vocabulary.mjs +184 -88
  66. package/engine/office-holders.mjs +20 -54
  67. package/engine/packagebuild-index.mjs +16 -1
  68. package/engine/pdf-build.mjs +97 -60
  69. package/engine/pdf-index.mjs +263 -0
  70. package/engine/pdf-render.mjs +47 -5
  71. package/engine/place-relation-terms.mjs +174 -0
  72. package/engine/place-relations.mjs +72 -160
  73. package/engine/rank-ladder.mjs +14 -41
  74. package/engine/reckoning-markers.mjs +2 -0
  75. package/engine/region-events.mjs +6 -12
  76. package/engine/scenes.mjs +73 -468
  77. package/engine/site-build.mjs +75 -101
  78. package/engine/site-index.mjs +3 -1
  79. package/engine/site-maps.mjs +43 -29
  80. package/engine/site-preview.mjs +40 -31
  81. package/engine/social-ties.mjs +2 -1
  82. package/engine/sql-tables.mjs +212 -9
  83. package/engine/standing-terms.mjs +145 -6
  84. package/engine/standings.mjs +37 -49
  85. package/engine/web-wikilinks.mjs +21 -1
  86. package/engine/wikilink-syntax.mjs +34 -4
  87. package/engine/wikilinks.mjs +27 -2
  88. package/engine/yaml-lint.mjs +7 -4
  89. package/hugo-theme/layouts/partials/infobox/entry.html +4 -1
  90. package/hugo-theme/layouts/partials/page-body.html +0 -3
  91. package/hugo-theme/static/css/style.css +25 -55
  92. package/package.json +1 -2
  93. package/packagebuild-metadata.jsonl +13 -13
  94. package/release.mjs +62 -4
  95. package/sohl/note-schemas.mjs +8 -73
  96. package/types/engine/address-values.d.mts +50 -3
  97. package/types/engine/address.d.mts +21 -0
  98. package/types/engine/anchors.d.mts +49 -2
  99. package/types/engine/art-slots.d.mts +1 -1
  100. package/types/engine/base-compiler.d.mts +13 -4
  101. package/types/engine/calendar-notes.d.mts +7 -0
  102. package/types/engine/content-format.d.mts +52 -0
  103. package/types/engine/content-images.d.mts +5 -1
  104. package/types/engine/content-tables.d.mts +9 -1
  105. package/types/engine/data-keys.d.mts +145 -0
  106. package/types/engine/derived-sections.d.mts +73 -0
  107. package/types/engine/diagnostics.d.mts +15 -0
  108. package/types/engine/event-fields.d.mts +99 -0
  109. package/types/engine/event-rows.d.mts +12 -0
  110. package/types/engine/event-views.d.mts +67 -0
  111. package/types/engine/exported-scene.d.mts +5 -2
  112. package/types/engine/frontmatter-lint.d.mts +3 -3
  113. package/types/engine/generated-sections.d.mts +143 -0
  114. package/types/engine/holdings.d.mts +6 -20
  115. package/types/engine/ids.d.mts +15 -12
  116. package/types/engine/index.d.mts +1 -1
  117. package/types/engine/infobox.d.mts +13 -3
  118. package/types/engine/journals.d.mts +24 -11
  119. package/types/engine/literature-works.d.mts +1 -2
  120. package/types/engine/map-graphviz.d.mts +2 -10
  121. package/types/engine/markdown-expressions.d.mts +12 -3
  122. package/types/engine/near-miss.d.mts +42 -0
  123. package/types/engine/note-addresses.d.mts +7 -1
  124. package/types/engine/note-event-terms.d.mts +68 -0
  125. package/types/engine/note-events.d.mts +71 -0
  126. package/types/engine/note-vocabulary.d.mts +37 -8
  127. package/types/engine/office-holders.d.mts +3 -0
  128. package/types/engine/pdf-index.d.mts +35 -0
  129. package/types/engine/pdf-render.d.mts +8 -2
  130. package/types/engine/place-relation-terms.d.mts +83 -0
  131. package/types/engine/place-relations.d.mts +11 -66
  132. package/types/engine/rank-ladder.d.mts +2 -0
  133. package/types/engine/region-events.d.mts +6 -12
  134. package/types/engine/scenes.d.mts +6 -32
  135. package/types/engine/site-build.d.mts +8 -22
  136. package/types/engine/site-maps.d.mts +15 -7
  137. package/types/engine/sql-tables.d.mts +77 -2
  138. package/types/engine/standing-terms.d.mts +37 -4
  139. package/types/engine/standings.d.mts +14 -9
  140. package/types/engine/wikilink-syntax.d.mts +20 -0
  141. package/types/engine/wikilinks.d.mts +3 -2
  142. package/types/engine/yaml-lint.d.mts +5 -4
  143. package/types/release.d.mts +4 -0
  144. package/docs/guides/government-migration.md +0 -52
  145. package/engine/itinerary-scenes.mjs +0 -166
  146. package/engine/lore-events.mjs +0 -197
  147. package/engine/map-notes.mjs +0 -1309
  148. package/engine/map-raster.mjs +0 -51
  149. package/hugo-theme/layouts/partials/holdings/entry.html +0 -21
  150. package/hugo-theme/layouts/partials/holdings/kind-groups.html +0 -71
  151. package/hugo-theme/layouts/partials/holdings.html +0 -61
  152. package/hugo-theme/layouts/partials/literature-works.html +0 -29
  153. package/hugo-theme/layouts/partials/place-map.html +0 -9
  154. package/types/engine/itinerary-scenes.d.mts +0 -29
  155. package/types/engine/lore-events.d.mts +0 -14
  156. package/types/engine/map-notes.d.mts +0 -297
  157. 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", `![${alt}](${fromHereSource(shortcode)}){.full-width}`);
318
+ }
319
+ if (sections.size) out.set(fileOf(record), sections);
320
+ }
321
+ return out;
322
+ }