@heroiclands/package-build 23.13.0 → 24.0.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 (233) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +2 -1
  3. package/assets/preview/gitea.ini +12 -7
  4. package/assets/styles/content-base.css +150 -1
  5. package/bin/content-commands.mjs +356 -210
  6. package/bin/package-build.mjs +3 -2
  7. package/ci/check-banners.mjs +7 -7
  8. package/config.mjs +21 -6
  9. package/content-config.mjs +199 -80
  10. package/docs/api.md +93 -72
  11. package/docs/authoring/links-and-markup.md +316 -78
  12. package/docs/commands.md +244 -78
  13. package/docs/configuration.md +210 -102
  14. package/docs/content-format.md +2 -2
  15. package/docs/diagnostics.md +2 -2
  16. package/docs/getting-started.md +1 -1
  17. package/docs/guides/icons.md +1 -1
  18. package/docs/guides/module-project.md +5 -8
  19. package/docs/guides/release-and-deploy.md +2 -2
  20. package/docs/guides/site.md +112 -23
  21. package/docs/guides/system-project.md +1 -1
  22. package/docs/guides/writing-in-invented-scripts.md +1247 -0
  23. package/docs/index.md +1 -0
  24. package/docs/project-setup.md +28 -30
  25. package/docs/reference/format-details.md +589 -308
  26. package/docs/reference/note-types.md +40 -10
  27. package/docs/reference/theme-pages.md +127 -108
  28. package/docs/source-preview.md +15 -1
  29. package/engine/asset-shipping.mjs +22 -10
  30. package/engine/base-compiler.mjs +62 -104
  31. package/engine/changelog-lint.mjs +1 -1
  32. package/engine/content-address.mjs +1 -1
  33. package/engine/content-alerts.mjs +8 -4
  34. package/engine/content-blocks.mjs +26 -11
  35. package/engine/content-embeds.mjs +1 -1
  36. package/engine/content-expand.mjs +376 -0
  37. package/engine/content-figures.mjs +13 -6
  38. package/engine/content-format.yaml +8 -0
  39. package/engine/content-glyphs.mjs +213 -0
  40. package/engine/content-images.mjs +6 -7
  41. package/engine/content-links.mjs +3 -3
  42. package/engine/content-lint.mjs +1 -1
  43. package/engine/content-render.mjs +453 -0
  44. package/engine/content-scripts.mjs +326 -27
  45. package/engine/content-spans.mjs +4 -1
  46. package/engine/content-styles.mjs +91 -0
  47. package/engine/content-symbols.mjs +186 -0
  48. package/engine/content-verse.mjs +6 -18
  49. package/engine/core-report.mjs +169 -0
  50. package/engine/core-vocabulary.mjs +208 -0
  51. package/engine/dictionary-html.mjs +119 -0
  52. package/engine/field-reference.mjs +1 -1
  53. package/engine/frontmatter-lint.mjs +1 -1
  54. package/engine/heading-attributes.mjs +89 -0
  55. package/engine/helpers.mjs +61 -101
  56. package/engine/homepage.mjs +17 -29
  57. package/engine/html-minify.mjs +70 -0
  58. package/engine/index.mjs +8 -5
  59. package/engine/language-rows.mjs +8 -2
  60. package/engine/language-terms.mjs +115 -14
  61. package/engine/language-words.mjs +12 -0
  62. package/engine/logographic.mjs +255 -0
  63. package/engine/map-lexicon.mjs +221 -25
  64. package/engine/map-places.mjs +5 -4
  65. package/engine/markdown-expressions.mjs +0 -2
  66. package/engine/markdownlint-run.mjs +9 -3
  67. package/engine/markup-validation.mjs +21 -0
  68. package/engine/metre-declaration.mjs +134 -0
  69. package/engine/note-matter.mjs +2 -2
  70. package/engine/note-vocabulary.mjs +187 -3
  71. package/engine/pdf-build.mjs +68 -133
  72. package/engine/pdf-render.mjs +102 -44
  73. package/engine/post-fields.mjs +157 -0
  74. package/engine/preview-index.mjs +130 -0
  75. package/engine/preview-source.mjs +450 -0
  76. package/engine/project-init.mjs +5 -12
  77. package/engine/prose-config.mjs +3 -3
  78. package/engine/run-words.mjs +3 -2
  79. package/engine/script-emphasis-terms.mjs +38 -0
  80. package/engine/script-emphasis.mjs +295 -0
  81. package/engine/script-font-delivery.mjs +4 -4
  82. package/engine/site-build.mjs +259 -314
  83. package/engine/site-config.mjs +26 -238
  84. package/engine/site-files.mjs +175 -0
  85. package/engine/site-index.mjs +9 -4
  86. package/engine/site-preview.mjs +50 -523
  87. package/engine/site-render.mjs +593 -0
  88. package/engine/site-root.mjs +22 -13
  89. package/engine/site-search.mjs +161 -0
  90. package/engine/site-serve.mjs +273 -0
  91. package/engine/site-theme-banner.mjs +154 -0
  92. package/engine/site-theme-urls.mjs +125 -0
  93. package/engine/site-theme.mjs +926 -0
  94. package/engine/text-maps.mjs +57 -10
  95. package/engine/typographer.mjs +248 -0
  96. package/engine/verse-metre.mjs +357 -0
  97. package/engine/wikilink-syntax.mjs +29 -47
  98. package/engine/wikilinks.mjs +758 -279
  99. package/engine/word-forms.mjs +39 -24
  100. package/engine/word-rows.mjs +12 -2
  101. package/engine/word-terms.mjs +29 -4
  102. package/manifest.mjs +3 -4
  103. package/package.json +10 -7
  104. package/{hugo-theme → site-theme}/data/banners.yaml +6 -10
  105. package/site-theme/layouts/base.hbs +31 -0
  106. package/site-theme/layouts/blog-list.hbs +30 -0
  107. package/site-theme/layouts/blog-post.hbs +40 -0
  108. package/site-theme/layouts/home.hbs +35 -0
  109. package/site-theme/layouts/list.hbs +20 -0
  110. package/site-theme/layouts/not-found.hbs +31 -0
  111. package/site-theme/layouts/page.hbs +7 -0
  112. package/site-theme/partials/copy-buttons.hbs +37 -0
  113. package/site-theme/partials/draft-notice.hbs +5 -0
  114. package/site-theme/partials/exturl.hbs +2 -0
  115. package/{hugo-theme/layouts/partials/footer.html → site-theme/partials/footer.hbs} +10 -7
  116. package/site-theme/partials/header.hbs +44 -0
  117. package/site-theme/partials/hero-banner.hbs +11 -0
  118. package/site-theme/partials/infobox-entry.hbs +2 -0
  119. package/site-theme/partials/infobox-section.hbs +31 -0
  120. package/site-theme/partials/infobox-value.hbs +2 -0
  121. package/site-theme/partials/infobox.hbs +18 -0
  122. package/site-theme/partials/list-members.hbs +9 -0
  123. package/site-theme/partials/nav-links.hbs +5 -0
  124. package/site-theme/partials/page-body.hbs +48 -0
  125. package/site-theme/partials/page-list.hbs +14 -0
  126. package/site-theme/partials/related.hbs +24 -0
  127. package/site-theme/partials/search-meta.hbs +6 -0
  128. package/site-theme/partials/search-names.hbs +2 -0
  129. package/site-theme/partials/search.hbs +52 -0
  130. package/site-theme/partials/tags.hbs +4 -0
  131. package/site-theme/partials/toc.hbs +7 -0
  132. package/{hugo-theme → site-theme}/static/css/style.css +14 -0
  133. package/stage.mjs +1 -2
  134. package/types/config.d.mts +8 -4
  135. package/types/content-config.d.mts +15 -15
  136. package/types/engine/asset-shipping.d.mts +5 -2
  137. package/types/engine/base-compiler.d.mts +20 -2
  138. package/types/engine/content-address.d.mts +1 -1
  139. package/types/engine/content-alerts.d.mts +8 -2
  140. package/types/engine/content-blocks.d.mts +11 -4
  141. package/types/engine/content-expand.d.mts +288 -0
  142. package/types/engine/content-figures.d.mts +11 -3
  143. package/types/engine/content-glyphs.d.mts +85 -0
  144. package/types/engine/content-images.d.mts +6 -7
  145. package/types/engine/content-links.d.mts +1 -1
  146. package/types/engine/content-render.d.mts +199 -0
  147. package/types/engine/content-scripts.d.mts +62 -0
  148. package/types/engine/content-spans.d.mts +1 -1
  149. package/types/engine/content-styles.d.mts +33 -0
  150. package/types/engine/content-symbols.d.mts +35 -0
  151. package/types/engine/core-report.d.mts +45 -0
  152. package/types/engine/core-vocabulary.d.mts +53 -0
  153. package/types/engine/dictionary-html.d.mts +8 -0
  154. package/types/engine/field-reference.d.mts +1 -1
  155. package/types/engine/heading-attributes.d.mts +20 -0
  156. package/types/engine/helpers.d.mts +3 -10
  157. package/types/engine/homepage.d.mts +11 -24
  158. package/types/engine/html-minify.d.mts +9 -0
  159. package/types/engine/index.d.mts +2 -1
  160. package/types/engine/language-terms.d.mts +24 -2
  161. package/types/engine/logographic.d.mts +45 -0
  162. package/types/engine/map-lexicon.d.mts +48 -8
  163. package/types/engine/map-places.d.mts +8 -0
  164. package/types/engine/markdown-expressions.d.mts +0 -2
  165. package/types/engine/metre-declaration.d.mts +39 -0
  166. package/types/engine/pdf-render.d.mts +6 -2
  167. package/types/engine/post-fields.d.mts +44 -0
  168. package/types/engine/preview-index.d.mts +72 -0
  169. package/types/engine/preview-source.d.mts +51 -0
  170. package/types/engine/script-emphasis-terms.d.mts +21 -0
  171. package/types/engine/script-emphasis.d.mts +84 -0
  172. package/types/engine/script-font-delivery.d.mts +4 -4
  173. package/types/engine/site-build.d.mts +71 -113
  174. package/types/engine/site-config.d.mts +14 -134
  175. package/types/engine/site-files.d.mts +73 -0
  176. package/types/engine/site-index.d.mts +11 -3
  177. package/types/engine/site-preview.d.mts +15 -19
  178. package/types/engine/site-render.d.mts +171 -0
  179. package/types/engine/site-root.d.mts +12 -4
  180. package/types/engine/site-search.d.mts +54 -0
  181. package/types/engine/site-serve.d.mts +44 -0
  182. package/types/engine/site-theme-banner.d.mts +51 -0
  183. package/types/engine/site-theme-urls.d.mts +60 -0
  184. package/types/engine/site-theme.d.mts +127 -0
  185. package/types/engine/text-maps.d.mts +17 -3
  186. package/types/engine/typographer.d.mts +11 -0
  187. package/types/engine/verse-metre.d.mts +131 -0
  188. package/types/engine/wikilink-syntax.d.mts +30 -48
  189. package/types/engine/wikilinks.d.mts +330 -14
  190. package/types/engine/word-forms.d.mts +3 -2
  191. package/types/manifest.d.mts +3 -4
  192. package/types/stage.d.mts +1 -2
  193. package/types/vendor/markdownlint.d.mts +8 -0
  194. package/vendor/markdownlint.mjs +20388 -0
  195. package/engine/web-wikilinks.mjs +0 -594
  196. package/hugo-theme/layouts/404.html +0 -85
  197. package/hugo-theme/layouts/_default/baseof.html +0 -67
  198. package/hugo-theme/layouts/_default/list.html +0 -139
  199. package/hugo-theme/layouts/_default/single.html +0 -4
  200. package/hugo-theme/layouts/blog/list.html +0 -54
  201. package/hugo-theme/layouts/blog-post/single.html +0 -36
  202. package/hugo-theme/layouts/index.html +0 -75
  203. package/hugo-theme/layouts/partials/banner-url.html +0 -141
  204. package/hugo-theme/layouts/partials/cdn-url.html +0 -36
  205. package/hugo-theme/layouts/partials/dictionary-lists.html +0 -58
  206. package/hugo-theme/layouts/partials/draft-notice.html +0 -41
  207. package/hugo-theme/layouts/partials/draft-state.html +0 -26
  208. package/hugo-theme/layouts/partials/header.html +0 -35
  209. package/hugo-theme/layouts/partials/hero-banner.html +0 -85
  210. package/hugo-theme/layouts/partials/infobox/entry.html +0 -18
  211. package/hugo-theme/layouts/partials/infobox/items.html +0 -26
  212. package/hugo-theme/layouts/partials/infobox/section.html +0 -56
  213. package/hugo-theme/layouts/partials/infobox/value.html +0 -43
  214. package/hugo-theme/layouts/partials/infobox.html +0 -57
  215. package/hugo-theme/layouts/partials/page-body.html +0 -152
  216. package/hugo-theme/layouts/partials/related.html +0 -138
  217. package/hugo-theme/layouts/partials/search.html +0 -84
  218. package/hugo-theme/layouts/partials/site-url.html +0 -32
  219. package/hugo-theme/layouts/partials/tags.html +0 -30
  220. package/hugo-theme/layouts/partials/toc.html +0 -28
  221. package/hugo-theme/layouts/partials/warn-missing-banner.html +0 -3
  222. package/hugo-theme/layouts/shortcodes/cartouche.html +0 -64
  223. package/hugo-theme/layouts/shortcodes/exturl.html +0 -12
  224. package/hugo-theme/layouts/shortcodes/glyph.html +0 -43
  225. package/hugo-theme/layouts/shortcodes/pagelist.html +0 -63
  226. package/hugo-theme/theme.toml +0 -11
  227. package/types/engine/web-wikilinks.d.mts +0 -107
  228. /package/{hugo-theme → site-theme}/data/banner-kinds.yaml +0 -0
  229. /package/{hugo-theme → site-theme}/static/css/sohl-icons.css +0 -0
  230. /package/{hugo-theme → site-theme}/static/fonts/game-icons-kb.woff2 +0 -0
  231. /package/{hugo-theme → site-theme}/static/img/banners/takheperu-hieroglyphs.svg +0 -0
  232. /package/{hugo-theme → site-theme}/static/img/cartouches/takheperu.svg +0 -0
  233. /package/{hugo-theme → site-theme}/static/js/inline-spoilers.js +0 -0
