@heroiclands/package-build 23.5.1 → 23.6.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/bin/content-commands.mjs +23 -5
  3. package/docs/api.md +9 -0
  4. package/docs/authoring/links-and-markup.md +77 -12
  5. package/docs/content-format.md +16 -0
  6. package/docs/guides/reference-pages.md +89 -0
  7. package/docs/reference/format-details.md +533 -84
  8. package/docs/reference/note-types.md +191 -149
  9. package/engine/base-compiler.mjs +21 -0
  10. package/engine/content-format.yaml +9 -0
  11. package/engine/content-index.mjs +19 -5
  12. package/engine/culture-reach.mjs +330 -0
  13. package/engine/dictionary-layout.mjs +340 -0
  14. package/engine/dictionary-rows.mjs +608 -0
  15. package/engine/glossary-entries.mjs +182 -0
  16. package/engine/glossary-language.mjs +99 -0
  17. package/engine/index.mjs +1 -0
  18. package/engine/infobox.mjs +8 -0
  19. package/engine/language-words.mjs +360 -0
  20. package/engine/literature-notes.mjs +10 -8
  21. package/engine/note-addresses.mjs +25 -0
  22. package/engine/note-vocabulary.mjs +161 -1
  23. package/engine/path-charset.mjs +120 -0
  24. package/engine/sql-tables.mjs +311 -21
  25. package/engine/word-classes.mjs +167 -0
  26. package/engine/word-rows.mjs +233 -0
  27. package/engine/word-terms.mjs +306 -0
  28. package/package.json +1 -1
  29. package/types/engine/content-index.d.mts +15 -5
  30. package/types/engine/culture-reach.d.mts +66 -0
  31. package/types/engine/dictionary-layout.d.mts +69 -0
  32. package/types/engine/dictionary-rows.d.mts +39 -0
  33. package/types/engine/glossary-entries.d.mts +12 -0
  34. package/types/engine/glossary-language.d.mts +42 -0
  35. package/types/engine/index.d.mts +1 -0
  36. package/types/engine/language-words.d.mts +33 -0
  37. package/types/engine/literature-notes.d.mts +2 -2
  38. package/types/engine/note-addresses.d.mts +17 -0
  39. package/types/engine/path-charset.d.mts +38 -0
  40. package/types/engine/sql-tables.d.mts +7 -2
  41. package/types/engine/word-classes.d.mts +87 -0
  42. package/types/engine/word-rows.d.mts +64 -0
  43. package/types/engine/word-terms.d.mts +68 -0
@@ -56,6 +56,8 @@ types:
56
56
  - lore
57
57
  - parents
58
58
  - culture
59
+ - language
60
+ - glossary
59
61
  - relations
60
62
  subTypes:
61
63
  - guild
@@ -203,6 +205,12 @@ types:
203
205
  - masteryLevel
204
206
  - parentSkill
205
207
  - names
208
+ - family
209
+ - abbreviation
210
+ - classModifiers
211
+ - fields
212
+ - words
213
+ - affixes
206
214
  subTypes:
207
215
  - social
208
216
  - nature
@@ -261,6 +269,7 @@ types:
261
269
  - language
262
270
  - events
263
271
  - appearanceProfile
272
+ - glossary
264
273
  subTypes:
265
274
  - cosmology
266
275
  - deity
@@ -112,6 +112,7 @@ import { checkForeignAssetBindings } from "./asset-bindings.mjs";
112
112
  import { addressSlug, canonicalKey } from "./content-address.mjs";
113
113
  import { isAddressTuple, ownDocumentSystem, readCanonicalKey, renderAddress } from "./address.mjs";
114
114
  import { NOTE_SYSTEM } from "./systems.mjs";
115
+ import { dictionaryAndTermRows, serializeRows } from "./dictionary-rows.mjs";
115
116
  import { assetAddressIndex } from "./art-fields.mjs";
116
117
  import { embedRole } from "./content-embeds.mjs";
117
118
  // One reader for a note's anchors, shared with the link checker and with the
