@heroiclands/package-build 23.12.8 → 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 (258) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +3 -2
  3. package/assets/preview/gitea.ini +12 -7
  4. package/assets/styles/content-base.css +293 -24
  5. package/bin/content-commands.mjs +370 -213
  6. package/bin/package-build-preview.mjs +2 -2
  7. package/bin/package-build.mjs +3 -2
  8. package/ci/check-banners.mjs +7 -7
  9. package/config.mjs +21 -6
  10. package/content-config.mjs +199 -80
  11. package/docs/api.md +110 -89
  12. package/docs/authoring/links-and-markup.md +469 -97
  13. package/docs/commands.md +259 -78
  14. package/docs/configuration.md +211 -103
  15. package/docs/content-format.md +4 -4
  16. package/docs/diagnostics.md +44 -2
  17. package/docs/getting-started.md +1 -1
  18. package/docs/guides/icons.md +1 -1
  19. package/docs/guides/module-project.md +5 -8
  20. package/docs/guides/release-and-deploy.md +2 -2
  21. package/docs/guides/site.md +112 -23
  22. package/docs/guides/system-project.md +1 -1
  23. package/docs/guides/writing-in-invented-scripts.md +1247 -0
  24. package/docs/index.md +1 -0
  25. package/docs/project-setup.md +28 -30
  26. package/docs/reference/format-details.md +677 -319
  27. package/docs/reference/note-types.md +47 -10
  28. package/docs/reference/theme-pages.md +178 -108
  29. package/docs/source-preview.md +16 -2
  30. package/engine/anchors.mjs +2 -2
  31. package/engine/asset-shipping.mjs +22 -10
  32. package/engine/base-compiler.mjs +62 -104
  33. package/engine/changelog-lint.mjs +1 -1
  34. package/engine/code-fences.mjs +3 -3
  35. package/engine/content-address.mjs +1 -1
  36. package/engine/content-alerts.mjs +8 -4
  37. package/engine/content-blocks.mjs +29 -14
  38. package/engine/content-embeds.mjs +1 -1
  39. package/engine/content-expand.mjs +376 -0
  40. package/engine/content-figures.mjs +18 -11
  41. package/engine/content-format.yaml +8 -0
  42. package/engine/content-glyphs.mjs +213 -0
  43. package/engine/content-images.mjs +6 -7
  44. package/engine/content-index.mjs +1 -1
  45. package/engine/content-links.mjs +3 -3
  46. package/engine/content-lint.mjs +1 -1
  47. package/engine/content-render.mjs +453 -0
  48. package/engine/content-scripts.mjs +376 -43
  49. package/engine/content-spans.mjs +6 -3
  50. package/engine/content-styles.mjs +91 -0
  51. package/engine/content-symbols.mjs +186 -0
  52. package/engine/content-verse.mjs +738 -0
  53. package/engine/core-report.mjs +169 -0
  54. package/engine/core-vocabulary.mjs +208 -0
  55. package/engine/dictionary-html.mjs +119 -0
  56. package/engine/dictionary-rows.mjs +15 -4
  57. package/engine/field-reference.mjs +5 -5
  58. package/engine/first-use.mjs +17 -27
  59. package/engine/frontmatter-lint.mjs +1 -1
  60. package/engine/heading-attributes.mjs +89 -0
  61. package/engine/helpers.mjs +61 -101
  62. package/engine/homepage.mjs +17 -29
  63. package/engine/html-minify.mjs +70 -0
  64. package/engine/index.mjs +8 -5
  65. package/engine/key-case-collisions.mjs +136 -0
  66. package/engine/language-data.mjs +203 -6
  67. package/engine/language-rows.mjs +8 -2
  68. package/engine/language-terms.mjs +186 -16
  69. package/engine/language-words.mjs +12 -0
  70. package/engine/logographic.mjs +255 -0
  71. package/engine/map-lexicon.mjs +262 -47
  72. package/engine/map-places.mjs +5 -4
  73. package/engine/markdown-expressions.mjs +0 -2
  74. package/engine/markdownlint-run.mjs +9 -3
  75. package/engine/markup-validation.mjs +21 -0
  76. package/engine/metre-declaration.mjs +134 -0
  77. package/engine/names.mjs +36 -9
  78. package/engine/note-matter.mjs +66 -0
  79. package/engine/note-vocabulary.mjs +187 -3
  80. package/engine/pdf-build.mjs +69 -133
  81. package/engine/pdf-render.mjs +205 -58
  82. package/engine/post-fields.mjs +157 -0
  83. package/engine/preview-index.mjs +130 -0
  84. package/engine/preview-source.mjs +450 -0
  85. package/engine/project-init.mjs +5 -12
  86. package/engine/prose-config.mjs +3 -3
  87. package/engine/read-stdin.mjs +29 -0
  88. package/engine/run-rows.mjs +2 -2
  89. package/engine/run-words.mjs +6 -112
  90. package/engine/script-emphasis-terms.mjs +38 -0
  91. package/engine/script-emphasis.mjs +295 -0
  92. package/engine/script-font-delivery.mjs +4 -4
  93. package/engine/site-build.mjs +260 -315
  94. package/engine/site-config.mjs +26 -238
  95. package/engine/site-files.mjs +175 -0
  96. package/engine/site-index.mjs +9 -4
  97. package/engine/site-preview.mjs +52 -525
  98. package/engine/site-render.mjs +593 -0
  99. package/engine/site-root.mjs +22 -13
  100. package/engine/site-search.mjs +161 -0
  101. package/engine/site-serve.mjs +273 -0
  102. package/engine/site-theme-banner.mjs +154 -0
  103. package/engine/site-theme-urls.mjs +125 -0
  104. package/engine/site-theme.mjs +926 -0
  105. package/engine/sql-tables.mjs +20 -3
  106. package/engine/text-maps.mjs +57 -10
  107. package/engine/typographer.mjs +248 -0
  108. package/engine/verse-metre.mjs +357 -0
  109. package/engine/wikilink-syntax.mjs +29 -47
  110. package/engine/wikilinks.mjs +758 -279
  111. package/engine/word-forms.mjs +364 -0
  112. package/engine/word-rows.mjs +12 -2
  113. package/engine/word-terms.mjs +38 -4
  114. package/manifest.mjs +3 -4
  115. package/package.json +11 -8
  116. package/{hugo-theme → site-theme}/data/banners.yaml +6 -10
  117. package/site-theme/layouts/base.hbs +31 -0
  118. package/site-theme/layouts/blog-list.hbs +30 -0
  119. package/site-theme/layouts/blog-post.hbs +40 -0
  120. package/site-theme/layouts/home.hbs +35 -0
  121. package/site-theme/layouts/list.hbs +20 -0
  122. package/site-theme/layouts/not-found.hbs +31 -0
  123. package/site-theme/layouts/page.hbs +7 -0
  124. package/site-theme/partials/copy-buttons.hbs +37 -0
  125. package/site-theme/partials/draft-notice.hbs +5 -0
  126. package/site-theme/partials/exturl.hbs +2 -0
  127. package/{hugo-theme/layouts/partials/footer.html → site-theme/partials/footer.hbs} +10 -7
  128. package/site-theme/partials/header.hbs +44 -0
  129. package/site-theme/partials/hero-banner.hbs +11 -0
  130. package/site-theme/partials/infobox-entry.hbs +2 -0
  131. package/site-theme/partials/infobox-section.hbs +31 -0
  132. package/site-theme/partials/infobox-value.hbs +2 -0
  133. package/site-theme/partials/infobox.hbs +18 -0
  134. package/site-theme/partials/list-members.hbs +9 -0
  135. package/site-theme/partials/nav-links.hbs +5 -0
  136. package/site-theme/partials/page-body.hbs +48 -0
  137. package/site-theme/partials/page-list.hbs +14 -0
  138. package/site-theme/partials/related.hbs +24 -0
  139. package/site-theme/partials/search-meta.hbs +6 -0
  140. package/site-theme/partials/search-names.hbs +2 -0
  141. package/site-theme/partials/search.hbs +52 -0
  142. package/site-theme/partials/tags.hbs +4 -0
  143. package/site-theme/partials/toc.hbs +7 -0
  144. package/{hugo-theme → site-theme}/static/css/style.css +106 -14
  145. package/stage.mjs +1 -2
  146. package/types/config.d.mts +8 -4
  147. package/types/content-config.d.mts +15 -15
  148. package/types/engine/asset-shipping.d.mts +5 -2
  149. package/types/engine/base-compiler.d.mts +20 -2
  150. package/types/engine/code-fences.d.mts +3 -3
  151. package/types/engine/content-address.d.mts +1 -1
  152. package/types/engine/content-alerts.d.mts +8 -2
  153. package/types/engine/content-blocks.d.mts +11 -4
  154. package/types/engine/content-expand.d.mts +288 -0
  155. package/types/engine/content-figures.d.mts +11 -3
  156. package/types/engine/content-glyphs.d.mts +85 -0
  157. package/types/engine/content-images.d.mts +6 -7
  158. package/types/engine/content-links.d.mts +1 -1
  159. package/types/engine/content-render.d.mts +199 -0
  160. package/types/engine/content-scripts.d.mts +70 -4
  161. package/types/engine/content-spans.d.mts +1 -1
  162. package/types/engine/content-styles.d.mts +33 -0
  163. package/types/engine/content-symbols.d.mts +35 -0
  164. package/types/engine/content-verse.d.mts +240 -0
  165. package/types/engine/core-report.d.mts +45 -0
  166. package/types/engine/core-vocabulary.d.mts +53 -0
  167. package/types/engine/dictionary-html.d.mts +8 -0
  168. package/types/engine/dictionary-rows.d.mts +10 -0
  169. package/types/engine/field-reference.d.mts +2 -2
  170. package/types/engine/heading-attributes.d.mts +20 -0
  171. package/types/engine/helpers.d.mts +3 -10
  172. package/types/engine/homepage.d.mts +11 -24
  173. package/types/engine/html-minify.d.mts +9 -0
  174. package/types/engine/index.d.mts +2 -1
  175. package/types/engine/key-case-collisions.d.mts +28 -0
  176. package/types/engine/language-data.d.mts +0 -2
  177. package/types/engine/language-terms.d.mts +24 -2
  178. package/types/engine/logographic.d.mts +45 -0
  179. package/types/engine/map-lexicon.d.mts +66 -13
  180. package/types/engine/map-places.d.mts +8 -0
  181. package/types/engine/markdown-expressions.d.mts +0 -2
  182. package/types/engine/metre-declaration.d.mts +39 -0
  183. package/types/engine/names.d.mts +34 -3
  184. package/types/engine/note-matter.d.mts +48 -0
  185. package/types/engine/pdf-render.d.mts +6 -2
  186. package/types/engine/post-fields.d.mts +44 -0
  187. package/types/engine/preview-index.d.mts +72 -0
  188. package/types/engine/preview-source.d.mts +51 -0
  189. package/types/engine/read-stdin.d.mts +11 -0
  190. package/types/engine/run-rows.d.mts +2 -2
  191. package/types/engine/run-words.d.mts +0 -15
  192. package/types/engine/script-emphasis-terms.d.mts +21 -0
  193. package/types/engine/script-emphasis.d.mts +84 -0
  194. package/types/engine/script-font-delivery.d.mts +4 -4
  195. package/types/engine/site-build.d.mts +71 -113
  196. package/types/engine/site-config.d.mts +14 -134
  197. package/types/engine/site-files.d.mts +73 -0
  198. package/types/engine/site-index.d.mts +11 -3
  199. package/types/engine/site-preview.d.mts +15 -19
  200. package/types/engine/site-render.d.mts +171 -0
  201. package/types/engine/site-root.d.mts +12 -4
  202. package/types/engine/site-search.d.mts +54 -0
  203. package/types/engine/site-serve.d.mts +44 -0
  204. package/types/engine/site-theme-banner.d.mts +51 -0
  205. package/types/engine/site-theme-urls.d.mts +60 -0
  206. package/types/engine/site-theme.d.mts +127 -0
  207. package/types/engine/sql-tables.d.mts +4 -1
  208. package/types/engine/text-maps.d.mts +17 -3
  209. package/types/engine/typographer.d.mts +11 -0
  210. package/types/engine/verse-metre.d.mts +131 -0
  211. package/types/engine/wikilink-syntax.d.mts +30 -48
  212. package/types/engine/wikilinks.d.mts +330 -14
  213. package/types/engine/word-forms.d.mts +83 -0
  214. package/types/manifest.d.mts +3 -4
  215. package/types/stage.d.mts +1 -2
  216. package/types/vendor/markdownlint.d.mts +8 -0
  217. package/vendor/markdownlint.mjs +20388 -0
  218. package/engine/content-poetry.mjs +0 -240
  219. package/engine/web-wikilinks.mjs +0 -594
  220. package/hugo-theme/layouts/404.html +0 -85
  221. package/hugo-theme/layouts/_default/baseof.html +0 -67
  222. package/hugo-theme/layouts/_default/list.html +0 -139
  223. package/hugo-theme/layouts/_default/single.html +0 -4
  224. package/hugo-theme/layouts/blog/list.html +0 -54
  225. package/hugo-theme/layouts/blog-post/single.html +0 -36
  226. package/hugo-theme/layouts/index.html +0 -75
  227. package/hugo-theme/layouts/partials/banner-url.html +0 -141
  228. package/hugo-theme/layouts/partials/cdn-url.html +0 -36
  229. package/hugo-theme/layouts/partials/dictionary-lists.html +0 -58
  230. package/hugo-theme/layouts/partials/draft-notice.html +0 -41
  231. package/hugo-theme/layouts/partials/draft-state.html +0 -26
  232. package/hugo-theme/layouts/partials/header.html +0 -35
  233. package/hugo-theme/layouts/partials/hero-banner.html +0 -85
  234. package/hugo-theme/layouts/partials/infobox/entry.html +0 -18
  235. package/hugo-theme/layouts/partials/infobox/items.html +0 -26
  236. package/hugo-theme/layouts/partials/infobox/section.html +0 -56
  237. package/hugo-theme/layouts/partials/infobox/value.html +0 -43
  238. package/hugo-theme/layouts/partials/infobox.html +0 -57
  239. package/hugo-theme/layouts/partials/page-body.html +0 -152
  240. package/hugo-theme/layouts/partials/related.html +0 -138
  241. package/hugo-theme/layouts/partials/search.html +0 -84
  242. package/hugo-theme/layouts/partials/site-url.html +0 -32
  243. package/hugo-theme/layouts/partials/tags.html +0 -30
  244. package/hugo-theme/layouts/partials/toc.html +0 -28
  245. package/hugo-theme/layouts/partials/warn-missing-banner.html +0 -3
  246. package/hugo-theme/layouts/shortcodes/cartouche.html +0 -64
  247. package/hugo-theme/layouts/shortcodes/exturl.html +0 -12
  248. package/hugo-theme/layouts/shortcodes/glyph.html +0 -43
  249. package/hugo-theme/layouts/shortcodes/pagelist.html +0 -63
  250. package/hugo-theme/theme.toml +0 -11
  251. package/types/engine/content-poetry.d.mts +0 -58
  252. package/types/engine/web-wikilinks.d.mts +0 -107
  253. /package/{hugo-theme → site-theme}/data/banner-kinds.yaml +0 -0
  254. /package/{hugo-theme → site-theme}/static/css/sohl-icons.css +0 -0
  255. /package/{hugo-theme → site-theme}/static/fonts/game-icons-kb.woff2 +0 -0
  256. /package/{hugo-theme → site-theme}/static/img/banners/takheperu-hieroglyphs.svg +0 -0
  257. /package/{hugo-theme → site-theme}/static/img/cartouches/takheperu.svg +0 -0
  258. /package/{hugo-theme → site-theme}/static/js/inline-spoilers.js +0 -0
