@heroiclands/package-build 25.1.3 → 25.2.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 (87) 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 +8 -6
  13. package/docs/diagnostics.md +75 -0
  14. package/docs/guides/book.md +3 -3
  15. package/docs/guides/module-project.md +1 -1
  16. package/docs/guides/reference-pages.md +7 -3
  17. package/docs/project-setup.md +3 -0
  18. package/docs/reference/format-details.md +299 -185
  19. package/docs/reference/note-types.md +16 -17
  20. package/docs/source-preview.md +2 -1
  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/first-use.mjs +301 -87
  35. package/engine/frontmatter-lint.mjs +40 -14
  36. package/engine/glossary-entries.mjs +5 -5
  37. package/engine/glossary-language.mjs +21 -44
  38. package/engine/homepage.mjs +0 -4
  39. package/engine/index-records.mjs +3 -1
  40. package/engine/infobox.mjs +6 -3
  41. package/engine/journals.mjs +14 -14
  42. package/engine/language-names.mjs +3 -2
  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 +13 -11
  55. package/engine/pdf-index.mjs +3 -2
  56. package/engine/pdf-render.mjs +2 -4
  57. package/engine/preview-source.mjs +15 -8
  58. package/engine/settlement-tables.mjs +4 -3
  59. package/engine/site-build.mjs +2 -7
  60. package/engine/site-search.mjs +3 -5
  61. package/engine/site-theme.mjs +1 -1
  62. package/engine/sql-tables.mjs +29 -14
  63. package/engine/wikilink-syntax.mjs +2 -0
  64. package/engine/word-citations.mjs +3 -3
  65. package/engine/word-rows.mjs +3 -1
  66. package/engine/word-terms.mjs +2 -2
  67. package/package.json +1 -1
  68. package/site-theme/static/css/style.css +0 -4
  69. package/types/engine/changelog-group.d.mts +6 -10
  70. package/types/engine/changelog-labels.d.mts +10 -0
  71. package/types/engine/changelog-lint.d.mts +11 -6
  72. package/types/engine/content-index.d.mts +16 -16
  73. package/types/engine/diagnostics.d.mts +1 -1
  74. package/types/engine/first-use.d.mts +97 -1
  75. package/types/engine/frontmatter-lint.d.mts +9 -2
  76. package/types/engine/glossary-language.d.mts +13 -6
  77. package/types/engine/homepage.d.mts +0 -3
  78. package/types/engine/journals.d.mts +3 -3
  79. package/types/engine/literature-notes.d.mts +2 -27
  80. package/types/engine/name-forms.d.mts +61 -0
  81. package/types/engine/name-refs.d.mts +51 -0
  82. package/types/engine/name-rows.d.mts +4 -0
  83. package/types/engine/names.d.mts +12 -92
  84. package/types/engine/note-frontmatter.d.mts +1 -4
  85. package/types/engine/note-language.d.mts +47 -0
  86. package/types/engine/settlement-tables.d.mts +1 -1
  87. package/types/engine/site-build.d.mts +2 -6
@@ -51,6 +51,7 @@ import {
51
51
  noteTerms,
52
52
  } from "./culture-reach.mjs";
53
53
  import { pronunciationProblem } from "./word-terms.mjs";
54
+ import { noteLang } from "./glossary-language.mjs";
54
55
 
55
56
  /** Whether a value is a plain map. */
56
57
  const isMap = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
@@ -126,11 +127,10 @@ export function checkGlossary(note, { index } = {}) {
126
127
  [i],
127
128
  "error",
128
129
  `\`${entry.name}\` resolves to no language: the entry states no ` +
129
- "`language`, and " +
130
- (type === "affiliation" ?
131
- "neither the affiliation, its parents nor its culture states one"
132
- : "the culture states no `data.language`") +
133
- "; state `language` on the entry or `data.language` on the note",
130
+ "`language`, and the note's language" +
131
+ (noteLang(note) ? ` ${noteLang(note)}` : "") +
132
+ " has no `note` in `languages:`; state `language` on the entry, or " +
133
+ "`data.lang` on the note naming a language with a note",
134
134
  ),
