@heroiclands/package-build 23.12.7 → 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 (76) hide show
  1. package/CHANGELOG.md +54 -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/assets.md +1 -1
  9. package/docs/authoring/links-and-markup.md +156 -22
  10. package/docs/commands.md +32 -10
  11. package/docs/configuration.md +8 -1
  12. package/docs/content-format.md +3 -3
  13. package/docs/diagnostics.md +42 -0
  14. package/docs/reference/format-details.md +105 -22
  15. package/docs/reference/note-types.md +8 -1
  16. package/docs/reference/theme-pages.md +51 -0
  17. package/docs/source-preview.md +1 -1
  18. package/e2e.mjs +1 -1
  19. package/engine/anchors.mjs +2 -2
  20. package/engine/asset-shipping.mjs +22 -8
  21. package/engine/code-fences.mjs +3 -3
  22. package/engine/content-blocks.mjs +3 -3
  23. package/engine/content-figures.mjs +5 -5
  24. package/engine/content-index.mjs +1 -1
  25. package/engine/content-scripts.mjs +55 -21
  26. package/engine/content-spans.mjs +2 -2
  27. package/engine/content-verse.mjs +750 -0
  28. package/engine/dictionary-rows.mjs +15 -4
  29. package/engine/field-reference.mjs +4 -4
  30. package/engine/first-use.mjs +17 -27
  31. package/engine/key-case-collisions.mjs +136 -0
  32. package/engine/language-data.mjs +203 -6
  33. package/engine/language-terms.mjs +74 -5
  34. package/engine/map-lexicon.mjs +42 -23
  35. package/engine/names.mjs +36 -9
  36. package/engine/note-matter.mjs +66 -0
  37. package/engine/pdf-build.mjs +1 -0
  38. package/engine/pdf-render.mjs +107 -18
  39. package/engine/read-stdin.mjs +29 -0
  40. package/engine/run-rows.mjs +2 -2
  41. package/engine/run-words.mjs +4 -111
  42. package/engine/site-build.mjs +4 -4
  43. package/engine/site-preview.mjs +14 -14
  44. package/engine/sql-tables.mjs +20 -3
  45. package/engine/web-wikilinks.mjs +1 -1
  46. package/engine/word-forms.mjs +349 -0
  47. package/engine/word-terms.mjs +9 -0
  48. package/hugo-theme/static/css/style.css +92 -14
  49. package/package.json +3 -3
  50. package/types/e2e.d.mts +2 -2
  51. package/types/engine/address-fields.d.mts +1 -1
  52. package/types/engine/address-values.d.mts +1 -1
  53. package/types/engine/asset-shipping.d.mts +5 -0
  54. package/types/engine/code-fences.d.mts +3 -3
  55. package/types/engine/content-format-check.d.mts +1 -1
  56. package/types/engine/content-scripts.d.mts +8 -4
  57. package/types/engine/content-verse.d.mts +240 -0
  58. package/types/engine/dictionary-rows.d.mts +10 -0
  59. package/types/engine/field-reference.d.mts +1 -1
  60. package/types/engine/key-case-collisions.d.mts +28 -0
  61. package/types/engine/language-data.d.mts +0 -2
  62. package/types/engine/map-lexicon.d.mts +19 -6
  63. package/types/engine/names.d.mts +34 -3
  64. package/types/engine/note-matter.d.mts +48 -0
  65. package/types/engine/note-vocabulary.d.mts +1 -1
  66. package/types/engine/pdf-coverage.d.mts +1 -1
  67. package/types/engine/read-stdin.d.mts +11 -0
  68. package/types/engine/run-rows.d.mts +2 -2
  69. package/types/engine/run-words.d.mts +0 -15
  70. package/types/engine/sql-tables.d.mts +4 -1
  71. package/types/engine/text-maps.d.mts +1 -1
  72. package/types/engine/web-wikilinks.d.mts +1 -1
  73. package/types/engine/word-forms.d.mts +82 -0
  74. package/types/release.d.mts +1 -1
  75. package/engine/content-poetry.mjs +0 -240
  76. package/types/engine/content-poetry.d.mts +0 -58
@@ -18,10 +18,10 @@ import footnotePlugin from "markdown-it-footnote";
18
18
  import deflistPlugin from "markdown-it-deflist";
19
19
  import { dictionaryListPlugin } from "./definition-list.mjs";
20
20
  import Handlebars from "handlebars";
21
- import matter from "gray-matter";
21
+ import { readMatter } from "./note-matter.mjs";
22
22
  import { ALERT_TYPES, alertMarkdownPlugin, scanAlerts } from "./content-alerts.mjs";