@@ -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
  *
@@ -130,7 +130,7 @@ export const NEAR_NAME_COLUMNS = Object.freeze([...IDENTITY, "near", "given", "m
130
130
  /** Every column of the `negation` relation, in order: one row per language. */
131
131
  export const NEGATION_COLUMNS = Object.freeze(["language", "particle", "mutation"]);
132
132
  /** Every column of the `stress` relation, in order. */
133
- export const STRESS_COLUMNS = Object.freeze(["language", "stress"]);
133
+ export const STRESS_COLUMNS = Object.freeze(["language", "two", "longer", "mark"]);
134
134
  /** Every column of the `finals` relation, in order. */
135
135
  export const FINAL_COLUMNS = Object.freeze([...IDENTITY, ...FINAL_KEYS]);
136
136
  /** Every column of the `syllables` relation, in order: one row per language. */
@@ -789,7 +789,13 @@ export function stressRows(records) {
789
789
  const rows = [];
790
790
  for (const [language, record] of languagesOf(records)) {
791
791
  const stress = soundsOf(isMap(record.data) ? record.data : {}).stress;
792
- if (typeof stress === "string") rows.push({ language, stress });
792
+ if (isMap(stress))
793
+ rows.push({
794
+ language,
795
+ two: stress.two ?? null,
796
+ longer: stress.longer ?? null,
797
+ mark: stress.mark ?? null,
798
+ });
793
799
  }
794
800
  return rows;
795
801
  }
@@ -126,13 +126,21 @@ export const HELD_CLIPS = Object.freeze(["first"]);
126
126
  /** How often a group of clusters stands inside a word. */
127
127
  export const CLUSTER_USES = Object.freeze(["common", "sparing"]);
128
128
 
129
- /** Where a language's stress falls. */
130
- export const STRESS_RULES = Object.freeze([
131
- "first syllable",
132
- "penultimate or final",
133
- "by weight",
134
- "as marked",
135
- ]);
129
+ /** The syllable a word of two stresses. */
130
+ export const STRESS_TWO = Object.freeze(["first", "last"]);
131
+
132
+ /** The syllable a word of three or more stresses, counted from its end. */
133
+ export const STRESS_LONGER = Object.freeze(["antepenultimate", "penultimate", "final"]);
134
+
135
+ /**
136
+ * The diacritics that mark a stress the rule would not choose, with the
137
+ * combining character each is written with.
138
+ */
139
+ export const STRESS_MARKS = Object.freeze({
140
+ acute: "\u0301",
141
+ grave: "\u0300",
142
+ circumflex: "\u0302",
143
+ });
136
144
 
137
145
  /** Which sounds meet where two parts of a word join. */
138
146
  export const JOIN_MEETINGS = Object.freeze(["C+C", "V+C", "V+V"]);
@@ -690,6 +698,39 @@ export const HARMONY_FIELDS = Object.freeze([
690
698
  }),
691
699
  ]);
