@heroiclands/package-build 23.6.1 → 23.7.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.
@@ -0,0 +1,378 @@
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
+ * What a language note's register, tongues, scope, sounds and cognates mean,
16
+ * beyond their shape.
17
+ *
18
+ * The inner-key check holds every entry to its declared keys, kinds and closed
19
+ * lists. This check owns the rest, each finding located at the key at fault:
20
+ *
21
+ * - no skill of another subType states these keys;
22
+ * - a tongue tag is lowercase letters and digits, optionally closing on
23
+ * `:<tongue>`, and stands once; a tongue's `language` names a language note;
24
+ * - every tag a register row or a letter row names is declared in
25
+ * `data.tongues`, `translation` apart: `exonym:vylari` is declared by
26
+ * `exonym:<tongue>`;
27
+ * - an edge stands once in `data.sounds.edges`;
28
+ * - every Address in `data.scope` resolves, and every type it names is a note
29
+ * type;
30
+ * - every language a cognate's `forms` names is a language note;
31
+ * - a register row states a `tongue` or a `kind`;
32
+ * - a sound-change set's name stands once in the note, a step's label once
33
+ * in its set, and its `from` and `to` name language notes;
34
+ * - a frame's classes are bases of the master word classes;
35
+ * - a calling-name closing states a `form` or `doubled: true`, not both.
36
+ *
37
+ * @module
38
+ */
39
+
40
+ import { pathLabel as label } from "./data-keys.mjs";
41
+ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
42
+ import { addressKeyOf } from "./culture-reach.mjs";
43
+ import { AddressEntries } from "./address-values.mjs";
44
+ import { isAddressTuple, renderAddress } from "./address.mjs";
45
+ import {
46
+ LANGUAGE_DATA_KEYS,
47
+ RESERVED_TONGUE,
48
+ SCOPE_KINDS,
49
+ TONGUE_PLACEHOLDER,
50
+ } from "./language-terms.mjs";
51
+ import { LANGUAGE_SUBTYPE } from "./word-terms.mjs";
52
+ import { WORD_CLASS_BASES } from "./word-classes.mjs";
53
+
54
+ /** The bases a frame's `class` names. */
55
+ const CLASS_BASES = new Set(WORD_CLASS_BASES.map((item) => item.token));
56
+
57
+ /** Whether a value is a plain map. */
58
+ const isMap = (value) =>
59
+ Boolean(value) &&
60
+ typeof value === "object" &&
61
+ !Array.isArray(value) &&
62
+ !isAddressTuple(value) &&
63
+ !(value instanceof AddressEntries);
64
+
65
+ /** A list value, or an empty one. */
66
+ const listOf = (value) => (Array.isArray(value) ? value : []);
67
+
68
+ /** The shape of a declared tongue tag. */
69
+ const TAG_PATTERN = new RegExp(`^[a-z][a-z0-9]*(?::${TONGUE_PLACEHOLDER})?$`);
70
+
71
+ /** The shape of a tongue tag a row names: a tag, or a tag and a tongue. */
72
+ const USED_TAG_PATTERN = /^([a-z][a-z0-9]*)(?::([a-z][a-z0-9]*))?$/;
73
+
74
+ /** The type a bare shortcode in each scope list defaults to. */
75
+ const SCOPE_DEFAULT_TYPES = Object.freeze({
76
+ folders: "folder",
77
+ cultures: "lore",
78
+ lore: "lore",
79
+ parents: "place",
80
+ });
81
+
82
+ /**
83
+ * Whether a tag a row names is one `data.tongues` declares.
84
+ *
85
+ * @param {string} tag - The tag as the row writes it.
86
+ * @param {Set<string>} declared - The declared tags.
87
+ * @returns {boolean}
88
+ */
89
+ export function tongueDeclared(tag, declared) {
90
+ if (tag === RESERVED_TONGUE) return true;
91
+ const match = USED_TAG_PATTERN.exec(tag);
92
+ if (!match) return false;
93
+ return match[2] === undefined ?
94
+ declared.has(tag)
95
+ : declared.has(`${match[1]}:${TONGUE_PLACEHOLDER}`);
96
+ }
97
+
98
+ /**
99
+ * The entries of a cognate's `forms`, whatever form it reached the check in:
100
+ * `[written key, value, Address]`.
101
+ *
102
+ * @param {unknown} forms - The `forms` value.
103
+ * @returns {Array<[string, unknown, unknown]>}
104
+ */
105
+ function formEntries(forms) {
106
+ if (forms instanceof AddressEntries)
107
+ return forms.entries.map((entry) => [entry.sourceKey, entry.value, entry.target]);
108
+ if (isMap(forms)) return Object.entries(forms).map(([key, value]) => [key, value, key]);
109
+ return [];
110
+ }
111
+
112
+ /**
113
+ * Check a `skill` note's language data against what it means.
114
+ *
115
+ * @param {object} note - A `skill` note: `file`, `raw` and parsed `fm`.
116
+ * @param {object} [context]
117
+ * @param {object} [context.index] - The index, for whether an Address
118
+ * resolves. Without one, only what the note says of itself is checked.
119
+ * @param {readonly string[]} [context.types] - The note types a scope may name.
120
+ * @returns {object[]} The findings.
121
+ */
122
+ export function checkLanguageData(note, { index, types } = {}) {
123
+ const fm = note?.fm ?? {};
124
+ const data = isMap(fm.data) ? fm.data : {};
125
+ const raw = note.raw ?? "";
126
+ const finding = (path, severity, message, key = false) => ({
127
+ file: note.file,
128
+ ...positionOfFrontmatterPath(raw, path, { key }),
129
+ severity,
130
+ message,
131
+ });
132
+ const stated = (key) => data[key] !== undefined && data[key] !== null;
133
+
134
+ if (String(fm.subType ?? "").toLowerCase() !== LANGUAGE_SUBTYPE)
135
+ return LANGUAGE_DATA_KEYS.filter(stated).map((key) =>
136
+ finding(
137
+ ["data", key],
138
+ "error",
139
+ `\`data.${key}\` describes a language, and this note's \`subType\` is not ` +
140
+ `\`${LANGUAGE_SUBTYPE}\``,
141
+ true,
142
+ ),
143
+ );
144
+
145
+ const findings = [];
146
+
147
+ /**
148
+ * The note an Address names, or a finding where it names none.
149
+ *
150
+ * @param {unknown} value - The Address as the note holds it.
151
+ * @param {Array<string|number>} path - Where it sits.
152
+ * @param {string|undefined} type - The type a bare shortcode defaults to.
153
+ * @param {boolean} [key] - Whether the Address is a map key.
154
+ * @returns {{target?: object, key?: string, finding?: object}}
155
+ */
156
+ function resolve(value, path, type, key = false) {
157
+ if (!index?.addressHit || value === undefined || value === null) return {};
158
+ const canonical = addressKeyOf(value, index, type);
159
+ if (!canonical)
160
+ return {
161
+ finding: finding(
162
+ path,
163
+ "error",
164
+ `\`${label(path)}\` is not an Address: ${JSON.stringify(
165
+ isAddressTuple(value) ? renderAddress(value) : value,
166
+ )}`,
167
+ key,
168
+ ),
169
+ };
170
+ const target = index.addressHit(canonical);
171
+ if (!target)
172
+ return {
173
+ finding: finding(
174
+ path,
175
+ "error",
176
+ `\`${label(path)}\` names ${canonical}, which does not resolve in this ` +
177
+ "package or its declared dependencies",
178
+ key,
179
+ ),
180
+ };
181
+ return { target, key: canonical };
182
+ }
183
+
184
+ /** As {@link resolve}, and the target must be a language note. */
185
+ function language(value, path, key = false) {
186
+ const result = resolve(value, path, "skill", key);
187
+ if (result.finding) return [result.finding];
188
+ if (!result.target) return [];
189
+ const target = result.target.fm ?? result.target;
190
+ if (target.type === "skill" && target.subType === LANGUAGE_SUBTYPE) return [];
191
+ return [
192
+ finding(
193
+ path,
194
+ "error",
195
+ `\`${label(path)}\` names ${result.key}, which is not a skill whose subType is ` +
196
+ "language",
197
+ key,
198
+ ),
199
+ ];
200
+ }
201
+
202
+ // ── Tongues ──────────────────────────────────────────────────────────────
203
+ const declared = new Set();
204
+ const tagAt = new Map();
205
+ listOf(data.tongues).forEach((tongue, i) => {
206
+ if (!isMap(tongue)) return;
207
+ const at = (key) => ["data", "tongues", i, key];
208
+ if (typeof tongue.tag === "string") {
209
+ if (!TAG_PATTERN.test(tongue.tag))
210
+ findings.push(
211
+ finding(
212
+ at("tag"),
213
+ "error",
214
+ `the tag \`${tongue.tag}\` is not lowercase letters and digits, optionally ` +
215
+ `closing on \`:${TONGUE_PLACEHOLDER}\``,
216
+ ),
217
+ );
218
+ else if (tagAt.has(tongue.tag))
219
+ findings.push(
220
+ finding(
221
+ at("tag"),
222
+ "error",
223
+ `the tag \`${tongue.tag}\` stands twice, first at ` +
224
+ `\`data.tongues[${tagAt.get(tongue.tag)}]\``,
225
+ ),
226
+ );
227
+ else {
228
+ tagAt.set(tongue.tag, i);
229
+ declared.add(tongue.tag);
230
+ }
231
+ }
232
+ findings.push(...language(tongue.language, at("language")));
233
+ });
234
+
235
+ /**
236
+ * A finding for each tag a row names that `data.tongues` does not declare.
237
+ *
238
+ * @param {unknown[]} tags - The tags the row names.
239
+ * @param {(j: number) => Array<string|number>} pathOf - Where the `j`th sits.
240
+ */
241
+ const undeclared = (tags, pathOf) => {
242
+ tags.forEach((tag, j) => {
243
+ if (typeof tag !== "string" || tongueDeclared(tag, declared)) return;
244
+ findings.push(
245
+ finding(
246
+ pathOf(j),
247
+ "error",
248
+ `the tongue \`${tag}\` is not declared in \`data.tongues\``,
249
+ ),
250
+ );
251
+ });
252
+ };
253
+ listOf(data.register).forEach((row, i) => {
254
+ if (!isMap(row)) return;
255
+ if (row.tongue == null && row.kind == null)
256
+ findings.push(
257
+ finding(
258
+ ["data", "register", i],
259
+ "error",
260
+ `\`data.register[${i}]\` states neither a \`tongue\` nor a \`kind\``,
261
+ ),
262
+ );
263
+ if (typeof row.tongue !== "string") return;
264
+ undeclared([row.tongue], () => ["data", "register", i, "tongue"]);
265
+ });
266
+
267
+ // ── Sounds ───────────────────────────────────────────────────────────────
268
+ const sounds = isMap(data.sounds) ? data.sounds : {};
269
+ listOf(sounds.letters).forEach((row, i) => {
270
+ if (!isMap(row) || row.tongue === undefined || row.tongue === null) return;
271
+ const path = ["data", "sounds", "letters", i, "tongue"];
272
+ if (Array.isArray(row.tongue)) undeclared(row.tongue, (j) => [...path, j]);
273
+ else undeclared([row.tongue], () => path);
274
+ });
275
+ const edgeAt = new Map();
276
+ listOf(sounds.edges).forEach((row, i) => {
277
+ if (!isMap(row) || typeof row.edge !== "string") return;
278
+ if (edgeAt.has(row.edge))
279
+ findings.push(
280
+ finding(
281
+ ["data", "sounds", "edges", i, "edge"],
282
+ "error",
283
+ `the edge \`${row.edge}\` stands twice, first at ` +
284
+ `\`data.sounds.edges[${edgeAt.get(row.edge)}]\``,
285
+ ),
286
+ );
287
+ else edgeAt.set(row.edge, i);
288
+ });
289
+
290
+ listOf(sounds.frames).forEach((row, i) => {
291
+ if (!isMap(row)) return;
292
+ listOf(row.class).forEach((base, j) => {
293
+ if (typeof base !== "string" || CLASS_BASES.has(base)) return;
294
+ findings.push(
295
+ finding(
296
+ ["data", "sounds", "frames", i, "class", j],
297
+ "error",
298
+ `\`${base}\` is not a base of the master word classes`,
299
+ ),
300
+ );
301
+ });
302
+ });
303
+
304
+ // ── Sound changes ────────────────────────────────────────────────────────
305
+ const setAt = new Map();
306
+ listOf(data.soundChanges).forEach((set, i) => {
307
+ if (!isMap(set)) return;
308
+ const at = (...rest) => ["data", "soundChanges", i, ...rest];
309
+ if (typeof set.name === "string") {
310
+ if (setAt.has(set.name))
311
+ findings.push(
312
+ finding(
313
+ at("name"),
314
+ "error",
315
+ `the set \`${set.name}\` stands twice, first at \`data.soundChanges[${setAt.get(set.name)}]\``,
316
+ ),
317
+ );
318
+ else setAt.set(set.name, i);
319
+ }
320
+ findings.push(...language(set.from, at("from")), ...language(set.to, at("to")));
321
+ const stepAt = new Map();
322
+ listOf(set.steps).forEach((step, j) => {
323
+ if (!isMap(step) || typeof step.step !== "string") return;
324
+ if (stepAt.has(step.step))
325
+ findings.push(
326
+ finding(
327
+ at("steps", j, "step"),
328
+ "error",
329
+ `the step \`${step.step}\` stands twice in its set, first at ` +
330
+ `\`data.soundChanges[${i}].steps[${stepAt.get(step.step)}]\``,
331
+ ),
332
+ );
333
+ else stepAt.set(step.step, j);
334
+ });
335
+ });
336
+
337
+ // ── Calling names ────────────────────────────────────────────────────────
338
+ const calling = isMap(data.callingNames) ? data.callingNames : {};
339
+ listOf(calling.closings).forEach((row, i) => {
340
+ if (!isMap(row)) return;
341
+ const form = row.form != null;
342
+ const doubled = row.doubled === true;
343
+ if (form === doubled)
344
+ findings.push(
345
+ finding(
346
+ ["data", "callingNames", "closings", i],
347
+ "error",
348
+ `\`data.callingNames.closings[${i}]\` states ${form ? "both" : "neither"} a ` +
349
+ "`form` and `doubled: true`; it states one",
350
+ ),
351
+ );
352
+ });
353
+
354
+ // ── Scope ────────────────────────────────────────────────────────────────
355
+ const scope = isMap(data.scope) ? data.scope : {};
356
+ for (const kind of SCOPE_KINDS) {
357
+ listOf(scope[kind]).forEach((value, i) => {
358
+ const path = ["data", "scope", kind, i];
359
+ if (kind === "paths") return;
360
+ if (kind === "types") {
361
+ if (types && typeof value === "string" && !types.includes(value))
362
+ findings.push(finding(path, "error", `\`${value}\` is not a note type`));
363
+ return;
364
+ }
365
+ const result = resolve(value, path, SCOPE_DEFAULT_TYPES[kind]);
366
+ if (result.finding) findings.push(result.finding);
367
+ });
368
+ }
369
+
370
+ // ── Cognates ─────────────────────────────────────────────────────────────
371
+ listOf(data.cognates).forEach((row, i) => {
372
+ if (!isMap(row)) return;
373
+ for (const [written, , target] of formEntries(row.forms))
374
+ findings.push(...language(target, ["data", "cognates", i, "forms", written], true));
375
+ });
376
+
377
+ return findings;
378
+ }