23
23
  import { spanMarkdownPlugin } from "./content-spans.mjs";
24
- import { scanPoetry, poetryStanzas } from "./content-poetry.mjs";
24
+ import { scanVerse, verseStanzas } from "./content-verse.mjs";
25
25
  import { scanFigures } from "./content-figures.mjs";
26
26
  import { scanBlocks } from "./content-blocks.mjs";
27
27
  import { splitHeadingAttributes } from "./heading-attributes.mjs";
@@ -358,7 +358,7 @@ function parserFor(fm, findings, file) {
358
358
  const close = new state.Token("paragraph_close", "p", -1);
359
359
  state.tokens.splice(i + 1, 0, open, title, close);
360
360
  }
361
- const poetry = scanPoetry(state.src).blocks;
361
+ const verse = scanVerse(state.src).blocks;
362
362
  const localFigures = scanFigures(state.src).figures;
363
363
  for (const fig of localFigures) if (fig.id) figures.set(fig.id, fig);
364
364
  for (const token of state.tokens) {
@@ -366,10 +366,10 @@ function parserFor(fm, findings, file) {
366
366
  token.meta = {
367
367
  ...token.meta,
368
368
  source: lines.slice(token.map[0], token.map[1]).join("\n"),
369
- poem: poetry.find((poem) => poem.start === token.map[0]),
369
+ poem: verse.find((poem) => poem.start === token.map[0]),
370
370
  };
371
371
  }
372
- // Poetry must become inline tokens before footnote collection, so a
372
+ // Verse must become inline tokens before footnote collection, so a
373
373
  // definition outside the fence remains visible to a verse reference.
374
374
  for (let i = state.tokens.length - 1; i >= 0; i--) {
375
375
  const token = state.tokens[i];
@@ -382,22 +382,22 @@ function parserFor(fm, findings, file) {
382
382
  replacement.push(part);
383
383
  return part;
384
384
  };
385
- push("hl_preview_poetry_open", "div", 1, [["class", "poetry"]]).map = token.map;
386
- for (const stanza of poetryStanzas(poem.body)) {
385
+ push("hl_preview_verse_open", "div", 1, [["class", "verse"]]).map = token.map;
386
+ for (const stanza of verseStanzas(poem.body)) {
387
387
  push("paragraph_open", "p", 1);
388
388
  stanza.forEach(({ text, level }, row) => {
389
389
  if (row) push("hardbreak", "br", 0);
390
- push("hl_preview_poetry_line_open", "span", 1, [
391
- ["class", `poetry-line i${level}`],
390
+ push("hl_preview_verse_line_open", "span", 1, [
391
+ ["class", `verse-line i${level}`],
392
392
  ]);
393
393
  const line = push("inline", "", 0);
394
394
  line.content = text;
395
395
  line.children = [];
396
- push("hl_preview_poetry_line_close", "span", -1);
396
+ push("hl_preview_verse_line_close", "span", -1);
397
397
  });
398
398
  push("paragraph_close", "p", -1);
399
399
  }
400
- push("hl_preview_poetry_close", "div", -1);
400
+ push("hl_preview_verse_close", "div", -1);
401
401
  state.tokens.splice(i, 1, ...replacement);
402
402
  }
403
403
  // Work backwards so figure ranges and heading removals retain source maps.
@@ -583,7 +583,7 @@ export function renderPreview(
583
583
  let frontmatter = {},
584
584
  body = String(text ?? "");
585
585
  try {
586
- const parsed = matter(body);
586
+ const parsed = readMatter(body);
587
587
  frontmatter =
588
588
  parsed.data && typeof parsed.data === "object" && !Array.isArray(parsed.data) ?
589
589
  structuredClone(parsed.data)
@@ -665,7 +665,7 @@ export function previewStyles() {
665
665
  .hl-preview .alert-warning { --hl-accent:var(--hl-warning); --hl-alert-bg:var(--hl-warning-bg); }
666
666
  .hl-preview .alert .alert-title { color:var(--hl-accent); font-weight:700; opacity:1; }
667
667
  .hl-preview .alert .alert-icon { width:1em; height:1em; margin-right:.5em; vertical-align:-.1em; color:var(--hl-accent); fill:currentColor; }
668
- .hl-preview .poetry-line { white-space:pre-wrap; }
669
- ${Array.from({ length: 9 }, (_, n) => `.hl-preview .poetry-line.i${n} { padding-left:${n * 2}em; }`).join("\n")}
668
+ .hl-preview .verse-line { white-space:pre-wrap; }
669
+ ${Array.from({ length: 9 }, (_, n) => `.hl-preview .verse-line.i${n} { padding-left:${n * 2}em; }`).join("\n")}
670
670
  `;
671
671
  }
@@ -28,6 +28,7 @@
28
28
 
29
29
  import { parseAddress, readCanonicalKey, renderAddress, isAddressTuple } from "./address.mjs";
30
30
  import { collectAnchors } from "./anchors.mjs";
31
+ import { keyCaseCollisionDiagnostics } from "./key-case-collisions.mjs";
31
32
  import { expandContentTables } from "./content-tables.mjs";
32
33
  import { NOTE_VOCABULARY, isGmNote } from "./note-vocabulary.mjs";
33
34
  import { encodeAddresses, flattenPublishedAddresses } from "./address-values.mjs";
@@ -260,12 +261,14 @@ export function findSqlBlocks(markdown) {
260
261
  * @param {object} [opts.addressContext] - Address resolution context.
261
262
  * @param {"all"|"public"} [opts.audience] - Whether to exclude GM notes from
262
263
  * local and dependency relations.
264
+ * @param {string} [opts.contentBase] - The directory the records' paths are
265
+ * relative to, so a refused key is reported at its file and line.
263
266
  * @returns {Promise<{query: (sql: string) => Promise<object[]>,
264
267
  * close: () => Promise<void>}>} The open database.
265
268
  */
266
269
  export async function openNotesDatabase(
267
270
  records,
268
- { dir, dependencies = [], addressContext, audience = "all" } = {},
271
+ { dir, dependencies = [], addressContext, audience = "all", contentBase } = {},
269
272
  ) {
270
273
  const { DuckDBInstance } = await import("@duckdb/node-api");
271
274
  const base = dir ?? fs.mkdtempSync(path.join(os.tmpdir(), "content-sql-"));
@@ -276,7 +279,20 @@ export async function openNotesDatabase(
276
279
  const instance = await DuckDBInstance.create(":memory:");
277
280
  const connection = await instance.connect();
278
281
  await connection.run("SET threads=1");
279
- await createRelations(connection, jsonl, encoded, "", audience);
282
+ try {
283
+ await createRelations(connection, jsonl, encoded, "", audience);
284
+ } catch (err) {
285
+ // DuckDB names the clashing key but not the notes that spell it.
286
+ if (/Duplicate name ".*" in struct/.test(String(err?.message))) {
287
+ const lines = keyCaseCollisionDiagnostics(records, { contentBase });
288
+ if (lines.length) {
289
+ const located = new Error(lines.join("\n"));
290
+ /** @type {any} */ (located).located = true;
291
+ throw located;
292
+ }
293
+ }
294
+ throw err;
295
+ }
280
296
  await createEventRelation(connection, path.join(base, "events.jsonl"), encoded, "", audience);
281
297
  await createNameRelation(connection, path.join(base, "names.jsonl"), encoded, "", audience);
282
298
  await createRunRelation(connection, path.join(base, "runs.jsonl"), encoded, "", audience);
@@ -630,7 +646,7 @@ async function createNameRelation(connection, file, records, prefix, audience) {
630
646
  }
631
647
 
632
648
  /**
633
- * Create the `runs` relation: one row per span, div and poetry fence an own
649
+ * Create the `runs` relation: one row per span, div and verse fence an own
634
650
  * note marks with `lang=`, `font=` or `map=`, with the columns
635
651
  * {@link module:engine/run-rows.RUN_COLUMNS} names. The columns are declared
636
652
  * rather than inferred, so every value stays a string and none becomes a
@@ -1732,6 +1748,7 @@ export async function prepareTreeSqlTables(
1732
1748
  (await openNotesDatabase(allRecords, {
1733
1749
  dependencies: await cachedDependencies(config),
1734
1750
  audience,
1751
+ contentBase,
1735
1752
  }));
1736
1753
  try {
1737
1754
  const generated = await generatedSectionsFor(db, indexRecords, contentBase, {
@@ -234,7 +234,7 @@ const FRONTMATTER_WIKILINK = new RegExp(WIKILINK.source, "g");
234
234
  * Values are read from the *parsed* frontmatter, so a `[[` inside a YAML comment
235
235
  * is not a hit, and every hit can be named by the path a reader would look at.
236
236
  *
237
- * @param {unknown} fm - Parsed frontmatter, as `gray-matter` returns it.
237
+ * @param {unknown} fm - Parsed frontmatter, as `readMatter` returns it.
238
238
  * @returns {Array<{path: string, link: string}>} In reading order; `path` is the
239
239
  * dotted key path of the offending value (`government.summary`, `aliases.1`).
240
240
  */
@@ -0,0 +1,349 @@
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 regular forms a language's own rules derive from its lexicon.** Each
16
+ * rule a language note states in machine-readable form gives a lexicon word
17
+ * further forms, and nothing else does:
18
+ *
19
+ * - **the noun plural**: `data.grammar.plural`, or the word's own `plural`;
20
+ * - **the verb pattern**: a verb's citation form with one ending of
21
+ * `data.grammar.verbs.persons`, which takes the place of a closing
22
+ * `data.grammar.verbs.replaces` where the pattern states one (see
23
+ * {@link module:engine/verb-pattern.verbForm});
24
+ * - **a replacing suffix**: a suffix of `data.affixes` that states `replaces`,
25
+ * or its `front` form, on a word whose class base its `attachesTo` names,
26
+ * or on any word where it states none: `vowel` takes the place of a closing
27
+ * vowel (a letter whose base is `a`, `e`, `i`, `o` or `u`), letters take the
28
+ * place of those letters, and a stem closing otherwise takes the suffix
29
+ * whole. An affix that states no `replaces` derives nothing here;
30
+ * - **agreement**: a word whose class base `data.grammar.agreement.classes`
31
+ * names, closing on one of `data.grammar.agreement.endings` (the longest
32
+ * that leaves a stem), also takes each other ending in its place, and each
33
+ * of those forms and the word itself takes the plural the
34
+ * `data.grammar.plural` rule gives;
35
+ * - **participles**: a verb closing on one of `data.grammar.participles.root`
36
+ * (the longest that leaves a root) drops it, and each suffix of `data.affixes`
37
+ * that `data.grammar.participles.affixes` names, by its `form`, attaches to
38
+ * the root as its `replaces` says, or whole where it states none. A
39
+ * participle closing on an agreement ending agrees.
40
+ *
41
+ * The mapped-run word check ({@link module:engine/run-words}) and
42
+ * `package-build lexicon --derived` both read {@link derivedForms}, so what
43
+ * the command lists is what the check accepts.
44
+ *
45
+ * @module
46
+ */
47
+
48
+ import { pluralByRule, pluralOf } from "./dictionary-rows.mjs";
49
+ import { replaceClosing, verbForm, verbPattern } from "./verb-pattern.mjs";
50
+ import { parseWordClass } from "./word-classes.mjs";
51
+ import { WORD } from "./word-pattern.mjs";
52
+
53
+ /** Whether a value is a plain map. */
54
+ const isMap = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
55
+
56
+ /** A list value, or an empty one. */
57
+ const listOf = (value) => (Array.isArray(value) ? value : []);
58
+
59
+ /** The characters a compound is split at. */
60
+ const JOINS = /[–-]/u;
61
+
62
+ /**
63
+ * A word as it is compared: NFC, lowercase, `’` read as `'`.
64
+ *
65
+ * @param {string} word - The word.
66
+ * @returns {string} Its folded form.
67
+ */
68
+ export function foldWord(word) {
69
+ return word.normalize("NFC").toLowerCase().replace(/’/gu, "'");
70
+ }
71
+
72
+ /** Whether a character is a vowel: its base letter is a, e, i, o or u. */
73
+ const isVowel = (char) => /^[aeiou]/iu.test(char.normalize("NFD"));
74
+
75
+ /**
76
+ * Add a text's words to a set, each whole and each part of a compound.
77
+ *
78
+ * @param {Set<string>} set - The set.
79
+ * @param {string} text - A form or a name.
80
+ */
81
+ export function addWords(set, text) {
82
+ for (const [word] of text.matchAll(WORD)) {
83
+ const folded = foldWord(word);
84
+ set.add(folded);
85
+ for (const part of folded.split(JOINS)) if (part) set.add(part);
86
+ }
87
+ }
88
+
89
+ /**
90
+ * The form a suffix makes of a stem.
91
+ *
92
+ * @param {string} stem - The stem, folded.
93
+ * @param {string} suffix - The suffix, folded, with no hyphen.
94
+ * @param {string|undefined} replaces - The affix's `replaces`; none adds the
95
+ * suffix whole.
96
+ * @returns {string} The derived form.
97
+ */
98
+ function attach(stem, suffix, replaces) {
99
+ if (replaces === "vowel") {
100
+ const last = Array.from(stem).at(-1);
101
+ return last !== undefined && isVowel(last) ?
102
+ stem.slice(0, -last.length) + suffix
103
+ : stem + suffix;
104
+ }
105
+ return replaceClosing(stem, suffix, replaces === undefined ? undefined : foldWord(replaces));
106
+ }
107
+
108
+ /**
109
+ * An affix's suffixes: its `form` and its `front` form, each written `-x`,
110
+ * folded and without the hyphen.
111
+ *
112
+ * @param {Record<string, any>} affix - One `data.affixes` entry.
113
+ * @returns {string[]} The suffixes.
114
+ */
115
+ function suffixesOf(affix) {
116
+ const out = [];
117
+ for (const written of [affix.form, affix.front]) {
118
+ if (typeof written !== "string") continue;
119
+ const form = foldWord(written.trim());
120
+ if (!/^[-–]/u.test(form) || /[-–]$/u.test(form)) continue;
121
+ const suffix = form.replace(/^[-–]+/u, "");
122
+ if (suffix) out.push(suffix);
123
+ }
124
+ return out;
125
+ }
126
+
127
+ /**
128
+ * The suffixes a language's affixes state `replaces` for, each with the class
129
+ * bases its `attachesTo` names.
130
+ *
131
+ * @param {Record<string, any>} data - The language note's `data`.
132
+ * @returns {Array<{suffix: string, replaces: string, classes: ReadonlySet<string>|undefined}>}
133
+ * The suffixes, folded; `classes` is `undefined` where the affix attaches to
134
+ * every class.
135
+ */
136
+ function replacingSuffixes(data) {
137
+ const out = [];
138
+ for (const affix of listOf(data.affixes)) {
139
+ if (!isMap(affix) || typeof affix.replaces !== "string") continue;
140
+ const classes =
141
+ Array.isArray(affix.attachesTo) ?
142
+ new Set(
143
+ affix.attachesTo
144
+ .filter((item) => typeof item === "string")
145
+ .map((item) => item.trim()),
146
+ )
147
+ : undefined;
148
+ for (const suffix of suffixesOf(affix))
149
+ out.push({ suffix, replaces: affix.replaces, classes });
150
+ }
151
+ return out;
152
+ }
153
+
154
+ /**
155
+ * The strings of a list, trimmed and folded, keeping those with letters.
156
+ *
157
+ * @param {unknown} value - The list.
158
+ * @returns {string[]} The strings.
159
+ */
160
+ const foldedStrings = (value) =>
161
+ listOf(value)
162
+ .filter((item) => typeof item === "string" && item.trim())
163
+ .map((item) => foldWord(item.trim()));
164
+
165
+ /**
166
+ * A language's agreement rule, as derivation reads it.
167
+ *
168
+ * @typedef {object} Agreement
169
+ * @property {ReadonlySet<string>} classes - The class bases that agree.
170
+ * @property {readonly string[]} endings - The endings, folded, longest first.
171
+ */
172
+
173
+ /**
174
+ * Read a language's `data.grammar.agreement`.
175
+ *
176
+ * @param {unknown} value - The authored value.
177
+ * @returns {Agreement|undefined} The rule, or `undefined` where none is stated
178
+ * in full.
179
+ */
180
+ function agreementOf(value) {
181
+ if (!isMap(value)) return undefined;
182
+ const classes = listOf(value.classes).filter((item) => typeof item === "string");
183
+ const endings = [
184
+ ...new Set(
185
+ listOf(value.endings)
186
+ .filter((item) => typeof item === "string")
187
+ .map((item) => foldWord(item.trim())),
188
+ ),
189
+ ];
190
+ if (!classes.length || endings.length < 2) return undefined;
191
+ return {
192
+ classes: new Set(classes.map((item) => item.trim())),
193
+ endings: endings.sort((a, b) => b.length - a.length),
194
+ };
195
+ }
196
+
197
+ /**
198
+ * The forms an agreeing word takes beside its own.
199
+ *
200
+ * @param {string} form - The word, folded.
201
+ * @param {Agreement} agreement - The rule.
202
+ * @param {Record<string, any>|undefined} grammar - The language's grammar,
203
+ * for its plural rule.
204
+ * @returns {string[]} The forms; none where the word closes on no ending.
205
+ */
206
+ function agreeingForms(form, agreement, grammar) {
207
+ const ending = agreement.endings.find((one) => form.length > one.length && form.endsWith(one));
208
+ if (ending === undefined) return [];
209
+ const stem = form.slice(0, form.length - ending.length);
210
+ const singulars = agreement.endings.map((one) => stem + one);
211
+ const out = singulars.filter((one) => one !== form);
212
+ for (const one of singulars) {
213
+ const plural = pluralByRule(one, grammar);
214
+ if (plural) out.push(foldWord(plural));
215
+ }
216
+ return out;
217
+ }
218
+
219
+ /**
220
+ * A language's participle rule, as derivation reads it.
221
+ *
222
+ * @typedef {object} Participles
223
+ * @property {readonly string[]} root - The root endings, folded, longest
224
+ * first.
225
+ * @property {Array<{suffix: string, replaces: string|undefined}>} suffixes -
226
+ * The participle suffixes, folded.
227
+ */
228
+
229
+ /**
230
+ * Read a language's `data.grammar.participles` against its affixes.
231
+ *
232
+ * @param {unknown} value - The authored value.
233
+ * @param {Record<string, any>} data - The language note's `data`.
234
+ * @returns {Participles|undefined} The rule, or `undefined` where it names no
235
+ * root ending or no suffix the affixes state.
236
+ */
237
+ function participlesOf(value, data) {
238
+ if (!isMap(value)) return undefined;
239
+ const root = [...new Set(foldedStrings(value.root))].sort((a, b) => b.length - a.length);
240
+ const named = new Set(
241
+ listOf(value.affixes)
242
+ .filter((item) => typeof item === "string")
243
+ .map((item) => item.trim()),
244
+ );
245
+ const suffixes = [];
246
+ for (const affix of listOf(data.affixes)) {
247
+ if (!isMap(affix) || typeof affix.form !== "string" || !named.has(affix.form.trim()))
248
+ continue;
249
+ const replaces = typeof affix.replaces === "string" ? affix.replaces : undefined;
250
+ for (const suffix of suffixesOf(affix)) suffixes.push({ suffix, replaces });
251
+ }
252
+ if (!root.length || !suffixes.length) return undefined;
253
+ return { root, suffixes };
254
+ }
255
+
256
+ /**
257
+ * The participles of a verb.
258
+ *
259
+ * @param {string} citation - The citation form, folded.
260
+ * @param {Participles} participles - The rule.
261
+ * @returns {string[]} The participles; none where the verb closes on no root
262
+ * ending.
263
+ */
264
+ function participleForms(citation, participles) {
265
+ const ending = participles.root.find(
266
+ (one) => citation.length > one.length && citation.endsWith(one),
267
+ );
268
+ if (ending === undefined) return [];
269
+ const root = citation.slice(0, citation.length - ending.length);
270
+ return participles.suffixes.map(({ suffix, replaces }) => attach(root, suffix, replaces));
271
+ }
272
+
273
+ /**
274
+ * One form a rule derives from a lexicon word.
275
+ *
276
+ * @typedef {object} DerivedForm
277
+ * @property {string} form - The form, folded.
278
+ * @property {Record<string, any>} word - The `data.words` entry it comes from.
279
+ */
280
+
281
+ /**
282
+ * Every form the language's rules derive from its lexicon words, in
283
+ * `data.words` order, each once, leaving out a form that is a lexicon word.
284
+ *
285
+ * @param {Record<string, any>|undefined} data - The language note's `data`.
286
+ * @returns {DerivedForm[]} The forms.
287
+ */
288
+ export function derivedForms(data) {
289
+ const words = listOf(data?.words).filter(
290
+ (word) => isMap(word) && typeof word.form === "string",
291
+ );
292
+ const grammar = isMap(data?.grammar) ? data.grammar : undefined;
293
+ const verbs = verbPattern(grammar?.verbs);
294
+ const replaces = verbs?.replaces === undefined ? undefined : foldWord(verbs.replaces);
295
+ const suffixes = replacingSuffixes(data ?? {});
296
+ const agreement = agreementOf(grammar?.agreement);
297
+ const participles = participlesOf(grammar?.participles, data ?? {});
298
+ const seen = new Set(words.map((word) => foldWord(word.form)));
299
+ const out = [];
300
+ for (const word of words) {
301
+ const add = (form) => {
302
+ const folded = foldWord(form);
303
+ if (!folded || seen.has(folded)) return;
304
+ seen.add(folded);
305
+ out.push({ form: folded, word });
306
+ };
307
+ const folded = foldWord(word.form);
308
+ const base = parseWordClass(word.class).base;
309
+ const plural = pluralOf(word, grammar);
310
+ if (plural) add(plural);
311
+ if (verbs && base === "v")
312
+ for (const { ending } of verbs.endings)
313
+ add(verbForm(folded, foldWord(ending), replaces));
314
+ const attaching = suffixes.filter(
315
+ ({ classes }) => classes === undefined || (base !== undefined && classes.has(base)),
316
+ );
317
+ if (attaching.length)
318
+ for (const [stem] of word.form.matchAll(WORD)) {
319
+ const one = foldWord(stem);
320
+ for (const { suffix, replaces: closing } of attaching)
321
+ add(attach(one, suffix, closing));
322
+ }
323
+ if (agreement && base !== undefined && agreement.classes.has(base))
324
+ for (const form of agreeingForms(folded, agreement, grammar)) add(form);
325
+ if (participles && base === "v")
326
+ for (const participle of participleForms(folded, participles)) {
327
+ add(participle);
328
+ if (agreement)
329
+ for (const form of agreeingForms(participle, agreement, grammar)) add(form);
330
+ }
331
+ }
332
+ return out;
333
+ }
334
+
335
+ /**
336
+ * The words a language note accepts of its own: its forms, and what its rules
337
+ * derive from them (see {@link derivedForms}), each whole and each part of a
338
+ * compound.
339
+ *
340
+ * @param {Record<string, any>} data - The language note's `data`.
341
+ * @returns {Set<string>} The words, folded.
342
+ */
343
+ export function lexiconWords(data) {
344
+ const accepted = new Set();
345
+ for (const word of listOf(data?.words))
346
+ if (isMap(word) && typeof word.form === "string") addWords(accepted, word.form);
347
+ for (const { form } of derivedForms(data)) addWords(accepted, form);
348
+ return accepted;
349
+ }
@@ -368,6 +368,15 @@ export const AFFIX_FIELDS = Object.freeze([
368
368
  "What it takes the place of at the close of the stem: `vowel` for any closing " +
369
369
  "vowel, or the letters it replaces; added to the whole stem where absent.",
370
370
  }),
371
+ Object.freeze({
372
+ name: "attachesTo",
373
+ kind: "list",
374
+ shape: "a list of word classes, such as `[n, adj]`",
375
+ entries: Object.freeze({ kind: "string", shape: "a word class, such as `n`" }),
376
+ describe:
377
+ "The word classes it attaches to, by their base; every class where absent. A " +
378
+ "suffix stating `replaces` derives forms of these classes' words alone.",
379
+ }),
371
380
  Object.freeze({
372
381
  name: "lengthens",
373
382
  kind: "boolean",
@@ -1378,33 +1378,109 @@ a.tag:hover {
1378
1378
  }
1379
1379
 
1380
1380
  /* Verse lines are hard breaks in the markup; paragraphs mark stanzas. */
1381
- .single-content .poetry {
1381
+ .single-content .verse {
1382
1382
  margin: 1.5rem 0;
1383
1383
  line-height: 1.7;
1384
1384
  }
1385
1385
 
1386
- .single-content .poetry p {
1386
+ .single-content .verse p {
1387
1387
  margin: 0;
1388
1388
  }
1389
1389
 
1390
- .single-content .poetry p + p {
1390
+ .single-content .verse p + p {
1391
1391
  margin-top: 1em;
1392
1392
  }
1393
1393
 
1394
- .single-content .poetry .poetry-line {
1394
+ .single-content .verse .verse-line {
1395
1395
  display: inline-block;
1396
- margin-inline-start: var(--poetry-indent, 0em);
1396
+ margin-inline-start: var(--verse-indent, 0em);
1397
1397
  white-space: pre-wrap;
1398
1398
  }
1399
1399
 
1400
- .single-content .poetry .poetry-line.i1 { --poetry-indent: 1.25em; }
1401
- .single-content .poetry .poetry-line.i2 { --poetry-indent: 2.5em; }
1402
- .single-content .poetry .poetry-line.i3 { --poetry-indent: 3.75em; }
1403
- .single-content .poetry .poetry-line.i4 { --poetry-indent: 5em; }
1404
- .single-content .poetry .poetry-line.i5 { --poetry-indent: 6.25em; }
1405
- .single-content .poetry .poetry-line.i6 { --poetry-indent: 7.5em; }
1406
- .single-content .poetry .poetry-line.i7 { --poetry-indent: 8.75em; }
1407
- .single-content .poetry .poetry-line.i8 { --poetry-indent: 10em; }
1400
+ .single-content .verse .verse-line.i1 { --verse-indent: 1.25em; }
1401
+ .single-content .verse .verse-line.i2 { --verse-indent: 2.5em; }
1402
+ .single-content .verse .verse-line.i3 { --verse-indent: 3.75em; }
1403
+ .single-content .verse .verse-line.i4 { --verse-indent: 5em; }
1404
+ .single-content .verse .verse-line.i5 { --verse-indent: 6.25em; }
1405
+ .single-content .verse .verse-line.i6 { --verse-indent: 7.5em; }
1406
+ .single-content .verse .verse-line.i7 { --verse-indent: 8.75em; }
1407
+ .single-content .verse .verse-line.i8 { --verse-indent: 10em; }
1408
+
1409
+ /* Numbered verse carries its numbers in a margin. A verse in columns is one
1410
+ grid per verse line: its number, then each column's parts stacked, the
1411
+ first in the normal tone and the rest muted, every part in the body serif
1412
+ unless its face sets it. A narrow verse stacks the columns of each line. */
1413
+ .single-content .verse.verse-numbered {
1414
+ position: relative;
1415
+ padding-inline-start: 3em;
1416
+ }
1417
+
1418
+ .single-content .verse .verse-number {
1419
+ color: var(--text-muted);
1420
+ font-size: 0.8em;
1421
+ }
1422
+
1423
+ .single-content .verse-numbered .verse-line .verse-number {
1424
+ position: absolute;
1425
+ inset-inline-start: 0;
1426
+ width: 2.2em;
1427
+ text-align: end;
1428
+ }
1429
+
1430
+ .single-content .verse.verse-columns {
1431
+ container-type: inline-size;
1432
+ }
1433
+
1434
+ .single-content .verse-numbered.verse-columns {
1435
+ padding-inline-start: 0;
1436
+ }
1437
+
1438
+ .single-content .verse-columns .verse-row {
1439
+ display: grid;
1440
+ grid-auto-flow: column;
1441
+ grid-auto-columns: 1fr;
1442
+ column-gap: 1.5rem;
1443
+ margin-bottom: 0.5em;
1444
+ }
1445
+
1446
+ .single-content .verse-numbered.verse-columns .verse-row {
1447
+ grid-template-columns: 2.5em;
1448
+ }
1449
+
1450
+ .single-content .verse-columns .verse-number {
1451
+ text-align: end;
1452
+ padding-top: 0.2em;
1453
+ }
1454
+
1455
+ .single-content .verse-columns :is(.verse-column, .verse-part) {
1456
+ display: block;
1457
+ white-space: pre-wrap;
1458
+ }
1459
+
1460
+ .single-content .verse-columns .verse-part {
1461
+ font-family: var(--font-body);
1462
+ }
1463
+
1464
+ .single-content .verse-columns .verse-secondary {
1465
+ color: var(--text-muted);
1466
+ }
1467
+
1468
+ .single-content .verse-columns .verse-part[dir="rtl"] {
1469
+ text-align: start;
1470
+ }
1471
+
1472
+ @container (max-width: 32rem) {
1473
+ .single-content .verse-columns .verse-row {
1474
+ grid-auto-flow: row;
1475
+ grid-template-columns: 1fr;
1476
+ }
1477
+ .single-content .verse-numbered.verse-columns .verse-row {
1478
+ grid-template-columns: 2.5em 1fr;
1479
+ }
1480
+ .single-content .verse-numbered.verse-columns .verse-column {
1481
+ grid-column: 2;
1482
+ }
1483
+ }
1408
1484
 
1409
1485
  /* Ornamental horizontal rule: thin line flanking a centered gold
1410
1486
  diamond. Uses gradients on ::before/::after to keep the markup
@@ -3021,6 +3097,8 @@ summary.info-sidebar-title::after {
3021
3097
  .single-body .dictionary > div > dd { display:inline; margin:0; font-weight:normal; }
3022
3098
  .single-body .dictionary > div > dd > :is(p, ul, ol, dl, blockquote, pre, table, div) { text-indent:0; }
3023
3099
  .single-body .dictionary > div > dd > p:first-child { display:inline; }
3100
+ .single-body .dictionary > div > dd + dd { display:block; text-indent:0; }
3101
+ .single-body .dictionary > div > dd + dd > p:first-child { display:block; }
3024
3102
  @media (max-width:600px) { .single-body .dictionary { --indent:100px; } }
3025
3103
 
3026
3104
  /* ---- Text in one of the faces ----
@@ -3028,7 +3106,7 @@ summary.info-sidebar-title::after {
3028
3106
  right-to-left block or verse reads from the right. `font=serif`, `sans`
3029
3107
  and `mono` set the site's own serif and mono, and the reader's sans. */
3030
3108
  .script-block { margin:1rem 0; line-height:1.7; }
3031
- .script-block[dir="rtl"], .poetry[dir="rtl"] { text-align:start; }
3109
+ .script-block[dir="rtl"], .verse[dir="rtl"] { text-align:start; }
3032
3110
  .font-serif { font-family:var(--font-body); }
3033
3111
  .font-sans { font-family:sans-serif; }
3034
3112
  .font-mono { font-family:var(--font-mono); }