@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,169 @@
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 core report: for each language that must cover the core vocabulary, the
16
+ * concepts of the list it has no word for.
17
+ *
18
+ * The list is the `data.concepts` of the package's `doc` notes. A language
19
+ * must cover it where it states `data.core: true`. A word covers every
20
+ * concept its `concept` names, and a language reads the words of its
21
+ * `data.family` chain as its own. A missing concept is a fact to work through,
22
+ * never a finding.
23
+ *
24
+ * @module
25
+ */
26
+
27
+ import { encodeAddresses } from "./address-values.mjs";
28
+ import { isLanguageRecord, languageLineages, noteAddressOf } from "./word-rows.mjs";
29
+
30
+ /** Whether a value is a plain map. */
31
+ const isMap = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
32
+
33
+ /** The slug a record is written by in a reference, such as `skill-vylari`. */
34
+ const slugOf = (record) => record.address?.slug ?? record.shortcode;
35
+
36
+ /** The name a language is reported by. */
37
+ const nameOf = (record) =>
38
+ typeof record.name?.full === "string" && record.name.full ? record.name.full : record.shortcode;
39
+
40
+ /**
41
+ * The concept list the records state, in record then authored order. An id
42
+ * another list repeats keeps its first place.
43
+ *
44
+ * @param {Iterable<Record<string, any>>} records - Index records.
45
+ * @returns {Array<{id: string, gloss: string, group: string}>} The concepts.
46
+ */
47
+ function conceptsOf(records) {
48
+ const out = [];
49
+ const seen = new Set();
50
+ for (const record of records) {
51
+ const list = record?.data?.concepts;
52
+ if (!Array.isArray(list)) continue;
53
+ for (const entry of list) {
54
+ if (
55
+ !isMap(entry) ||
56
+ typeof entry.id !== "string" ||
57
+ typeof entry.gloss !== "string" ||
58
+ typeof entry.group !== "string" ||
59
+ seen.has(entry.id)
60
+ )
61
+ continue;
62
+ seen.add(entry.id);
63
+ out.push({ id: entry.id, gloss: entry.gloss, group: entry.group });
64
+ }
65
+ }
66
+ return out;
67
+ }
68
+
69
+ /** The concept ids a word serves. */
70
+ function idsOf(word) {
71
+ const value = isMap(word) ? word.concept : undefined;
72
+ return (Array.isArray(value) ? value : [value]).filter((id) => typeof id === "string");
73
+ }
74
+
75
+ /**
76
+ * The core report of a set of index records.
77
+ *
78
+ * @param {Iterable<Record<string, any>>} records - Index records.
79
+ * @param {{language?: string}} [options] - A language to report alone, by its
80
+ * name, shortcode or reference slug whether or not it states `data.core`.
81
+ * @returns {{concepts: number, languages: Array<{name: string, language: string, core: boolean, total: number, covered: number, missing: Array<{group: string, count: number, total: number, concepts: Array<{id: string, gloss: string}>}>}>}|{problem: string, about: string}}
82
+ * The report, or the problem when there is no concept list or the language
83
+ * asked for is none.
84
+ */
85
+ export function coreReport(records, { language } = {}) {
86
+ const all = [...records].map((record) => encodeAddresses(record));
87
+ const concepts = conceptsOf(all);
88
+ if (!concepts.length)
89
+ return {
90
+ about: "concepts",
91
+ problem: "no note states `data.concepts`, so there is no core vocabulary to cover",
92
+ };
93
+ const languages = all.filter(isLanguageRecord);
94
+ let chosen;
95
+ if (language === undefined) chosen = languages.filter((record) => record.data?.core === true);
96
+ else {
97
+ const want = language.toLowerCase();
98
+ chosen = languages.filter((record) =>
99
+ [nameOf(record), record.shortcode, slugOf(record)].some(
100
+ (key) => String(key).toLowerCase() === want,
101
+ ),
102
+ );
103
+ if (!chosen.length)
104
+ return {
105
+ about: "language",
106
+ problem: `\`${language}\` is no language note: name one by its name, its shortcode or its reference`,
107
+ };
108
+ }
109
+ chosen.sort((a, b) => nameOf(a).localeCompare(nameOf(b)));
110
+
111
+ const { languages: byKey, chain } = languageLineages(all);
112
+ const groups = [...new Set(concepts.map((concept) => concept.group))];
113
+ return {
114
+ concepts: concepts.length,
115
+ languages: chosen.map((record) => {
116
+ const have = new Set();
117
+ for (const key of chain(noteAddressOf(record) ?? ""))
118
+ for (const word of byKey.get(key)?.data?.words ?? [])
119
+ for (const id of idsOf(word)) have.add(id);
120
+ const missing = [];
121
+ for (const group of groups) {
122
+ const members = concepts.filter((concept) => concept.group === group);
123
+ const lacking = members.filter((concept) => !have.has(concept.id));
124
+ if (lacking.length)
125
+ missing.push({
126
+ group,
127
+ count: lacking.length,
128
+ total: members.length,
129
+ concepts: lacking.map(({ id, gloss }) => ({ id, gloss })),
130
+ });
131
+ }
132
+ const lacking = missing.reduce((sum, group) => sum + group.count, 0);
133
+ return {
134
+ name: nameOf(record),
135
+ language: slugOf(record),
136
+ core: record.data?.core === true,
137
+ total: concepts.length,
138
+ covered: concepts.length - lacking,
139
+ missing,
140
+ };
141
+ }),
142
+ };
143
+ }
144
+
145
+ /**
146
+ * A core report as text: one block per language, its missing concepts under
147
+ * their groups with counts, each concept's id and gloss tab-separated.
148
+ *
149
+ * @param {{concepts: number, languages: Array<object>}} report - A report from {@link coreReport}.
150
+ * @returns {string} The text, ending in a newline.
151
+ */
152
+ export function coreReportText(report) {
153
+ if (!report.languages.length) return "No language states `data.core: true`.\n";
154
+ return report.languages
155
+ .map((one) => {
156
+ if (!one.missing.length)
157
+ return `${one.name} (${one.language}): all ${one.total} core concepts have a word\n`;
158
+ const lines = [
159
+ `${one.name} (${one.language}): ${one.total - one.covered} of ${one.total} core concepts have no word`,
160
+ ];
161
+ for (const group of one.missing) {
162
+ lines.push(` ${group.group}: ${group.count} of ${group.total}`);
163
+ for (const concept of group.concepts)
164
+ lines.push(` ${concept.id}\t${concept.gloss}`);
165
+ }
166
+ return `${lines.join("\n")}\n`;
167
+ })
168
+ .join("\n");
169
+ }
@@ -0,0 +1,208 @@
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 setting's core vocabulary: the concept list a `doc` note states in
16
+ * `data.concepts`, the `data.core` flag that says a language must cover it, and
17
+ * the `concept` a word of a language serves.
18
+ *
19
+ * This check owns what the values mean, each finding located at the key at
20
+ * fault:
21
+ *
22
+ * - a concept's `id` is lowercase letters, digits and `-`;
23
+ * - an `id` is stated once in a list, and by one note in the package;
24
+ * - a concept's `gloss` and `group` are not blank;
25
+ * - `data.core` is stated on a language only;
26
+ * - a word's `concept` names entries of the concept list, each once.
27
+ *
28
+ * @module
29
+ */
30
+
31
+ import { pathLabel as label } from "./data-keys.mjs";
32
+ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
33
+ import { nearest } from "./near-miss.mjs";
34
+ import { LANGUAGE_SUBTYPE } from "./word-terms.mjs";
35
+
36
+ /** What a concept's `id` is made of. */
37
+ export const CONCEPT_ID = /^[a-z0-9-]+$/;
38
+
39
+ /** Whether a value is a plain map. */
40
+ const isMap = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
41
+
42
+ /** A list value, or an empty one. */
43
+ const listOf = (value) => (Array.isArray(value) ? value : []);
44
+
45
+ /**
46
+ * The notes of an index that state a concept list, read once per index.
47
+ *
48
+ * @type {WeakMap<object, Array<{file: string, ids: Map<string, number>}>>}
49
+ */
50
+ const stated = new WeakMap();
51
+
52
+ /**
53
+ * Each note of the index that states `data.concepts`, with the ids it states
54
+ * and where each first stands.
55
+ *
56
+ * @param {{notes?: Iterable<{file: string, fm?: Record<string, any>}>}} index - The lint index.
57
+ * @returns {Array<{file: string, ids: Map<string, number>}>} The notes.
58
+ */
59
+ function listsIn(index) {
60
+ const known = stated.get(index);
61
+ if (known) return known;
62
+ const lists = [];
63
+ for (const note of index.notes ?? []) {
64
+ const concepts = note?.fm?.data?.concepts;
65
+ if (!Array.isArray(concepts)) continue;
66
+ const ids = new Map();
67
+ concepts.forEach((entry, at) => {
68
+ if (isMap(entry) && typeof entry.id === "string" && !ids.has(entry.id))
69
+ ids.set(entry.id, at);
70
+ });
71
+ lists.push({ file: note.file, ids });
72
+ }
73
+ stated.set(index, lists);
74
+ return lists;
75
+ }
76
+
77
+ /**
78
+ * Every concept id the index's notes state.
79
+ *
80
+ * @param {{notes?: Iterable<object>}|undefined} index - The lint index.
81
+ * @returns {Set<string>|undefined} The ids, or `undefined` without an index.
82
+ */
83
+ export function conceptIdsIn(index) {
84
+ if (!index?.notes) return undefined;
85
+ return new Set(listsIn(index).flatMap((list) => [...list.ids.keys()]));
86
+ }
87
+
88
+ /**
89
+ * Check a `doc` note's `data.concepts`: each id's spelling, an id stated twice,
90
+ * an id another note states, and a blank `gloss` or `group`.
91
+ *
92
+ * @param {{file: string, raw?: string, fm?: Record<string, any>}} note - The note.
93
+ * @param {{index?: {notes?: Iterable<object>}}} [context] - The lint index.
94
+ * @returns {object[]} The findings, each at its key.
95
+ */
96
+ export function checkConceptList(note, { index } = {}) {
97
+ const concepts = note?.fm?.data?.concepts;
98
+ if (!Array.isArray(concepts)) return [];
99
+ const raw = note.raw ?? "";
100
+ const finding = (path, message) => ({
101
+ file: note.file,
102
+ ...positionOfFrontmatterPath(raw, path),
103
+ severity: "error",
104
+ message,
105
+ });
106
+ const others = index ? listsIn(index).filter((list) => list.file !== note.file) : [];
107
+ const findings = [];
108
+ const seen = new Set();
109
+ concepts.forEach((entry, at) => {
110
+ if (!isMap(entry)) return;
111
+ const where = (key) => ["data", "concepts", at, key];
112
+ if (typeof entry.id === "string") {
113
+ if (!CONCEPT_ID.test(entry.id))
114
+ findings.push(
115
+ finding(
116
+ where("id"),
117
+ `\`${label(where("id"))}\` is \`${entry.id}\`; a concept's id is lowercase letters, digits and \`-\``,
118
+ ),
119
+ );
120
+ else if (seen.has(entry.id))
121
+ findings.push(
122
+ finding(
123
+ where("id"),
124
+ `\`${entry.id}\` is stated twice in this list; a concept's id is unique`,
125
+ ),
126
+ );
127
+ else {
128
+ const also = others.find((list) => list.ids.has(entry.id));
129
+ if (also)
130
+ findings.push(
131
+ finding(
132
+ where("id"),
133
+ `\`${entry.id}\` is also stated by ${also.file}; a concept's id is unique`,
134
+ ),
135
+ );
136
+ }
137
+ seen.add(entry.id);
138
+ }
139
+ for (const key of ["gloss", "group"])
140
+ if (typeof entry[key] === "string" && entry[key].trim() === "")
141
+ findings.push(
142
+ finding(where(key), `\`${label(where(key))}\` is blank; state the ${key}`),
143
+ );
144
+ });
145
+ return findings;
146
+ }
147
+
148
+ /**
149
+ * Check a `skill` note's `data.core`: stated on a language only.
150
+ *
151
+ * @param {{file: string, raw?: string, fm?: Record<string, any>}} note - A `skill` note.
152
+ * @returns {object[]} The findings.
153
+ */
154
+ export function checkCoreFlag(note) {
155
+ const fm = note?.fm ?? {};
156
+ if (!isMap(fm.data) || fm.data.core === undefined || fm.data.core === null) return [];
157
+ if (String(fm.subType ?? "").toLowerCase() === LANGUAGE_SUBTYPE) return [];
158
+ return [
159
+ {
160
+ file: note.file,
161
+ ...positionOfFrontmatterPath(note.raw ?? "", ["data", "core"], { key: true }),
162
+ severity: "error",
163
+ message:
164
+ "`data.core` says a language must cover the core vocabulary, and this note's " +
165
+ `\`subType\` is not \`${LANGUAGE_SUBTYPE}\``,
166
+ },
167
+ ];
168
+ }
169
+
170
+ /**
171
+ * What is wrong with a word's `concept`: an id the list does not declare, and
172
+ * an id stated twice.
173
+ *
174
+ * @param {unknown} value - The word's `concept`: an id, or a list of them.
175
+ * @param {Set<string>|undefined} known - The ids the index declares, or
176
+ * `undefined` where there is no index to check against.
177
+ * @returns {Array<{entry: number|null, message: string}>} Each problem and the
178
+ * list entry it sits at, `null` for a single id.
179
+ */
180
+ export function conceptProblems(value, known) {
181
+ if (!known) return [];
182
+ const single = typeof value === "string";
183
+ const entries = single ? [value] : listOf(value);
184
+ const problems = [];
185
+ const seen = new Set();
186
+ entries.forEach((id, at) => {
187
+ if (typeof id !== "string") return;
188
+ const entry = single ? null : at;
189
+ if (seen.has(id)) {
190
+ problems.push({
191
+ entry,
192
+ message: `\`${id}\` is stated twice in this word's \`concept\``,
193
+ });
194
+ return;
195
+ }
196
+ seen.add(id);
197
+ if (known.has(id)) return;
198
+ const near = nearest(id, known);
199
+ problems.push({
200
+ entry,
201
+ message:
202
+ known.size ?
203
+ `\`${id}\` is not a core concept${near ? `; did you mean \`${near}\`?` : ""}`
204
+ : `\`${id}\` is not a core concept: no note states \`data.concepts\``,
205
+ });
206
+ });
207
+ return problems;
208
+ }
@@ -0,0 +1,119 @@
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
+ * **Dictionary rows in finished HTML.** {@link groupDictionaryLists} gives a
16
+ * definition list written as HTML the rows and the `dictionary` class that
17
+ * {@link module:engine/definition-list.dictionaryListPlugin} gives one written
18
+ * in Markdown, so both read the same on a page.
19
+ *
20
+ * The page is read as tokens, and a stack of open elements gives each nested
21
+ * list its own rows. Comments, `script`, `style`, `pre` and `textarea` are
22
+ * opaque: a literal example inside one is left as written. A list that already
23
+ * has rows keeps them.
24
+ *
25
+ * @module
26
+ */
27
+
28
+ /** One token: a comment, an opaque element, a declaration, a tag, text, or a bare `<`. */
29
+ const TOKEN =
30
+ /<!--[^]*?-->|<script\b(?:[^"'<>]|"[^"]*"|'[^']*')*>[^]*?<\/script\s*>|<style\b(?:[^"'<>]|"[^"]*"|'[^']*')*>[^]*?<\/style\s*>|<pre\b(?:[^"'<>]|"[^"]*"|'[^']*')*>[^]*?<\/pre\s*>|<textarea\b(?:[^"'<>]|"[^"]*"|'[^']*')*>[^]*?<\/textarea\s*>|<![^>]*>|<\/?[a-z][a-z0-9:-]*(?:[^"'<>]|"[^"]*"|'[^']*')*>|[^<]+|</gi;
31
+
32
+ /** Elements with no closing tag. */
33
+ const VOID = new Set([
34
+ "area",
35
+ "base",
36
+ "br",
37
+ "col",
38
+ "embed",
39
+ "hr",
40
+ "img",
41
+ "input",
42
+ "link",
43
+ "meta",
44
+ "param",
45
+ "source",
46
+ "track",
47
+ "wbr",
48
+ ]);
49
+
50
+ /** Add the `dictionary` class to a `<dl …>` opening tag. */
51
+ function withDictionaryClass(token) {
52
+ if (
53
+ /\sclass\s*=\s*["'](?:[^"']*\s)?dictionary(?:\s|["'])/i.test(token) ||
54
+ /\sclass\s*=\s*dictionary(?:\s|>)/i.test(token)
55
+ )
56
+ return token;
57
+ if (/\sclass\s*=\s*["']/i.test(token))
58
+ return token.replace(/(\sclass\s*=\s*["'])/i, "$1dictionary ");
59
+ if (/\sclass\s*=/i.test(token))
60
+ return token.replace(/(\sclass\s*=\s*)([^\s>]+)/i, '$1"dictionary $2"');
61
+ return token.replace(/^<dl/i, '<dl class="dictionary"');
62
+ }
63
+
64
+ /**
65
+ * Group each term of every definition list with its definitions.
66
+ *
67
+ * @param {string} html - A page's HTML.
68
+ * @returns {string} The HTML with a `dictionary` class on each `dl` and a `div`
69
+ * around each term and the definitions that follow it.
70
+ */
71
+ export function groupDictionaryLists(html) {
72
+ const source = String(html ?? "");
73
+ if (!/<dl(?:\s|>)/i.test(source)) return source;
74
+ const tokens = source.match(TOKEN) ?? [];
75
+ const parts = [];
76
+ const lists = [];
77
+ const elements = [];
78
+ tokens.forEach((original, i) => {
79
+ let token = original;
80
+ const previous = tokens[i - 1];
81
+ const next = tokens[i + 1];
82
+ // Whitespace between a term and its definition, and between a definition's
83
+ // opening and its first paragraph, would add a word space to a fixed gap.
84
+ if (
85
+ lists.length &&
86
+ i > 0 &&
87
+ next !== undefined &&
88
+ /^\s+$/.test(token) &&
89
+ ((/^<\/dt\s*>$/i.test(previous) && /^<dd(?:\s|>)/i.test(next)) ||
90
+ (/^<dd(?:\s[^>]*)?>$/i.test(previous) && /^<p(?:\s|>)/i.test(next)))
91
+ )
92
+ token = "";
93
+ const opaque = /^<(?:!--|script\b|style\b|pre\b|textarea\b)/i.test(token);
94
+ if (!opaque && /^<\/?[a-z]/i.test(token)) {
95
+ const tag = token.replace(/^<\/?([a-z][a-z0-9:-]*)[^]*$/i, "$1").toLowerCase();
96
+ const closing = token.startsWith("</");
97
+ if (tag === "dl" && !closing) {
98
+ lists.push({ row: false });
99
+ token = withDictionaryClass(token);
100
+ } else if (tag === "dt" && !closing && lists.length && elements.at(-1) === "dl") {
101
+ const list = lists.at(-1);
102
+ if (list.row) parts.push("</div>");
103
+ parts.push("<div>");
104
+ list.row = true;
105
+ } else if (tag === "dl" && closing && lists.length) {
106
+ const list = lists.at(-1);
107
+ if (list.row) parts.push("</div>");
108
+ lists.pop();
109
+ }
110
+ if (closing) {
111
+ if (elements.length) elements.pop();
112
+ } else if (!(/\/\s*>$/.test(token) || VOID.has(tag))) {
113
+ elements.push(tag);
114
+ }
115
+ }
116
+ parts.push(token);
117
+ });
118
+ return parts.join("");
119
+ }
@@ -143,12 +143,23 @@ const defines = (entry) => typeof entry.definition === "string" && entry.definit
143
143
  export function pluralOf(word, grammar) {
144
144
  if (parseWordClass(word.class).base !== "n") return null;
145
145
  if (typeof word.plural === "string" && word.plural) return word.plural;
146
+ return pluralByRule(word.form, grammar);
147
+ }
148
+
149
+ /**
150
+ * The plural a language's `data.grammar.plural` rule gives a form: the first
151
+ * literal ending replacement that fits, whatever the form's class.
152
+ *
153
+ * @param {string} form - The form.
154
+ * @param {Record<string, any>|undefined} grammar - Its language's
155
+ * `data.grammar`.
156
+ * @returns {string|null} The plural, or `null` where no rule fits.
157
+ */
158
+ export function pluralByRule(form, grammar) {
146
159
  for (const rule of Array.isArray(grammar?.plural) ? grammar.plural : []) {
147
160
  if (typeof rule?.ending !== "string" || typeof rule.becomes !== "string") continue;
148
- if (word.form.endsWith(rule.ending))
149
- return (
150
- (rule.ending ? word.form.slice(0, -rule.ending.length) : word.form) + rule.becomes
151
- );
161
+ if (form.endsWith(rule.ending))
162
+ return (rule.ending ? form.slice(0, -rule.ending.length) : form) + rule.becomes;
152
163
  }
153
164
  return null;
154
165
  }
@@ -35,7 +35,7 @@
35
35
  */
36
36
 
37
37
  import path from "node:path";
38
- import matter from "gray-matter";
38
+ import { writeMatter } from "./note-matter.mjs";
39
39
  import { formatNoteFrontmatter } from "./note-format.mjs";
40
40
 
41
41
  import { authoredFields, runtimeOnlyFields } from "./field-spec.mjs";
@@ -368,7 +368,7 @@ function deepMerge(base, overlay) {
368
368
  * The one question that decides whether `docs item-fields` writes a note
369
369
  * envelope: `assets/content/` walks every file under it for its `type:`, so a
370
370
  * generated page filed there needs one to publish at all, while a page filed
371
- * anywhere else — a repository's own `docs/` — is read by nobody but Hugo's
371
+ * anywhere else — a repository's own `docs/` — is read by nobody but the
372
372
  * `--check` guard and the reader following a link, neither of which wants
373
373
  * frontmatter.
374
374
  *
@@ -422,7 +422,7 @@ export function shortcodeFromBasename(destination) {
422
422
  * {@link shortcodeFromBasename}.
423
423
  * @param {Record<string, unknown>} [options.frontmatter] - The consumer's
424
424
  * declared `docs.itemFields.frontmatter`.
425
- * @returns {Record<string, unknown>} The envelope, ready for `matter.stringify`.
425
+ * @returns {Record<string, unknown>} The envelope, ready for `writeMatter`.
426
426
  */
427
427
  export function itemFieldsEnvelope({ title, shortcode, frontmatter }) {
428
428
  return deepMerge(
@@ -464,8 +464,8 @@ export function renderItemFieldsPage(body, { title, destination, contentRoot, fr
464
464
  shortcode: shortcodeFromBasename(destination),
465
465
  frontmatter,
466
466
  });
467
- const page = matter.stringify(body, envelope);
468
- // `matter.stringify` closes the frontmatter fence directly onto the
467
+ const page = writeMatter(body, envelope);
468
+ // `writeMatter` closes the frontmatter fence directly onto the
469
469
  // body's first line; Prettier's markdown printer requires a blank line
470
470
  // between them, so a page written without one fails a consumer's
471
471
  // `lint:format` the moment it is committed. Insert it here rather than
@@ -83,6 +83,14 @@ function readLabel(text, env = {}) {
83
83
  }
84
84
  const labelText = (text, env) => readLabel(text, env).text;
85
85
 
86
+ /** The kind a span's `data-type` supplies; an invalid value is refused by the span check. */
87
+ function spanKind(span) {
88
+ const value = span.attrs?.find(([key]) => key === "data-type")?.[1];
89
+ if (typeof value !== "string") return undefined;
90
+ const [type, subType] = value.split("/");
91
+ return { type, subType: subType ?? null };
92
+ }
93
+
86
94
  /** Join overlapping source ranges so lookups stay logarithmic. */
87
95
  function ranges(values) {
88
96
  const out = [];
@@ -119,7 +127,6 @@ function prose(source, capturedNames) {
119
127
  const links = [],
120
128
  regions = [],
121
129
  excluded = [],
122
- bold = [],
123
130
  html = [];
124
131
  const masked = source
125
132
  .replace(/!\[\[[^\]\n]+\]\]|\{\{[^}\n]+\}\}/g, (text, start) => {
@@ -142,7 +149,6 @@ function prose(source, capturedNames) {
142
149
  excluded.push({ start, end });
143
150
  else if (node.type === "html") html.push(node);
144
151
  else if (["strong", "emphasis", "delete", "link", "linkReference"].includes(node.type)) {
145
- if (node.type === "strong") bold.push({ start, end });
146
152
  if (["link", "linkReference"].includes(node.type) && source[start] === "[")
147
153
  links.push({ start, end, labelled: true });
148
154
  for (const child of node.children ?? []) inline(child);
@@ -224,18 +230,8 @@ function prose(source, capturedNames) {
224
230
  const linked = ranges(authoredLinks);
225
231
  const marked = (capturedNames ?? scanSpans(source).names)
226
232
  .filter((span) => eligible(span) && !contains(linked, span))
227
- .map((span) => ({ ...span, label: span.visible }));
228
- const explicit = ranges([...marked, ...authoredLinks]);
229
- return {
230
- marked,
231
- links: authoredLinks,
232
- bold: bold
233
- .filter((span) => eligible(span) && !overlaps(explicit, span))
234
- .map((span) => ({
235
- ...span,
236
- label: labelText(source.slice(span.start, span.end), env),
237
- })),
238
- };
233
+ .map((span) => ({ ...span, label: span.visible, nameKind: spanKind(span) }));
234
+ return { marked, links: authoredLinks };
239
235
  }
240
236
 
241
237
  /**
@@ -264,7 +260,7 @@ export function createFirstUseChecker({
264
260
  { raw, bodyLine: authored.bodyLine, bodyColumn: authored.bodyColumn },
265
261
  )
266
262
  : undefined;
267
- const { marked, links, bold } = prose(raw, capturedNames);
263
+ const { marked, links } = prose(raw, capturedNames);
268
264
  const events = [
269
265
  ...marked.map((span) => ({ ...span, kind: "marked" })),
270
266
  ...links.map((link) => {
@@ -286,7 +282,10 @@ export function createFirstUseChecker({
286
282
  for (const event of [...events].sort(
287
283
  (a, b) => a.label.length - b.label.length || a.label.localeCompare(b.label),
288
284
  )) {
289
- if (recognized(event.label, event.preferred) || unknown.has(normalize(event.label)))
285
+ if (
286
+ recognized(event.label, event.preferred, event.nameKind) ||
287
+ unknown.has(normalize(event.label))
288
+ )
290
289
  continue;
291
290
  const entry = {
292
291
  key: "name:" + normalize(event.label),
@@ -309,7 +308,8 @@ export function createFirstUseChecker({
309
308
  fullUsed = new Set();
310
309
  for (const event of events.sort((a, b) => a.start - b.start)) {
311
310
  const entry =
312
- recognized(event.label, event.preferred) ?? unknown.get(normalize(event.label));
311
+ recognized(event.label, event.preferred, event.nameKind) ??
312
+ unknown.get(normalize(event.label));
313
313
  if (event.kind === "marked" && entry.indexed) {
314
314
  const destinations = (entry.candidates ?? [entry])
315
315
  .map((target) => target.key)
@@ -338,16 +338,6 @@ export function createFirstUseChecker({
338
338
  used.add(entry.key);
339
339
  if (introduces) fullUsed.add(entry.key);
340
340
  }
341
- for (const span of bold) {
342
- if (typeof span.label !== "string") continue;
343
- const entry = recognized(span.label);
344
- if (entry)
345
- add(
346
- span.start,
347
- "bold-name",
348
- `“${span.label}” matches an indexed name; mark or link this name`,
349
- );
350
- }
351
341
  const lineStarts = [0];
352
342
  for (let i = 0; i < raw.length; i++) if (raw[i] === "\n") lineStarts.push(i + 1);
353
343
  return [...findings]
@@ -813,7 +813,7 @@ function checkKeyedMap(note, { field, segments, entries, raw, packs }) {
813
813
  * lands at — would mean checking the value against the sections that could
814
814
  * exist (every content type, plus whatever a repository configured) rather
815
815
  * than against the genres its type declares. Two vocabularies in one field is
816
- * the cause, and it is removed rather than the symptom: a section is a Hugo
816
+ * the cause, and it is removed rather than the symptom: a section is a
817
817
  * directory the note format does not
818
818
  * carry, and a page introducing a type is an ordinary note addressed
819
819
  * `doc-<type>`. So the closed list answers for every note, whatever it is