@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
@@ -0,0 +1,182 @@
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 culture's or an affiliation's own glossary terms: `data.glossary` on a
16
+ * `lore` note of `subType: culture` or on an `affiliation`, a list of
17
+ * `{ name, name_sort?, common?, definition?, pronunciation?, language? }`.
18
+ *
19
+ * It holds the terms that belong to the people or body using them rather than
20
+ * to a language's grammar: euphemisms, idioms, terms of art, and titles that
21
+ * are not ranks. An entry with a `definition` creates a term or replaces every
22
+ * sense the term's other sources give it. An entry without one changes no
23
+ * sense, and only marks a term the note already has — a word of a language
24
+ * that reaches it, or a rank or office — as common, sorted or pronounced.
25
+ *
26
+ * The shape is the inner-key check's; this check owns what an entry means:
27
+ *
28
+ * - an error on `data.glossary` written on a `lore` note that is not a
29
+ * culture, at the key;
30
+ * - an error on two entries of one name, compared after NFC normalisation and
31
+ * case kept, at the second;
32
+ * - an error on an entry's `language` that names a note other than a language
33
+ * skill, or nothing, at the value;
34
+ * - an error on an entry that resolves to no language
35
+ * ({@link module:engine/glossary-language}), at the entry;
36
+ * - an error on an entry with no definition that names no term of the note, at
37
+ * its name;
38
+ * - an error on a pronunciation that is not a respelling, at the value;
39
+ * - a warning on a definition that only repeats the one sense it replaces, at
40
+ * the definition.
41
+ *
42
+ * @module
43
+ */
44
+
45
+ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
46
+ import {
47
+ addressKeyOf,
48
+ entryLanguage,
49
+ glossaryName,
50
+ isCultureNote,
51
+ noteTerms,
52
+ } from "./culture-reach.mjs";
53
+ import { pronunciationProblem } from "./word-terms.mjs";
54
+
55
+ /** Whether a value is a plain map. */
56
+ const isMap = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
57
+
58
+ /**
59
+ * Check a culture's or an affiliation's `data.glossary`.
60
+ *
61
+ * @param {object} note - A `lore` or `affiliation` note.
62
+ * @param {{index?: object}} [context] - The index, for what the note's terms
63
+ * and languages are. Without one, only what the note says of itself is
64
+ * checked.
65
+ * @returns {object[]} The findings.
66
+ */
67
+ export function checkGlossary(note, { index } = {}) {
68
+ const data = note?.fm?.data;
69
+ if (!isMap(data) || data.glossary === undefined || data.glossary === null) return [];
70
+ const raw = note.raw ?? "";
71
+ const at = (path, severity, message, key = false) => ({
72
+ file: note.file,
73
+ ...positionOfFrontmatterPath(raw, ["data", "glossary", ...path], { key }),
74
+ severity,
75
+ message,
76
+ });
77
+ const type = String(note?.type ?? note?.fm?.type ?? "").toLowerCase();
78
+ if (type !== "affiliation" && !isCultureNote(note))
79
+ return [
80
+ at(
81
+ [],
82
+ "error",
83
+ "`data.glossary` states a culture's own glossary terms, and this note's " +
84
+ "`subType` is not `culture`",
85
+ true,
86
+ ),
87
+ ];
88
+ if (!Array.isArray(data.glossary)) return [];
89
+
90
+ const findings = [];
91
+ const terms = index?.notes ? noteTerms(index, note) : undefined;
92
+ const seen = new Set();
93
+ data.glossary.forEach((entry, i) => {
94
+ if (!isMap(entry) || typeof entry.name !== "string" || !entry.name.trim()) return;
95
+ const name = glossaryName(entry.name);
96
+ if (seen.has(name)) {
97
+ findings.push(
98
+ at(
99
+ [i, "name"],
100
+ "error",
101
+ `\`${entry.name}\` stands twice in this glossary; one entry says ` +
102
+ "everything the note says of a term",
103
+ ),
104
+ );
105
+ return;
106
+ }
107
+ seen.add(name);
108
+ if (typeof entry.pronunciation === "string") {
109
+ const problem = pronunciationProblem(entry.pronunciation);
110
+ if (problem)
111
+ findings.push(
112
+ at(
113
+ [i, "pronunciation"],
114
+ "error",
115
+ `\`data.glossary[${i}].pronunciation\` is not a respelling: ${problem}`,
116
+ ),
117
+ );
118
+ }
119
+ if (!terms) return;
120
+ const stated = entry.language !== undefined && entry.language !== null;
121
+ const named = stated ? languageProblem(index, entry.language) : undefined;
122
+ if (named) findings.push(at([i, "language"], "error", named));
123
+ else if (!entryLanguage(index, note, entry))
124
+ findings.push(
125
+ at(
126
+ [i],
127
+ "error",
128
+ `\`${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",
134
+ ),
135
+ );
136
+ const senses = terms.get(name);
137
+ const defined = typeof entry.definition === "string" && entry.definition.trim() !== "";
138
+ if (!defined && !senses)
139
+ findings.push(
140
+ at(
141
+ [i, "name"],
142
+ "error",
143
+ `\`${entry.name}\` names nothing: it is no word of a language that reaches ` +
144
+ "this note and no rank or office it holds, and the entry gives no " +
145
+ "`definition` to make it a term",
146
+ ),
147
+ );
148
+ else if (defined && senses?.length === 1 && senses[0].trim() === entry.definition.trim())
149
+ findings.push(
150
+ at(
151
+ [i, "definition"],
152
+ "warning",
153
+ `the definition of \`${entry.name}\` repeats the one sense it replaces; ` +
154
+ "leave `definition` out",
155
+ ),
156
+ );
157
+ });
158
+ return findings;
159
+ }
160
+
161
+ /**
162
+ * Why an entry's `language` names no language skill, or `undefined` when it
163
+ * names one.
164
+ *
165
+ * @param {object} index - The link index.
166
+ * @param {unknown} value - The entry's `language`.
167
+ * @returns {string|undefined}
168
+ */
169
+ function languageProblem(index, value) {
170
+ const key = addressKeyOf(value, index, "skill");
171
+ if (!key) return `\`language\` is not an Address: ${JSON.stringify(value)}`;
172
+ const target = index.addressHit?.(key);
173
+ if (!target)
174
+ return (
175
+ `\`language\` names ${key}, which does not resolve in this package or its declared ` +
176
+ "dependencies"
177
+ );
178
+ const fm = target.fm ?? target;
179
+ if (String(fm.type ?? "").toLowerCase() === "skill" && fm.subType === "language")
180
+ return undefined;
181
+ return `\`language\` names ${key}, which is not a skill whose subType is language`;
182
+ }
@@ -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
+ * The language a glossary term is in. The first match wins:
16
+ *
17
+ * 1. the entry's own `language`;
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`.
23
+ *
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.
29
+ *
30
+ * A leaf module, importing nothing.
31
+ *
32
+ * @module
33
+ */
34
+
35
+ /** A note's parsed frontmatter, whichever shape the caller hands over. */
36
+ const payload = (note) => note?.fm ?? note ?? {};
37
+
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
+ /** A note's type or subType, lowercased. */
45
+ const lower = (value) => String(value ?? "").toLowerCase();
46
+
47
+ /**
48
+ * How a caller reads Addresses and finds notes.
49
+ *
50
+ * @typedef {object} GlossaryLanguageReader
51
+ * @property {(value: unknown, type: string) => string|undefined} keyOf - A
52
+ * value's canonical Address in the `note` system, read with `type` as the
53
+ * 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.
56
+ * @property {string} [self] - The owning note's own Address, for a language.
57
+ */
58
+
59
+ /**
60
+ * The language a glossary entry resolves to.
61
+ *
62
+ * @param {object|undefined} entry - The entry, or nothing for the note's own
63
+ * language.
64
+ * @param {object} owner - The note the entry is written on.
65
+ * @param {GlossaryLanguageReader} reader - How to read Addresses and notes.
66
+ * @returns {string|undefined} The language's Address, or `undefined` where the
67
+ * chain finds none.
68
+ */
69
+ export function glossaryLanguage(entry, owner, { keyOf, lookup, self }) {
70
+ const own = keyOf(entry?.language, "skill");
71
+ if (own) return own;
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;
99
+ }
package/engine/index.mjs CHANGED
@@ -216,6 +216,7 @@ export * as contentLint from "./content-lint.mjs";
216
216
 
217
217
  /** The charset authored content is held to, so a book can choose its face. */
218
218
  export * as contentCharset from "./content-charset.mjs";
219
+ export * as pathCharset from "./path-charset.mjs";
219
220
 
220
221
  /** Naming an interface icon in a note, without drawing one there. */
221
222
  export * as contentIcons from "./content-icons.mjs";
@@ -317,6 +317,14 @@ export const NOTE_FIELD_PRESENTATION = Object.freeze({
317
317
  "map.scene": Object.freeze({ withheld: "canvas machinery, not a summary row" }),
318
318
  "map.fixup": Object.freeze({ withheld: "canvas machinery, not a summary row" }),
319
319
  "map.thumbnail": Object.freeze({ withheld: "an image, which the box never carries" }),
320
+ // A language's words and a glossary's terms are what dictionary sections
321
+ // are drawn from, not facts a summary row can carry.
322
+ "skill.classModifiers": Object.freeze({ withheld: "word machinery, not a summary row" }),
323
+ "skill.fields": Object.freeze({ withheld: "word machinery, not a summary row" }),
324
+ "skill.words": Object.freeze({ withheld: "word machinery, not a summary row" }),
325
+ "skill.affixes": Object.freeze({ withheld: "word machinery, not a summary row" }),
326
+ "lore.glossary": Object.freeze({ withheld: "glossary machinery, not a summary row" }),
327
+ "affiliation.glossary": Object.freeze({ withheld: "glossary machinery, not a summary row" }),
320
328
 
321
329
  assocSkill: Object.freeze({ label: "Skill" }),
322
330
  assocAffiliation: Object.freeze({ label: "Affiliation" }),
@@ -0,0 +1,360 @@
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 language's words: `data.words` on a `skill` note of `subType: language`,
16
+ * with the `data.classModifiers`, `data.fields` and `data.affixes` they are
17
+ * read against. The language of a word is the note it sits on.
18
+ *
19
+ * The inner-key check holds every entry to its declared keys and kinds. This
20
+ * check owns what the values mean, each finding located at the key at fault:
21
+ *
22
+ * - no skill of another subType states these keys;
23
+ * - a word's `class` is a master-set base followed by master-set modifiers,
24
+ * each declared in `data.classModifiers`, and a declared modifier no word
25
+ * uses is a warning;
26
+ * - a word's `field` is one of `data.fields`;
27
+ * - a `borrowed` source names its `language`;
28
+ * - no form stands twice in one language;
29
+ * - every `attested` Address and source language resolves, and a source
30
+ * language names a language skill;
31
+ * - a pronunciation is a respelling;
32
+ * - a `built` piece no word or affix of the language glosses is a warning.
33
+ *
34
+ * {@link checkLanguageFamily} owns `data.family`, the parent language a
35
+ * daughter reads words from: it is stated only on a language, names a
36
+ * language, and its chain never returns to the note it starts from.
37
+ * {@link checkLanguageAbbreviation} owns `data.abbreviation`, the tag a
38
+ * dictionary prints for the language: it is stated only on a language, and is
39
+ * not blank.
40
+ *
41
+ * @module
42
+ */
43
+
44
+ import { pathLabel as label } from "./data-keys.mjs";
45
+ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
46
+ import { readCanonicalKey } from "./address.mjs";
47
+ import { addressKeyOf, ownKey } from "./culture-reach.mjs";
48
+ import {
49
+ BORROWED_SOURCE_KIND,
50
+ LANGUAGE_SUBTYPE,
51
+ WORD_DATA_KEYS,
52
+ pronunciationProblem,
53
+ } from "./word-terms.mjs";
54
+ import { WORD_CLASS_MODIFIERS, parseWordClass } from "./word-classes.mjs";
55
+ import { pieceGlosses } from "./word-rows.mjs";
56
+
57
+ const MODIFIER_TOKENS = new Set(WORD_CLASS_MODIFIERS.map((item) => item.token));
58
+
59
+ /** Whether a value is a plain map. */
60
+ const isMap = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
61
+
62
+ /** A list value, or an empty one. */
63
+ const listOf = (value) => (Array.isArray(value) ? value : []);
64
+
65
+ /**
66
+ * Check a `skill` note's words against what they mean.
67
+ *
68
+ * @param {object} note - A `skill` note.
69
+ * @param {{index?: object}} [context] - The index, for whether an Address
70
+ * resolves. Without one, only what the note says of itself is checked.
71
+ * @returns {object[]} The findings.
72
+ */
73
+ export function checkLanguageWords(note, { index } = {}) {
74
+ const fm = note?.fm ?? {};
75
+ const data = isMap(fm.data) ? fm.data : {};
76
+ const raw = note.raw ?? "";
77
+ const finding = (path, severity, message, key = false) => ({
78
+ file: note.file,
79
+ ...positionOfFrontmatterPath(raw, path, { key }),
80
+ severity,
81
+ message,
82
+ });
83
+ const stated = (key) => data[key] !== undefined && data[key] !== null;
84
+
85
+ if (String(fm.subType ?? "").toLowerCase() !== LANGUAGE_SUBTYPE)
86
+ return WORD_DATA_KEYS.filter(stated).map((key) =>
87
+ finding(
88
+ ["data", key],
89
+ "error",
90
+ `\`data.${key}\` holds a language's words, and this note's \`subType\` is not ` +
91
+ `\`${LANGUAGE_SUBTYPE}\``,
92
+ true,
93
+ ),
94
+ );
95
+
96
+ const findings = [];
97
+
98
+ const declared = new Map();
99
+ listOf(data.classModifiers).forEach((token, i) => {
100
+ if (typeof token === "string" && MODIFIER_TOKENS.has(token) && !declared.has(token))
101
+ declared.set(token, i);
102
+ });
103
+ const fieldNames = new Set(
104
+ listOf(data.fields)
105
+ .filter((field) => isMap(field) && typeof field.name === "string")
106
+ .map((field) => field.name),
107
+ );
108
+ /**
109
+ * Whether an Address resolves, and, for a language, names a language skill.
110
+ *
111
+ * @param {unknown} value - The Address as the note holds it.
112
+ * @param {Array<string|number>} path - Where it sits.
113
+ * @param {string} type - The type a bare shortcode defaults to.
114
+ * @returns {object[]} The findings.
115
+ */
116
+ function addressFindings(value, path, type) {
117
+ if (!index?.addressHit || value === undefined || value === null) return [];
118
+ const key = addressKeyOf(value, index, type);
119
+ if (!key)
120
+ return [
121
+ finding(
122
+ path,
123
+ "error",
124
+ `\`${label(path)}\` is not an Address: ${JSON.stringify(value)}`,
125
+ ),
126
+ ];
127
+ const target = index.addressHit(key);
128
+ if (!target)
129
+ return [
130
+ finding(
131
+ path,
132
+ "error",
133
+ `\`${label(path)}\` names ${key}, which does not resolve in this package or ` +
134
+ "its declared dependencies",
135
+ ),
136
+ ];
137
+ return [{ target, key }];
138
+ }
139
+
140
+ /** As {@link addressFindings}, and the target must be a language skill. */
141
+ function languageFindings(value, path) {
142
+ const out = [];
143
+ for (const result of addressFindings(value, path, "skill")) {
144
+ if (!result.target) {
145
+ out.push(result);
146
+ continue;
147
+ }
148
+ const target = result.target.fm ?? result.target;
149
+ if (target.type === "skill" && target.subType === "language") continue;
150
+ out.push(
151
+ finding(
152
+ path,
153
+ "error",
154
+ `\`${label(path)}\` names ${result.key}, which is not a skill whose subType ` +
155
+ "is language",
156
+ ),
157
+ );
158
+ }
159
+ return out;
160
+ }
161
+
162
+ const glossOf = pieceGlosses(data);
163
+ const used = new Set();
164
+ const forms = new Map();
165
+ listOf(data.words).forEach((word, i) => {
166
+ if (!isMap(word)) return;
167
+ const at = (key) => ["data", "words", i, key];
168
+
169
+ if (typeof word.class === "string") {
170
+ const parsed = parseWordClass(word.class);
171
+ if (parsed.reason)
172
+ findings.push(
173
+ finding(at("class"), "error", `the class \`${word.class}\`: ${parsed.reason}`),
174
+ );
175
+ else
176
+ for (const token of parsed.modifiers) {
177
+ used.add(token);
178
+ if (!declared.has(token))
179
+ findings.push(
180
+ finding(
181
+ at("class"),
182
+ "error",
183
+ `the modifier \`${token}\` in \`${word.class}\` is not declared in ` +
184
+ "`data.classModifiers`",
185
+ ),
186
+ );
187
+ }
188
+ }
189
+
190
+ if (typeof word.field === "string" && !fieldNames.has(word.field))
191
+ findings.push(
192
+ finding(
193
+ at("field"),
194
+ "error",
195
+ `the field \`${word.field}\` is not one of this language's \`data.fields\``,
196
+ ),
197
+ );
198
+
199
+ if (typeof word.form === "string") {
200
+ const key = word.form.normalize("NFC");
201
+ if (forms.has(key))
202
+ findings.push(
203
+ finding(
204
+ at("form"),
205
+ "error",
206
+ `\`${word.form}\` stands twice in this language, first at ` +
207
+ `\`data.words[${forms.get(key)}]\``,
208
+ ),
209
+ );
210
+ else forms.set(key, i);
211
+ }
212
+
213
+ if (isMap(word.source)) {
214
+ if (word.source.kind === BORROWED_SOURCE_KIND && (word.source.language ?? "") === "")
215
+ findings.push(
216
+ finding(
217
+ at("source"),
218
+ "error",
219
+ "a `borrowed` source names the tongue the word was borrowed from in its `language`",
220
+ ),
221
+ );
222
+ findings.push(...languageFindings(word.source.language, [...at("source"), "language"]));
223
+ }
224
+
225
+ listOf(word.attested).forEach((value, j) => {
226
+ for (const result of addressFindings(value, [...at("attested"), j], undefined))
227
+ if (!result.target) findings.push(result);
228
+ });
229
+
230
+ if (typeof word.pronunciation === "string") {
231
+ const problem = pronunciationProblem(word.pronunciation);
232
+ if (problem)
233
+ findings.push(
234
+ finding(
235
+ at("pronunciation"),
236
+ "error",
237
+ `\`data.words[${i}].pronunciation\` is not a respelling: ${problem}`,
238
+ ),
239
+ );
240
+ }
241
+
242
+ listOf(word.built).forEach((piece, j) => {
243
+ if (typeof piece !== "string" || glossOf(piece) !== null) return;
244
+ findings.push(
245
+ finding(
246
+ [...at("built"), j],
247
+ "warning",
248
+ `the piece \`${piece}\` of \`${word.form}\` matches no word or affix of this ` +
249
+ "language, so it prints with no gloss",
250
+ ),
251
+ );
252
+ });
253
+ });
254
+
255
+ for (const [token, i] of declared)
256
+ if (!used.has(token))
257
+ findings.push(
258
+ finding(
259
+ ["data", "classModifiers", i],
260
+ "warning",
261
+ `the modifier \`${token}\` is declared, and no word uses it`,
262
+ ),
263
+ );
264
+
265
+ return findings;
266
+ }
267
+
268
+ /**
269
+ * Check a `skill` note's `data.abbreviation`: an error on a skill of another
270
+ * subType, at the key, and on a blank value, at the value.
271
+ *
272
+ * @param {object} note - A `skill` note.
273
+ * @returns {object[]} The findings.
274
+ */
275
+ export function checkLanguageAbbreviation(note) {
276
+ const fm = note?.fm ?? {};
277
+ const value = isMap(fm.data) ? fm.data.abbreviation : undefined;
278
+ if (value === undefined || value === null) return [];
279
+ const finding = (message, key = false) => ({
280
+ file: note.file,
281
+ ...positionOfFrontmatterPath(note.raw ?? "", ["data", "abbreviation"], { key }),
282
+ severity: "error",
283
+ message,
284
+ });
285
+ if (String(fm.subType ?? "").toLowerCase() !== LANGUAGE_SUBTYPE)
286
+ return [
287
+ finding(
288
+ "`data.abbreviation` is the tag a dictionary prints for a language, and this " +
289
+ `note's \`subType\` is not \`${LANGUAGE_SUBTYPE}\``,
290
+ true,
291
+ ),
292
+ ];
293
+ if (typeof value === "string" && !value.trim())
294
+ return [
295
+ finding("`data.abbreviation` is blank; state the tag, as `Prov.`, or leave it out"),
296
+ ];
297
+ return [];
298
+ }
299
+
300
+ /**
301
+ * Check a `skill` note's `data.family`.
302
+ *
303
+ * An Address that does not resolve is the reference check's to report, so this
304
+ * speaks about a key on the wrong subType, a target that is not a language, and
305
+ * a chain of parents that returns to this note.
306
+ *
307
+ * @param {object} note - A `skill` note.
308
+ * @param {{index?: object}} [context] - The index, for what the chain reaches.
309
+ * @returns {object[]} The findings.
310
+ */
311
+ export function checkLanguageFamily(note, { index } = {}) {
312
+ const fm = note?.fm ?? {};
313
+ const value = isMap(fm.data) ? fm.data.family : undefined;
314
+ if (value === undefined || value === null) return [];
315
+ const raw = note.raw ?? "";
316
+ const finding = (message, key = false) => ({
317
+ file: note.file,
318
+ ...positionOfFrontmatterPath(raw, ["data", "family"], { key }),
319
+ severity: "error",
320
+ message,
321
+ });
322
+ if (String(fm.subType ?? "").toLowerCase() !== LANGUAGE_SUBTYPE)
323
+ return [
324
+ finding(
325
+ "`data.family` names the parent of a language, and this note's `subType` is " +
326
+ `not \`${LANGUAGE_SUBTYPE}\``,
327
+ true,
328
+ ),
329
+ ];
330
+ if (!index?.addressHit) return [];
331
+ const key = addressKeyOf(value, index, "skill");
332
+ const target = key ? index.addressHit(key) : undefined;
333
+ if (!target) return [];
334
+ // The type is the Address's own; the subType is the target's.
335
+ const isLanguage = (address, hit) =>
336
+ readCanonicalKey(address)?.type === "skill" &&
337
+ (hit?.fm ?? hit)?.subType === LANGUAGE_SUBTYPE;
338
+ if (!isLanguage(key, target))
339
+ return [
340
+ finding(`\`data.family\` names ${key}, which is not a skill whose subType is language`),
341
+ ];
342
+ const own = ownKey(note, index);
343
+ const seen = new Set();
344
+ let current = key;
345
+ while (current && !seen.has(current)) {
346
+ if (current === own)
347
+ return [
348
+ finding(
349
+ `\`data.family\` names ${key}, and the chain of parents returns to this ` +
350
+ "language: a family is a cycle",
351
+ ),
352
+ ];
353
+ seen.add(current);
354
+ const hit = index.addressHit(current);
355
+ if (!isLanguage(current, hit)) break;
356
+ const payload = hit.fm ?? hit;
357
+ current = addressKeyOf(payload.data?.family, index, "skill");
358
+ }
359
+ return [];
360
+ }