135
135
  );
136
136
  const senses = noteTerms(index, note, entry).get(name);
@@ -16,16 +16,11 @@
16
16
  *
17
17
  * 1. the entry's own `language`;
18
18
  * 2. the note it is written on, when that note is a language;
19
- * 3. on an affiliation, its own `data.language`, then the nearest parent
20
- * affiliation's (breadth first, parents in the order listed), then its
21
- * culture's `data.language`;
22
- * 4. on a culture, its own `data.language`.
19
+ * 3. the language note of the code the content index derived for the note it
20
+ * is written on, its `lang` — see {@link module:engine/note-language}.
23
21
  *
24
- * An affiliation's culture is the one the index resolves for it, its own or
25
- * one inherited through its parents, and a culture's language includes one it
26
- * inherits from its parent cultures. The lint and the `glossary` relation both
27
- * resolve through here, each handing over how it reads an Address and looks a
28
- * note up.
22
+ * The lint and the `glossary` relation both resolve through here, each handing
23
+ * over how it reads an Address, looks a note up and finds a code's note.
29
24
  *
30
25
  * A leaf module, importing nothing.
31
26
  *
@@ -35,12 +30,6 @@
35
30
  /** A note's parsed frontmatter, whichever shape the caller hands over. */
36
31
  const payload = (note) => note?.fm ?? note ?? {};
37
32
 
38
- /** A note's `data`, or an empty map. */
39
- const dataOf = (note) => {
40
- const data = payload(note).data;
41
- return data && typeof data === "object" && !Array.isArray(data) ? data : {};
42
- };
43
-
44
33
  /** A note's type or subType, lowercased. */
45
34
  const lower = (value) => String(value ?? "").toLowerCase();
46
35
 
@@ -51,11 +40,22 @@ const lower = (value) => String(value ?? "").toLowerCase();
51
40
  * @property {(value: unknown, type: string) => string|undefined} keyOf - A
52
41
  * value's canonical Address in the `note` system, read with `type` as the
53
42
  * default type, or `undefined` where it is no Address.
54
- * @property {(key: string) => object|undefined} lookup - The note an Address
55
- * names, or `undefined` where none is known.
43
+ * @property {(code: string) => string|undefined} noteOf - The Address of a
44
+ * language code's note, or `undefined` where no note is configured for it.
56
45
  * @property {string} [self] - The owning note's own Address, for a language.
57
46
  */
58
47
 
48
+ /**
49
+ * The language a note is in, as the content index derived it.
50
+ *
51
+ * @param {object} note - An index record, or a link-index note carrying it.
52
+ * @returns {string|undefined} The code.
53
+ */
54
+ export function noteLang(note) {
55
+ const lang = note?.lang;
56
+ return typeof lang === "string" && lang ? lang : undefined;
57
+ }
58
+
59
59
  /**
60
60
  * The language a glossary entry resolves to.
61
61
  *
@@ -66,34 +66,11 @@ const lower = (value) => String(value ?? "").toLowerCase();
66
66
  * @returns {string|undefined} The language's Address, or `undefined` where the
67
67
  * chain finds none.
68
68
  */