692
700
 
701
+ /**
702
+ * The keys of a language's `data.sounds.stress`.
703
+ *
704
+ * @type {readonly import("./data-keys.mjs").InnerKeySpec[]}
705
+ */
706
+ export const STRESS_FIELDS = Object.freeze([
707
+ Object.freeze({
708
+ name: "two",
709
+ kind: "string",
710
+ required: true,
711
+ allowed: STRESS_TWO,
712
+ shape: `one of ${STRESS_TWO.join(", ")}`,
713
+ describe: "The stressed syllable of a word of two syllables.",
714
+ }),
715
+ Object.freeze({
716
+ name: "longer",
717
+ kind: "string",
718
+ required: true,
719
+ allowed: STRESS_LONGER,
720
+ shape: `one of ${STRESS_LONGER.join(", ")}`,
721
+ describe: "The stressed syllable of a word of three or more, counted from its end.",
722
+ }),
723
+ Object.freeze({
724
+ name: "mark",
725
+ kind: "string",
726
+ allowed: Object.freeze(Object.keys(STRESS_MARKS)),
727
+ shape: `one of ${Object.keys(STRESS_MARKS).join(", ")}`,
728
+ describe:
729
+ "The diacritic that marks a stress the rule would not choose; it moves the stress to " +
730
+ "the syllable it sits on.",
731
+ }),
732
+ ]);
733
+
693
734
  /**
694
735
  * The keys of a language's `data.sounds.syllables`: what a syllable may open
695
736
  * and close on, and what a word may.
@@ -901,10 +942,10 @@ export const SOUND_FIELDS = Object.freeze([
901
942
  }),
902
943
  Object.freeze({
903
944
  name: "stress",
904
- kind: "string",
905
- allowed: STRESS_RULES,
906
- shape: `one of ${STRESS_RULES.join(", ")}`,
907
- describe: "Where a word's stress falls.",
945
+ kind: "map",
946
+ shape: "a map — `{ two, longer, mark? }`",
947
+ fields: STRESS_FIELDS,
948
+ describe: "Where a word's stress falls, as a rule a program reads.",
908
949
  }),
909
950
  Object.freeze({
910
951
  name: "finals",
@@ -1623,6 +1664,52 @@ export const VERB_PERSON_FIELDS = Object.freeze(
1623
1664
  ),
1624
1665
  );
1625
1666
 
1667
+ /**
1668
+ * The endings `data.grammar.native` writes under a logographic map: the noun
1669
+ * plural, then the six persons of the verb pattern.
1670
+ */
1671
+ export const WRITTEN_ENDINGS = Object.freeze([
1672
+ "plural",
1673
+ ...VERB_PERSON_FIELDS.map((person) => person.name),
1674
+ ]);
1675
+
1676
+ /** The keys of one `data.grammar.native` entry. */
1677
+ const GRAMMAR_NATIVE_FIELDS = Object.freeze([
1678
+ Object.freeze({
1679
+ name: "ending",
1680
+ kind: "string",
1681
+ required: true,
1682
+ allowed: WRITTEN_ENDINGS,
1683
+ shape: `one of ${WRITTEN_ENDINGS.join(", ")}`,
1684
+ describe:
1685
+ "The ending written: `plural` for a noun's plural, or a person of `verbs.persons` " +
1686
+ "for a verb's present form.",
1687
+ }),
1688
+ Object.freeze({
1689
+ name: "map",
1690
+ kind: "string",
1691
+ required: true,
1692
+ shape: "a logographic mapping note's `data.map.name`",
1693
+ describe: "The logographic map the ending is written for.",
1694
+ }),
1695
+ Object.freeze({
1696
+ name: "font",
1697
+ kind: "string",
1698
+ shape: "a face the map lists",
1699
+ describe:
1700
+ "The one face the writing is for; unstated, it is for every face the map lists, " +
1701
+ "and a writing stated for a face wins over it there.",
1702
+ }),
1703
+ Object.freeze({
1704
+ name: "text",
1705
+ kind: "string",
1706
+ required: true,
1707
+ allowEmpty: true,
1708
+ shape: "the ending in the script, as the map's characters; empty writes the stem alone",
1709
+ describe: "The signs written after the stem's writing.",
1710
+ }),
1711
+ ]);
1712
+
1626
1713
  /** The two alternatives of a supplied optional verb pattern. */
