@heroiclands/package-build 23.12.8 → 23.13.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 (64) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +1 -1
  3. package/assets/preview/gitea.ini +2 -2
  4. package/assets/styles/content-base.css +143 -23
  5. package/bin/content-commands.mjs +15 -4
  6. package/bin/package-build-preview.mjs +2 -2
  7. package/docs/api.md +18 -18
  8. package/docs/authoring/links-and-markup.md +156 -22
  9. package/docs/commands.md +22 -7
  10. package/docs/configuration.md +1 -1
  11. package/docs/content-format.md +3 -3
  12. package/docs/diagnostics.md +42 -0
  13. package/docs/reference/format-details.md +99 -22
  14. package/docs/reference/note-types.md +8 -1
  15. package/docs/reference/theme-pages.md +51 -0
  16. package/docs/source-preview.md +1 -1
  17. package/engine/anchors.mjs +2 -2
  18. package/engine/code-fences.mjs +3 -3
  19. package/engine/content-blocks.mjs +3 -3
  20. package/engine/content-figures.mjs +5 -5
  21. package/engine/content-index.mjs +1 -1
  22. package/engine/content-scripts.mjs +55 -21
  23. package/engine/content-spans.mjs +2 -2
  24. package/engine/content-verse.mjs +750 -0
  25. package/engine/dictionary-rows.mjs +15 -4
  26. package/engine/field-reference.mjs +4 -4
  27. package/engine/first-use.mjs +17 -27
  28. package/engine/key-case-collisions.mjs +136 -0
  29. package/engine/language-data.mjs +203 -6
  30. package/engine/language-terms.mjs +74 -5
  31. package/engine/map-lexicon.mjs +42 -23
  32. package/engine/names.mjs +36 -9
  33. package/engine/note-matter.mjs +66 -0
  34. package/engine/pdf-build.mjs +1 -0
  35. package/engine/pdf-render.mjs +107 -18
  36. package/engine/read-stdin.mjs +29 -0
  37. package/engine/run-rows.mjs +2 -2
  38. package/engine/run-words.mjs +4 -111
  39. package/engine/site-build.mjs +4 -4
  40. package/engine/site-preview.mjs +14 -14
  41. package/engine/sql-tables.mjs +20 -3
  42. package/engine/web-wikilinks.mjs +1 -1
  43. package/engine/word-forms.mjs +349 -0
  44. package/engine/word-terms.mjs +9 -0
  45. package/hugo-theme/static/css/style.css +92 -14
  46. package/package.json +2 -2
  47. package/types/engine/code-fences.d.mts +3 -3
  48. package/types/engine/content-scripts.d.mts +8 -4
  49. package/types/engine/content-verse.d.mts +240 -0
  50. package/types/engine/dictionary-rows.d.mts +10 -0
  51. package/types/engine/field-reference.d.mts +1 -1
  52. package/types/engine/key-case-collisions.d.mts +28 -0
  53. package/types/engine/language-data.d.mts +0 -2
  54. package/types/engine/map-lexicon.d.mts +19 -6
  55. package/types/engine/names.d.mts +34 -3
  56. package/types/engine/note-matter.d.mts +48 -0
  57. package/types/engine/read-stdin.d.mts +11 -0
  58. package/types/engine/run-rows.d.mts +2 -2
  59. package/types/engine/run-words.d.mts +0 -15
  60. package/types/engine/sql-tables.d.mts +4 -1
  61. package/types/engine/web-wikilinks.d.mts +1 -1
  62. package/types/engine/word-forms.d.mts +82 -0
  63. package/engine/content-poetry.mjs +0 -240
  64. package/types/engine/content-poetry.d.mts +0 -58
