@heroiclands/package-build 25.1.2 → 25.2.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 (86) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/assets/styles/content-base.css +0 -9
  3. package/bin/content-commands.mjs +22 -16
  4. package/bin/package-build.mjs +24 -25
  5. package/content-config.mjs +7 -34
  6. package/docs/api.md +38 -21
  7. package/docs/authoring/first-note.md +3 -3
  8. package/docs/authoring/frontmatter.md +30 -2
  9. package/docs/authoring/links-and-markup.md +92 -31
  10. package/docs/commands.md +103 -59
  11. package/docs/configuration.md +10 -45
  12. package/docs/content-format.md +7 -5
  13. package/docs/diagnostics.md +75 -0
  14. package/docs/encoding-core.md +11 -0
  15. package/docs/guides/book.md +3 -3
  16. package/docs/guides/module-project.md +1 -1
  17. package/docs/guides/reference-pages.md +7 -3
  18. package/docs/project-setup.md +3 -0
  19. package/docs/reference/format-details.md +329 -187
  20. package/docs/reference/note-types.md +274 -274
  21. package/engine/changelog-group.mjs +84 -173
  22. package/engine/changelog-labels.mjs +73 -0
  23. package/engine/changelog-lint.mjs +168 -44
  24. package/engine/content-expand.mjs +58 -38
  25. package/engine/content-format.yaml +0 -2
  26. package/engine/content-index.mjs +49 -23
  27. package/engine/content-links.mjs +4 -0
  28. package/engine/content-lint.mjs +2 -3
  29. package/engine/content-render.mjs +6 -2
  30. package/engine/culture-parents.mjs +1 -1
  31. package/engine/culture-reach.mjs +10 -1
  32. package/engine/diagnostics.mjs +3 -2
  33. package/engine/dictionary-rows.mjs +3 -1
  34. package/engine/encoding-lexicon.mjs +12 -2
  35. package/engine/first-use.mjs +301 -87
  36. package/engine/frontmatter-lint.mjs +40 -14
  37. package/engine/glossary-entries.mjs +5 -5
  38. package/engine/glossary-language.mjs +21 -44
  39. package/engine/homepage.mjs +0 -4
  40. package/engine/index-records.mjs +3 -1
  41. package/engine/infobox.mjs +6 -3
  42. package/engine/language-names.mjs +70 -10
  43. package/engine/language-rows.mjs +6 -7
  44. package/engine/lexical-sections.mjs +3 -1
  45. package/engine/literature-notes.mjs +4 -76
  46. package/engine/metadata-index.mjs +15 -8
  47. package/engine/name-forms.mjs +99 -0
  48. package/engine/name-refs.mjs +143 -0
  49. package/engine/name-rows.mjs +11 -1
  50. package/engine/names.mjs +27 -22
  51. package/engine/note-addresses.mjs +0 -3
  52. package/engine/note-frontmatter.mjs +3 -5
  53. package/engine/note-language.mjs +348 -0
  54. package/engine/note-vocabulary.mjs +35 -13
  55. package/engine/pdf-index.mjs +3 -2
  56. package/engine/pdf-render.mjs +2 -4
  57. package/engine/settlement-tables.mjs +4 -3
  58. package/engine/site-build.mjs +2 -7
  59. package/engine/site-search.mjs +3 -5
  60. package/engine/site-theme.mjs +1 -1
  61. package/engine/sql-tables.mjs +29 -14
  62. package/engine/wikilink-syntax.mjs +2 -0
  63. package/engine/word-citations.mjs +3 -3
  64. package/engine/word-rows.mjs +3 -1
  65. package/engine/word-terms.mjs +2 -2
  66. package/package.json +1 -1
  67. package/site-theme/static/css/style.css +0 -4
  68. package/types/engine/changelog-group.d.mts +6 -10
  69. package/types/engine/changelog-labels.d.mts +10 -0
  70. package/types/engine/changelog-lint.d.mts +11 -6
  71. package/types/engine/content-index.d.mts +16 -16
  72. package/types/engine/diagnostics.d.mts +1 -1
  73. package/types/engine/first-use.d.mts +97 -1
  74. package/types/engine/frontmatter-lint.d.mts +9 -2
  75. package/types/engine/glossary-language.d.mts +13 -6
  76. package/types/engine/homepage.d.mts +0 -3
  77. package/types/engine/language-names.d.mts +3 -0
  78. package/types/engine/literature-notes.d.mts +2 -27
  79. package/types/engine/name-forms.d.mts +61 -0
  80. package/types/engine/name-refs.d.mts +51 -0
  81. package/types/engine/name-rows.d.mts +4 -0
  82. package/types/engine/names.d.mts +12 -92
  83. package/types/engine/note-frontmatter.d.mts +1 -4
  84. package/types/engine/note-language.d.mts +47 -0
  85. package/types/engine/settlement-tables.d.mts +1 -1
  86. package/types/engine/site-build.d.mts +2 -6
