@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,364 @@
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
+ * A word's `variants` are known forms as written, and each takes the rules
42
+ * its word takes: the plural rule, the verb pattern, the suffixes, agreement
43
+ * and participles. A word's own stated `plural` belongs to its citation form
44
+ * alone.
45
+ *
46
+ * The mapped-run word check ({@link module:engine/run-words}) and
47
+ * `package-build lexicon --derived` both read {@link derivedForms}, so what
48
+ * the command lists is what the check accepts.
49
+ *
50
+ * @module
51
+ */
52
+
53
+ import { pluralByRule, pluralOf } from "./dictionary-rows.mjs";
54
+ import { replaceClosing, verbForm, verbPattern } from "./verb-pattern.mjs";
55
+ import { parseWordClass } from "./word-classes.mjs";
56
+ import { WORD } from "./word-pattern.mjs";
57
+
58
+ /** Whether a value is a plain map. */
59
+ const isMap = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
60
+
61
+ /** A list value, or an empty one. */
62
+ const listOf = (value) => (Array.isArray(value) ? value : []);
63
+
64
+ /** The characters a compound is split at. */
65
+ const JOINS = /[–-]/u;
66
+
67
+ /**
68
+ * A word as it is compared: NFC, lowercase, `’` read as `'`.
69
+ *
70
+ * @param {string} word - The word.
71
+ * @returns {string} Its folded form.
72
+ */
73
+ export function foldWord(word) {
74
+ return word.normalize("NFC").toLowerCase().replace(/’/gu, "'");
75
+ }
76
+
77
+ /** Whether a character is a vowel: its base letter is a, e, i, o or u. */
78
+ const isVowel = (char) => /^[aeiou]/iu.test(char.normalize("NFD"));
79
+
80
+ /**
81
+ * Add a text's words to a set, each whole and each part of a compound.
82
+ *
83
+ * @param {Set<string>} set - The set.
84
+ * @param {string} text - A form or a name.
85
+ */
86
+ export function addWords(set, text) {
87
+ for (const [word] of text.matchAll(WORD)) {
88
+ const folded = foldWord(word);
89
+ set.add(folded);
90
+ for (const part of folded.split(JOINS)) if (part) set.add(part);
91
+ }
92
+ }
93
+
94
+ /**
95
+ * The form a suffix makes of a stem.
96
+ *
97
+ * @param {string} stem - The stem, folded.
98
+ * @param {string} suffix - The suffix, folded, with no hyphen.
99
+ * @param {string|undefined} replaces - The affix's `replaces`; none adds the
100
+ * suffix whole.
101
+ * @returns {string} The derived form.
102
+ */
103
+ function attach(stem, suffix, replaces) {
104
+ if (replaces === "vowel") {
105
+ const last = Array.from(stem).at(-1);
106
+ return last !== undefined && isVowel(last) ?
107
+ stem.slice(0, -last.length) + suffix
108
+ : stem + suffix;
109
+ }
110
+ return replaceClosing(stem, suffix, replaces === undefined ? undefined : foldWord(replaces));
111
+ }
112
+
113
+ /**
114
+ * An affix's suffixes: its `form` and its `front` form, each written `-x`,
115
+ * folded and without the hyphen.
116
+ *
117
+ * @param {Record<string, any>} affix - One `data.affixes` entry.
118
+ * @returns {string[]} The suffixes.
119
+ */
120
+ function suffixesOf(affix) {
121
+ const out = [];
122
+ for (const written of [affix.form, affix.front]) {
123
+ if (typeof written !== "string") continue;
124
+ const form = foldWord(written.trim());
125
+ if (!/^[-–]/u.test(form) || /[-–]$/u.test(form)) continue;
126
+ const suffix = form.replace(/^[-–]+/u, "");
127
+ if (suffix) out.push(suffix);
128
+ }
129
+ return out;
130
+ }
131
+
132
+ /**
133
+ * The suffixes a language's affixes state `replaces` for, each with the class
134
+ * bases its `attachesTo` names.
135
+ *
136
+ * @param {Record<string, any>} data - The language note's `data`.
137
+ * @returns {Array<{suffix: string, replaces: string, classes: ReadonlySet<string>|undefined}>}
138
+ * The suffixes, folded; `classes` is `undefined` where the affix attaches to
139
+ * every class.
140
+ */
141
+ function replacingSuffixes(data) {
142
+ const out = [];
143
+ for (const affix of listOf(data.affixes)) {
144
+ if (!isMap(affix) || typeof affix.replaces !== "string") continue;
145
+ const classes =
146
+ Array.isArray(affix.attachesTo) ?
147
+ new Set(
148
+ affix.attachesTo
149
+ .filter((item) => typeof item === "string")
150
+ .map((item) => item.trim()),
151
+ )
152
+ : undefined;
153
+ for (const suffix of suffixesOf(affix))
154
+ out.push({ suffix, replaces: affix.replaces, classes });
155
+ }
156
+ return out;
157
+ }
158
+
159
+ /**
160
+ * The strings of a list, trimmed and folded, keeping those with letters.
161
+ *
162
+ * @param {unknown} value - The list.
163
+ * @returns {string[]} The strings.
164
+ */
165
+ const foldedStrings = (value) =>
166
+ listOf(value)
167
+ .filter((item) => typeof item === "string" && item.trim())
168
+ .map((item) => foldWord(item.trim()));
169
+
170
+ /**
171
+ * A language's agreement rule, as derivation reads it.
172
+ *
173
+ * @typedef {object} Agreement
174
+ * @property {ReadonlySet<string>} classes - The class bases that agree.
175
+ * @property {readonly string[]} endings - The endings, folded, longest first.
176
+ */
177
+
178
+ /**
179
+ * Read a language's `data.grammar.agreement`.
180
+ *
181
+ * @param {unknown} value - The authored value.
182
+ * @returns {Agreement|undefined} The rule, or `undefined` where none is stated
183
+ * in full.
184
+ */
185
+ function agreementOf(value) {
186
+ if (!isMap(value)) return undefined;
187
+ const classes = listOf(value.classes).filter((item) => typeof item === "string");
188
+ const endings = [
189
+ ...new Set(
190
+ listOf(value.endings)
191
+ .filter((item) => typeof item === "string")
192
+ .map((item) => foldWord(item.trim())),
193
+ ),
194
+ ];
195
+ if (!classes.length || endings.length < 2) return undefined;
196
+ return {
197
+ classes: new Set(classes.map((item) => item.trim())),
198
+ endings: endings.sort((a, b) => b.length - a.length),
199
+ };
200
+ }
201
+
202
+ /**
203
+ * The forms an agreeing word takes beside its own.
204
+ *
205
+ * @param {string} form - The word, folded.
206
+ * @param {Agreement} agreement - The rule.
207
+ * @param {Record<string, any>|undefined} grammar - The language's grammar,
208
+ * for its plural rule.
209
+ * @returns {string[]} The forms; none where the word closes on no ending.
210
+ */
211
+ function agreeingForms(form, agreement, grammar) {
212
+ const ending = agreement.endings.find((one) => form.length > one.length && form.endsWith(one));
213
+ if (ending === undefined) return [];
214
+ const stem = form.slice(0, form.length - ending.length);
215
+ const singulars = agreement.endings.map((one) => stem + one);
216
+ const out = singulars.filter((one) => one !== form);
217
+ for (const one of singulars) {
218
+ const plural = pluralByRule(one, grammar);
219
+ if (plural) out.push(foldWord(plural));
220
+ }
221
+ return out;
222
+ }
223
+
224
+ /**
225
+ * A language's participle rule, as derivation reads it.
226
+ *
227
+ * @typedef {object} Participles
228
+ * @property {readonly string[]} root - The root endings, folded, longest
229
+ * first.
230
+ * @property {Array<{suffix: string, replaces: string|undefined}>} suffixes -
231
+ * The participle suffixes, folded.
232
+ */
233
+
234
+ /**
235
+ * Read a language's `data.grammar.participles` against its affixes.
236
+ *
237
+ * @param {unknown} value - The authored value.
238
+ * @param {Record<string, any>} data - The language note's `data`.
239
+ * @returns {Participles|undefined} The rule, or `undefined` where it names no
240
+ * root ending or no suffix the affixes state.
241
+ */
242
+ function participlesOf(value, data) {
243
+ if (!isMap(value)) return undefined;
244
+ const root = [...new Set(foldedStrings(value.root))].sort((a, b) => b.length - a.length);
245
+ const named = new Set(
246
+ listOf(value.affixes)
247
+ .filter((item) => typeof item === "string")
248
+ .map((item) => item.trim()),
249
+ );
250
+ const suffixes = [];
251
+ for (const affix of listOf(data.affixes)) {
252
+ if (!isMap(affix) || typeof affix.form !== "string" || !named.has(affix.form.trim()))
253
+ continue;
254
+ const replaces = typeof affix.replaces === "string" ? affix.replaces : undefined;
255
+ for (const suffix of suffixesOf(affix)) suffixes.push({ suffix, replaces });
256
+ }
257
+ if (!root.length || !suffixes.length) return undefined;
258
+ return { root, suffixes };
259
+ }
260
+
261
+ /**
262
+ * The participles of a verb.
263
+ *
264
+ * @param {string} citation - The citation form, folded.
265
+ * @param {Participles} participles - The rule.
266
+ * @returns {string[]} The participles; none where the verb closes on no root
267
+ * ending.
268
+ */
269
+ function participleForms(citation, participles) {
270
+ const ending = participles.root.find(
271
+ (one) => citation.length > one.length && citation.endsWith(one),
272
+ );
273
+ if (ending === undefined) return [];
274
+ const root = citation.slice(0, citation.length - ending.length);
275
+ return participles.suffixes.map(({ suffix, replaces }) => attach(root, suffix, replaces));
276
+ }
277
+
278
+ /**
279
+ * One form a rule derives from a lexicon word.
280
+ *
281
+ * @typedef {object} DerivedForm
282
+ * @property {string} form - The form, folded.
283
+ * @property {Record<string, any>} word - The `data.words` entry it comes from.
284
+ */
285
+
286
+ /**
287
+ * Every form the language's rules derive from its lexicon words, and each
288
+ * declared variant of a word, in `data.words` order, each once, leaving out a
289
+ * form that is a lexicon word.
290
+ *
291
+ * @param {Record<string, any>|undefined} data - The language note's `data`.
292
+ * @returns {DerivedForm[]} The forms.
293
+ */
294
+ export function derivedForms(data) {
295
+ const words = listOf(data?.words).filter(
296
+ (word) => isMap(word) && typeof word.form === "string",
297
+ );
298
+ const grammar = isMap(data?.grammar) ? data.grammar : undefined;
299
+ const verbs = verbPattern(grammar?.verbs);
300
+ const replaces = verbs?.replaces === undefined ? undefined : foldWord(verbs.replaces);
301
+ const suffixes = replacingSuffixes(data ?? {});
302
+ const agreement = agreementOf(grammar?.agreement);
303
+ const participles = participlesOf(grammar?.participles, data ?? {});
304
+ const seen = new Set(words.map((word) => foldWord(word.form)));
305
+ const out = [];
306
+ for (const word of words) {
307
+ const add = (form) => {
308
+ const folded = foldWord(form);
309
+ if (!folded || seen.has(folded)) return;
310
+ seen.add(folded);
311
+ out.push({ form: folded, word });
312
+ };
313
+ const base = parseWordClass(word.class).base;
314
+ const variants = listOf(word.variants).filter(
315
+ (variant) => typeof variant === "string" && variant.trim() !== "",
316
+ );
317
+ for (const [at, written] of [word.form, ...variants].entries()) {
318
+ const folded = foldWord(written);
319
+ if (at > 0) add(written);
320
+ const plural =
321
+ at === 0 ? pluralOf(word, grammar)
322
+ : base === "n" ? pluralByRule(written, grammar)
323
+ : null;
324
+ if (plural) add(plural);
325
+ if (verbs && base === "v")
326
+ for (const { ending } of verbs.endings)
327
+ add(verbForm(folded, foldWord(ending), replaces));
328
+ const attaching = suffixes.filter(
329
+ ({ classes }) => classes === undefined || (base !== undefined && classes.has(base)),
330
+ );
331
+ if (attaching.length)
332
+ for (const [stem] of written.matchAll(WORD)) {
333
+ const one = foldWord(stem);
334
+ for (const { suffix, replaces: closing } of attaching)
335
+ add(attach(one, suffix, closing));
336
+ }
337
+ if (agreement && base !== undefined && agreement.classes.has(base))
338
+ for (const form of agreeingForms(folded, agreement, grammar)) add(form);
339
+ if (participles && base === "v")
340
+ for (const participle of participleForms(folded, participles)) {
341
+ add(participle);
342
+ if (agreement)
343
+ for (const form of agreeingForms(participle, agreement, grammar)) add(form);
344
+ }
345
+ }
346
+ }
347
+ return out;
348
+ }
349
+
350
+ /**
351
+ * The words a language note accepts of its own: its forms, and what its rules
352
+ * derive from them (see {@link derivedForms}), each whole and each part of a
353
+ * compound.
354
+ *
355
+ * @param {Record<string, any>} data - The language note's `data`.
356
+ * @returns {Set<string>} The words, folded.
357
+ */
358
+ export function lexiconWords(data) {
359
+ const accepted = new Set();
360
+ for (const word of listOf(data?.words))
361
+ if (isMap(word) && typeof word.form === "string") addWords(accepted, word.form);
362
+ for (const { form } of derivedForms(data)) addWords(accepted, form);
363
+ return accepted;
364
+ }
@@ -69,8 +69,18 @@ export const WORD_COLUMNS = Object.freeze([
69
69
  /** Every column of the `glossary` relation, in order. */
70
70
  export const GLOSSARY_COLUMNS = Object.freeze(["note", "position", ...GLOSSARY_ENTRY_KEYS]);
71
71
 
72
+ /**
73
+ * The affix's own keys an affix row carries: all but `native`, its writings
74
+ * under a logographic map, which `package-build lexicon` resolves.
75
+ */
76
+ const AFFIX_ROW_KEYS = Object.freeze(AFFIX_KEYS.filter((key) => key !== "native"));
77
+
72
78
  /** Every column of the `affixes` relation, in order. */
73
- export const AFFIX_COLUMNS = Object.freeze([...WORD_IDENTITY_COLUMNS, ...AFFIX_KEYS, "written"]);
79
+ export const AFFIX_COLUMNS = Object.freeze([
80
+ ...WORD_IDENTITY_COLUMNS,
81
+ ...AFFIX_ROW_KEYS,
82
+ "written",
83
+ ]);
74
84
 
75
85
  /** Every column of the `fields` relation, in order. */
76
86
  export const FIELD_COLUMNS = Object.freeze([...WORD_IDENTITY_COLUMNS, ...FIELD_KEYS]);
@@ -322,7 +332,7 @@ function languageListRows(records, list, keys) {
322
332
  * @returns {object[]} One row per affix, in record then entry order.
323
333
  */
324
334
  export function affixRows(records) {
325
- return languageListRows(records, "affixes", AFFIX_KEYS).map((row) => ({
335
+ return languageListRows(records, "affixes", AFFIX_ROW_KEYS).map((row) => ({
326
336
  ...row,
327
337
  written: row.zero === true ? ZERO_AFFIX : row.form,
328
338
  }));
@@ -126,7 +126,8 @@ export const WORD_NATIVE_FIELDS = Object.freeze([
126
126
  required: true,
127
127
  shape: "the word in the script, as the map's characters",
128
128
  describe:
129
- "The word as the script writes it, where the map's rows would write it otherwise.",
129
+ "The word as the script writes it, where the map's rows would write it otherwise, " +
130
+ "or as a logographic map writes it.",
130
131
  }),
131
132
  ]);
132
133
 
@@ -150,7 +151,7 @@ export const WORD_FIELDS = Object.freeze([
150
151
  entries: Object.freeze({ kind: "string", shape: "a form, bare" }),
151
152
  describe:
152
153
  "The word's other forms, such as a long form beside a short one; a marked use of " +
153
- "one cites the word.",
154
+ "one cites the word, and a mapped run accepts it and the forms the language's rules derive from it.",
154
155
  }),
155
156
  Object.freeze({
156
157
  name: "name_sort",
@@ -191,6 +192,15 @@ export const WORD_FIELDS = Object.freeze([
191
192
  describe:
192
193
  "The meaning, in brief; a list states one sense per entry, which a dictionary numbers.",
193
194
  }),
195
+ Object.freeze({
196
+ name: "concept",
197
+ kind: "string-or-list",
198
+ shape: "a core concept's id, or a list of them",
199
+ entries: Object.freeze({ kind: "string", shape: "a core concept's id" }),
200
+ describe:
201
+ "The core concepts the word serves, each an `id` of a `data.concepts` list. The " +
202
+ "core report counts the word toward them.",
203
+ }),
194
204
  Object.freeze({
195
205
  name: "source",
196
206
  kind: "map",
@@ -250,8 +260,10 @@ export const WORD_FIELDS = Object.freeze([
250
260
  fields: WORD_NATIVE_FIELDS,
251
261
  }),
252
262
  describe:
253
- "The word's native form under a map, stated only where the map's rows spell it " +
254
- "differently. Its derived forms are spelled by the rows.",
263
+ "The word's native form under a map. Under a map that spells by its rows it is " +
264
+ "stated only where the rows spell the word differently, and the word's derived " +
265
+ "forms are spelled by the rows; under a logographic map it is the word's writing, " +
266
+ "which its derived forms and the words built on it are written from.",
255
267
  }),
256
268
  ]);
257
269
 
@@ -368,6 +380,15 @@ export const AFFIX_FIELDS = Object.freeze([
368
380
  "What it takes the place of at the close of the stem: `vowel` for any closing " +
369
381
  "vowel, or the letters it replaces; added to the whole stem where absent.",
370
382
  }),
383
+ Object.freeze({
384
+ name: "attachesTo",
385
+ kind: "list",
386
+ shape: "a list of word classes, such as `[n, adj]`",
387
+ entries: Object.freeze({ kind: "string", shape: "a word class, such as `n`" }),
388
+ describe:
389
+ "The word classes it attaches to, by their base; every class where absent. A " +
390
+ "suffix stating `replaces` derives forms of these classes' words alone.",
391
+ }),
371
392
  Object.freeze({
372
393
  name: "lengthens",
373
394
  kind: "boolean",
@@ -402,6 +423,19 @@ export const AFFIX_FIELDS = Object.freeze([
402
423
  shape: "a tone pattern, such as `L`",
403
424
  describe: "The tone pattern the affix carries, written as a word's `tones` is.",
404
425
  }),
426
+ Object.freeze({
427
+ name: "native",
428
+ kind: "list",
429
+ shape: "a list of `{ map, font?, text }`",
430
+ entries: Object.freeze({
431
+ kind: "map",
432
+ shape: "a map — `{ map, font?, text }`",
433
+ fields: WORD_NATIVE_FIELDS,
434
+ }),
435
+ describe:
436
+ "The affix's writing under a logographic map, which a word whose `built` names the " +
437
+ "affix is written with.",
438
+ }),
405
439
  ]);
406
440
 
407
441
  /** The keys of {@link WORD_FIELD_FIELDS}, in declared order. */
package/manifest.mjs CHANGED
@@ -121,10 +121,9 @@ export const HOMEPAGE_ORIGIN = "https://www.heroiclands.org";
121
121
  * to drift — and the one that drifts is this one, because nothing fetches `url`
122
122
  * the way Foundry fetches `manifest`, so a wrong value is never reported.
123
123
  *
124
- * The site's own `baseURL` says the same thing, and is deliberately not the
125
- * source: it sits at `site/hugo.toml` in most repositories and `kb/hugo.toml`
126
- * in the system, so reading it would mean the packaging half knowing where each
127
- * repository keeps its site configuration.
124
+ * `package.json`'s `homepage` says the same thing, and is deliberately not the
125
+ * source: reading it would mean the packaging half depending on the site's
126
+ * configuration.
128
127
  *
129
128
  * @param {string} contentPackage - The package's content-package name.
130
129
  * @returns {string} The homepage URL, with its trailing slash.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "23.12.8",
3
+ "version": "24.0.0",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -123,17 +123,18 @@
123
123
  "engine",
124
124
  "githooks",
125
125
  "hm3",
126
- "hugo-theme",
127
126
  "index.mjs",
128
127
  "lang.mjs",
129
128
  "manifest.mjs",
130
129
  "markdownlint-config.mjs",
131
130
  "prettier-config.mjs",
132
131
  "release.mjs",
132
+ "site-theme",
133
133
  "sohl",
134
134
  "stage.mjs",
135
135
  "templates.mjs",
136
- "types"
136
+ "types",
137
+ "vendor"
137
138
  ],
138
139
  "dependencies": {
139
140
  "@duckdb/node-api": "^1.5.5-r.4",
@@ -151,16 +152,16 @@
151
152
  "fflate": "^0.8.3",
152
153
  "flesch": "^2.0.1",
153
154
  "glob": "^13.0.6",
154
- "gray-matter": "^4.0.3",
155
155
  "handlebars": "^4.7.10",
156
+ "highlight.js": "^11.12.0",
156
157
  "ignore": "^7.0.12",
158
+ "js-yaml": "^5.5.0",
157
159
  "jsonc-parser": "^3.3.1",
158
160
  "loglevel": "^1.9.2",
159
161
  "loglevel-plugin-prefix": "^0.8.4",
160
162
  "markdown-it": "^15.0.0",
161
163
  "markdown-it-deflist": "^4.0.0",
162
164
  "markdown-it-footnote": "^4.0.0",
163
- "markdownlint": "^0.42.0",
164
165
  "minimatch": "^10.2.6",
165
166
  "pagefind": "^1.5.2",
166
167
  "prettier": "^3.9.6",
@@ -173,7 +174,6 @@
173
174
  "retext-simplify": "^8.0.0",
174
175
  "retext-stringify": "^4.0.0",
175
176
  "sharp": "^0.35.4",
176
- "smol-toml": "^1.8.0",
177
177
  "ssh2-sftp-client": "^12.1.1",
178
178
  "syllable": "^5.0.1",
179
179
  "typescript": "^6.0.3",
@@ -185,6 +185,8 @@
185
185
  "devDependencies": {
186
186
  "@changesets/cli": "^3.0.0",
187
187
  "@types/node": "^26.2.0",
188
+ "esbuild": "^0.28.2",
189
+ "markdownlint": "^0.42.0",
188
190
  "npm-run-all": "^4.1.5",
189
191
  "vitest": "^5.0.0"
190
192
  },
@@ -193,7 +195,8 @@
193
195
  "test:watch": "vitest",
194
196
  "build": "node bin/package-build.mjs ci",
195
197
  "deploy:npm": "node ci/publish-npm.mjs",
196
- "prepack": "run-s build:types build:asset-index",
198
+ "prepack": "run-s build:vendor build:types build:asset-index",
199
+ "build:vendor": "node scripts/vendor-markdownlint.mjs",
197
200
  "build:types": "tsc -p tsconfig.dts.json",
198
201
  "build:asset-index": "node ci/emit-asset-index.mjs",
199
202
  "format": "node ci/prettier.mjs --write",
@@ -210,7 +213,7 @@
210
213
  "lint:content-format:fields": "node bin/package-build.mjs content-format fields --fields sohl && node bin/package-build.mjs content-format fields --fields hm3",
211
214
  "changeset": "changeset",
212
215
  "changeset:version": "changeset version && node bin/package-build.mjs changelog group && npm install --package-lock-only",
213
- "prepare": "git config core.hooksPath .githooks || true",
216
+ "prepare": "(git config core.hooksPath .githooks || true) && (test ! -e scripts/vendor-markdownlint.mjs || npm run build:vendor)",
214
217
  "build:deps": "node bin/package-build.mjs deps fetch"
215
218
  },
216
219
  "engines": {
@@ -1,16 +1,16 @@
1
1
  # Which banner images exist in the shared artwork set.
2
2
  #
3
- # `partials/hero-banner.html` resolves a page's hero image to a path under
3
+ # The hero banner resolves a page's hero image to a path under
4
4
  # `images/banners/`, and that path is emitted into a `background-image` with no
5
5
  # way to test it: the artwork is served from the consuming site's
6
- # `params.cdnBaseURL`, and Hugo cannot ask a remote host whether a URL exists.
7
- # So a banner that was never drawn would 404 in silence — the band renders
8
- # with its title, palette and gradient intact and a dead image URL behind it,
9
- # and nothing fails: not the build, not Hugo, not the deploy guard.
6
+ # `params.cdnBaseURL`, and the build cannot ask a remote host whether a URL
7
+ # exists. So a banner that was never drawn would 404 in silence — the band
8
+ # renders with its title, palette and gradient intact and a dead image URL
9
+ # behind it, and nothing fails: not the build, not the deploy guard.
10
10
  #
11
11
  # This file is the declaration that replaces that missing test. A name listed
12
12
  # here is asserted to exist; a resolved banner whose name is absent falls back
13
- # to `default.webp` and Hugo warns once for it during the consumer's build.
13
+ # to `default.webp` and the build warns once for it.
14
14
  #
15
15
  # It speaks for the names this theme resolves itself — a hand-authored
16
16
  # `banner:` and the subtype defaults below. A note's own `data.banner` arrives
@@ -21,10 +21,6 @@
21
21
  # (`ci/check-banners.mjs`, part of `npm run lint`) fetches every name below
22
22
  # from a real asset host and fails on any non-200. Add a name here only after
23
23
  # the file is published, or the check will say so.
24
- #
25
- # A consumer publishing its own artwork set overrides the whole list by
26
- # shipping its own `data/banners.yaml` — Hugo reads the site's data directory
27
- # in preference to the theme's.
28
24
 
29
25
  # The asset root every name below is resolved under, and the name used when a
30
26
  # resolved banner is not in `available`. Both are relative to the consuming
@@ -0,0 +1,31 @@
1
+ {{!-- SPDX-License-Identifier: GPL-3.0-or-later --}}
2
+ <!DOCTYPE html>
3
+ <html lang="en">
4
+ <head>
5
+ <meta charset="utf-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1">
7
+ <title>{{head.title}}</title>
8
+ <meta name="description" content="{{head.description}}">
9
+ <link rel="preconnect" href="https://fonts.googleapis.com">
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
+ <link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Lora:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500;1,600&display=swap" rel="stylesheet">
12
+ <link rel="stylesheet" href="{{site.urls.style}}">
13
+ {{#if site.urls.scriptFonts}}
14
+ <link rel="stylesheet" href="{{site.urls.scriptFonts}}">
15
+ {{/if}}
16
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.3.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">
17
+ <link rel="stylesheet" href="{{site.urls.icons}}">
18
+ <link rel="stylesheet" href="{{site.urls.code}}">
19
+ {{#if site.feed}}
20
+ <link rel="alternate" type="application/atom+xml" title="{{site.feed.title}}" href="{{site.feed.url}}">
21
+ {{/if}}
22
+ </head>
23
+ <body>
24
+ {{> header site}}
25
+ <main>
26
+ {{{body}}}
27
+ </main>
28
+ {{> footer site}}
29
+ {{> copy-buttons}}
30
+ </body>
31
+ </html>
@@ -0,0 +1,30 @@
1
+ {{!-- SPDX-License-Identifier: GPL-3.0-or-later --}}
2
+ {{#if hero}}
3
+ {{> hero-banner hero}}
4
+ {{/if}}
5
+ <article class="single">
6
+ <div class="single-content">
7
+ {{#if body}}
8
+ <div class="single-body">
9
+ {{{body}}}
10
+ </div>
11
+ {{/if}}
12
+ {{#if posts}}
13
+ <ul class="blog-list">
14
+ {{#each posts}}
15
+ <li class="blog-list-item">
16
+ {{#if date}}
17
+ <time class="blog-list-date" datetime="{{date.iso}}">{{date.text}}</time>
18
+ {{/if}}
19
+ <a class="blog-list-title" href="{{url}}">{{title}}</a>
20
+ {{#if preview}}
21
+ <p class="blog-list-desc">{{preview}}</p>
22
+ {{/if}}
23
+ </li>
24
+ {{/each}}
25
+ </ul>
26
+ {{else}}
27
+ <p class="page-list-empty">No posts yet.</p>
28
+ {{/if}}
29
+ </div>
30
+ </article>