@@ -143,12 +143,23 @@ const defines = (entry) => typeof entry.definition === "string" && entry.definit
143
143
  export function pluralOf(word, grammar) {
144
144
  if (parseWordClass(word.class).base !== "n") return null;
145
145
  if (typeof word.plural === "string" && word.plural) return word.plural;
146
+ return pluralByRule(word.form, grammar);
147
+ }
148
+
149
+ /**
150
+ * The plural a language's `data.grammar.plural` rule gives a form: the first
151
+ * literal ending replacement that fits, whatever the form's class.
152
+ *
153
+ * @param {string} form - The form.
154
+ * @param {Record<string, any>|undefined} grammar - Its language's
155
+ * `data.grammar`.
156
+ * @returns {string|null} The plural, or `null` where no rule fits.
157
+ */
158
+ export function pluralByRule(form, grammar) {
146
159
  for (const rule of Array.isArray(grammar?.plural) ? grammar.plural : []) {
147
160
  if (typeof rule?.ending !== "string" || typeof rule.becomes !== "string") continue;
148
- if (word.form.endsWith(rule.ending))
149
- return (
150
- (rule.ending ? word.form.slice(0, -rule.ending.length) : word.form) + rule.becomes
151
- );
161
+ if (form.endsWith(rule.ending))
162
+ return (rule.ending ? form.slice(0, -rule.ending.length) : form) + rule.becomes;
152
163
  }
153
164
  return null;
154
165
  }
@@ -35,7 +35,7 @@
35
35
  */
36
36
 
37
37
  import path from "node:path";
38
- import matter from "gray-matter";
38
+ import { writeMatter } from "./note-matter.mjs";
39
39
  import { formatNoteFrontmatter } from "./note-format.mjs";
40
40
 
41
41
  import { authoredFields, runtimeOnlyFields } from "./field-spec.mjs";