@@ -1396,11 +1397,13 @@ export function indexRecordsFor({
1396
1397
  * `paths.contentIndex`.
1397
1398
  * @param {object} [options.config] - A resolved configuration; loaded when omitted.
1398
1399
  * @returns {{file: string, notes: number, assets: number, records: number,
1399
- * bytes: number, full: number, draft: number, stub: number}} Where it was
1400
- * written, how many notes and how many assets it holds, how many records that
1401
- * is in all, its size, and the ladder those notes sit on — so the ratio of
1402
- * written to unwritten is visible on every build rather than discovered in a
1403
- * year.
1400
+ * bytes: number, full: number, draft: number, stub: number,
1401
+ * dictionary: {file: string, rows: number}, terms: {file: string, rows: number}}}
1402
+ * Where it was written, how many notes and how many assets it holds, how many
1403
+ * records that is in all, its size, and the ladder those notes sit on — so
1404
+ * the ratio of written to unwritten is visible on every build rather than
1405
+ * discovered in a year — and where the `dictionary` and `terms` datafiles
1406
+ * were written beside it, with their row counts.
1404
1407
  * @throws {Error} When the content tree is absent, or when it yields no note at
1405
1408
  * all — an empty index is indistinguishable from a mis-pointed tree, and a
1406
1409
  * reader would take it as the authoritative statement that this package has
@@ -1443,6 +1446,16 @@ export function emitContentIndex({ contentBase, outDir, config } = {}) {
1443
1446
  fs.mkdirSync(dir, { recursive: true });
1444
1447
  fs.writeFileSync(file, text);
1445
1448
 
1449
+ // The `dictionary` and `terms` relations as datafiles beside the index, for
1450
+ // `jq`, an editor or another package. Nothing in the build reads them back.
1451
+ const derived = dictionaryAndTermRows(records.map((record) => encodeAddresses(record)));
1452
+ const datafiles = {};
1453
+ for (const relation of ["dictionary", "terms"]) {
1454
+ const rowsFile = path.join(dir, `${contentPackage}-${relation}.jsonl`);
1455
+ fs.writeFileSync(rowsFile, serializeRows(derived[relation]));
1456
+ datafiles[relation] = { file: rowsFile, rows: derived[relation].length };
1457
+ }
1458
+
1446
1459
  // Counted separately because they are genuinely different numbers: an item
1447
1460
  // note yields a second record for its documentation journal and a file
1448
1461
  // yields an asset record, so reporting records as notes would overstate how
@@ -1463,5 +1476,6 @@ export function emitContentIndex({ contentBase, outDir, config } = {}) {
1463
1476
  full: noteRecords.length - stub - draft,
1464
1477
  draft,
1465
1478
  stub,
1479
+ ...datafiles,
1466
1480
  };
1467
1481
  }