69
- export function glossaryLanguage(entry, owner, { keyOf, lookup, self }) {
69
+ export function glossaryLanguage(entry, owner, { keyOf, noteOf, self }) {
70
70
  const own = keyOf(entry?.language, "skill");
71
71
  if (own) return own;
72
72
  const fm = payload(owner);
73
- const type = lower(fm.type);
74
- if (type === "skill" && lower(fm.subType) === "language") return self;
75
- if (type === "lore" && lower(fm.subType) === "culture")
76
- return keyOf(dataOf(owner).language, "skill");
77
- if (type !== "affiliation") return undefined;
78
-
79
- const seen = new Set();
80
- let level = [owner];
81
- while (level.length) {
82
- const next = [];
83
- for (const body of level) {
84
- if (seen.has(body)) continue;
85
- seen.add(body);
86
- const language = keyOf(dataOf(body).language, "skill");
87
- if (language) return language;
88
- for (const parent of Array.isArray(dataOf(body).parents) ? dataOf(body).parents : []) {
89
- const key = keyOf(parent, "affiliation");
90
- const target = key ? lookup(key) : undefined;
91
- if (target && lower(payload(target).type) === "affiliation") next.push(target);
92
- }
93
- }
94
- level = next;
95
- }
96
- const culture = keyOf(dataOf(owner).culture, "lore");
97
- const target = culture ? lookup(culture) : undefined;
98
- return target ? keyOf(dataOf(target).language, "skill") : undefined;
73
+ if (lower(fm.type) === "skill" && lower(fm.subType) === "language") return self;
74
+ const code = noteLang(owner);
75
+ return code ? noteOf(code) : undefined;
99
76
  }
@@ -355,9 +355,6 @@ export function homepageTitle(fm, config) {
355
355
  * **No `url` and no `slug`.** The homepage publishes at the package root, so
356
356
  * the page has no address to state.
357
357
  *
358
- * An authored `aliases` is dropped, as it is on every other page: this build
359
- * emits no redirects.
360
- *
361
358
  * `related` is derived like every other page's — what links to the homepage
362
359
  * is a fact about the rest of the tree — so an authored value is dropped and
363
360
  * the caller's block, where it has one, is set in its place.
@@ -372,7 +369,6 @@ export function homepageTitle(fm, config) {
372
369
  */
373
370
  export function homepageFrontmatter(fm, { contentPackage, title, related }) {
374
371
  const data = { ...fm, package: contentPackage, title };
375
- delete data.aliases;
376
372
  delete data.related;
377
373
  if (related) data.related = related;
378
374
  return data;
@@ -56,7 +56,9 @@ export const DERIVED_KEYS = Object.freeze([
56
56
  "address",
57
57
  "anchors",
58
58
  "nameAscii",
59
- "aliasesAscii",
59
+ "formsAscii",
60
+ "lang",
61
+ "languageCode",
60
62
  "foundry",
61
63
  "documentation",
62
64
  "documents",
@@ -129,6 +129,7 @@ import {
129
129
  } from "./standings.mjs";
130
130
  import { AddressEntries } from "./address-values.mjs";
131
131
  import { noteCalendar } from "./note-calendars.mjs";
132
+ import { nameFormsOf } from "./name-forms.mjs";
132
133
 
133
134
  /**
134
135
  * How a section arranges what it holds.
@@ -288,6 +289,9 @@ export const NOTE_FIELD_PRESENTATION = Object.freeze({
288
289
  withheld: "template machinery, not a fact about the subject",
289
290
  }),
290
291
  id: Object.freeze({ withheld: "document identity machinery, not a fact about the subject" }),
292
+ lang: Object.freeze({
293
+ withheld: "language machinery, a code rather than a fact a reader follows",
294
+ }),
291
295
  pack: Object.freeze({ withheld: "compendium routing machinery, not a fact about the subject" }),
292
296
  packFolder: Object.freeze({
293
297
  withheld: "compendium routing machinery, not a fact about the subject",
@@ -1040,9 +1044,8 @@ function noteBox(
1040
1044
  const shown = new Set();
1041
1045
  const name = fm?.name?.full;
1042
1046
  if (hasValue(name)) rows.push({ label: "Name", kind: "text", value: String(name) });
1043
- if (Array.isArray(fm?.name?.aliases) && fm.name.aliases.length) {
1044
- rows.push({ label: "Aliases", kind: "list", value: fm.name.aliases });
1045
- }
1047
+ const forms = nameFormsOf(fm?.name);
1048
+ if (forms.length) rows.push({ label: "Other names", kind: "list", value: forms });
1046
1049
  if (fm?.type === "being" && Object.hasOwn(BEING_SUBTYPE_LABELS, fm.subType)) {
1047
1050
  rows.push({ label: "Type", kind: "text", value: BEING_SUBTYPE_LABELS[fm.subType] });
1048
1051
  }
@@ -116,9 +116,9 @@ function namedBlockLines(markdown, syntax) {
116
116
  * code blocks are respected so `# foo` inside ``` or ~~~ blocks doesn't
117
117
  * trigger a split. Content before the first H1 (if non-empty) becomes a
118
118
  * leading page. Each H1 yields a page whose name is the heading
119
- * text (with any `{#anchor-id}` suffix stripped out and surfaced as
120
- * `anchorSlug`). The classes the heading declares come along as `classes`, so
121
- * a surface that honours one has it without reading the suffix again.
119
+ * text without inline markup (with any `{#anchor-id}` suffix stripped out and
120
+ * surfaced as `anchorSlug`). The classes the heading declares come along as
121
+ * `classes`, so a surface that honours one has it without reading the suffix again.
122
122
  *
123
123
  * `leadName` names that leading page. A journal note's is "Introduction",
124
124
  * because it introduces the pages that follow. An item doc's is the item — a
@@ -220,7 +220,7 @@ export function splitPages(body, leadName = "Introduction", resolveRole, syntax)
220
220
  anchorSlug: null,
221
221
  level: section.level,
222
222
  classes: section.classes,
223
- continues: item.slug || item.label || captionPageName(item.caption),
223
+ continues: item.slug || item.label || pageNameText(item.caption),
224
224
  lines: [],
225
225
  };
226
226
  }
@@ -231,7 +231,7 @@ export function splitPages(body, leadName = "Introduction", resolveRole, syntax)
231
231
  if (figure) {
232
232
  closeCurrent();
233
233
  current = {
234
- name: figure.label || captionPageName(figure.caption),
234
+ name: figure.label || pageNameText(figure.caption),
235
235
  anchorSlug: figure.id || null,
236
236
  level: 1,
237
237
  classes: section.classes.includes(WITHHELD_CLASS) ? [WITHHELD_CLASS] : [],
@@ -243,7 +243,7 @@ export function splitPages(body, leadName = "Introduction", resolveRole, syntax)
243
243
  if (opening) {
244
244
  closeCurrent();
245
245
  current = {
246
- name: opening.text,
246
+ name: pageNameText(opening.text),
247
247
  anchorSlug: opening.id || null,
248
248
  level: opening.level,
249
249
  classes: opening.classes,
@@ -384,20 +384,20 @@ export function journalPageId(entryId, page) {
384
384
  }
385
385
 
386
386
  /**
387
- * The name of the page a caption begins: the caption's visible text.
387
+ * The name of a page a heading or caption begins: its visible text.
388
388
  *
389
- * Foundry shows a page name as plain text, so a caption's markup — emphasis, a
389
+ * Foundry shows a page name as plain text, so inline markup — emphasis, a
390
390
  * code span, a Markdown link, or the `@UUID[...]{label}` a resolved wikilink
391
391
  * has already become, inside its wrapping `<span>` — reduces to the text a
392
392
  * reader sees, the same text a reference to the caption displays. The markup
393
393
  * itself stays in the page's content, where it renders.
394
394
  *
395
- * @param {string} caption - The figure's caption, as the journal pass sees it.
395
+ * @param {string} markdown - A heading or caption, as the journal pass sees it.
396
396
  * @returns {string} Its visible text, whitespace collapsed.
397
397
  */
398
- function captionPageName(caption) {
399
- if (!caption) return "";
400
- const [inline] = md.parseInline(caption, {});
398
+ function pageNameText(markdown) {
399
+ if (!markdown) return "";
400
+ const [inline] = md.parseInline(markdown, {});
401
401
  const text = (inline?.children ?? [])
402
402
  .map((token) =>
403
403
  token.type === "text" || token.type === "code_inline" ? token.content
@@ -501,7 +501,7 @@ function buildFigurePageFields(figure, page, pageId, footnoteNumbers, resolveRol
501
501
  // image's own render takes when no configuration resolves it.
502
502
  }
503
503
  return {
504
- name: figure.label || captionPageName(figure.caption),
504
+ name: figure.label || pageNameText(figure.caption),
505
505
  type: "image",
506
506
  src,
507
507
  image: figure.hasCaption ? { caption: figure.caption } : {},
@@ -520,7 +520,7 @@ function buildFigurePageFields(figure, page, pageId, footnoteNumbers, resolveRol
520
520
  runs,
521
521
  );
522
522
  return {
523
- name: figure.label || captionPageName(figure.caption),
523
+ name: figure.label || pageNameText(figure.caption),
524
524
  type: "text",
525
525
  text: {
526
526
  format: 1,
@@ -17,8 +17,9 @@
17
17
  *
18
18
  * A `skill` note of `subType: language` states them as `data.names`, and
19
19
  * `package-build npc new` draws a person's name from them, or checks a name it
20
- * is given against them. A culture reaches them through `data.language`, so
21
- * cultures sharing a tongue share its lists.
20
+ * is given against them. A culture reaches them through its language, the
21
+ * note `languages:` names for its `data.lang`, so cultures sharing a tongue
22
+ * share its lists.
22
23
  *
23
24
  * - `pattern` — the name's shape: placeholders such as `{given}` and `{clan}`,
24
25
  * each naming a list, with literal text around them or directly against
@@ -68,6 +68,7 @@ import {
68
68
  SOUND_CHANGE_STEP_KEYS,
69
69
  TONGUE_KEYS,
70
70
  } from "./language-terms.mjs";
71
+ import { nameFormsOf } from "./name-forms.mjs";
71
72
 
72
73
  /** The columns every list relation opens with. */
73
74
  const IDENTITY = Object.freeze(["language", "position"]);
@@ -354,7 +355,7 @@ const soundsOf = (data) => (isMap(data.sounds) ? data.sounds : {});
354
355
 
355
356
  /**
356
357
  * Every name a note of the package carries, with the Addresses carrying it:
357
- * each string of a note's `name`, its aliases, an affiliation's rank and
358
+ * each string of a note's `name`, its listed forms, an affiliation's rank and
358
359
  * office titles, and the name of each anchor, cited with the anchor.
359
360
  *
360
361
  * @param {Iterable<Record<string, any>>} records - Index records.
@@ -371,12 +372,10 @@ function carriedNames(records) {
371
372
  for (const record of records) {
372
373
  const address = noteAddressOf(record);
373
374
  if (!address || record.documents || record.type === "folder") continue;
374
- if (isMap(record.name))
375
- for (const [key, value] of Object.entries(record.name)) {
376
- if (key === "aliases" && Array.isArray(value))
377
- for (const alias of value) carry(alias, address);
378
- else carry(value, address);
379
- }
375
+ if (isMap(record.name)) {
376
+ for (const value of Object.values(record.name)) carry(value, address);
377
+ for (const form of nameFormsOf(record.name)) carry(form, address);
378
+ }
380
379
  const governance = isMap(record.data?.governance) ? record.data.governance : {};
381
380
  for (const rank of Array.isArray(governance.ranks) ? governance.ranks : [])
382
381
  if (isMap(rank)) carry(rank.title, address);
@@ -26,6 +26,7 @@ import { scanBlocks } from "./content-blocks.mjs";
26
26
  import { renderDictionary, renderGlossary } from "./dictionary-layout.mjs";
27
27
  import { DICTIONARY_COLUMNS, TERM_COLUMNS, dictionaryAndTermRows } from "./dictionary-rows.mjs";
28
28
  import { glossaryLanguage } from "./glossary-language.mjs";
29
+ import { languageNotesByCode } from "./note-language.mjs";
29
30
  import { scanHeadingAttributes } from "./heading-attributes.mjs";
30
31
  import { isNoteRecord } from "./index-records.mjs";
31
32
 
@@ -117,6 +118,7 @@ export function lexicalSectionsFor(records, all = records) {
117
118
  const visible = encodeAddresses(records).filter(isNoteRecord);
118
119
  const complete = encodeAddresses(all).filter(isNoteRecord);
119
120
  const byKey = new Map(visible.map((record) => [keyOf(record), record]));
121
+ const languageNotes = languageNotesByCode(complete, keyOf);
120
122
  const addressContext = {
121
123
  types: new Set(complete.map((record) => record.type)),
122
124
  packages: new Set(complete.map((record) => record.package)),
@@ -136,7 +138,7 @@ export function lexicalSectionsFor(records, all = records) {
136
138
  const pageLanguage = glossaryLanguage(undefined, record, {
137
139
  self: key,
138
140
  keyOf: (value) => (typeof value === "string" ? value : undefined),
139
- lookup: (address) => byKey.get(address),
141
+ noteOf: (code) => languageNotes.get(code),
140
142
  });
141
143
  sections.set(key, [
142
144
  {
@@ -17,10 +17,8 @@
17
17
  * `data.culture`, is a field every lore note but a culture note may state,
18
18
  * declared in `culture-choice.mjs`.
19
19
  *
20
- * `data.language` is shared beyond literature: a work names the tongue it is
21
- * composed in, a culture the tongue its people speak, whose name lists
22
- * `package-build npc new` draws from, and an affiliation the trade, temple or
23
- * order tongue it keeps.
20
+ * The tongue a work is composed in is its `data.lang`, which every note may
21
+ * state.
24
22
  *
25
23
  * `DataFieldSpec` declares the keys a type accepts, not the subType that may
26
24
  * write them, so {@link checkLiteratureNote} scopes these keys to the subTypes
@@ -29,74 +27,13 @@
29
27
  * @module
30
28
  */
31
29
 
32
- import { isAddressTuple, parseAddress, renderAddress } from "./address.mjs";
33
30
  import { positionOfFrontmatterPath } from "./diagnostics.mjs";
34
31
 
35
32
  /** The `lore` subType whose notes are works of literature. */
36
33
  export const LITERATURE_SUBTYPE = "literature";
37
34
 
38
- /** The `lore` subTypes that may name a language. */
39
- export const LANGUAGE_SUBTYPES = Object.freeze([LITERATURE_SUBTYPE, "culture"]);
40
-
41
- /**
42
- * Require `data.language` to address a `skill` note whose subType is
43
- * `language`.
44
- *
45
- * An Address that does not resolve is the reference check's to report, so
46
- * this speaks only about a target that exists and is the wrong kind.
47
- *
48
- * @param {object} note
49
- * @param {{index?: object}} [options]
50
- * @returns {object[]} The findings.
51
- */
52
- export function checkLanguageChoice(note, { index } = {}) {
53
- const value = note.fm?.data?.language;
54
- if (value === undefined || value === null || !index?.addressHit) return [];
55
- const tuple = parseAddress(value, {
56
- package: index.contentPackage,
57
- system: "note",
58
- type: "skill",
59
- types: index.types,
60
- packages: index.packages,
61
- });
62
- if (tuple.reason) return [];
63
- const target = index.addressHit(renderAddress(tuple));
64
- if (!target) return [];
65
- const fm = target.fm ?? target;
66
- if (tuple.type === "skill" && fm.subType === "language") return [];
67
- return [
68
- {
69
- file: note.file,
70
- ...positionOfFrontmatterPath(note.raw ?? "", ["data", "language"]),
71
- severity: "error",
72
- message: `data.language ${isAddressTuple(value) ? renderAddress(value) : value} names a note that is not a skill whose subType is language`,
73
- },
74
- ];
75
- }
76
-
77
- /**
78
- * `data.language`: the tongue a work is composed in, the tongue a culture's
79
- * people speak, or the tongue an affiliation keeps. Optional on all three.
80
- *
81
- * @type {object}
82
- */
83
- export const LANGUAGE_FIELD = Object.freeze({
84
- name: "language",
85
- shape: "an Address",
86
- kind: "address",
87
- ref: "skill",
88
- accepts: Object.freeze(["skill"]),
89
- check: checkLanguageChoice,
90
- describe:
91
- "The tongue the note is in, as a language skill note: the tongue a work is composed " +
92
- "in, a culture's people speak, or an affiliation keeps as its trade, temple or order " +
93
- "tongue. `package-build npc new` draws a culture's names from its language's " +
94
- "`data.names`. A `literature` or `culture` lore note, or an affiliation, only.",
95
- });
96
-
97
35
  /**
98
- * The fields only a `literature` note may state, beside the shared
99
- * {@link LANGUAGE_FIELD}. Every one is optional.
36
+ * The fields only a `literature` note may state. Every one is optional.
100
37
  *
101
38
  * @type {ReadonlyArray<object>}
102
39
  */
@@ -124,8 +61,7 @@ export const LITERATURE_FIELDS = Object.freeze([
124
61
  const LITERATURE_KEYS = Object.freeze(LITERATURE_FIELDS.map((field) => field.name));
125
62
 
126
63
  /**
127
- * Refuse a literature field on a lore note of any other subType, and
128
- * `data.language` on a lore note that is neither a work nor a culture.
64
+ * Refuse a literature field on a lore note of any other subType.
129
65
  *
130
66
  * @param {object} note
131
67
  * @returns {object[]} One error per key written off the subTypes that take it.
@@ -153,13 +89,5 @@ export function checkLiteratureNote(note) {
153
89
  `\`subType\` is not \`literature\``,
154
90
  ),
155
91
  );
156
- if (!LANGUAGE_SUBTYPES.includes(subType) && stated(LANGUAGE_FIELD.name))
157
- findings.push(
158
- finding(
159
- LANGUAGE_FIELD.name,
160
- "`data.language` names the tongue of a work of literature or of a culture, " +
161
- "and this note's `subType` is neither `literature` nor `culture`",
162
- ),
163
- );
164
92
  return findings;
165
93
  }
@@ -62,6 +62,7 @@ import {
62
62
  } from "./content-address.mjs";
63
63
  import { HOMEPAGE_TYPE } from "./homepage.mjs";
64
64
  import { isGmNote } from "./note-vocabulary.mjs";
65
+ import { nameFormLists } from "./name-forms.mjs";
65
66
 
66
67
  /**
67
68
  * Written once a fetch completes, so a half-finished cache is never used.
@@ -448,7 +449,13 @@ export function loadForeignIndexes(config, localPackages, bases = PACKAGE_BASE)
448
449
  ...(typeof record.name?.sort === "string" && record.name.sort ?
449
450
  { nameSort: record.name.sort }
450
451
  : {}),
451
- aliases: record.name?.aliases,
452
+ // Every form the note's name lists, by language, and the
453
+ // language the publishing package derived for the note.
454
+ forms:
455
+ nameFormLists(record.name).length ?
456
+ Object.fromEntries(nameFormLists(record.name))
457
+ : undefined,
458
+ lang: typeof record.lang === "string" ? record.lang : undefined,
452
459
  // Absent where the package publishes no page for it. A consumer
453
460
  // must tolerate that rather than invent an href, exactly as it
454
461
  // already tolerates an entry with no `uuid`.
@@ -489,15 +496,15 @@ export function loadForeignIndexes(config, localPackages, bases = PACKAGE_BASE)
489
496
  // walking that parent's own chain.
490
497
  culture: record.data?.culture ?? undefined,
491
498
  ...(parts.type === "lore" && record.subType === "culture" ?
492
- {
493
- data: {
494
- language: record.data?.language,
495
- appearanceProfile: record.data?.appearanceProfile,
496
- },
497
- }
499
+ { data: { appearanceProfile: record.data?.appearanceProfile } }
498
500
  : {}),
499
501
  ...(parts.type === "skill" && record.subType === "language" ?
500
- { data: { names: record.data?.names } }
502
+ {
503
+ data: { names: record.data?.names },
504
+ ...(typeof record.languageCode === "string" ?
505
+ { languageCode: record.languageCode }
506
+ : {}),
507
+ }
501
508
  : {}),
502
509
  population: record.data?.population ?? undefined,
503
510
  ...(Object.hasOwn(record.data ?? {}, "government") ?
@@ -0,0 +1,99 @@
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
+ * A note's names, by language.
16
+ *
17
+ * `name` is `{full, <code>: [forms]}`. `full` is the English name a page,
18
+ * a compendium entry and an index print. Every other name the subject goes by
19
+ * is listed under the code of its language: a code `languages:` declares, or
20
+ * `en`, which needs no declaration. The first form of a list is that
21
+ * language's standard form, and `full` counts as an English form.
22
+ *
23
+ * `sort`, and a character's `given` and `clan`, are single strings and no
24
+ * language's forms, so a configured language may not take one of those names.
25
+ *
26
+ * A leaf module, importing nothing.
27
+ *
28
+ * @module
29
+ */
30
+
31
+ /**
32
+ * A whole visible label as names are matched: NFC, straight apostrophes,
33
+ * single spaces, trimmed, and case folded. Matching ignores case; a name is
34
+ * displayed as it was written, never in this form.
35
+ *
36
+ * @param {string} text - A visible name, `full` or a form.
37
+ * @returns {string} Its matching key.
38
+ */
39
+ export function normalizeName(text) {
40
+ return displayName(text).toLowerCase();
41
+ }
42
+
43
+ /**
44
+ * A whole visible label as it is displayed: NFC, straight apostrophes, single
45
+ * spaces and trimmed, its case as written.
46
+ *
47
+ * @param {string} text - A visible name, `full` or a form.
48
+ * @returns {string} The label for display.
49
+ */
50
+ export function displayName(text) {
51
+ return text.normalize("NFC").replaceAll("’", "'").replace(/\s+/g, " ").trim();
52
+ }
53
+
54
+ /** The language every note falls back to, and a code no configuration need declare. */
55
+ export const FALLBACK_LANGUAGE = "en";
56
+
57
+ /** The single-string name keys every note accepts, in display order. */
58
+ export const NOTE_NAME_KEYS = Object.freeze(["full", "sort"]);
59
+
60
+ /** Additional name components accepted by an individual character. */
61
+ export const CHARACTER_NAME_KEYS = Object.freeze(["given", "clan"]);
62
+
63
+ /** Every `name` key that is not a language's list of forms. */
64
+ export const NAME_STRING_KEYS = Object.freeze([...NOTE_NAME_KEYS, ...CHARACTER_NAME_KEYS]);
65
+
66
+ const STRING_KEYS = new Set(NAME_STRING_KEYS);
67
+
68
+ /**
69
+ * A note's form lists, in authored order: each key of `name` that is not one
70
+ * of {@link NAME_STRING_KEYS} and holds a list, with its string forms.
71
+ *
72
+ * Read without the configuration, so a reader of an index gets every list a
73
+ * note states; which codes a note may state is the frontmatter lint's
74
+ * question, not a reader's.
75
+ *
76
+ * @param {unknown} name - A note's `name`.
77
+ * @returns {Array<[string, string[]]>} `[code, forms]` pairs; a form that is
78
+ * not a nonempty string is left out, and so is a list left empty by that.
79
+ */
80
+ export function nameFormLists(name) {
81
+ if (!name || typeof name !== "object" || Array.isArray(name)) return [];
82
+ const out = [];
83
+ for (const [code, value] of Object.entries(name)) {
84
+ if (STRING_KEYS.has(code) || !Array.isArray(value)) continue;
85
+ const forms = value.filter((form) => typeof form === "string" && form.trim() !== "");
86
+ if (forms.length) out.push([code, forms]);
87
+ }
88
+ return out;
89
+ }
90
+
91
+ /**
92
+ * Every form a note's name lists, in authored order, without `full`.
93
+ *
94
+ * @param {unknown} name - A note's `name`.
95
+ * @returns {string[]} The forms; empty where it lists none.
96
+ */
97
+ export function nameFormsOf(name) {
98
+ return nameFormLists(name).flatMap(([, forms]) => forms);
99
+ }