@@ -422,7 +422,7 @@ export function shortcodeFromBasename(destination) {
422
422
  * {@link shortcodeFromBasename}.
423
423
  * @param {Record<string, unknown>} [options.frontmatter] - The consumer's
424
424
  * declared `docs.itemFields.frontmatter`.
425
- * @returns {Record<string, unknown>} The envelope, ready for `matter.stringify`.
425
+ * @returns {Record<string, unknown>} The envelope, ready for `writeMatter`.
426
426
  */
427
427
  export function itemFieldsEnvelope({ title, shortcode, frontmatter }) {
428
428
  return deepMerge(
@@ -464,8 +464,8 @@ export function renderItemFieldsPage(body, { title, destination, contentRoot, fr
464
464
  shortcode: shortcodeFromBasename(destination),
465
465
  frontmatter,
466
466
  });
467
- const page = matter.stringify(body, envelope);
468
- // `matter.stringify` closes the frontmatter fence directly onto the
467
+ const page = writeMatter(body, envelope);
468
+ // `writeMatter` closes the frontmatter fence directly onto the
469
469
  // body's first line; Prettier's markdown printer requires a blank line
470
470
  // between them, so a page written without one fails a consumer's
471
471
  // `lint:format` the moment it is committed. Insert it here rather than
@@ -83,6 +83,14 @@ function readLabel(text, env = {}) {
83
83
  }
84
84
  const labelText = (text, env) => readLabel(text, env).text;
85
85
 
86
+ /** The kind a span's `data-type` supplies; an invalid value is refused by the span check. */
87
+ function spanKind(span) {
88
+ const value = span.attrs?.find(([key]) => key === "data-type")?.[1];
89
+ if (typeof value !== "string") return undefined;
90
+ const [type, subType] = value.split("/");
91
+ return { type, subType: subType ?? null };
92
+ }
93
+
86
94
  /** Join overlapping source ranges so lookups stay logarithmic. */
87
95
  function ranges(values) {
88
96
  const out = [];
@@ -119,7 +127,6 @@ function prose(source, capturedNames) {
119
127
  const links = [],
120
128
  regions = [],
121
129
  excluded = [],
122
- bold = [],
123
130
  html = [];
124
131
  const masked = source
125
132
  .replace(/!\[\[[^\]\n]+\]\]|\{\{[^}\n]+\}\}/g, (text, start) => {
@@ -142,7 +149,6 @@ function prose(source, capturedNames) {
142
149
  excluded.push({ start, end });
143
150
  else if (node.type === "html") html.push(node);
144
151
  else if (["strong", "emphasis", "delete", "link", "linkReference"].includes(node.type)) {
145
- if (node.type === "strong") bold.push({ start, end });
146
152
  if (["link", "linkReference"].includes(node.type) && source[start] === "[")
147
153
  links.push({ start, end, labelled: true });
148
154
  for (const child of node.children ?? []) inline(child);
@@ -224,18 +230,8 @@ function prose(source, capturedNames) {
224
230
  const linked = ranges(authoredLinks);
225
231
  const marked = (capturedNames ?? scanSpans(source).names)
226
232
  .filter((span) => eligible(span) && !contains(linked, span))
227
- .map((span) => ({ ...span, label: span.visible }));
228
- const explicit = ranges([...marked, ...authoredLinks]);
229
- return {
230
- marked,
231
- links: authoredLinks,
232
- bold: bold
233
- .filter((span) => eligible(span) && !overlaps(explicit, span))
234
- .map((span) => ({
235
- ...span,
236
- label: labelText(source.slice(span.start, span.end), env),
237
- })),
238
- };
233
+ .map((span) => ({ ...span, label: span.visible, nameKind: spanKind(span) }));
234
+ return { marked, links: authoredLinks };
239
235
  }
240
236
 
241
237
  /**
@@ -264,7 +260,7 @@ export function createFirstUseChecker({
264
260
  { raw, bodyLine: authored.bodyLine, bodyColumn: authored.bodyColumn },
265
261
  )
266
262
  : undefined;
267
- const { marked, links, bold } = prose(raw, capturedNames);
263
+ const { marked, links } = prose(raw, capturedNames);
268
264
  const events = [
269
265
  ...marked.map((span) => ({ ...span, kind: "marked" })),
270
266
  ...links.map((link) => {
@@ -286,7 +282,10 @@ export function createFirstUseChecker({
286
282
  for (const event of [...events].sort(
287
283
  (a, b) => a.label.length - b.label.length || a.label.localeCompare(b.label),
288
284
  )) {
289
- if (recognized(event.label, event.preferred) || unknown.has(normalize(event.label)))
285
+ if (
286
+ recognized(event.label, event.preferred, event.nameKind) ||
287
+ unknown.has(normalize(event.label))
288
+ )
290
289
  continue;
291
290
  const entry = {
292
291
  key: "name:" + normalize(event.label),
@@ -309,7 +308,8 @@ export function createFirstUseChecker({
309
308
  fullUsed = new Set();
310
309
  for (const event of events.sort((a, b) => a.start - b.start)) {
311
310
  const entry =
312
- recognized(event.label, event.preferred) ?? unknown.get(normalize(event.label));
311
+ recognized(event.label, event.preferred, event.nameKind) ??
312
+ unknown.get(normalize(event.label));
313
313
  if (event.kind === "marked" && entry.indexed) {
314
314
  const destinations = (entry.candidates ?? [entry])
315
315
  .map((target) => target.key)
@@ -338,16 +338,6 @@ export function createFirstUseChecker({
338
338
  used.add(entry.key);
339
339
  if (introduces) fullUsed.add(entry.key);
340
340
  }
341
- for (const span of bold) {
342
- if (typeof span.label !== "string") continue;
343
- const entry = recognized(span.label);
344
- if (entry)
345
- add(
346
- span.start,
347
- "bold-name",
348
- `“${span.label}” matches an indexed name; mark or link this name`,
349
- );
350
- }
351
341
  const lineStarts = [0];
352
342
  for (let i = 0; i < raw.length; i++) if (raw[i] === "\n") lineStarts.push(i + 1);
353
343
  return [...findings]
@@ -0,0 +1,136 @@
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
+ * Keys that differ only in case, found in the content index's records.
16
+ *
17
+ * The notes relation is read with `read_json_auto`, which infers one struct
18
+ * from every record and treats the names of a struct's fields without regard
19
+ * to case. A map whose keys an author chooses therefore cannot hold `carved`
20
+ * in one note and `Carved` in another: the relation refuses to load. This
21
+ * module names each such pair, with the note and the line of both keys, so the
22
+ * author renames one.
23
+ *
24
+ * @module
25
+ */
26
+
27
+ import fs from "node:fs";
28
+ import path from "node:path";
29
+ import { formatDiagnostic, positionInFrontmatter } from "./diagnostics.mjs";
30
+
31
+ /**
32
+ * Walk a record, noting every key under its case-folded path.
33
+ *
34
+ * @param {unknown} value - The value at `trail`.
35
+ * @param {string} trail - The case-folded path to it.
36
+ * @param {object} record - The record being walked.
37
+ * @param {Map<string, Map<string, Set<object>>>} groups - Case-folded path to
38
+ * each spelling found there and the records that use it.
39
+ */
40
+ function walk(value, trail, record, groups) {
41
+ if (Array.isArray(value)) {
42
+ for (const entry of value) walk(entry, trail, record, groups);
43
+ return;
44
+ }
45
+ if (value === null || typeof value !== "object") return;
46
+ for (const [key, child] of Object.entries(value)) {
47
+ const at = `${trail}.${key.toLowerCase()}`;
48
+ let spellings = groups.get(at);
49
+ if (!spellings) groups.set(at, (spellings = new Map()));
50
+ let users = spellings.get(key);
51
+ if (!users) spellings.set(key, (users = new Set()));
52
+ users.add(record);
53
+ walk(child, at, record, groups);
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Every key that two records, or one record, spell with different case at the
59
+ * same path.
60
+ *
61
+ * @param {object[]} records - Content-index records.
62
+ * @returns {Array<{key: string, other: string, record: object, otherRecord: object}>}
63
+ * One entry per spelling that collides with another, naming the record that
64
+ * uses it and a record that uses the other spelling.
65
+ */
66
+ export function findKeyCaseCollisions(records) {
67
+ const groups = new Map();
68
+ for (const record of records) walk(record, "", record, groups);
69
+ const found = [];
70
+ for (const spellings of groups.values()) {
71
+ if (spellings.size < 2) continue;
72
+ const keys = [...spellings.keys()].sort();
73
+ const [first, ...rest] = keys;
74
+ for (const key of keys) {
75
+ const other = key === first ? rest[0] : first;
76
+ found.push({
77
+ key,
78
+ other,
79
+ record: [...spellings.get(key)][0],
80
+ otherRecord: [...spellings.get(other)][0],
81
+ });
82
+ }
83
+ }
84
+ return found;
85
+ }
86
+
87
+ /**
88
+ * The note a record came from and where a key sits in it.
89
+ *
90
+ * @param {object} record - A content-index record.
91
+ * @param {string} key - The key to find.
92
+ * @param {string|undefined} contentBase - The directory `record.file.path` is
93
+ * relative to.
94
+ * @returns {{file: string, line?: number, column?: number}} The path relative
95
+ * to the working directory, with the position where the key is found.
96
+ */
97
+ function noteLocation(record, key, contentBase) {
98
+ const relative = record.file?.path ?? "";
99
+ const absolute = contentBase ? path.join(contentBase, relative) : undefined;
100
+ const file = absolute ? path.relative(process.cwd(), absolute) : relative;
101
+ let position = {};
102
+ if (absolute) {
103
+ try {
104
+ position = positionInFrontmatter(fs.readFileSync(absolute, "utf8"), key, undefined);
105
+ } catch {
106
+ position = {};
107
+ }
108
+ }
109
+ return { file, ...position };
110
+ }
111
+
112
+ /**
113
+ * The diagnostics for every case collision in the records, one per spelling,
114
+ * each naming the note and line of the spelling it collides with.
115
+ *
116
+ * @param {object[]} records - Content-index records.
117
+ * @param {object} [options]
118
+ * @param {string} [options.contentBase] - The directory the records' paths are
119
+ * relative to.
120
+ * @returns {string[]} Formatted `file:line:column: error: message` lines.
121
+ */
122
+ export function keyCaseCollisionDiagnostics(records, { contentBase } = {}) {
123
+ return findKeyCaseCollisions(records).map(({ key, other, record, otherRecord }) => {
124
+ const here = noteLocation(record, key, contentBase);
125
+ const there = noteLocation(otherRecord, other, contentBase);
126
+ const where = [there.file, there.line, there.column].filter((part) => part !== undefined);
127
+ return formatDiagnostic({
128
+ ...here,
129
+ severity: "error",
130
+ message:
131
+ `\`${key}\` and \`${other}\` name one key: the content index reads keys ` +
132
+ `without regard to case, so \`${other}\` in ${where.join(":")} must be spelled ` +
133
+ `the same way`,
134
+ });
135
+ });
136
+ }
@@ -39,7 +39,8 @@
39
39
  * and NFC (see {@link foreignRowFindings});
40
40
  * - a sound-change set's name stands once in the note, a step's label once
41
41
  * in its set, and its `from` and `to` name language notes;
42
- * - a frame's classes are bases of the master word classes;
42
+ * - a frame's classes, and the classes an affix's `attachesTo` names, are
43
+ * bases of the master word classes, each named once;
43
44
  * - a calling-name closing states a `form` or `doubled: true`, not both;
44
45
  * - a form stands once in `data.older`;
45
46
  * - the mutation `data.negation` names is one of `data.sounds.mutations`;
@@ -69,6 +70,7 @@ import {
69
70
  } from "./language-terms.mjs";
70
71
  import { LANGUAGE_SUBTYPE } from "./word-terms.mjs";
71
72
  import { WORD_CLASS_BASES } from "./word-classes.mjs";
73
+ import { nearest } from "./near-miss.mjs";
72
74
 
73
75
  /** The bases a frame's `class` names. */
74
76
  const CLASS_BASES = new Set(WORD_CLASS_BASES.map((item) => item.token));
@@ -117,9 +119,6 @@ export function tongueDeclared(tag, declared) {
117
119
  /** The tag families whose rows name a name of another tongue's register. */
118
120
  const FOREIGN_FAMILIES = new Set(["other", "exonym"]);
119
121
 
120
- /** How a foreign row that its tongue's register does not bear out is reported. */
121
- export const FOREIGN_ROW_SEVERITY = "warning";
122
-
123
122
  /** A name as a foreign row is compared: NFC and lowercase. */
124
123
  const foldName = (name) => name.normalize("NFC").toLowerCase();
125
124
 
@@ -190,7 +189,7 @@ function foreignRowFindings(rows, index, finding) {
190
189
  findings.push(
191
190
  finding(
192
191
  ["data", "register", i, "tongue"],
193
- FOREIGN_ROW_SEVERITY,
192
+ "error",
194
193
  languages.length ?
195
194
  `the tongue \`${tongue}\` in \`${row.tongue}\` names ${languages.length} ` +
196
195
  `language notes, ${languages.join(" and ")}; the \`data.tongues\` entries ` +
@@ -217,7 +216,7 @@ function foreignRowFindings(rows, index, finding) {
217
216
  findings.push(
218
217
  finding(
219
218
  ["data", "register", i, "name"],
220
- FOREIGN_ROW_SEVERITY,
219
+ "error",
221
220
  `\`${row.name}\` is registered as \`${row.tongue}\`, but the register of ` +
222
221
  `${title} (${fm.type}-${fm.shortcode}) lists no \`${tongue}\` name ` +
223
222
  `\`${row.name}\`; correct the spelling, or register the name there first`,
@@ -241,6 +240,198 @@ function formEntries(forms) {
241
240
  return [];
242
241
  }
243
242
 
243
+ /** A suggestion clause for a near miss, or nothing. */
244
+ const suggestion = (value, candidates) => {
245
+ const near = nearest(value, candidates);
246
+ return near ? `; did you mean \`${near}\`?` : "";
247
+ };
248
+
249
+ /** A suggestion clause for a word class written out or mistyped, or nothing. */
250
+ const classSuggestion = (value, bases) => {
251
+ const spelled = WORD_CLASS_BASES.find((base) => base.expansion === value.trim().toLowerCase());
252
+ return spelled ? `; did you mean \`${spelled.token}\`?` : suggestion(value, bases);
253
+ };
254
+
255
+ /**
256
+ * Each entry of a list of strings that repeats an earlier one, as a finding.
257
+ *
258
+ * @param {unknown[]} list - The list.
259
+ * @param {Array<string|number>} path - Where it sits.
260
+ * @param {string} verb - What the entry does: `names` or `states`.
261
+ * @param {Function} finding - The note's finding builder.
262
+ * @returns {object[]} The findings.
263
+ */
264
+ function repeats(list, path, verb, finding) {
265
+ const seen = new Set();
266
+ const out = [];
267
+ list.forEach((item, i) => {
268
+ if (typeof item !== "string") return;
269
+ const key = item.trim();
270
+ if (seen.has(key))
271
+ out.push(
272
+ finding(
273
+ [...path, i],
274
+ "error",
275
+ `\`${label([...path, i])}\` ${verb} \`${key}\` a second time`,
276
+ ),
277
+ );
278
+ seen.add(key);
279
+ });
280
+ return out;
281
+ }
282
+
283
+ /**
284
+ * Check `data.grammar.agreement` against what it means: classes of the master
285
+ * list, each once, and at least two distinct endings to agree among.
286
+ *
287
+ * @param {unknown} agreement - The authored value.
288
+ * @param {Function} finding - The note's finding builder.
289
+ * @returns {object[]} The findings.
290
+ */
291
+ function checkAgreement(agreement, finding) {
292
+ if (!isMap(agreement)) return [];
293
+ const out = [];
294
+ const at = ["data", "grammar", "agreement"];
295
+ const bases = WORD_CLASS_BASES.map((base) => base.token);
296
+ if (Array.isArray(agreement.classes)) {
297
+ if (!agreement.classes.length)
298
+ out.push(
299
+ finding(
300
+ [...at, "classes"],
301
+ "error",
302
+ "`data.grammar.agreement.classes` must name at least one word class",
303
+ ),
304
+ );
305
+ agreement.classes.forEach((item, i) => {
306
+ if (typeof item !== "string" || bases.includes(item.trim())) return;
307
+ out.push(
308
+ finding(
309
+ [...at, "classes", i],
310
+ "error",
311
+ `\`${label([...at, "classes", i])}\` is \`${item}\`, which is not a word class` +
312
+ classSuggestion(item, bases),
313
+ ),
314
+ );
315
+ });
316
+ out.push(...repeats(agreement.classes, [...at, "classes"], "names", finding));
317
+ }
318
+ if (Array.isArray(agreement.endings)) {
319
+ const distinct = new Set(
320
+ agreement.endings.filter((item) => typeof item === "string").map((item) => item.trim()),
321
+ );
322
+ if (distinct.size < 2)
323
+ out.push(
324
+ finding(
325
+ [...at, "endings"],
326
+ "error",
327
+ "`data.grammar.agreement.endings` must state at least two endings for a word to agree among",
328
+ ),
329
+ );
330
+ out.push(...repeats(agreement.endings, [...at, "endings"], "states", finding));
331
+ }
332
+ return out;
333
+ }
334
+
335
+ /**
336
+ * Check each `data.affixes[].attachesTo` against what it means: bases of the
337
+ * master word classes, each once, and at least one.
338
+ *
339
+ * @param {unknown} affixes - The note's `data.affixes`.
340
+ * @param {Function} finding - The note's finding builder.
341
+ * @returns {object[]} The findings.
342
+ */
343
+ function checkAffixClasses(affixes, finding) {
344
+ const out = [];
345
+ const bases = WORD_CLASS_BASES.map((base) => base.token);
346
+ listOf(affixes).forEach((affix, i) => {
347
+ if (!isMap(affix) || !Array.isArray(affix.attachesTo)) return;
348
+ const at = ["data", "affixes", i, "attachesTo"];
349
+ if (!affix.attachesTo.length)
350
+ out.push(finding(at, "error", `\`${label(at)}\` must name at least one word class`));
351
+ affix.attachesTo.forEach((item, j) => {
352
+ if (typeof item !== "string" || bases.includes(item.trim())) return;
353
+ out.push(
354
+ finding(
355
+ [...at, j],
356
+ "error",
357
+ `\`${label([...at, j])}\` is \`${item}\`, which is not a word class` +
358
+ classSuggestion(item, bases),
359
+ ),
360
+ );
361
+ });
362
+ out.push(...repeats(affix.attachesTo, at, "names", finding));
363
+ });
364
+ return out;
365
+ }
366
+
367
+ /**
368
+ * Check `data.grammar.participles` against what it means: root endings with
369
+ * letters, and affixes that are suffixes the language's `data.affixes` states.
370
+ *
371
+ * @param {unknown} participles - The authored value.
372
+ * @param {unknown} affixes - The note's `data.affixes`.
373
+ * @param {Function} finding - The note's finding builder.
374
+ * @returns {object[]} The findings.
375
+ */
376
+ function checkParticiples(participles, affixes, finding) {
377
+ if (!isMap(participles)) return [];
378
+ const out = [];
379
+ const at = ["data", "grammar", "participles"];
380
+ if (Array.isArray(participles.root)) {
381
+ if (!participles.root.length)
382
+ out.push(
383
+ finding(
384
+ [...at, "root"],
385
+ "error",
386
+ "`data.grammar.participles.root` must name at least one ending a verb drops to give its root",
387
+ ),
388
+ );
389
+ participles.root.forEach((item, i) => {
390
+ if (typeof item === "string" && !item.trim())
391
+ out.push(
392
+ finding(
393
+ [...at, "root", i],
394
+ "error",
395
+ `\`${label([...at, "root", i])}\` must name the letters a verb drops to give its root`,
396
+ ),
397
+ );
398
+ });
399
+ out.push(...repeats(participles.root, [...at, "root"], "states", finding));
400
+ }
401
+ if (Array.isArray(participles.affixes)) {
402
+ if (!participles.affixes.length)
403
+ out.push(
404
+ finding(
405
+ [...at, "affixes"],
406
+ "error",
407
+ "`data.grammar.participles.affixes` must name at least one suffix of `data.affixes`",
408
+ ),
409
+ );
410
+ const stated = listOf(affixes)
411
+ .filter((affix) => isMap(affix) && typeof affix.form === "string")
412
+ .map((affix) => affix.form.trim());
413
+ const suffixes = stated.filter((form) => /^[-–]/u.test(form) && !/[-–]$/u.test(form));
414
+ participles.affixes.forEach((item, i) => {
415
+ if (typeof item !== "string") return;
416
+ const form = item.trim();
417
+ const where = `\`${label([...at, "affixes", i])}\` is \`${item}\``;
418
+ if (suffixes.includes(form)) return;
419
+ out.push(
420
+ finding(
421
+ [...at, "affixes", i],
422
+ "error",
423
+ stated.includes(form) ?
424
+ `${where}, a ${/^[-–]/u.test(form) ? "infix" : "prefix"}; a participle is a suffix on the root`
425
+ : `${where}, which is not a suffix of \`data.affixes\`` +
426
+ suggestion(form, suffixes),
427
+ ),
428
+ );
429
+ });
430
+ out.push(...repeats(participles.affixes, [...at, "affixes"], "names", finding));
431
+ }
432
+ return out;
433
+ }
434
+
244
435
  /**
245
436
  * Check a `skill` note's language data against what it means.
246
437
  *
@@ -345,6 +536,12 @@ export function checkLanguageData(note, { index, types } = {}) {
345
536
  );
346
537
  }
347
538
 
539
+ findings.push(
540
+ ...checkAgreement(grammar.agreement, finding),
541
+ ...checkParticiples(grammar.participles, data.affixes, finding),
542
+ ...checkAffixClasses(data.affixes, finding),
543
+ );
544
+
348
545
  /**
349
546
  * The note an Address names, or a finding where it names none.
350
547
  *
@@ -1651,7 +1651,56 @@ const VERB_PATTERN_FIELDS = Object.freeze([
1651
1651
  }),
1652
1652
  ]);
1653
1653
 
1654
- /** The optional, display-only noun and verb patterns of one language. */
1654
+ /** The two keys of a language's optional agreement rule. */
1655
+ const AGREEMENT_FIELDS = Object.freeze([
1656
+ Object.freeze({
1657
+ name: "classes",
1658
+ kind: "list",
1659
+ required: true,
1660
+ shape: "a list of word class bases, such as `[adj, pron]`",
1661
+ entries: Object.freeze({ kind: "string", shape: "a word class base" }),
1662
+ describe: "The word classes that agree, each a base of the master word classes.",
1663
+ }),
1664
+ Object.freeze({
1665
+ name: "endings",
1666
+ kind: "list",
1667
+ required: true,
1668
+ shape: "a list of at least two literal endings, such as `[os, a, on]`",
1669
+ entries: Object.freeze({ kind: "string", shape: "a literal ending string" }),
1670
+ describe:
1671
+ "The singular endings an agreeing word swaps among, one for each gender; the " +
1672
+ "`plural` rule gives each one's plural. A word closing on none does not agree.",
1673
+ }),
1674
+ ]);
1675
+
1676
+ /** The two keys of a language's optional participle rule. */
1677
+ const PARTICIPLE_FIELDS = Object.freeze([
1678
+ Object.freeze({
1679
+ name: "root",
1680
+ kind: "list",
1681
+ required: true,
1682
+ shape: "a list of closing letters, such as `[athen, ethen]`",
1683
+ entries: Object.freeze({ kind: "string", shape: "the closing letters of a citation form" }),
1684
+ describe:
1685
+ "The infinitive endings a verb's citation form drops to give its root, the longest " +
1686
+ "that matches first. A verb closing on none has no participle.",
1687
+ }),
1688
+ Object.freeze({
1689
+ name: "affixes",
1690
+ kind: "list",
1691
+ required: true,
1692
+ shape: "a list of suffixes of `data.affixes`, such as `[-andar, -thos]`",
1693
+ entries: Object.freeze({
1694
+ kind: "string",
1695
+ shape: "the `form` of a suffix of `data.affixes`",
1696
+ }),
1697
+ describe:
1698
+ "The participle suffixes, each attaching to the root as its own `replaces` says: " +
1699
+ "`vowel` or letters take the place of the root's close, and none adds it whole.",
1700
+ }),
1701
+ ]);
1702
+
1703
+ /** The optional noun, verb, agreement and participle patterns of one language. */
1655
1704
  const GRAMMAR_FIELDS = Object.freeze([
1656
1705
  innerList(
1657
1706
  "plural",
@@ -1680,6 +1729,26 @@ const GRAMMAR_FIELDS = Object.freeze([
1680
1729
  fields: VERB_PATTERN_FIELDS,
1681
1730
  describe: "One optional regular present pattern, printed once at the dictionary head.",
1682
1731
  }),
1732
+ Object.freeze({
1733
+ name: "agreement",
1734
+ kind: "map",
1735
+ shape: "a map — `{ classes, endings }`",
1736
+ fields: AGREEMENT_FIELDS,
1737
+ describe:
1738
+ "How words of the named classes agree with a noun: a word closing on one of the " +
1739
+ "`endings` also takes each other one, and each form takes the `plural` rule. Read " +
1740
+ "by the mapped-run word check.",
1741
+ }),
1742
+ Object.freeze({
1743
+ name: "participles",
1744
+ kind: "map",
1745
+ shape: "a map — `{ root, affixes }`",
1746
+ fields: PARTICIPLE_FIELDS,
1747
+ describe:
1748
+ "Participles on the verb root: a verb closing on a `root` ending drops it, and each " +
1749
+ "named suffix attaches to what is left; a participle closing on an `agreement` " +
1750
+ "ending agrees. Read by the mapped-run word check.",
1751
+ }),
1683
1752
  ]);
1684
1753
 
1685
1754
  /**
@@ -1693,12 +1762,12 @@ export const LANGUAGE_DATA_FIELDS = Object.freeze([
1693
1762
  Object.freeze({
1694
1763
  name: "grammar",
1695
1764
  kind: "map",
1696
- shape: "a map — `{ plural?, verbs? }`",
1765
+ shape: "a map — `{ plural?, verbs?, agreement?, participles? }`",
1697
1766
  fields: GRAMMAR_FIELDS,
1698
1767
  describe:
1699
- "Optional noun plurals and one verb pattern for dictionary presentation. Only the " +
1700
- "shape of supplied keys is checked; no word must follow or state a rule. A language " +
1701
- "skill note only.",
1768
+ "Optional noun plurals, one verb pattern, agreement and participles, for dictionary " +
1769
+ "presentation and the mapped-run word check. Only the shape of supplied keys is " +
1770
+ "checked; no word must follow or state a rule. A language skill note only.",
1702
1771
  }),
1703
1772
  listOfMaps(
1704
1773
  "tongues",