@@ -0,0 +1,348 @@
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 language a note is in**, a code `languages:` declares or `en`. A
16
+ * note's language is the first of:
17
+ *
18
+ * 1. its own `data.lang`;
19
+ * 2. its `data.culture`'s language: the culture note it names, resolved by
20
+ * these same rules;
21
+ * 3. its `data.parents`, nearest first, each resolved by these same rules;
22
+ * 4. `en`.
23
+ *
24
+ * Steps 2 and 3 take only a language some note states: a culture or parent
25
+ * that itself falls through to `en` contributes nothing, and the chain goes on.
26
+ * A parent in another package contributes the language its published entry
27
+ * carries.
28
+ *
29
+ * The content index writes the result onto every note record as `lang`, and
30
+ * every reader of a note's language reads it from there. The configured
31
+ * language note of each code carries that code as `languageCode`, so a reader
32
+ * holding only records maps a code to its note.
33
+ *
34
+ * Findings, each an error:
35
+ *
36
+ * - a `data.lang` naming no declared code, at its value;
37
+ * - a `data.culture` whose culture resolves to no language, at its value;
38
+ * - every `data.parents` entry on a cycle, at the entry, whether or not a walk
39
+ * reaches it. A cycle among culture notes is reported by the culture
40
+ * hierarchy check instead.
41
+ *
42
+ * @module
43
+ */
44
+
45
+ import fs from "node:fs";
46
+
47
+ import { canonicalKey, isAddressTuple, parseAddress, renderAddress } from "./address.mjs";
48
+ import { isAddressSegment } from "./address-charset.mjs";
49
+ import { AddressLink } from "./address-values.mjs";
50
+ import { formatDiagnostic, positionOfFrontmatterPath } from "./diagnostics.mjs";
51
+ import { isNoteRecord, noteFile } from "./index-records.mjs";
52
+ import { FALLBACK_LANGUAGE } from "./name-forms.mjs";
53
+ import { NOTE_SYSTEM } from "./systems.mjs";
54
+
55
+ /**
56
+ * The canonical key an Address names, without any anchor.
57
+ *
58
+ * @param {unknown} value - A tuple, an {@link AddressLink}, or anything else.
59
+ * @returns {string|undefined} The key, or `undefined` for anything else.
60
+ */
61
+ function addressKey(value) {
62
+ const tuple = value instanceof AddressLink ? value.target : value;
63
+ return isAddressTuple(tuple) ? renderAddress(tuple) : undefined;
64
+ }
65
+
66
+ /**
67
+ * Whether a record is a culture note.
68
+ *
69
+ * @param {Record<string, any>|undefined} record - An index record.
70
+ * @returns {boolean} Whether it is a `lore` note with `subType: culture`.
71
+ */
72
+ function isCultureNote(record) {
73
+ return record?.type === "lore" && String(record.subType ?? "").toLowerCase() === "culture";
74
+ }
75
+
76
+ /**
77
+ * Write each note's language onto its record as `lang`, and each configured
78
+ * language note's code onto its record as `languageCode`.
79
+ *
80
+ * Reads each note's authored `data.lang`, `data.culture` and `data.parents`,
81
+ * so it runs before the index writes a resolved culture over `data.culture`.
82
+ *
83
+ * @param {Array<Record<string, any>>} records - Index records, every Address a
84
+ * tuple. Records that are not notes are passed over.
85
+ * @param {object} options - Options.
86
+ * @param {string} options.contentPackage - The package the records belong to.
87
+ * @param {Readonly<Record<string, {note?: string}>>} [options.languages] - The
88
+ * configured languages, by code.
89
+ * @param {string} [options.contentBase] - The content tree, for opening a note
90
+ * to locate a finding.
91
+ * @param {(key: string) => Record<string, any>|undefined} [options.foreignRecord] -
92
+ * Another package's entry for a canonical address.
93
+ * @param {object[]} [options.problems] - Receives each finding. Omitted, the
94
+ * findings are collected and one Error lists them all.
95
+ * @param {Map<string, {raw: string}>} [options.parsed] - Notes already read, keyed by file.
96
+ * @returns {Array<Record<string, any>>} The same records.
97
+ * @throws {Error} When `problems` is omitted and anything is reported.
98
+ */
99
+ export function resolveNoteLanguages(
100
+ records,
101
+ { contentPackage, languages, contentBase, foreignRecord, problems, parsed },
102
+ ) {
103
+ const codes = new Set([FALLBACK_LANGUAGE, ...Object.keys(languages ?? {})]);
104
+ const notes = new Map();
105
+ for (const record of records) {
106
+ if (!isNoteRecord(record) || !isAddressSegment(String(record.shortcode ?? ""))) continue;
107
+ const key = canonicalKey(contentPackage, NOTE_SYSTEM, record.type, record.shortcode);
108
+ if (!notes.has(key)) notes.set(key, record);
109
+ }
110
+ const localOf = (value) => {
111
+ const tuple = value instanceof AddressLink ? value.target : value;
112
+ if (!isAddressTuple(tuple) || tuple.package !== contentPackage) return undefined;
113
+ return notes.get(canonicalKey(contentPackage, NOTE_SYSTEM, tuple.type, tuple.shortcode));
114
+ };
115
+
116
+ const findings = [];
117
+ /**
118
+ * Report one finding on a note, at a frontmatter value.
119
+ *
120
+ * @param {Record<string, any>} record - The note.
121
+ * @param {Array<string|number>} keyPath - Where the value is written.
122
+ * @param {string} message - What is wrong.
123
+ */
124
+ const report = (record, keyPath, message) => {
125
+ const file = contentBase ? noteFile(contentBase, record) : record.file?.path;
126
+ const raw =
127
+ parsed?.get(file)?.raw ??
128
+ (contentBase && fs.existsSync(file) ? fs.readFileSync(file, "utf8") : undefined);
129
+ findings.push({
130
+ file,
131
+ ...positionOfFrontmatterPath(raw, keyPath),
132
+ severity: "error",
133
+ message,
134
+ });
135
+ };
136
+
137
+ const memo = new Map();
138
+ const inProgress = new Set();
139
+
140
+ /**
141
+ * The language a source Address states: a local note's, resolved, or the
142
+ * one a foreign entry publishes.
143
+ *
144
+ * @param {unknown} value - A `data.culture` or `data.parents` Address.
145
+ * @returns {string|undefined} The code.
146
+ */
147
+ const statedThrough = (value) => {
148
+ const key = addressKey(value);
149
+ if (!key) return undefined;
150
+ const local = localOf(value);
151
+ if (local) return resolve(local);
152
+ const tuple = value instanceof AddressLink ? value.target : value;
153
+ if (tuple.package === contentPackage) return undefined;
154
+ const lang = foreignRecord?.(key)?.lang;
155
+ return typeof lang === "string" && lang ? lang : undefined;
156
+ };
157
+
158
+ /**
159
+ * The language a note states through its own field, its culture or its
160
+ * parents, ignoring the fallback.
161
+ *
162
+ * @param {Record<string, any>} record - The note.
163
+ * @returns {string|undefined} The code.
164
+ */
165
+ const resolveRecord = (record) => {
166
+ const own = record.data?.lang;
167
+ if (own !== undefined && own !== null) {
168
+ if (typeof own === "string" && codes.has(own)) return own;
169
+ report(
170
+ record,
171
+ ["data", "lang"],
172
+ `data.lang ${typeof own === "string" ? own : JSON.stringify(own)} names no ` +
173
+ `language: state a code \`languages:\` declares, or ${FALLBACK_LANGUAGE} ` +
174
+ `(${[...codes].sort().join(", ")})`,
175
+ );
176
+ }
177
+ const culture = record.data?.culture;
178
+ const cultureRecord = localOf(culture);
179
+ if (addressKey(culture) && cultureRecord !== record) {
180
+ const lang = statedThrough(culture);
181
+ if (lang) return lang;
182
+ if (isCultureNote(cultureRecord) && !inProgress.has(cultureRecord))
183
+ report(
184
+ record,
185
+ ["data", "culture"],
186
+ `data.culture ${cultureRecord.type}-${cultureRecord.shortcode} resolves to ` +
187
+ "no language: state data.lang on the culture or on a parent culture",
188
+ );
189
+ }
190
+ const parents = Array.isArray(record.data?.parents) ? record.data.parents : [];
191
+ for (const [position, parent] of parents.entries()) {
192
+ const target = localOf(parent);
193
+ if (target && inProgress.has(target)) continue;
194
+ const lang = statedThrough(parent);
195
+ if (lang) return lang;
196
+ }
197
+ return undefined;
198
+ };
199
+
200
+ /**
201
+ * A local note's stated language, memoised; a note already being resolved
202
+ * is a cycle and states nothing.
203
+ *
204
+ * @param {Record<string, any>} record - The note.
205
+ * @returns {string|undefined} The code.
206
+ */
207
+ const resolve = (record) => {
208
+ if (memo.has(record)) return memo.get(record);
209
+ if (inProgress.has(record)) return undefined;
210
+ inProgress.add(record);
211
+ const result = resolveRecord(record);
212
+ inProgress.delete(record);
213
+ memo.set(record, result);
214
+ return result;
215
+ };
216
+
217
+ // Walked in address order, so which note of a cycle is met first is a fact
218
+ // about the content rather than about directory-read order.
219
+ for (const key of [...notes.keys()].sort()) resolve(notes.get(key));
220
+
221
+ // Every `data.parents` entry on a cycle, whether or not a walk reached it.
222
+ for (const { record, position, target } of cyclicParents(notes, localOf))
223
+ if (!(isCultureNote(record) && isCultureNote(target)))
224
+ report(
225
+ record,
226
+ ["data", "parents", position],
227
+ `data.parents.${position} names ${target.type}-${target.shortcode}, ` +
228
+ "which closes a parents cycle; a note is never its own ancestor",
229
+ );
230
+ for (const record of records) {
231
+ if (!isNoteRecord(record) || !isAddressSegment(String(record.shortcode ?? ""))) continue;
232
+ const own = notes.get(
233
+ canonicalKey(contentPackage, NOTE_SYSTEM, record.type, record.shortcode),
234
+ );
235
+ record.lang = memo.get(own) ?? FALLBACK_LANGUAGE;
236
+ }
237
+
238
+ // Each configured language note carries its code.
239
+ const types = new Set(["skill", ...records.map((record) => String(record.type ?? ""))]);
240
+ for (const [code, spec] of Object.entries(languages ?? {})) {
241
+ if (typeof spec?.note !== "string") continue;
242
+ const tuple = parseAddress(spec.note, {
243
+ package: contentPackage,
244
+ system: NOTE_SYSTEM,
245
+ type: "skill",
246
+ types,
247
+ });
248
+ const record = tuple.reason ? undefined : localOf(tuple);
249
+ if (record) record.languageCode = code;
250
+ }
251
+
252
+ findings.sort(
253
+ (a, b) =>
254
+ String(a.file).localeCompare(String(b.file), "en") ||
255
+ (a.line ?? 0) - (b.line ?? 0) ||
256
+ (a.column ?? 0) - (b.column ?? 0),
257
+ );
258
+ if (problems) problems.push(...findings);
259
+ else if (findings.length)
260
+ throw Object.assign(new Error(findings.map(formatDiagnostic).join("\n")), {
261
+ located: true,
262
+ });
263
+ return records;
264
+ }
265
+
266
+ /**
267
+ * Every local `data.parents` entry that lies on a cycle: its target reaches
268
+ * its note again through `data.parents`.
269
+ *
270
+ * @param {Map<string, Record<string, any>>} notes - Local notes by key.
271
+ * @param {(value: unknown) => Record<string, any>|undefined} localOf - The
272
+ * local note an Address names.
273
+ * @returns {Array<{record: Record<string, any>, position: number, target: Record<string, any>}>}
274
+ * The entries, in note-key then list order.
275
+ */
276
+ function cyclicParents(notes, localOf) {
277
+ const edges = new Map();
278
+ for (const record of notes.values()) {
279
+ const parents = Array.isArray(record.data?.parents) ? record.data.parents : [];
280
+ edges.set(
281
+ record,
282
+ parents
283
+ .map((parent, position) => ({ position, target: localOf(parent) }))
284
+ .filter((edge) => edge.target),
285
+ );
286
+ }
287
+ // Tarjan's strongly connected components: an edge inside one component,
288
+ // or from a note to itself, lies on a cycle.
289
+ const index = new Map();
290
+ const low = new Map();
291
+ const component = new Map();
292
+ const stack = [];
293
+ const onStack = new Set();
294
+ let counter = 0;
295
+ const visit = (record) => {
296
+ index.set(record, counter);
297
+ low.set(record, counter);
298
+ counter++;
299
+ stack.push(record);
300
+ onStack.add(record);
301
+ for (const { target } of edges.get(record) ?? []) {
302
+ if (!index.has(target)) {
303
+ visit(target);
304
+ low.set(record, Math.min(low.get(record), low.get(target)));
305
+ } else if (onStack.has(target))
306
+ low.set(record, Math.min(low.get(record), index.get(target)));
307
+ }
308
+ if (low.get(record) !== index.get(record)) return;
309
+ let member;
310
+ do {
311
+ member = stack.pop();
312
+ onStack.delete(member);
313
+ component.set(member, record);
314
+ } while (member !== record);
315
+ };
316
+ const keys = [...notes.keys()].sort();
317
+ for (const key of keys) if (!index.has(notes.get(key))) visit(notes.get(key));
318
+ const out = [];
319
+ for (const key of keys) {
320
+ const record = notes.get(key);
321
+ for (const { position, target } of edges.get(record) ?? [])
322
+ if (target === record || component.get(target) === component.get(record))
323
+ out.push({ record, position, target });
324
+ }
325
+ return out;
326
+ }
327
+
328
+ /**
329
+ * Each language code's note, read from records alone: the Address of every
330
+ * record carrying a `languageCode`.
331
+ *
332
+ * @param {Iterable<Record<string, any>>} records - Index records, local and
333
+ * any dependency's, or link-index notes carrying them.
334
+ * @param {(record: Record<string, any>) => string|undefined} addressOf - A
335
+ * record's Address, as the caller keys notes.
336
+ * @returns {Map<string, string>} Code to the language note's Address; the first
337
+ * record naming a code wins.
338
+ */
339
+ export function languageNotesByCode(records, addressOf) {
340
+ const out = new Map();
341
+ for (const record of records) {
342
+ const code = record?.languageCode;
343
+ if (typeof code !== "string" || out.has(code)) continue;
344
+ const address = addressOf(record);
345
+ if (address) out.set(code, address);
346
+ }
347
+ return out;
348
+ }
@@ -125,7 +125,7 @@ import {
125
125
  requiredHeightGenders,
126
126
  } from "./appearance-profile.mjs";