@@ -24,6 +24,7 @@
24
24
  * each declared in `data.classModifiers`, and a declared modifier no word
25
25
  * uses is a warning;
26
26
  * - a word's `field` is one of `data.fields`;
27
+ * - a word's `concept` names entries of the concept list, each once;
27
28
  * - a `borrowed` source names its `language`;
28
29
  * - no form stands twice in one language;
29
30
  * - a word's `gloss` states at least one sense;
@@ -61,6 +62,7 @@ import {
61
62
  pronunciationProblem,
62
63
  } from "./word-terms.mjs";
63
64
  import { WORD_CLASS_MODIFIERS, parseWordClass } from "./word-classes.mjs";
65
+ import { conceptIdsIn, conceptProblems } from "./core-vocabulary.mjs";
64
66
  import { lineageChain, pieceGlosses } from "./word-rows.mjs";
65
67
 
66
68
  const MODIFIER_TOKENS = new Set(WORD_CLASS_MODIFIERS.map((item) => item.token));
@@ -192,6 +194,7 @@ export function checkLanguageWords(note, { index } = {}) {
192
194
  }
193
195
  const used = new Set();
194
196
  const forms = new Map();
197
+ const concepts = conceptIdsIn(index);
195
198
  listOf(data.words).forEach((word, i) => {
196
199
  if (!isMap(word)) return;
197
200
  const at = (key) => ["data", "words", i, key];
@@ -226,6 +229,15 @@ export function checkLanguageWords(note, { index } = {}) {
226
229
  ),
227
230
  );
228
231
 
232
+ for (const problem of conceptProblems(word.concept, concepts))
233
+ findings.push(
234
+ finding(
235
+ problem.entry === null ? at("concept") : [...at("concept"), problem.entry],
236
+ "error",
237
+ problem.message,
238
+ ),
239
+ );
240
+
229
241
  if (typeof word.form === "string") {
230
242
  const key = word.form.normalize("NFC");
231
243
  if (forms.has(key))
@@ -0,0 +1,255 @@
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 logographic map writes meanings, so a word's writing comes from the
16
+ * lexicon and never from its sound.** A mapping note stating
17
+ * `data.map.logographic: true` writes each word of a run in this order:
18
+ *
19
+ * 1. **The word's own `native` entry** for the map, and for the run's face
20
+ * where an entry names one.
21
+ * 2. **Otherwise its parts.** A word stating `built` is written by the
22
+ * writings of its pieces joined in order: a piece is a word of the
23
+ * lexicon, matched exactly and then with its joining hyphens taken off, or
24
+ * an affix of `data.affixes` with its own `native`; a word piece resolves
25
+ * by this same rule. A noun's plural and a verb's present forms are written
26
+ * by the stem's writing followed by the ending's, which
27
+ * `data.grammar.native` states by the ending's name: `plural`, or one of
28
+ * the six persons. A hyphenated word no entry states is written by its
29
+ * parts, each by this rule.
30
+ * 3. **Otherwise the word has no writing**, which a run reports at the word.
31
+ *
32
+ * A run's word is matched case folded, so one roman form may stand for more
33
+ * than one entry. Where those entries are written alike the word takes that
34
+ * writing; where they differ, or one has a writing and another none, the word
35
+ * is ambiguous, and the finding names every entry's gloss. A part matched to
36
+ * entries written differently makes the word ambiguous the same way.
37
+ *
38
+ * @module
39
+ */
40
+
41
+ import { pluralOf } from "./dictionary-rows.mjs";
42
+ import { verbForm, verbPattern } from "./verb-pattern.mjs";
43
+ import { parseWordClass } from "./word-classes.mjs";
44
+ import { foldWord } from "./word-forms.mjs";
45
+ import { glossText } from "./word-terms.mjs";
46
+
47
+ /** Whether a value is a plain map. */
48
+ const isMap = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
49
+
50
+ /** A list value, or an empty one. */
51
+ const listOf = (value) => (Array.isArray(value) ? value : []);
52
+
53
+ /** A form with its joining hyphens taken off. */
54
+ const bare = (form) => form.replace(/^[-–]+|[-–]+$/gu, "");
55
+
56
+ /** The characters a hyphenated word is split at. */
57
+ const JOINS = /[–-]/u;
58
+
59
+ /**
60
+ * Where a word's writing comes from, or why it has none.
61
+ *
62
+ * @typedef {{text: string, source: "own"|"parts"}
63
+ * | {missing: true, noLexicon?: true}
64
+ * | {ambiguous: {form: string, glosses: string[]}}} Writing
65
+ */
66
+
67
+ /**
68
+ * The text a list of native entries states for a map in a face: the entry for
69
+ * the face, else the entry for no face.
70
+ *
71
+ * @param {unknown} natives - A `native` list as authored.
72
+ * @param {string} map - The map's name.
73
+ * @param {string|undefined} font - The face.
74
+ * @returns {string|undefined} The text.
75
+ */
76
+ export function nativeText(natives, map, font) {
77
+ const entries = listOf(natives).filter(
78
+ (entry) => isMap(entry) && entry.map === map && typeof entry.text === "string",
79
+ );
80
+ const own = font === undefined ? undefined : entries.find((entry) => entry.font === font);
81
+ return (own ?? entries.find((entry) => typeof entry.font !== "string"))?.text;
82
+ }
83
+
84
+ /**
85
+ * One writing for several candidates: the shared one where every candidate is
86
+ * written alike, else an ambiguity naming each candidate's gloss.
87
+ *
88
+ * @param {string} form - The form the candidates were matched by.
89
+ * @param {Array<{writing: Writing, gloss: string}>} candidates - At least one.
90
+ * @returns {Writing} The writing.
91
+ */
92
+ function oneOf(form, candidates) {
93
+ const passed = candidates.find(({ writing }) => "ambiguous" in writing);
94
+ if (passed) return passed.writing;
95
+ const texts = new Set(
96
+ candidates.map(({ writing }) => ("text" in writing ? writing.text : undefined)),
97
+ );
98
+ if (texts.size === 1) return candidates[0].writing;
99
+ return { ambiguous: { form, glosses: candidates.map(({ gloss }) => gloss) } };
100
+ }
101
+
102
+ /**
103
+ * The writings of a language's words under one logographic map in one face.
104
+ *
105
+ * @param {Record<string, any>|undefined} data - The language note's `data`.
106
+ * @param {string} map - The map's name.
107
+ * @param {string|undefined} font - The face.
108
+ * @returns {{word: (word: string) => Writing, entry: (index: number) => Writing}}
109
+ * The writing of a run's word, and of the `data.words` entry at an index.
110
+ */
111
+ export function writingsOf(data, map, font) {
112
+ const words = listOf(data?.words);
113
+ const affixes = listOf(data?.affixes).filter(isMap);
114
+ const grammar = isMap(data?.grammar) ? data.grammar : undefined;
115
+ const verbs = verbPattern(grammar?.verbs);
116
+ const replaces = verbs?.replaces === undefined ? undefined : foldWord(verbs.replaces);
117
+ const endings = grammar?.native;
118
+ const indexed = words
119
+ .map((word, index) => ({ word, index }))
120
+ .filter(({ word }) => isMap(word) && typeof word.form === "string");
121
+ const glossOf = (entry) => glossText(entry.gloss) ?? String(entry.form ?? "");
122
+
123
+ /**
124
+ * @param {number} index - A `data.words` index.
125
+ * @param {Set<number>} visiting - The entries whose parts are being read.
126
+ * @returns {Writing}
127
+ */
128
+ const entry = (index, visiting) => {
129
+ const word = words[index];
130
+ const own = nativeText(word?.native, map, font);
131
+ if (own !== undefined) return { text: own, source: "own" };
132
+ const pieces = listOf(word?.built).filter((piece) => typeof piece === "string");
133
+ if (!pieces.length || visiting.has(index)) return { missing: true };
134
+ const inner = new Set(visiting).add(index);
135
+ let text = "";
136
+ for (const piece of pieces) {
137
+ const found = part(piece, inner);
138
+ if (!("text" in found)) return found;
139
+ text += found.text;
140
+ }
141
+ return { text, source: "parts" };
142
+ };
143
+
144
+ /** The candidates of a list of matched words and affixes. */
145
+ const candidatesOf = (matchedWords, matchedAffixes, visiting) => [
146
+ ...matchedWords.map(({ word, index }) => ({
147
+ writing: entry(index, visiting),
148
+ gloss: glossOf(word),
149
+ })),
150
+ ...matchedAffixes.map((affix) => {
151
+ const text = nativeText(affix.native, map, font);
152
+ return {
153
+ writing: /** @type {Writing} */ (
154
+ text === undefined ? { missing: true } : { text, source: "own" }
155
+ ),
156
+ gloss: glossOf(affix),
157
+ };
158
+ }),
159
+ ];
160
+
161
+ /**
162
+ * A `built` piece: a word written exactly so, else an affix, else either
163
+ * with its joining hyphens taken off.
164
+ *
165
+ * @param {string} piece - The piece.
166
+ * @param {Set<number>} visiting - The entries whose parts are being read.
167
+ * @returns {Writing}
168
+ */
169
+ const part = (piece, visiting) => {
170
+ const affixForms = (affix) =>
171
+ [affix.form, affix.front].filter((form) => typeof form === "string");
172
+ const stages = [
173
+ [indexed.filter(({ word }) => word.form === piece), []],
174
+ [[], affixes.filter((affix) => affixForms(affix).includes(piece))],
175
+ [indexed.filter(({ word }) => bare(word.form) === bare(piece)), []],
176
+ [[], affixes.filter((affix) => affixForms(affix).some((f) => bare(f) === bare(piece)))],
177
+ ];
178
+ for (const [matchedWords, matchedAffixes] of stages) {
179
+ const candidates = candidatesOf(matchedWords, matchedAffixes, visiting);
180
+ if (candidates.length) return oneOf(piece, candidates);
181
+ }
182
+ return { missing: true };
183
+ };
184
+
185
+ /**
186
+ * The ending's writing a derived form takes after its stem's.
187
+ *
188
+ * @param {string} ending - A name of
189
+ * {@link module:engine/language-terms.WRITTEN_ENDINGS}.
190
+ * @returns {string|undefined} The text.
191
+ */
192
+ const endingText = (ending) =>
193
+ nativeText(
194
+ listOf(endings).filter((one) => isMap(one) && one.ending === ending),
195
+ map,
196
+ font,
197
+ );
198
+
199
+ /**
200
+ * A word of a run, matched case folded.
201
+ *
202
+ * @param {string} written - The word as written.
203
+ * @returns {Writing}
204
+ */
205
+ const word = (written) => {
206
+ const folded = foldWord(written);
207
+ const none = new Set();
208
+ const stated = indexed.filter(({ word: one }) => foldWord(one.form) === folded);
209
+ if (stated.length) return oneOf(written, candidatesOf(stated, [], none));
210
+ const stripped = indexed.filter(({ word: one }) => bare(foldWord(one.form)) === folded);
211
+ if (stripped.length) return oneOf(written, candidatesOf(stripped, [], none));
212
+ const derived = [];
213
+ for (const { word: one, index } of indexed) {
214
+ const base = parseWordClass(one.class).base;
215
+ const plural = pluralOf(one, grammar);
216
+ const forms = [
217
+ ...(plural ? [{ form: plural, ending: "plural" }] : []),
218
+ ...(verbs && base === "v" ?
219
+ verbs.endings.map(({ name, ending }) => ({
220
+ form: verbForm(foldWord(one.form), foldWord(ending), replaces),
221
+ ending: name,
222
+ }))
223
+ : []),
224
+ ];
225
+ for (const { form, ending } of forms) {
226
+ if (foldWord(form) !== folded) continue;
227
+ const stem = entry(index, none);
228
+ const tail = endingText(ending);
229
+ derived.push({
230
+ writing: /** @type {Writing} */ (
231
+ !("text" in stem) ? stem
232
+ : tail === undefined ? { missing: true }
233
+ : { text: stem.text + tail, source: "parts" }
234
+ ),
235
+ gloss: glossOf(one),
236
+ });
237
+ }
238
+ }
239
+ if (derived.length) return oneOf(written, derived);
240
+ if (JOINS.test(written)) {
241
+ let text = "";
242
+ for (const piece of written.split(JOINS)) {
243
+ if (!piece) continue;
244
+ const found = word(piece);
245
+ if ("missing" in found) return { missing: true };
246
+ if ("ambiguous" in found) return found;
247
+ text += found.text;
248
+ }
249
+ return { text, source: "parts" };
250
+ }
251
+ return { missing: true };
252
+ };
253
+
254
+ return { word, entry: (index) => entry(index, new Set()) };
255
+ }