@heroiclands/package-build 23.3.0 → 23.3.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.
@@ -0,0 +1,634 @@
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 name lists: how its speakers' names are built, and the names
16
+ * each part is drawn from.
17
+ *
18
+ * A `skill` note of `subType: language` states them as `data.names`, and
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.
22
+ *
23
+ * - `pattern` — the name's shape: placeholders such as `{given}` and `{clan}`,
24
+ * each naming a list, with literal text around them or directly against
25
+ * them. `"{given} {clan}"`, `"{family} {given}"`, `"{given} yra {clan}"` and
26
+ * `"{given} {given.male}sen"` are all patterns. A tongue with several forms
27
+ * of name states a list of patterns, any one of which a name may take; a
28
+ * tongue whose forms differ by gender states a map of gender words to a
29
+ * pattern or a list of them. A gender with no pattern of its own takes every
30
+ * pattern stated.
31
+ * - `given` — given names, one list per gender word of the closed gender
32
+ * vocabulary. `female` and `male` are required. `{given}` draws from the
33
+ * person's gender's list, or from every given list together where that gender
34
+ * has none; `{given.<gender>}` draws from that gender's list whatever the
35
+ * person's gender, as a patronymic draws a father's name.
36
+ * - every other key — a list of names the pattern draws from by that key:
37
+ * `clan`, `family`, `place`. A list name is letters, digits and underscores,
38
+ * beginning with a letter.
39
+ *
40
+ * Every placeholder must name a list, and every list must be named by some
41
+ * pattern's placeholder. Each list holds at least one name, and each name is
42
+ * non-empty text.
43
+ *
44
+ * This module imports nothing from `note-vocabulary.mjs`, which declares the
45
+ * field and calls {@link checkLanguageNames}; the being-look vocabulary is
46
+ * handed in, as `appearance-profile.mjs` takes it.
47
+ *
48
+ * @module
49
+ */
50
+
51
+ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
52
+ import { isPlainMap } from "./data-keys.mjs";
53
+ import { nearest } from "./near-miss.mjs";
54
+
55
+ /** The `skill` subType whose notes may carry name lists. */
56
+ export const NAMES_SUBTYPE = "language";
57
+
58
+ /** The placeholder that draws from the given-name list for a gender. */
59
+ export const GIVEN_LIST = "given";
60
+
61
+ /** What a list name may be spelled with. */
62
+ const LIST_NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
63
+
64
+ /** A placeholder's text: a list name, and for `given` an optional gender. */
65
+ const PLACEHOLDER = /^([A-Za-z][A-Za-z0-9_]*)(?:\.([A-Za-z][A-Za-z0-9_]*))?$/;
66
+
67
+ /**
68
+ * The genders every `data.names.given` states a list for.
69
+ *
70
+ * A function rather than a `const`: `note-vocabulary.mjs` reads it while the
71
+ * module graph is still evaluating.
72
+ *
73
+ * @returns {readonly string[]} The genders.
74
+ */
75
+ export function requiredGivenGenders() {
76
+ return Object.freeze(["female", "male"]);
77
+ }
78
+
79
+ /**
80
+ * One piece of a parsed pattern: literal text, or a placeholder naming a list
81
+ * and, for `given`, the gender whose list it draws from.
82
+ *
83
+ * @typedef {{literal: string} | {list: string, gender?: string}} PatternPart
84
+ */
85
+
86
+ /** A placeholder as written, without its braces. */
87
+ function placeholderText(part) {
88
+ return part.gender ? `${part.list}.${part.gender}` : part.list;
89
+ }
90
+
91
+ /**
92
+ * A pattern read into literal text and placeholders.
93
+ *
94
+ * @param {string} pattern - The pattern as written.
95
+ * @returns {{parts: PatternPart[], problems: string[]}} The parts, and what is
96
+ * wrong with the pattern's own spelling.
97
+ */
98
+ export function parsePattern(pattern) {
99
+ const parts = [];
100
+ const problems = [];
101
+ let literal = "";
102
+ let i = 0;
103
+ while (i < pattern.length) {
104
+ const ch = pattern[i];
105
+ if (ch === "}") {
106
+ problems.push(`has a \`}\` with no \`{\` before it, at character ${i + 1}`);
107
+ i++;
108
+ continue;
109
+ }
110
+ if (ch !== "{") {
111
+ literal += ch;
112
+ i++;
113
+ continue;
114
+ }
115
+ const close = pattern.indexOf("}", i + 1);
116
+ const open = pattern.indexOf("{", i + 1);
117
+ if (close < 0 || (open >= 0 && open < close)) {
118
+ problems.push(`has a \`{\` with no \`}\` after it, at character ${i + 1}`);
119
+ i++;
120
+ continue;
121
+ }
122
+ const text = pattern.slice(i + 1, close);
123
+ const match = PLACEHOLDER.exec(text);
124
+ if (!match)
125
+ problems.push(
126
+ `has the placeholder \`{${text}}\`; a placeholder names a list in letters, ` +
127
+ "digits and underscores, beginning with a letter",
128
+ );
129
+ else if (match[2] !== undefined && match[1] !== GIVEN_LIST)
130
+ problems.push(
131
+ `has the placeholder \`{${text}}\`; only \`given\` takes a gender after a dot, ` +
132
+ "as `{given.male}` does",
133
+ );
134
+ else {
135
+ if (literal) parts.push({ literal });
136
+ literal = "";
137
+ parts.push(
138
+ match[2] === undefined ? { list: match[1] } : { list: match[1], gender: match[2] },
139
+ );
140
+ }
141
+ i = close + 1;
142
+ }
143
+ if (literal) parts.push({ literal });
144
+ if (!problems.length && !parts.some((part) => "list" in part))
145
+ problems.push("has no placeholder, so it draws on no list");
146
+ return { parts, problems };
147
+ }
148
+
149
+ /**
150
+ * Every pattern string `data.names.pattern` states, each with its path from
151
+ * `data.names`: one string, a list of them, or a map of genders to either.
152
+ *
153
+ * @param {unknown} pattern - `data.names.pattern` as authored.
154
+ * @returns {Array<{path: Array<string|number>, text: unknown, gender?: string}>}
155
+ * The patterns, in the order they are written; `gender` is the map key a
156
+ * pattern sits under.
157
+ */
158
+ function patternEntries(pattern) {
159
+ const out = [];
160
+ const add = (value, path, gender) => {
161
+ if (Array.isArray(value))
162
+ value.forEach((text, i) => out.push({ path: [...path, i], text, gender }));
163
+ else out.push({ path, text: value, gender });
164
+ };
165
+ if (!isPlainMap(pattern)) add(pattern, ["pattern"]);
166
+ else
167
+ for (const [gender, value] of Object.entries(pattern))
168
+ if (value !== null && value !== undefined) add(value, ["pattern", gender], gender);
169
+ return out;
170
+ }
171
+
172
+ /**
173
+ * The patterns a name of `gender` may take: every pattern where they are not
174
+ * keyed by gender, the gender's own where it has some, else every pattern
175
+ * stated.
176
+ *
177
+ * @param {object} names - Checked `data.names`.
178
+ * @param {string} gender - A gender word.
179
+ * @returns {string[]} The patterns.
180
+ */
181
+ export function patternsFor(names, gender) {
182
+ const pattern = names.pattern;
183
+ if (!isPlainMap(pattern)) return [pattern].flat();
184
+ if (pattern[gender] !== undefined && pattern[gender] !== null) return [pattern[gender]].flat();
185
+ return [
186
+ ...new Set(
187
+ Object.values(pattern)
188
+ .filter((v) => v !== null && v !== undefined)
189
+ .flat(),
190
+ ),
191
+ ];
192
+ }
193
+
194
+ /**
195
+ * One thing wrong with a language's name lists.
196
+ *
197
+ * @typedef {object} NamesProblem
198
+ * @property {Array<string|number>} path - Where, from `data.names` itself.
199
+ * @property {boolean} [key] - The fault is the key at `path`, not its value.
200
+ * @property {boolean} [shape] - The frontmatter lint's inner-key walk reports
201
+ * this too, from the field's declaration.
202
+ * @property {string} message - What is wrong, naming the full `data.` path.
203
+ */
204
+
205
+ /** `data.names.<path>`, as a reader writes it: `pattern[1]` for a list entry. */
206
+ function label(path) {
207
+ return path.reduce(
208
+ (text, segment) =>
209
+ typeof segment === "number" ? `${text}[${segment}]` : `${text}.${segment}`,
210
+ "data.names",
211
+ );
212
+ }
213
+
214
+ /** The gender words of the being-look vocabulary. */
215
+ function genderWords(lookVocabulary) {
216
+ return (lookVocabulary.gender ?? []).map(({ value }) => value);
217
+ }
218
+
219
+ /**
220
+ * Everything wrong with a language's name lists.
221
+ *
222
+ * @param {unknown} names - `data.names` as authored.
223
+ * @param {Readonly<Record<string, readonly {value: string}[]>>} lookVocabulary -
224
+ * The being-look vocabulary, whose `gender` words key `given`.
225
+ * @returns {NamesProblem[]} The problems, in the order a reader meets them.
226
+ */
227
+ export function namesProblems(names, lookVocabulary) {
228
+ const problems = [];
229
+ const problem = (path, message, key = false) =>
230
+ problems.push({ path, message, ...(key ? { key } : {}) });
231
+ const shape = (path, message, key = false) =>
232
+ problems.push({ path, message, shape: true, ...(key ? { key } : {}) });
233
+
234
+ if (!isPlainMap(names)) {
235
+ shape([], `\`${label([])}\` should be a map, but reads ${JSON.stringify(names)}`);
236
+ return problems;
237
+ }
238
+
239
+ /** One list of names: present, a list, not empty, every entry text. */
240
+ const checkList = (path) => {
241
+ const list = path.reduce((value, segment) => value?.[segment], names);
242
+ if (!Array.isArray(list)) {
243
+ shape(path, `\`${label(path)}\` should be a list of names`);
244
+ return;
245
+ }
246
+ if (!list.length) {
247
+ problem(path, `\`${label(path)}\` must hold at least one name`);
248
+ return;
249
+ }
250
+ list.forEach((entry, i) => {
251
+ if (typeof entry !== "string")
252
+ shape(
253
+ [...path, i],
254
+ `\`${label(path)}\` entry ${i + 1} should be a name, but reads ` +
255
+ JSON.stringify(entry),
256
+ );
257
+ else if (!entry.trim())
258
+ problem(
259
+ [...path, i],
260
+ `\`${label(path)}\` entry ${i + 1} should be a name, but reads ` +
261
+ JSON.stringify(entry),
262
+ );
263
+ });
264
+ };
265
+
266
+ const lists = Object.keys(names).filter((key) => key !== "pattern");
267
+ for (const key of lists) {
268
+ if (key === GIVEN_LIST) continue;
269
+ if (!LIST_NAME.test(key)) {
270
+ problem(
271
+ [key],
272
+ `"${key}" is not a list name; a list of \`data.names\` is named in letters, ` +
273
+ "digits and underscores, beginning with a letter",
274
+ true,
275
+ );
276
+ continue;
277
+ }
278
+ checkList([key]);
279
+ }
280
+
281
+ const given = names[GIVEN_LIST];
282
+ if (given === undefined || given === null) shape([], "`data.names` must state `given`", true);
283
+ else if (!isPlainMap(given))
284
+ shape([GIVEN_LIST], `\`${label([GIVEN_LIST])}\` should be a map of genders to lists`);
285
+ else {
286
+ const genders = genderWords(lookVocabulary);
287
+ for (const gender of requiredGivenGenders()) {
288
+ if (given[gender] === undefined || given[gender] === null)
289
+ problem(
290
+ [GIVEN_LIST],
291
+ `\`${label([GIVEN_LIST])}\` must state a list for \`${gender}\``,
292
+ true,
293
+ );
294
+ }
295
+ for (const gender of Object.keys(given)) {
296
+ if (!genders.includes(gender)) {
297
+ const guess = nearest(gender, genders);
298
+ problem(
299
+ [GIVEN_LIST, gender],
300
+ `${JSON.stringify(gender)} is not a gender; \`${label([GIVEN_LIST])}\` is ` +
301
+ `keyed by ${genders.join(", ")}` +
302
+ (guess ? `. Did you mean "${guess}"?` : ""),
303
+ true,
304
+ );
305
+ continue;
306
+ }
307
+ if (given[gender] === null) continue;
308
+ checkList([GIVEN_LIST, gender]);
309
+ }
310
+ }
311
+
312
+ const pattern = names.pattern;
313
+ if (pattern === undefined || pattern === null) {
314
+ shape([], "`data.names` must state `pattern`", true);
315
+ return problems;
316
+ }
317
+ const scalar = (value) => value === null || typeof value !== "object";
318
+ if (!scalar(pattern) && !Array.isArray(pattern) && !isPlainMap(pattern)) {
319
+ shape(["pattern"], `\`${label(["pattern"])}\` should be a pattern, a list or a map`);
320
+ return problems;
321
+ }
322
+ const genders = genderWords(lookVocabulary);
323
+ // A pattern that does not read leaves which lists are used unknown.
324
+ let unreadable = false;
325
+ /** A list of patterns, at `path`, holds at least one. */
326
+ const nonEmpty = (value, path) => {
327
+ if (!Array.isArray(value) || value.length) return;
328
+ problem(path, `\`${label(path)}\` must hold at least one pattern`);
329
+ unreadable = true;
330
+ };
331
+ nonEmpty(pattern, ["pattern"]);
332
+ if (isPlainMap(pattern)) {
333
+ for (const [gender, value] of Object.entries(pattern)) {
334
+ if (!genders.includes(gender)) {
335
+ const guess = nearest(gender, genders);
336
+ problem(
337
+ ["pattern", gender],
338
+ `${JSON.stringify(gender)} is not a gender; \`${label(["pattern"])}\` is ` +
339
+ `keyed by ${genders.join(", ")}` +
340
+ (guess ? `. Did you mean "${guess}"?` : ""),
341
+ true,
342
+ );
343
+ }
344
+ if (isPlainMap(value))
345
+ shape(
346
+ ["pattern", gender],
347
+ `\`${label(["pattern", gender])}\` should be a pattern or a list of them`,
348
+ );
349
+ else nonEmpty(value, ["pattern", gender]);
350
+ }
351
+ }
352
+
353
+ const givenMap = isPlainMap(given) ? given : {};
354
+ const named = new Set();
355
+ for (const { path, text } of patternEntries(pattern)) {
356
+ if (isPlainMap(text)) continue;
357
+ if (typeof text !== "string") {
358
+ // A list entry's kind is the inner-key walk's to report; a lone
359
+ // value's is not, since that walk admits any scalar there.
360
+ if (typeof path.at(-1) === "number")
361
+ shape(
362
+ path,
363
+ `\`${label(path)}\` should be a pattern, but reads ${JSON.stringify(text)}`,
364
+ );
365
+ else
366
+ problem(
367
+ path,
368
+ `\`${label(path)}\` should be a pattern such as "{given} {clan}", but reads ` +
369
+ JSON.stringify(text),
370
+ );
371
+ unreadable = true;
372
+ continue;
373
+ }
374
+ const parsed = parsePattern(text);
375
+ for (const message of parsed.problems) problem(path, `\`${label(path)}\` ${message}`);
376
+ if (parsed.problems.length) unreadable = true;
377
+ for (const part of parsed.parts) {
378
+ if (!("list" in part)) continue;
379
+ named.add(part.list);
380
+ if (part.gender !== undefined) {
381
+ if (givenMap[part.gender] === undefined || givenMap[part.gender] === null)
382
+ problem(
383
+ path,
384
+ `\`{${placeholderText(part)}}\` in \`${label(path)}\` names no list: ` +
385
+ `\`${label([GIVEN_LIST])}\` has no \`${part.gender}\` list`,
386
+ );
387
+ continue;
388
+ }
389
+ if (lists.includes(part.list)) continue;
390
+ const guess = nearest(part.list, lists);
391
+ problem(
392
+ path,
393
+ `\`{${part.list}}\` in \`${label(path)}\` names no list of \`data.names\`` +
394
+ (lists.length ? `; the lists are ${lists.join(", ")}` : "") +
395
+ (guess ? `. Did you mean "{${guess}}"?` : ""),
396
+ );
397
+ }
398
+ }
399
+ if (unreadable) return problems;
400
+ for (const list of lists) {
401
+ if (named.has(list)) continue;
402
+ problem(
403
+ [list],
404
+ `\`${label([list])}\` is never drawn from: no pattern of \`${label(["pattern"])}\` ` +
405
+ `has \`{${list}}\``,
406
+ true,
407
+ );
408
+ }
409
+ return problems;
410
+ }
411
+
412
+ /**
413
+ * The frontmatter lint's check of `data.names`.
414
+ *
415
+ * Scoped to a `skill` note of `subType: language`: on any other skill it is an
416
+ * error at the key. On a language it reports what the field's declared inner
417
+ * keys cannot: a pattern that does not parse, a placeholder naming no list, a
418
+ * list no pattern names, a list name a placeholder cannot spell, an empty list
419
+ * of patterns or of names, an empty name, a pattern or given list keyed by no
420
+ * gender, and a missing `female` or `male` list. Each is located at the pattern, list, entry or key at fault.
421
+ *
422
+ * @param {object} note - The note: `file`, `raw` and parsed `fm`.
423
+ * @param {Readonly<Record<string, readonly {value: string}[]>>} lookVocabulary -
424
+ * The being-look vocabulary.
425
+ * @returns {object[]} Findings.
426
+ */
427
+ export function checkLanguageNames(note, lookVocabulary) {
428
+ const data = note?.fm?.data;
429
+ if (!isPlainMap(data)) return [];
430
+ if (data.names === undefined || data.names === null) return [];
431
+ const subType = String(note.fm?.subType ?? "").toLowerCase();
432
+ if (subType !== NAMES_SUBTYPE)
433
+ return [
434
+ {
435
+ file: note.file,
436
+ ...positionOfFrontmatterPath(note.raw ?? "", ["data", "names"], { key: true }),
437
+ severity: "error",
438
+ message:
439
+ "`data.names` holds a language's name lists, and this note's `subType` is " +
440
+ `not \`${NAMES_SUBTYPE}\``,
441
+ },
442
+ ];
443
+ const raw = note.raw ?? "";
444
+ return namesProblems(data.names, lookVocabulary)
445
+ .filter((problem) => !problem.shape)
446
+ .map((problem) => ({
447
+ file: note.file,
448
+ ...positionOfFrontmatterPath(raw, ["data", "names", ...problem.path], {
449
+ key: Boolean(problem.key),
450
+ }),
451
+ severity: "error",
452
+ message: problem.message,
453
+ }));
454
+ }
455
+
456
+ /**
457
+ * A language's name-list problems as findings at the note that states them,
458
+ * shape included, for a caller holding the note but not running the lint.
459
+ *
460
+ * @param {object} note - The language note: `file`, `raw` and parsed `fm`.
461
+ * @param {Readonly<Record<string, readonly {value: string}[]>>} lookVocabulary -
462
+ * The being-look vocabulary.
463
+ * @returns {object[]} Every problem, as findings.
464
+ */
465
+ export function namesFindings(note, lookVocabulary) {
466
+ const raw = note?.raw ?? "";
467
+ return namesProblems(note?.fm?.data?.names, lookVocabulary).map((problem) => ({
468
+ ...(note?.file ? { file: note.file } : {}),
469
+ ...positionOfFrontmatterPath(raw, ["data", "names", ...problem.path], {
470
+ key: Boolean(problem.key),
471
+ }),
472
+ severity: "error",
473
+ message: problem.message,
474
+ }));
475
+ }
476
+
477
+ /**
478
+ * The given names a gender draws from: its own list, or every given list
479
+ * together where it has none.
480
+ *
481
+ * @param {object} names - Checked `data.names`.
482
+ * @param {string} gender - A gender word.
483
+ * @returns {string[]} The names.
484
+ */
485
+ export function givenNames(names, gender) {
486
+ const given = names[GIVEN_LIST];
487
+ if (Array.isArray(given[gender])) return given[gender];
488
+ return [...new Set(Object.values(given).filter(Array.isArray).flat())];
489
+ }
490
+
491
+ /** The names one placeholder draws from, for a person of `gender`. */
492
+ function listFor(names, part, gender) {
493
+ if (part.list !== GIVEN_LIST) return names[part.list];
494
+ if (part.gender !== undefined) return names[GIVEN_LIST][part.gender];
495
+ return givenNames(names, gender);
496
+ }
497
+
498
+ /**
499
+ * A name drawn from a language's lists: one of the gender's patterns, where it
500
+ * has more than one, then each placeholder an even draw from its list.
501
+ *
502
+ * @param {object} names - Checked `data.names`.
503
+ * @param {string} gender - A gender word.
504
+ * @param {() => number} next - A stream of numbers in `[0, 1)`.
505
+ * @returns {string} The name.
506
+ */
507
+ export function drawName(names, gender, next) {
508
+ const patterns = patternsFor(names, gender);
509
+ const pattern =
510
+ patterns.length > 1 ? patterns[Math.floor(next() * patterns.length)] : patterns[0];
511
+ return parsePattern(pattern)
512
+ .parts.map((part) => {
513
+ if ("literal" in part) return part.literal;
514
+ const list = listFor(names, part, gender);
515
+ return list[Math.floor(next() * list.length)];
516
+ })
517
+ .join("");
518
+ }
519
+
520
+ const nfc = (text) => text.normalize("NFC");
521
+
522
+ /** A pattern's parts with literal text in NFC. */
523
+ function partsOf(pattern) {
524
+ return parsePattern(pattern).parts.map((part) =>
525
+ "literal" in part ? { literal: nfc(part.literal) } : part,
526
+ );
527
+ }
528
+
529
+ /**
530
+ * Whether `text` is the parts, each placeholder one of `lookup(part)`'s
531
+ * names. Every split is tried, so parts that touch read correctly.
532
+ */
533
+ function fits(text, parts, lookup) {
534
+ const failed = new Set();
535
+ const walk = (at, from) => {
536
+ if (at === parts.length) return from === text.length;
537
+ const memo = `${at}:${from}`;
538
+ if (failed.has(memo)) return false;
539
+ const part = parts[at];
540
+ const candidates = "literal" in part ? [part.literal] : lookup(part);
541
+ for (const candidate of candidates) {
542
+ if (
543
+ candidate &&
544
+ text.startsWith(candidate, from) &&
545
+ walk(at + 1, from + candidate.length)
546
+ )
547
+ return true;
548
+ }
549
+ failed.add(memo);
550
+ return false;
551
+ };
552
+ return walk(0, 0);
553
+ }
554
+
555
+ /** The text each placeholder holds, where `text` has the pattern's literal shape. */
556
+ function looseParts(text, parts) {
557
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
558
+ const source = parts
559
+ .map((part) => ("literal" in part ? escape(part.literal) : "(.+?)"))
560
+ .join("");
561
+ const match = new RegExp(`^${source}$`, "su").exec(text);
562
+ if (!match) return undefined;
563
+ return parts.filter((part) => "list" in part).map((part, i) => [part, match[i + 1]]);
564
+ }
565
+
566
+ /**
567
+ * Why a name does not fit a language's lists, or `undefined` where it fits.
568
+ *
569
+ * A name fits when it takes one of the patterns for `gender`, with every
570
+ * placeholder filled by a name of its list: `{given}` from the gender's given
571
+ * list, `{given.<gender>}` from that gender's. Names compare in Unicode NFC, so
572
+ * a precomposed and a decomposed accent are one letter.
573
+ *
574
+ * @param {string} full - The name as given.
575
+ * @param {object} names - Checked `data.names`.
576
+ * @param {string} gender - A gender word.
577
+ * @returns {string|undefined} One sentence naming the part that does not fit.
578
+ */
579
+ export function nameMisfit(full, names, gender) {
580
+ const text = nfc(full);
581
+ const given = names[GIVEN_LIST];
582
+ const own = givenNames(names, gender).map(nfc);
583
+ const every = Object.values(given).filter(Array.isArray).flat().map(nfc);
584
+ const lookup = (forGiven) => (part) =>
585
+ part.list === GIVEN_LIST && part.gender === undefined ?
586
+ forGiven
587
+ : listFor(names, part, gender).map(nfc);
588
+
589
+ const patterns = patternsFor(names, gender);
590
+ if (patterns.some((pattern) => fits(text, partsOf(pattern), lookup(own)))) return undefined;
591
+
592
+ const elsewhere = patternEntries(names.pattern)
593
+ .map(({ text: pattern }) => pattern)
594
+ .filter((pattern) => typeof pattern === "string" && !patterns.includes(pattern));
595
+ const other = elsewhere.find((pattern) => fits(text, partsOf(pattern), lookup(every)));
596
+ if (other !== undefined)
597
+ return `it has the form ${JSON.stringify(other)}, which is not a ${gender} pattern`;
598
+
599
+ const listedFor = (value) =>
600
+ Object.entries(given)
601
+ .filter(([, list]) => Array.isArray(list) && list.map(nfc).includes(value))
602
+ .map(([g]) => g);
603
+ /** The parts of one pattern that hold no name of their list, where its shape reads. */
604
+ const failures = (pattern) => {
605
+ const parts = partsOf(pattern);
606
+ const loose = looseParts(text, parts);
607
+ if (!loose) return undefined;
608
+ const literal = parts.reduce(
609
+ (n, part) => n + ("literal" in part ? part.literal.length : 0),
610
+ 0,
611
+ );
612
+ const failed = loose.filter(([part, value]) => !lookup(own)(part).includes(value));
613
+ return { failed, literal };
614
+ };
615
+ const best = patterns
616
+ .map(failures)
617
+ .filter((result) => result && result.failed.length)
618
+ .sort((a, b) => a.failed.length - b.failed.length || b.literal - a.literal)[0];
619
+ if (best) {
620
+ const [part, value] = best.failed[0];
621
+ const name = placeholderText(part);
622
+ if (part.list === GIVEN_LIST && part.gender === undefined && every.includes(value))
623
+ return (
624
+ `${JSON.stringify(value)}, the {${name}} part, is not a ${gender} given name; ` +
625
+ `it is listed for ${listedFor(value).join(" and ")}`
626
+ );
627
+ return `${JSON.stringify(value)}, the {${name}} part, is not in the ${name} list`;
628
+ }
629
+ if (patterns.some((pattern) => fits(text, partsOf(pattern), lookup(every))))
630
+ return `its given name is not a ${gender} given name`;
631
+ return patterns.length === 1 ?
632
+ `it does not read as the pattern ${JSON.stringify(patterns[0])} filled from the lists`
633
+ : `it reads as none of the patterns ${patterns.map((p) => JSON.stringify(p)).join(", ")} filled from the lists`;
634
+ }