127
127
  import { checkExtraFeatures } from "./appearance-features.mjs";
128
- import { LANGUAGE_FIELD, LITERATURE_FIELDS, checkLiteratureNote } from "./literature-notes.mjs";
128
+ import { LITERATURE_FIELDS, checkLiteratureNote } from "./literature-notes.mjs";
129
129
  import {
130
130
  AFFIX_FIELDS,
131
131
  GLOSSARY_ENTRY_FIELDS,
@@ -773,7 +773,8 @@ const NAMES_FIELD = Object.freeze({
773
773
  'builds a name from placeholders such as "{given} {clan}" and literal text, `given` ' +
774
774
  "maps a gender to a list of given names, " +
775
775
  `${wordList([...requiredGivenGenders()]).replace(" or ", " and ")} required, and ` +
776
- "every other key is a list of names a placeholder of the same name draws from. A " +
776
+ "`en` holds groups of English names, and every other key is a list of names a " +
777
+ "placeholder of the same name draws from. A " +
777
778
  "language skill note only. See [name lists](format-details.md#name-lists).",
778
779
  fields: Object.freeze([
779
780
  Object.freeze({
@@ -783,7 +784,8 @@ const NAMES_FIELD = Object.freeze({
783
784
  shape: 'a pattern such as "{given} {clan}", a list of them, or a map of genders to either',
784
785
  describe:
785
786
  "How a name is built: each `{list}` placeholder is filled from the list of " +
786
- "that name, `{given.<gender>}` from that gender's given names, and the text " +
787
+ "that name, `{given.<gender>}` from that gender's given names, `{en.<list>}` from a " +
788
+ "list of English names, and the text " +
787
789
  "around them is written as it stands. A list states every form a name may " +
788
790
  "take; a map keyed by gender states each gender's forms, and a gender with " +
789
791
  "none takes every pattern stated.",
@@ -822,6 +824,24 @@ const NAMES_FIELD = Object.freeze({
822
824
  }),
823
825
  }),
824
826
  }),
827
+ Object.freeze({
828
+ name: "en",
829
+ kind: "map",
830
+ shape: "a map of list names to lists of English names",
831
+ describe:
832
+ "English names, never encoded in the tongue's script. A pattern draws from one " +
833
+ "list as `{en.<list>}`, as `{en.epithet}` does.",
834
+ values: Object.freeze({
835
+ kind: "list",
836
+ shape: "a list of English names",
837
+ describe: "A list of English names a placeholder draws from.",
838
+ entries: Object.freeze({
839
+ kind: "string",
840
+ shape: "a name",
841
+ describe: "One English name, as written.",
842
+ }),
843
+ }),
844
+ }),
825
845
  ]),
826
846
  values: Object.freeze({
827
847
  kind: "list",
@@ -1015,7 +1035,9 @@ export const NOTE_TOP_LEVEL_FIELDS = Object.freeze([
1015
1035
  }),
1016
1036
  Object.freeze({
1017
1037
  name: "name",
1018
- describe: "The display names — a required `full`, and any `aliases` and `sort`.",
1038
+ describe:
1039
+ "The display names — a required `full`, the English name; the other names, " +
1040
+ "listed under the code of their language; and any `sort`.",
1019
1041
  }),
1020
1042
  Object.freeze({
1021
1043
  name: "type",
@@ -1079,6 +1101,14 @@ export const NOTE_TOP_LEVEL_KEY_SET = Object.freeze(new Set(NOTE_TOP_LEVEL_KEYS)
1079
1101
  * @type {readonly DataFieldSpec[]}
1080
1102
  */
1081
1103
  export const SHARED_DATA_FIELDS = Object.freeze([
1104
+ Object.freeze({
1105
+ name: "lang",
1106
+ kind: "string",
1107
+ shape: "a language code `languages:` declares, or `en`",
1108
+ describe:
1109
+ "The language the note is in. Absent, the content index derives it: the " +
1110
+ "note's culture's language, then its `data.parents`' nearest first, then `en`.",
1111
+ }),
1082
1112
  Object.freeze({
1083
1113
  name: "seeAlso",
1084
1114
  kind: "list",
@@ -1896,13 +1926,6 @@ export const NOTE_VOCABULARY = Object.freeze({
1896
1926
  ...CULTURE_FIELD,
1897
1927
  describe: "The people this affiliation belongs to, as a culture lore note.",
1898
1928
  },
1899
- {
1900
- ...LANGUAGE_FIELD,
1901
- describe:
1902
- "The trade, temple or order tongue the affiliation keeps, as a language " +
1903
- "skill note. A glossary entry that states no `language` is in it. The " +
1904
- "affiliation chooses its own common vocabulary and context meanings.",
1905
- },
1906
1929
  GLOSSARY_FIELD,
1907
1930
  {
1908
1931
  name: "relations",
@@ -2445,7 +2468,7 @@ export const NOTE_VOCABULARY = Object.freeze({
2445
2468
  accepts: ["lore"],
2446
2469
  check: checkCultureParents,
2447
2470
  describe:
2448
- "Culture only: parent culture lore notes, searched in listed depth-first order for omitted language and appearance profile fields.",
2471
+ "Culture only: parent culture lore notes, searched in listed depth-first order for an omitted appearance profile, and nearest first for an omitted `data.lang`.",
2449
2472
  },
2450
2473
  {
2451
2474
  ...CULTURE_FIELD,
@@ -2455,7 +2478,6 @@ export const NOTE_VOCABULARY = Object.freeze({
2455
2478
  "culture, and states the field only as itself.",
2456
2479
  },
2457
2480
  ...LITERATURE_FIELDS,
2458
- LANGUAGE_FIELD,
2459
2481
  EVENTS_FIELD,
2460
2482
  APPEARANCE_PROFILE_FIELD,
2461
2483
  {
@@ -28,7 +28,7 @@
28
28
  * - A note whose name opens with one of the book's `pdf.indexHeads` is also
29
29
  * filed under the rest of its name, with the head after a comma
30
30
  * (`Targud, Kingdom of`), and that entry carries the note's pages.
31
- * - An alias (`name.aliases`) and an event's in-world name
31
+ * - A listed name form (`name.<code>`) and an event's in-world name
32
32
  * (`data.events[].names`) list a cross-reference to the note's name.
33
33
  *
34
34
  * Terms sort on the ASCII fold the content index derives for names
@@ -42,6 +42,7 @@
42
42
  */
43
43
 
44
44
  import { asciiName, nameSortOf } from "./name-sort.mjs";
45
+ import { nameFormsOf } from "./name-forms.mjs";
45
46
 
46
47
  /**
47
48
  * The marker an entry heading or an authored mention carries.
@@ -153,7 +154,7 @@ export function indexTerms(entries, { owns = () => true, foreign = [], heads = [
153
154
  terms.push({ name: inverted, slug, main: false, inverted: true });
154
155
  }
155
156
  const names = [
156
- ...(Array.isArray(entry.record?.name?.aliases) ? entry.record.name.aliases : []),
157
+ ...nameFormsOf(entry.record?.name),
157
158
  ...(Array.isArray(entry.record?.data?.events) ? entry.record.data.events : []).flatMap(
158
159
  (event) => (Array.isArray(event?.names) ? event.names.map((n) => n?.name) : []),
159
160
  ),
@@ -2012,11 +2012,9 @@ export function bookTypstPreamble(runs = {}, families = {}) {
2012
2012
  " }\n" +
2013
2013
  " if lang != none { text(lang: lang, styled) } else { styled }\n" +
2014
2014
  "}",
2015
- // Span classes reach the book intact; name styling sets an absolute
2016
- // weight so it remains bold inside an ordinary strong phrase.
2015
+ // Span classes reach the book intact.
2017
2016
  "#let book-span(body, classes: (), lang: none) = {\n" +
2018
- ' let styled = if classes.contains("name") { text(weight: "bold", body) } else { body }\n' +
2019
- " box(book-script-text(styled, classes: classes, lang: lang))\n" +
2017
+ " box(book-script-text(body, classes: classes, lang: lang))\n" +
2020
2018
  "}",
2021
2019
  // Use the file's natural measure for auto. Every requested width is
2022
2020
  // bounded by the available measure and by the height of a page.
@@ -43,6 +43,7 @@ import { MARKET_CLASSES, marketClass } from "./market-class.mjs";
43
43
  import { hasTag } from "./note-vocabulary.mjs";
44
44
  import { shortcodesOf } from "./holdings.mjs";
45
45
  import { WIKILINK, parseWikilink } from "./wikilink-syntax.mjs";
46
+ import { nameFormsOf } from "./name-forms.mjs";
46
47
 
47
48
  /** The heading text a settlements table stands under, at level two. */
48
49
  export const SETTLEMENTS_HEADING = "Settlements";
@@ -332,7 +333,7 @@ export function findSettlementTables(raw) {
332
333
  * @property {string} shortcode - Lower case.
333
334
  * @property {string} subType
334
335
  * @property {string} title
335
- * @property {Set<string>} names - Its name and aliases, lower case.
336
+ * @property {Set<string>} names - Its name and listed forms, lower case.
336
337
  * @property {string[]} parents - Enclosing places' shortcodes.
337
338
  * @property {{heading: number, tables: SettlementTable[]}} section
338
339
  */
@@ -353,7 +354,7 @@ export function findSettlementTables(raw) {
353
354
  const CORPUS = new WeakMap();
354
355
 
355
356
  /**
356
- * A note's name and aliases, lower case.
357
+ * A note's name and listed forms, lower case.
357
358
  *
358
359
  * @param {object} fm - The frontmatter.
359
360
  * @returns {Set<string>} The names.
@@ -367,7 +368,7 @@ function namesOf(fm) {
367
368
  if (typeof name === "string") add(name);
368
369
  else if (name && typeof name === "object") {
369
370
  add(name.full);
370
- for (const alias of Array.isArray(name.aliases) ? name.aliases : []) add(alias);
371
+ for (const form of nameFormsOf(name)) add(form);
371
372
  }
372
373
  return out;
373
374
  }
@@ -578,9 +578,6 @@ export function tableUniverse(pages) {
578
578
  /**
579
579
  * The frontmatter a page publishes with.
580
580
  *
581
- * An authored `aliases` is a list of names a reader might call the note. It is
582
- * dropped: this build emits no redirects.
583
- *
584
581
  * A content page carries the package the build **derived**. No note
585
582
  * declares one, so the note's frontmatter alone would publish a page that does
586
583
  * not say which package it belongs to. Writing the derived value keeps a page
@@ -589,9 +586,8 @@ export function tableUniverse(pages) {
589
586
  *
590
587
  * **`related` is derived, never authored.** What links to a page is a fact
591
588
  * about every other page, known only once the whole tree has resolved — see
592
- * {@link module:engine/related-pages} — so an authored value is dropped the
593
- * way `aliases` is, and {@link renderPages} sets the derived block once it
594
- * holds the graph.
589
+ * {@link module:engine/related-pages} — so an authored value is dropped, and
590
+ * {@link renderPages} sets the derived block once it holds the graph.
595
591
  *
596
592
  * @param {object} page - The page.
597
593
  * @param {object} options
@@ -621,7 +617,6 @@ export function pageFrontmatter(page, { decorate, webSrc, artSrc }) {
621
617
  kbfolder: page.folder,
622
618
  };
623
619
  if (decorate) decorate(data, page);
624
- delete data.aliases;
625
620
  delete data.related;
626
621
  if (webSrc && artSrc) resolveArtFields(data, webSrc, artSrc);
627
622
  return data;
@@ -30,7 +30,7 @@
30
30
  * the world. A note reached by more than one path carries every continent and
31
31
  * region it reaches, so a being with homes in two regions is found under both.
32
32
  *
33
- * The note's name and its aliases are weighted, so a search for a name finds
33
+ * The note's name and its listed forms are weighted, so a search for a name finds
34
34
  * its note first. A note's tags and its draft state are not filters.
35
35
  *
36
36
  * @module
@@ -38,6 +38,7 @@
38
38
 
39
39
  import { placeShortcodes } from "./map-places.mjs";
40
40
  import { resolveInfoboxRef } from "./site-index.mjs";
41
+ import { nameFormsOf } from "./name-forms.mjs";
41
42
 
42
43
  /** The filters a note's page carries, in the order a reader is offered them. */
43
44
  export const SEARCH_FILTERS = Object.freeze([
@@ -153,9 +154,6 @@ export function searchMetadata(fm, { geography, index }) {
153
154
  values: values[name].filter(Boolean),
154
155
  })).filter((filter) => filter.values.length);
155
156
  const name = isMapping(fm?.name) ? fm.name : {};
156
- const names = [
157
- text(name.full),
158
- ...(Array.isArray(name.aliases) ? name.aliases.map(text) : []),
159
- ].filter(Boolean);
157
+ const names = [text(name.full), ...nameFormsOf(name).map(text)].filter(Boolean);
160
158
  return { filters, names: [...new Set(names)] };
161
159
  }
@@ -261,7 +261,7 @@ export function tableOfContents(headings) {
261
261
  return lines.join("\n");
262
262
  }
263
263
 
264
- /** How much more a page's name and aliases count in a search than a word of its body. */
264
+ /** How much more a page's name and listed forms count in a search than a word of its body. */
265
265
  const NAME_WEIGHT = 10;
266
266
 
267
267
  /** The most `h2` headings a page may have before it gets a contents list. */