1627
1714
  const VERB_PATTERN_FIELDS = Object.freeze([
1628
1715
  Object.freeze({
@@ -1651,7 +1738,56 @@ const VERB_PATTERN_FIELDS = Object.freeze([
1651
1738
  }),
1652
1739
  ]);
1653
1740
 
1654
- /** The optional, display-only noun and verb patterns of one language. */
1741
+ /** The two keys of a language's optional agreement rule. */
1742
+ const AGREEMENT_FIELDS = Object.freeze([
1743
+ Object.freeze({
1744
+ name: "classes",
1745
+ kind: "list",
1746
+ required: true,
1747
+ shape: "a list of word class bases, such as `[adj, pron]`",
1748
+ entries: Object.freeze({ kind: "string", shape: "a word class base" }),
1749
+ describe: "The word classes that agree, each a base of the master word classes.",
1750
+ }),
1751
+ Object.freeze({
1752
+ name: "endings",
1753
+ kind: "list",
1754
+ required: true,
1755
+ shape: "a list of at least two literal endings, such as `[os, a, on]`",
1756
+ entries: Object.freeze({ kind: "string", shape: "a literal ending string" }),
1757
+ describe:
1758
+ "The singular endings an agreeing word swaps among, one for each gender; the " +
1759
+ "`plural` rule gives each one's plural. A word closing on none does not agree.",
1760
+ }),
1761
+ ]);
1762
+
1763
+ /** The two keys of a language's optional participle rule. */
1764
+ const PARTICIPLE_FIELDS = Object.freeze([
1765
+ Object.freeze({
1766
+ name: "root",
1767
+ kind: "list",
1768
+ required: true,
1769
+ shape: "a list of closing letters, such as `[athen, ethen]`",
1770
+ entries: Object.freeze({ kind: "string", shape: "the closing letters of a citation form" }),
1771
+ describe:
1772
+ "The infinitive endings a verb's citation form drops to give its root, the longest " +
1773
+ "that matches first. A verb closing on none has no participle.",
1774
+ }),
1775
+ Object.freeze({
1776
+ name: "affixes",
1777
+ kind: "list",
1778
+ required: true,
1779
+ shape: "a list of suffixes of `data.affixes`, such as `[-andar, -thos]`",
1780
+ entries: Object.freeze({
1781
+ kind: "string",
1782
+ shape: "the `form` of a suffix of `data.affixes`",
1783
+ }),
1784
+ describe:
1785
+ "The participle suffixes, each attaching to the root as its own `replaces` says: " +
1786
+ "`vowel` or letters take the place of the root's close, and none adds it whole.",
1787
+ }),
1788
+ ]);
1789
+
1790
+ /** The optional noun, verb, agreement and participle patterns of one language. */
1655
1791
  const GRAMMAR_FIELDS = Object.freeze([
1656
1792
  innerList(
1657
1793
  "plural",
@@ -1680,6 +1816,39 @@ const GRAMMAR_FIELDS = Object.freeze([
1680
1816
  fields: VERB_PATTERN_FIELDS,
1681
1817
  describe: "One optional regular present pattern, printed once at the dictionary head.",
1682
1818
  }),
1819
+ Object.freeze({
1820
+ name: "agreement",
1821
+ kind: "map",
1822
+ shape: "a map — `{ classes, endings }`",
1823
+ fields: AGREEMENT_FIELDS,
1824
+ describe:
1825
+ "How words of the named classes agree with a noun: a word closing on one of the " +
1826
+ "`endings` also takes each other one, and each form takes the `plural` rule. Read " +
1827
+ "by the mapped-run word check.",
1828
+ }),
1829
+ Object.freeze({
1830
+ name: "participles",
1831
+ kind: "map",
1832
+ shape: "a map — `{ root, affixes }`",
1833
+ fields: PARTICIPLE_FIELDS,
1834
+ describe:
1835
+ "Participles on the verb root: a verb closing on a `root` ending drops it, and each " +
1836
+ "named suffix attaches to what is left; a participle closing on an `agreement` " +
1837
+ "ending agrees. Read by the mapped-run word check.",
1838
+ }),
1839
+ Object.freeze({
1840
+ name: "native",
1841
+ kind: "list",
1842
+ shape: "a list of `{ ending, map, font?, text }`",
1843
+ entries: Object.freeze({
1844
+ kind: "map",
1845
+ shape: "a map — `{ ending, map, font?, text }`",
1846
+ fields: GRAMMAR_NATIVE_FIELDS,
1847
+ }),
1848
+ describe:
1849
+ "Under a logographic map, the signs a noun's plural or a verb's present form is " +
1850
+ "written with after its stem's writing, one entry per ending, map and face.",
1851
+ }),
1683
1852
  ]);
1684
1853
 
1685
1854
  /**
@@ -1693,12 +1862,13 @@ export const LANGUAGE_DATA_FIELDS = Object.freeze([
1693
1862
  Object.freeze({
1694
1863
  name: "grammar",
1695
1864
  kind: "map",
1696
- shape: "a map — `{ plural?, verbs? }`",
1865
+ shape: "a map — `{ plural?, verbs?, agreement?, participles?, native? }`",
1697
1866
  fields: GRAMMAR_FIELDS,
1698
1867
  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.",
1868
+ "Optional noun plurals, one verb pattern, agreement, participles and the endings' " +
1869
+ "logographic writings, for dictionary presentation, the mapped-run word check and " +
1870
+ "logographic maps. Only the shape of supplied keys is " +
1871
+ "checked; no word must follow or state a rule. A language skill note only.",
1702
1872
  }),
1703
1873
  listOfMaps(
1704
1874
  "tongues",