@@ -0,0 +1,330 @@
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
+ * Which terms reach a culture or an affiliation, and whether an affiliation
16
+ * reaches a culture at all.
17
+ *
18
+ * A culture's terms are the words of its language, and the ranks, offices and
19
+ * language words of every affiliation that reaches it — through its own
20
+ * `data.culture`, the one the index resolves for it, or the culture of an
21
+ * affiliation above it through `data.parents` —
22
+ * each affiliation's language being the one its glossary resolves to
23
+ * ({@link module:engine/glossary-language}). An affiliation's terms are its own
24
+ * ranks and offices and the words of that language. A language's words include
25
+ * the words it inherits through `data.family`, and a word is a term only where
26
+ * its class's base is a glossary base. The `dictionary` relation
27
+ * ({@link module:engine/dictionary-rows}) names a glossary entry's term from
28
+ * the same reach.
29
+ *
30
+ * **An affiliation that reaches no culture is a warning**, at its `type`, in a
31
+ * tree that describes peoples, which is a tree holding at least one culture
32
+ * note. A tree holding none describes no peoples, and the rule does not speak
33
+ * there.
34
+ *
35
+ * The corpus is read once per index and kept beside it.
36
+ *
37
+ * @module
38
+ */
39
+
40
+ import { isAddressTuple, parseAddress, renderAddress } from "./address.mjs";
41
+ import { AddressLink } from "./address-values.mjs";
42
+ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
43
+ import { glossaryLanguage } from "./glossary-language.mjs";
44
+ import { parseWordClass, WORD_CLASS_BASES } from "./word-classes.mjs";
45
+ import { LANGUAGE_SUBTYPE } from "./word-terms.mjs";
46
+
47
+ /** The bases whose words are glossary terms. */
48
+ const GLOSSARY_BASES = new Set(
49
+ WORD_CLASS_BASES.filter((item) => item.glossary).map((item) => item.token),
50
+ );
51
+
52
+ /** A note's parsed frontmatter, whichever shape the index hands over. */
53
+ const payload = (note) => note?.fm ?? note ?? {};
54
+
55
+ /** A note's `data`, or an empty map. */
56
+ const dataOf = (note) => {
57
+ const data = payload(note).data;
58
+ return data && typeof data === "object" && !Array.isArray(data) ? data : {};
59
+ };
60
+
61
+ /** A note's type, lowercased. */
62
+ const typeOf = (note) => String(note?.type ?? payload(note).type ?? "").toLowerCase();
63
+
64
+ /** A note's subType, lowercased. */
65
+ const subTypeOf = (note) => String(payload(note).subType ?? "").toLowerCase();
66
+
67
+ /** Whether a note is a culture. */
68
+ export const isCultureNote = (note) => typeOf(note) === "lore" && subTypeOf(note) === "culture";
69
+
70
+ /** Whether a note is a language. */
71
+ export const isLanguageNote = (note) =>
72
+ typeOf(note) === "skill" && subTypeOf(note) === LANGUAGE_SUBTYPE;
73
+
74
+ /** A name as the glossary compares it: NFC, case kept. */
75
+ export const glossaryName = (name) => String(name ?? "").normalize("NFC");
76
+
77
+ /**
78
+ * A value's canonical Address as a string, whether the note holds it parsed or
79
+ * as written, or `undefined` where it is no Address.
80
+ *
81
+ * @param {unknown} value - The value.
82
+ * @param {object|undefined} index - The index, for the package and the types.
83
+ * @param {string} type - The type a bare shortcode defaults to.
84
+ * @returns {string|undefined} The Address.
85
+ */
86
+ export function addressKeyOf(value, index, type) {
87
+ if (value === undefined || value === null || value === "") return undefined;
88
+ if (isAddressTuple(value)) return renderAddress(value);
89
+ if (value instanceof AddressLink) return renderAddress(value.target);
90
+ if (typeof value !== "string") return undefined;
91
+ const tuple = parseAddress(value, {
92
+ package: index?.contentPackage,
93
+ system: "note",
94
+ type,
95
+ types: index?.types,
96
+ packages: index?.packages,
97
+ });
98
+ return tuple.reason ? undefined : renderAddress(tuple);
99
+ }
100
+
101
+ /** A note's own Address, keyed as a reference to it is: in the `note` system. */
102
+ export function ownKey(note, index) {
103
+ const shortcode = payload(note).shortcode;
104
+ if (typeof shortcode !== "string" || !shortcode || !index?.contentPackage) return undefined;
105
+ return renderAddress({
106
+ package: index.contentPackage,
107
+ system: "note",
108
+ type: typeOf(note),
109
+ shortcode,
110
+ });
111
+ }
112
+
113
+ /**
114
+ * Add a sense to a culture's or an affiliation's term.
115
+ *
116
+ * @param {Map<string, Map<string, string[]>>} terms - Note to name to senses.
117
+ * @param {string} culture - The note's Address.
118
+ * @param {unknown} name - The term.
119
+ * @param {unknown} sense - Its sense text.
120
+ */
121
+ function addTerm(terms, culture, name, sense) {
122
+ if (typeof name !== "string" || !name.trim()) return;
123
+ if (!terms.has(culture)) terms.set(culture, new Map());
124
+ const byName = terms.get(culture);
125
+ const key = glossaryName(name);
126
+ if (!byName.has(key)) byName.set(key, []);
127
+ if (typeof sense === "string") byName.get(key).push(sense);
128
+ }
129
+
130
+ /**
131
+ * The glossary-base words of every local language note, keyed by the note.
132
+ *
133
+ * @param {readonly object[]} notes - The index's notes.
134
+ * @param {object} index - The index.
135
+ * @returns {Map<string, Array<{form: string, gloss: unknown}>>} Language to its
136
+ * glossary-base words.
137
+ */
138
+ function languageWords(notes, index) {
139
+ const out = new Map();
140
+ for (const note of notes) {
141
+ if (!isLanguageNote(note)) continue;
142
+ const words = dataOf(note).words;
143
+ if (!Array.isArray(words)) continue;
144
+ const language = ownKey(note, index);
145
+ if (!language) continue;
146
+ for (const word of words) {
147
+ if (!word || typeof word !== "object" || typeof word.form !== "string") continue;
148
+ const parsed = parseWordClass(word.class);
149
+ if (parsed.reason || !GLOSSARY_BASES.has(parsed.base)) continue;
150
+ if (!out.has(language)) out.set(language, []);
151
+ out.get(language).push({ form: word.form, gloss: word.gloss });
152
+ }
153
+ }
154
+ return out;
155
+ }
156
+
157
+ /**
158
+ * How the lint reads Addresses and notes for {@link glossaryLanguage}.
159
+ *
160
+ * @param {object} index - The link index.
161
+ * @param {object} note - The owning note.
162
+ * @returns {import("./glossary-language.mjs").GlossaryLanguageReader}
163
+ */
164
+ function readerOf(index, note) {
165
+ return {
166
+ keyOf: (value, type) => addressKeyOf(value, index, type),
167
+ lookup: (key) => index.addressHit?.(key),
168
+ self: ownKey(note, index),
169
+ };
170
+ }
171
+
172
+ /**
173
+ * The language a glossary entry on a note resolves to.
174
+ *
175
+ * @param {object} index - The link index.
176
+ * @param {object} note - The culture or affiliation the entry is written on.
177
+ * @param {object|undefined} entry - The entry, or nothing for the note's own.
178
+ * @returns {string|undefined} The language's Address.
179
+ */
180
+ export function entryLanguage(index, note, entry) {
181
+ return glossaryLanguage(entry, note, readerOf(index, note));
182
+ }
183
+
184
+ /**
185
+ * What the index says about each culture's and affiliation's terms, read once.
186
+ *
187
+ * @typedef {object} GlossaryCorpus
188
+ * @property {boolean} describesPeoples - The tree holds a culture note.
189
+ * @property {Map<string, Map<string, string[]>>} terms - Each culture's and
190
+ * affiliation's terms by NFC name, each with the sense texts its sources
191
+ * give it.
192
+ */
193
+
194
+ /** @type {WeakMap<object, GlossaryCorpus>} */
195
+ const CORPUS = new WeakMap();
196
+
197
+ /**
198
+ * Add an affiliation's ranks and offices to a note's terms.
199
+ *
200
+ * @param {Map<string, Map<string, string[]>>} terms - Note to name to senses.
201
+ * @param {string} key - The note the terms belong to.
202
+ * @param {object} data - The affiliation's `data`.
203
+ */
204
+ function addGovernance(terms, key, data) {
205
+ const ranks = data.governance?.ranks;
206
+ for (const rung of Array.isArray(ranks) ? ranks : [])
207
+ if (rung && typeof rung === "object") addTerm(terms, key, rung.title, rung.description);
208
+ const offices = data.governance?.offices;
209
+ if (offices && typeof offices === "object" && !Array.isArray(offices))
210
+ for (const [title, office] of Object.entries(offices))
211
+ addTerm(terms, key, title, typeof office === "string" ? office : office?.description);
212
+ }
213
+
214
+ /**
215
+ * Every culture an affiliation's terms reach: its own, and the culture of each
216
+ * affiliation above it through `data.parents`.
217
+ *
218
+ * @param {object} index - The link index.
219
+ * @param {object} note - The affiliation.
220
+ * @returns {string[]} The cultures' Addresses.
221
+ */
222
+ function reachedCultures(index, note) {
223
+ const out = new Set();
224
+ const seen = new Set();
225
+ const queue = [note];
226
+ while (queue.length) {
227
+ const body = queue.shift();
228
+ if (!body || seen.has(body)) continue;
229
+ seen.add(body);
230
+ const data = dataOf(body);
231
+ const culture = addressKeyOf(data.culture, index, "lore");
232
+ if (culture) out.add(culture);
233
+ for (const parent of Array.isArray(data.parents) ? data.parents : []) {
234
+ const key = addressKeyOf(parent, index, "affiliation");
235
+ const target = key ? index.addressHit?.(key) : undefined;
236
+ if (target && typeOf(target) === "affiliation") queue.push(target);
237
+ }
238
+ }
239
+ return [...out];
240
+ }
241
+
242
+ /**
243
+ * The glossary corpus of an index.
244
+ *
245
+ * @param {object} index - The link index: `notes`, `contentPackage`, `types`.
246
+ * @returns {GlossaryCorpus}
247
+ */
248
+ export function glossaryCorpus(index) {
249
+ const cached = CORPUS.get(index);
250
+ if (cached) return cached;
251
+ const notes = index.notes ?? [];
252
+ const words = languageWords(notes, index);
253
+ const families = new Map();
254
+ for (const note of notes)
255
+ if (isLanguageNote(note)) {
256
+ const key = ownKey(note, index);
257
+ if (key) families.set(key, addressKeyOf(dataOf(note).family, index, "skill"));
258
+ }
259
+ const terms = new Map();
260
+ // A language's words include those it inherits through `data.family`.
261
+ const addWords = (key, language) => {
262
+ const seen = new Set();
263
+ for (
264
+ let current = language;
265
+ current && !seen.has(current);
266
+ current = families.get(current)
267
+ ) {
268
+ seen.add(current);
269
+ for (const word of words.get(current) ?? []) addTerm(terms, key, word.form, word.gloss);
270
+ }
271
+ };
272
+ for (const note of notes) {
273
+ const culture = isCultureNote(note);
274
+ if (!culture && typeOf(note) !== "affiliation") continue;
275
+ const key = ownKey(note, index);
276
+ if (!key) continue;
277
+ if (!terms.has(key)) terms.set(key, new Map());
278
+ const data = dataOf(note);
279
+ if (culture) {
280
+ addWords(key, addressKeyOf(data.language, index, "skill"));
281
+ continue;
282
+ }
283
+ addGovernance(terms, key, data);
284
+ addWords(key, entryLanguage(index, note, undefined));
285
+ for (const home of reachedCultures(index, note)) {
286
+ addGovernance(terms, home, data);
287
+ addWords(home, entryLanguage(index, note, undefined));
288
+ }
289
+ }
290
+ const corpus = { describesPeoples: notes.some(isCultureNote), terms };
291
+ CORPUS.set(index, corpus);
292
+ return corpus;
293
+ }
294
+
295
+ /**
296
+ * A culture's or an affiliation's terms, by NFC name, each with the sense
297
+ * texts its sources give.
298
+ *
299
+ * @param {object} index - The link index.
300
+ * @param {object} note - The culture or affiliation note.
301
+ * @returns {Map<string, string[]>} The terms; empty for a note no source reaches.
302
+ */
303
+ export function noteTerms(index, note) {
304
+ const key = ownKey(note, index);
305
+ return (key && glossaryCorpus(index).terms.get(key)) || new Map();
306
+ }
307
+
308
+ /**
309
+ * Warn of an affiliation that reaches no culture, its own or one it inherits,
310
+ * in a tree that describes peoples.
311
+ *
312
+ * @param {object} note - An `affiliation` note.
313
+ * @param {{index?: object}} [context]
314
+ * @returns {object[]} One warning, at the note's `type`, or none.
315
+ */
316
+ export function checkAffiliationReach(note, { index } = {}) {
317
+ if (!index?.notes || !glossaryCorpus(index).describesPeoples) return [];
318
+ if (addressKeyOf(dataOf(note).culture, index, "lore")) return [];
319
+ return [
320
+ {
321
+ file: note.file,
322
+ ...positionOfFrontmatterPath(note.raw ?? "", ["type"]),
323
+ severity: "warning",
324
+ message:
325
+ "this affiliation reaches no culture, its own or one inherited through " +
326
+ "`data.parents`, so its ranks, offices and language enter no culture's glossary; " +
327
+ "state `data.culture`",
328
+ },
329
+ ];
330
+ }