@heroiclands/package-build 23.13.0 → 24.0.1

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 (236) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +2 -1
  3. package/assets/preview/gitea.ini +12 -7
  4. package/assets/styles/content-base.css +150 -1
  5. package/bin/content-commands.mjs +356 -210
  6. package/bin/package-build.mjs +3 -2
  7. package/ci/check-banners.mjs +7 -7
  8. package/config.mjs +21 -6
  9. package/content-config.mjs +199 -80
  10. package/docs/api.md +93 -72
  11. package/docs/authoring/links-and-markup.md +316 -78
  12. package/docs/commands.md +248 -80
  13. package/docs/configuration.md +210 -102
  14. package/docs/content-format.md +2 -2
  15. package/docs/diagnostics.md +2 -2
  16. package/docs/getting-started.md +1 -1
  17. package/docs/guides/icons.md +1 -1
  18. package/docs/guides/module-project.md +5 -8
  19. package/docs/guides/release-and-deploy.md +2 -2
  20. package/docs/guides/site.md +155 -23
  21. package/docs/guides/system-project.md +1 -1
  22. package/docs/guides/writing-in-invented-scripts.md +1247 -0
  23. package/docs/index.md +1 -0
  24. package/docs/project-setup.md +28 -30
  25. package/docs/reference/format-details.md +589 -308
  26. package/docs/reference/note-types.md +40 -10
  27. package/docs/reference/theme-pages.md +127 -108
  28. package/docs/source-preview.md +15 -1
  29. package/engine/asset-shipping.mjs +22 -10
  30. package/engine/base-compiler.mjs +62 -104
  31. package/engine/changelog-lint.mjs +1 -1
  32. package/engine/content-address.mjs +1 -1
  33. package/engine/content-alerts.mjs +8 -4
  34. package/engine/content-blocks.mjs +26 -11
  35. package/engine/content-embeds.mjs +1 -1
  36. package/engine/content-expand.mjs +376 -0
  37. package/engine/content-figures.mjs +13 -6
  38. package/engine/content-format.yaml +8 -0
  39. package/engine/content-glyphs.mjs +213 -0
  40. package/engine/content-images.mjs +6 -7
  41. package/engine/content-links.mjs +3 -3
  42. package/engine/content-lint.mjs +1 -1
  43. package/engine/content-render.mjs +453 -0
  44. package/engine/content-scripts.mjs +326 -27
  45. package/engine/content-spans.mjs +4 -1
  46. package/engine/content-styles.mjs +91 -0
  47. package/engine/content-symbols.mjs +186 -0
  48. package/engine/content-verse.mjs +6 -18
  49. package/engine/core-report.mjs +169 -0
  50. package/engine/core-vocabulary.mjs +208 -0
  51. package/engine/dictionary-html.mjs +119 -0
  52. package/engine/field-reference.mjs +1 -1
  53. package/engine/frontmatter-lint.mjs +1 -1
  54. package/engine/heading-attributes.mjs +89 -0
  55. package/engine/helpers.mjs +61 -101
  56. package/engine/homepage.mjs +17 -29
  57. package/engine/html-minify.mjs +70 -0
  58. package/engine/index.mjs +8 -5
  59. package/engine/language-rows.mjs +8 -2
  60. package/engine/language-terms.mjs +115 -14
  61. package/engine/language-words.mjs +12 -0
  62. package/engine/logographic.mjs +255 -0
  63. package/engine/map-lexicon.mjs +221 -25
  64. package/engine/map-places.mjs +5 -4
  65. package/engine/markdown-expressions.mjs +0 -2
  66. package/engine/markdownlint-run.mjs +9 -3
  67. package/engine/markup-validation.mjs +21 -0
  68. package/engine/metre-declaration.mjs +134 -0
  69. package/engine/note-matter.mjs +2 -2
  70. package/engine/note-vocabulary.mjs +187 -3
  71. package/engine/pdf-build.mjs +68 -133
  72. package/engine/pdf-render.mjs +102 -44
  73. package/engine/post-fields.mjs +157 -0
  74. package/engine/preview-index.mjs +130 -0
  75. package/engine/preview-source.mjs +450 -0
  76. package/engine/project-init.mjs +5 -12
  77. package/engine/prose-config.mjs +3 -3
  78. package/engine/run-words.mjs +3 -2
  79. package/engine/script-emphasis-terms.mjs +38 -0
  80. package/engine/script-emphasis.mjs +295 -0
  81. package/engine/script-font-delivery.mjs +4 -4
  82. package/engine/site-build.mjs +265 -313
  83. package/engine/site-config.mjs +26 -238
  84. package/engine/site-files.mjs +175 -0
  85. package/engine/site-index.mjs +9 -4
  86. package/engine/site-preview.mjs +50 -523
  87. package/engine/site-reach.mjs +213 -0
  88. package/engine/site-render.mjs +668 -0
  89. package/engine/site-root.mjs +90 -39
  90. package/engine/site-search.mjs +161 -0
  91. package/engine/site-serve.mjs +274 -0
  92. package/engine/site-theme-banner.mjs +154 -0
  93. package/engine/site-theme-urls.mjs +125 -0
  94. package/engine/site-theme.mjs +939 -0
  95. package/engine/sql-tables.mjs +9 -6
  96. package/engine/text-maps.mjs +57 -10
  97. package/engine/typographer.mjs +248 -0
  98. package/engine/verse-metre.mjs +357 -0
  99. package/engine/wikilink-syntax.mjs +29 -47
  100. package/engine/wikilinks.mjs +758 -279
  101. package/engine/word-forms.mjs +39 -24
  102. package/engine/word-rows.mjs +12 -2
  103. package/engine/word-terms.mjs +29 -4
  104. package/manifest.mjs +3 -4
  105. package/package.json +10 -7
  106. package/{hugo-theme → site-theme}/data/banners.yaml +6 -10
  107. package/site-theme/layouts/base.hbs +31 -0
  108. package/site-theme/layouts/blog-list.hbs +30 -0
  109. package/site-theme/layouts/blog-post.hbs +40 -0
  110. package/site-theme/layouts/home.hbs +35 -0
  111. package/site-theme/layouts/list.hbs +20 -0
  112. package/site-theme/layouts/not-found.hbs +31 -0
  113. package/site-theme/layouts/page.hbs +7 -0
  114. package/site-theme/partials/copy-buttons.hbs +37 -0
  115. package/site-theme/partials/draft-notice.hbs +5 -0
  116. package/site-theme/partials/exturl.hbs +2 -0
  117. package/{hugo-theme/layouts/partials/footer.html → site-theme/partials/footer.hbs} +10 -7
  118. package/site-theme/partials/header.hbs +44 -0
  119. package/site-theme/partials/hero-banner.hbs +11 -0
  120. package/site-theme/partials/infobox-entry.hbs +2 -0
  121. package/site-theme/partials/infobox-section.hbs +31 -0
  122. package/site-theme/partials/infobox-value.hbs +2 -0
  123. package/site-theme/partials/infobox.hbs +18 -0
  124. package/site-theme/partials/list-members.hbs +9 -0
  125. package/site-theme/partials/nav-links.hbs +5 -0
  126. package/site-theme/partials/page-body.hbs +48 -0
  127. package/site-theme/partials/page-list.hbs +14 -0
  128. package/site-theme/partials/related.hbs +24 -0
  129. package/site-theme/partials/search-meta.hbs +6 -0
  130. package/site-theme/partials/search-names.hbs +2 -0
  131. package/site-theme/partials/search.hbs +52 -0
  132. package/site-theme/partials/tags.hbs +4 -0
  133. package/site-theme/partials/toc.hbs +7 -0
  134. package/{hugo-theme → site-theme}/static/css/style.css +14 -0
  135. package/stage.mjs +1 -2
  136. package/types/config.d.mts +8 -4
  137. package/types/content-config.d.mts +15 -15
  138. package/types/engine/asset-shipping.d.mts +5 -2
  139. package/types/engine/base-compiler.d.mts +20 -2
  140. package/types/engine/content-address.d.mts +1 -1
  141. package/types/engine/content-alerts.d.mts +8 -2
  142. package/types/engine/content-blocks.d.mts +11 -4
  143. package/types/engine/content-expand.d.mts +288 -0
  144. package/types/engine/content-figures.d.mts +11 -3
  145. package/types/engine/content-glyphs.d.mts +85 -0
  146. package/types/engine/content-images.d.mts +6 -7
  147. package/types/engine/content-links.d.mts +1 -1
  148. package/types/engine/content-render.d.mts +199 -0
  149. package/types/engine/content-scripts.d.mts +62 -0
  150. package/types/engine/content-spans.d.mts +1 -1
  151. package/types/engine/content-styles.d.mts +33 -0
  152. package/types/engine/content-symbols.d.mts +35 -0
  153. package/types/engine/core-report.d.mts +45 -0
  154. package/types/engine/core-vocabulary.d.mts +53 -0
  155. package/types/engine/dictionary-html.d.mts +8 -0
  156. package/types/engine/field-reference.d.mts +1 -1
  157. package/types/engine/heading-attributes.d.mts +20 -0
  158. package/types/engine/helpers.d.mts +3 -10
  159. package/types/engine/homepage.d.mts +11 -24
  160. package/types/engine/html-minify.d.mts +9 -0
  161. package/types/engine/index.d.mts +2 -1
  162. package/types/engine/language-terms.d.mts +24 -2
  163. package/types/engine/logographic.d.mts +45 -0
  164. package/types/engine/map-lexicon.d.mts +48 -8
  165. package/types/engine/map-places.d.mts +8 -0
  166. package/types/engine/markdown-expressions.d.mts +0 -2
  167. package/types/engine/metre-declaration.d.mts +39 -0
  168. package/types/engine/pdf-render.d.mts +6 -2
  169. package/types/engine/post-fields.d.mts +44 -0
  170. package/types/engine/preview-index.d.mts +72 -0
  171. package/types/engine/preview-source.d.mts +51 -0
  172. package/types/engine/script-emphasis-terms.d.mts +21 -0
  173. package/types/engine/script-emphasis.d.mts +84 -0
  174. package/types/engine/script-font-delivery.d.mts +4 -4
  175. package/types/engine/site-build.d.mts +71 -113
  176. package/types/engine/site-config.d.mts +14 -134
  177. package/types/engine/site-files.d.mts +73 -0
  178. package/types/engine/site-index.d.mts +11 -3
  179. package/types/engine/site-preview.d.mts +15 -19
  180. package/types/engine/site-reach.d.mts +76 -0
  181. package/types/engine/site-render.d.mts +173 -0
  182. package/types/engine/site-root.d.mts +14 -4
  183. package/types/engine/site-search.d.mts +54 -0
  184. package/types/engine/site-serve.d.mts +44 -0
  185. package/types/engine/site-theme-banner.d.mts +51 -0
  186. package/types/engine/site-theme-urls.d.mts +60 -0
  187. package/types/engine/site-theme.d.mts +127 -0
  188. package/types/engine/text-maps.d.mts +17 -3
  189. package/types/engine/typographer.d.mts +11 -0
  190. package/types/engine/verse-metre.d.mts +131 -0
  191. package/types/engine/wikilink-syntax.d.mts +30 -48
  192. package/types/engine/wikilinks.d.mts +330 -14
  193. package/types/engine/word-forms.d.mts +3 -2
  194. package/types/manifest.d.mts +3 -4
  195. package/types/stage.d.mts +1 -2
  196. package/types/vendor/markdownlint.d.mts +8 -0
  197. package/vendor/markdownlint.mjs +20388 -0
  198. package/engine/web-wikilinks.mjs +0 -594
  199. package/hugo-theme/layouts/404.html +0 -85
  200. package/hugo-theme/layouts/_default/baseof.html +0 -67
  201. package/hugo-theme/layouts/_default/list.html +0 -139
  202. package/hugo-theme/layouts/_default/single.html +0 -4
  203. package/hugo-theme/layouts/blog/list.html +0 -54
  204. package/hugo-theme/layouts/blog-post/single.html +0 -36
  205. package/hugo-theme/layouts/index.html +0 -75
  206. package/hugo-theme/layouts/partials/banner-url.html +0 -141
  207. package/hugo-theme/layouts/partials/cdn-url.html +0 -36
  208. package/hugo-theme/layouts/partials/dictionary-lists.html +0 -58
  209. package/hugo-theme/layouts/partials/draft-notice.html +0 -41
  210. package/hugo-theme/layouts/partials/draft-state.html +0 -26
  211. package/hugo-theme/layouts/partials/header.html +0 -35
  212. package/hugo-theme/layouts/partials/hero-banner.html +0 -85
  213. package/hugo-theme/layouts/partials/infobox/entry.html +0 -18
  214. package/hugo-theme/layouts/partials/infobox/items.html +0 -26
  215. package/hugo-theme/layouts/partials/infobox/section.html +0 -56
  216. package/hugo-theme/layouts/partials/infobox/value.html +0 -43
  217. package/hugo-theme/layouts/partials/infobox.html +0 -57
  218. package/hugo-theme/layouts/partials/page-body.html +0 -152
  219. package/hugo-theme/layouts/partials/related.html +0 -138
  220. package/hugo-theme/layouts/partials/search.html +0 -84
  221. package/hugo-theme/layouts/partials/site-url.html +0 -32
  222. package/hugo-theme/layouts/partials/tags.html +0 -30
  223. package/hugo-theme/layouts/partials/toc.html +0 -28
  224. package/hugo-theme/layouts/partials/warn-missing-banner.html +0 -3
  225. package/hugo-theme/layouts/shortcodes/cartouche.html +0 -64
  226. package/hugo-theme/layouts/shortcodes/exturl.html +0 -12
  227. package/hugo-theme/layouts/shortcodes/glyph.html +0 -43
  228. package/hugo-theme/layouts/shortcodes/pagelist.html +0 -63
  229. package/hugo-theme/theme.toml +0 -11
  230. package/types/engine/web-wikilinks.d.mts +0 -107
  231. /package/{hugo-theme → site-theme}/data/banner-kinds.yaml +0 -0
  232. /package/{hugo-theme → site-theme}/static/css/sohl-icons.css +0 -0
  233. /package/{hugo-theme → site-theme}/static/fonts/game-icons-kb.woff2 +0 -0
  234. /package/{hugo-theme → site-theme}/static/img/banners/takheperu-hieroglyphs.svg +0 -0
  235. /package/{hugo-theme → site-theme}/static/img/cartouches/takheperu.svg +0 -0
  236. /package/{hugo-theme → site-theme}/static/js/inline-spoilers.js +0 -0
@@ -0,0 +1,357 @@
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 verse fence declares its metre, and the build counts its beats.** A
16
+ * metre is declared once, as an entry of a `doc` note's `data.metres`:
17
+ *
18
+ * ```yaml
19
+ * data:
20
+ * metres:
21
+ * - { name: hexameter, beats: 6, kind: accentual }
22
+ * ```
23
+ *
24
+ * A fence that states `scan=` with the `name` and `lang=` with a language
25
+ * whose note states `data.sounds.stress` is counted line by line, and a line
26
+ * that cannot have the metre's beats is a warning at that line. No `scan=`
27
+ * means no check.
28
+ *
29
+ * **Syllables** are vowel groups: a vowel is a letter whose base is `a`, `e`,
30
+ * `i`, `o` or `u`, and a pair of vowels the language lists in
31
+ * `data.sounds.syllables.vowelPairs` is one syllable. **The stressed
32
+ * syllable** follows `data.sounds.stress`: `two` for a word of two, `longer`
33
+ * for a word of three or more, and a vowel carrying the rule's `mark` is
34
+ * stressed wherever it sits.
35
+ *
36
+ * **Beats** follow the accentual convention:
37
+ *
38
+ * - a word of two or more syllables has one beat, on its stressed syllable;
39
+ * - a one-syllable noun, verb, adjective, numeral or proper name has a beat;
40
+ * a one-syllable particle, preposition, conjunction, adverb or pronoun has
41
+ * none, by the `class` of the lexicon entry the word is, or is derived
42
+ * from; a word whose entries differ is a beat that may be left out;
43
+ * - a one-syllable word the lexicon does not hold is a content word;
44
+ * - a word of four or more syllables whose stress is not on its first may take
45
+ * a second, lighter beat on its first syllable, and a line scans when its
46
+ * count can be the metre's, choosing second beats as it needs;
47
+ * - the caesura is not written.
48
+ *
49
+ * @module
50
+ */
51
+
52
+ import { nearest } from "./near-miss.mjs";
53
+ import { isNoteRecord } from "./index-records.mjs";
54
+ import { METRE_KINDS, METRE_NAME } from "./metre-declaration.mjs";
55
+ import { languageRecordsByName } from "./map-lexicon.mjs";
56
+ import { STRESS_LONGER, STRESS_MARKS, STRESS_TWO } from "./language-terms.mjs";
57
+ import { parseWordClass } from "./word-classes.mjs";
58
+ import { derivedForms, foldWord } from "./word-forms.mjs";
59
+ import { WORD } from "./word-pattern.mjs";
60
+
61
+ export { METRE_KINDS, METRE_NAME, checkMetresField } from "./metre-declaration.mjs";
62
+
63
+ /** The bases whose one-syllable words carry a beat. */
64
+ export const BEAT_BASES = Object.freeze(["n", "v", "adj", "num", "name"]);
65
+
66
+ /** The characters a compound is split at. */
67
+ const JOINS = /[–-]/u;
68
+
69
+ /** A letter, which a counted word holds at least one of. */
70
+ const LETTER = /\p{L}/u;
71
+
72
+ /** Whether a value is a plain map. */
73
+ const isMap = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
74
+
75
+ /** A list value, or an empty one. */
76
+ const listOf = (value) => (Array.isArray(value) ? value : []);
77
+
78
+ /** A character's base letter, lowercase. */
79
+ const baseOf = (char) => char.normalize("NFD")[0].toLowerCase();
80
+
81
+ /** Whether a character is a vowel: its base letter is a, e, i, o or u. */
82
+ const isVowel = (char) => "aeiou".includes(baseOf(char));
83
+
84
+ /**
85
+ * The syllables of a word, each as the vowel group that makes it.
86
+ *
87
+ * @param {string} word - The word.
88
+ * @param {Iterable<string>} pairs - The vowel pairs that are one syllable.
89
+ * @returns {string[]} The nuclei, in order; none for a word with no vowel.
90
+ */
91
+ export function nucleiOf(word, pairs) {
92
+ const chars = Array.from(String(word).normalize("NFC"));
93
+ const known = new Set(pairs);
94
+ const longest = Math.max(1, ...Array.from(known, (pair) => Array.from(pair).length));
95
+ const nuclei = [];
96
+ for (let at = 0; at < chars.length; at++) {
97
+ if (!isVowel(chars[at])) continue;
98
+ let taken = 1;
99
+ for (let size = Math.min(longest, chars.length - at); size > 1; size--) {
100
+ const group = chars.slice(at, at + size);
101
+ if (!group.every(isVowel)) continue;
102
+ if (known.has(group.join("")) || known.has(group.map(baseOf).join(""))) {
103
+ taken = size;
104
+ break;
105
+ }
106
+ }
107
+ nuclei.push(chars.slice(at, at + taken).join(""));
108
+ at += taken - 1;
109
+ }
110
+ return nuclei;
111
+ }
112
+
113
+ /**
114
+ * The index of the stressed syllable of a word.
115
+ *
116
+ * @param {string} word - The word.
117
+ * @param {{two: string, longer: string, mark?: string}} rule - The language's
118
+ * `data.sounds.stress`.
119
+ * @param {Iterable<string>} pairs - The vowel pairs that are one syllable.
120
+ * @returns {number} The 0-based syllable; 0 for a word of one syllable or none.
121
+ */
122
+ export function stressIndex(word, rule, pairs) {
123
+ const nuclei = nucleiOf(word, pairs);
124
+ const mark = rule.mark === undefined ? undefined : STRESS_MARKS[rule.mark];
125
+ if (mark) {
126
+ const marked = nuclei.findIndex((nucleus) => nucleus.normalize("NFD").includes(mark));
127
+ if (marked >= 0) return marked;
128
+ }
129
+ const count = nuclei.length;
130
+ if (count <= 1) return 0;
131
+ if (count === 2) return rule.two === "last" ? 1 : 0;
132
+ if (rule.longer === "antepenultimate") return count - 3;
133
+ if (rule.longer === "final") return count - 1;
134
+ return count - 2;
135
+ }
136
+
137
+ /**
138
+ * What a language's lines are counted by.
139
+ *
140
+ * @typedef {object} MetreBook
141
+ * @property {string} language - The language's configured name.
142
+ * @property {{two: string, longer: string, mark?: string}} rule - Its stress rule.
143
+ * @property {ReadonlySet<string>} pairs - Its vowel pairs.
144
+ * @property {ReadonlyMap<string, ReadonlySet<string>>} classes - Each lexicon
145
+ * form, and each form its rules derive, folded, with the class bases of its
146
+ * entries.
147
+ */
148
+
149
+ /** Whether a stress value is a complete rule. */
150
+ const isRule = (stress) =>
151
+ isMap(stress) && STRESS_TWO.includes(stress.two) && STRESS_LONGER.includes(stress.longer);
152
+
153
+ /**
154
+ * Each configured language's metre book: one per language whose `note` names a
155
+ * language note stating a complete `data.sounds.stress`.
156
+ *
157
+ * @param {Iterable<Record<string, any>>|undefined} records - Index records.
158
+ * @param {{languages?: Readonly<Record<string, {tag: string, note?: string}>>, contentPackage?: string}} config -
159
+ * The resolved configuration.
160
+ * @returns {Map<string, MetreBook>} The books by language name.
161
+ */
162
+ export function metreBooksFromRecords(records, config = {}) {
163
+ const books = new Map();
164
+ for (const [language, record] of languageRecordsByName([...(records ?? [])], config)) {
165
+ const data = isMap(record.data) ? record.data : {};
166
+ const sounds = isMap(data.sounds) ? data.sounds : {};
167
+ if (!isRule(sounds.stress)) continue;
168
+ const syllables = isMap(sounds.syllables) ? sounds.syllables : {};
169
+ const classes = new Map();
170
+ const add = (form, word) => {
171
+ const base = parseWordClass(word.class).base;
172
+ if (base === undefined) return;
173
+ const folded = foldWord(form);
174
+ if (!classes.has(folded)) classes.set(folded, new Set());
175
+ classes.get(folded).add(base);
176
+ };
177
+ for (const word of listOf(data.words))
178
+ if (isMap(word) && typeof word.form === "string") add(word.form, word);
179
+ for (const { form, word } of derivedForms(data)) add(form, word);
180
+ books.set(
181
+ language,
182
+ Object.freeze({
183
+ language,
184
+ rule: Object.freeze({ ...sounds.stress }),
185
+ pairs: new Set(listOf(syllables.vowelPairs).filter((p) => typeof p === "string")),
186
+ classes,
187
+ }),
188
+ );
189
+ }
190
+ return books;
191
+ }
192
+
193
+ /**
194
+ * Every metre the index's `doc` notes declare, by name. The first note to
195
+ * declare a name keeps it.
196
+ *
197
+ * @param {Iterable<Record<string, any>>|undefined} records - Index records.
198
+ * @returns {Map<string, import("./metre-declaration.mjs").Metre>} The metres.
199
+ */
200
+ export function metresFromRecords(records) {
201
+ const metres = new Map();
202
+ for (const record of records ?? []) {
203
+ if (!record || !isNoteRecord(record)) continue;
204
+ for (const entry of listOf(record.data?.metres)) {
205
+ if (
206
+ !isMap(entry) ||
207
+ typeof entry.name !== "string" ||
208
+ !METRE_NAME.test(entry.name) ||
209
+ !Number.isInteger(Number(entry.beats)) ||
210
+ Number(entry.beats) < 1 ||
211
+ !METRE_KINDS.includes(entry.kind) ||
212
+ metres.has(entry.name)
213
+ )
214
+ continue;
215
+ metres.set(
216
+ entry.name,
217
+ Object.freeze({ name: entry.name, beats: Number(entry.beats), kind: entry.kind }),
218
+ );
219
+ }
220
+ }
221
+ return metres;
222
+ }
223
+
224
+ /**
225
+ * The beats a word can carry.
226
+ *
227
+ * @param {string} word - The word as written.
228
+ * @param {MetreBook} book - The language's book.
229
+ * @returns {{min: number, max: number}} The fewest and the most.
230
+ */
231
+ function wordBeats(word, book) {
232
+ const folded = foldWord(word);
233
+ const count = nucleiOf(folded, book.pairs).length;
234
+ if (count === 0) return { min: 0, max: 0 };
235
+ if (count > 1) {
236
+ const first = stressIndex(folded, book.rule, book.pairs) === 0;
237
+ return { min: 1, max: count >= 4 && !first ? 2 : 1 };
238
+ }
239
+ const bases = book.classes.get(folded);
240
+ if (!bases) return { min: 1, max: 1 };
241
+ const carrying = [...bases].filter((base) => BEAT_BASES.includes(base)).length;
242
+ if (carrying === bases.size) return { min: 1, max: 1 };
243
+ return { min: 0, max: carrying ? 1 : 0 };
244
+ }
245
+
246
+ /**
247
+ * The beats a line of verse can have.
248
+ *
249
+ * @param {string} text - The line.
250
+ * @param {MetreBook} book - The line's language's book.
251
+ * @returns {{min: number, max: number}} The beats without the second beats a
252
+ * long word may take, and with all of them.
253
+ */
254
+ export function lineBeats(text, book) {
255
+ let min = 0;
256
+ let max = 0;
257
+ for (const [whole] of String(text).matchAll(WORD)) {
258
+ const parts = book.classes.has(foldWord(whole)) ? [whole] : whole.split(JOINS);
259
+ for (const part of parts) {
260
+ if (!LETTER.test(part)) continue;
261
+ const beats = wordBeats(part, book);
262
+ min += beats.min;
263
+ max += beats.max;
264
+ }
265
+ }
266
+ return { min, max };
267
+ }
268
+
269
+ /**
270
+ * The warning for a line that cannot have its metre's beats.
271
+ *
272
+ * @param {{min: number, max: number}} beats - The line's beats.
273
+ * @param {import("./metre-declaration.mjs").Metre} metre - The fence's metre.
274
+ * @returns {string} The message.
275
+ */
276
+ export function metreMessage({ min, max }, metre) {
277
+ const has = max === min ? `${min} beats` : `${min} beats (${max} with secondary beats)`;
278
+ return `this line has ${has}; the metre \`${metre.name}\` has ${metre.beats}`;
279
+ }
280
+
281
+ /**
282
+ * Check the verse fences that state `scan=`.
283
+ *
284
+ * @param {ReadonlyArray<{start: number, end: number, indent: string, attributes: Record<string, string>, classes: string[]}>} blocks -
285
+ * The fences, from {@link module:engine/content-verse.scanVerse}.
286
+ * @param {string[]} lines - The source's lines.
287
+ * @param {{metres?: ReadonlyMap<string, import("./metre-declaration.mjs").Metre>, metreBooks?: ReadonlyMap<string, MetreBook>, lexicons?: ReadonlyMap<string, unknown>, columnsClass: string, columnKeys: readonly string[], attributeColumns: (line: string, key: string) => {key: number, value: number}}} ctx -
288
+ * The metres and books, and how the fence says its columns and where its
289
+ * keys sit.
290
+ * @returns {{errors: object[], warnings: object[]}} The findings at 1-based
291
+ * positions.
292
+ */
293
+ export function metreFindings(blocks, lines, ctx) {
294
+ const errors = [];
295
+ const warnings = [];
296
+ const metres = ctx.metres ?? new Map();
297
+ for (const block of blocks) {
298
+ const name = block.attributes.scan;
299
+ if (name === undefined) continue;
300
+ const opening = lines[block.start];
301
+ const at = (key, part) => ({
302
+ line: block.start + 1,
303
+ column: ctx.attributeColumns(opening, key)[part],
304
+ });
305
+ const error = (position, message) => errors.push({ ...position, message });
306
+ if (
307
+ block.classes.includes(ctx.columnsClass) ||
308
+ ctx.columnKeys.some((key) => block.attributes[key] !== undefined)
309
+ ) {
310
+ error(
311
+ at("scan", "key"),
312
+ "`scan=` counts the beats of one language's lines; a verse in columns sets a line in each of two",
313
+ );
314
+ continue;
315
+ }
316
+ const metre = metres.get(name);
317
+ if (!metre) {
318
+ const near = nearest(name, [...metres.keys()]);
319
+ error(
320
+ at("scan", "value"),
321
+ `\`${name}\` is not a declared metre: \`scan=\` names the \`name\` of a \`data.metres\` entry of a \`doc\` note` +
322
+ (near ? `; did you mean \`${near}\`?` : ""),
323
+ );
324
+ continue;
325
+ }
326
+ const lang = block.attributes.lang;
327
+ if (lang === undefined) {
328
+ error(
329
+ at("scan", "key"),
330
+ "`scan=` counts beats by a language's stress rule: the fence needs a `lang=`",
331
+ );
332
+ continue;
333
+ }
334
+ const book = ctx.metreBooks?.get(lang);
335
+ if (!book) {
336
+ error(
337
+ at("lang", "value"),
338
+ ctx.lexicons?.has(lang) ?
339
+ `the language \`${lang}\` states no \`data.sounds.stress\`, which \`scan=\` counts beats by`
340
+ : `the language \`${lang}\` is not a configured language with a \`note\`, so it has no stress rule for \`scan=\` to count beats by`,
341
+ );
342
+ continue;
343
+ }
344
+ for (let line = block.start + 1; line < block.end; line++) {
345
+ const text = lines[line];
346
+ if (!text.trim()) continue;
347
+ const beats = lineBeats(text, book);
348
+ if (metre.beats >= beats.min && metre.beats <= beats.max) continue;
349
+ warnings.push({
350
+ line: line + 1,
351
+ column: /^\s*/.exec(text)[0].length + 1,
352
+ message: metreMessage(beats, metre),
353
+ });
354
+ }
355
+ }
356
+ return { errors, warnings };
357
+ }
@@ -19,36 +19,23 @@ import { parseAddress, isAddressTuple, splitAnchor } from "./address.mjs";
19
19
  /**
20
20
  * What a `[[…]]` **is**, before anything decides where it points.
21
21
  *
22
- * One authored link compiles to two different addresses — a Foundry `@UUID`
23
- * enricher for the packs, a URL for the web — and those two destinations are the
24
- * *only* thing that legitimately differs. The syntax is the author's, and it is
25
- * the same syntax whichever build is reading it.
22
+ * One authored link is written three ways — a Foundry `@UUID` content link for
23
+ * the packs, a URL for the web, a cross-reference for the book — and those
24
+ * forms are the *only* thing that legitimately differs. The syntax is the
25
+ * author's, and it is the same syntax whichever build is reading it.
26
26
  *
27
- * It was written twice, and the two copies had already drifted:
27
+ * So the parse lives here, once, and two readers take it: the link checker
28
+ * (`content-links.mjs`) and the resolver (`wikilinks.mjs`). Each receives the
29
+ * parts and decides only what it is for: which document the address names.
28
30
  *
29
- * | Body | `foundry` side | `web` side |
30
- * | --- | --- | --- |
31
- * | `[[weapongear-bsw` ⏎ `]]` in a table cell | not a link | a link whose target ends in a newline |
32
- * | a stray `[[`, a real link two paragraphs later | finds the real link | swallows both paragraphs as one target |
33
- *
34
- * The web side's pattern omitted `\n` from the excluded set, so an unclosed
35
- * bracket consumed everything up to the next `]]` anywhere in the document —
36
- * the same shape of corruption a hand-rolled code-fence regex caused on the one
37
- * page whose subject is link syntax. It was also internally
38
- * inconsistent: its *frontmatter* scan excluded newlines while its body scan did
39
- * not.
40
- *
41
- * So the parse lives here, once. A resolver receives the parts and decides only
42
- * what it is actually for: which document the address names.
43
- *
44
- * The one rule about a link's *shape* that both resolvers share also lives here
45
- * — **a link carries a label** ({@link unlabelledLinkMessage}) — because the
46
- * two state it through one message and the author reads the same words
47
- * whichever ran first.
31
+ * The one rule about a link's *shape* also lives here — **a link carries a
32
+ * label** ({@link unlabelledLinkMessage}) — because every build states it
33
+ * through one message and the author reads the same words whichever ran
34
+ * first.
48
35
  *
49
36
  * For the same reason, so does the **vocabulary of link findings**
50
37
  * ({@link LINK_FINDING_REASONS}) and the message each one reports through
51
- * ({@link linkFindingMessage}). Three builds read one authored link; an author
38
+ * ({@link linkFindingMessage}). Every build reads one authored link; an author
52
39
  * meets whichever ran first, and a consumer switching on a `reason` should not
53
40
  * be switching on which build produced it.
54
41
  *
@@ -66,8 +53,8 @@ import { parseAddress, isAddressTuple, splitAnchor } from "./address.mjs";
66
53
  * **A leading `!` makes it an embed, not a link**, and the lookbehind is what
67
54
  * keeps every reader from seeing one as the other. Stated here rather than in
68
55
  * each reader for the reason the rest of this module exists: the checker, the
69
- * pack compilers and the web resolver all match on this pattern, and three
70
- * copies of the exclusion is three places for it to be forgotten. An embed's
56
+ * resolver and the embed reader all match on this pattern, and a copy of the
57
+ * exclusion in each is a place for it to be forgotten. An embed's
71
58
  * own grammar is {@link module:engine/content-embeds}.
72
59
  */
73
60
  export const WIKILINK = /(?<!!)\[\[([^\]\n]+)\]\](?:\{(?:[^{}"\n]|"(?:\\.|[^"\\\n])*")*\})?/g;
@@ -138,12 +125,8 @@ export function parseWikilink(rawInner, literal = "") {
138
125
  * `[[x|]]` is labelled and `[[x]]` is not, and only the first addresses
139
126
  * anything.
140
127
  *
141
- * Stated here because the two resolvers had already drawn the line in two
142
- * places and drawn it differently: the packs tested falsiness and were right,
143
- * the web tested `??` — which falls through on `null` only — and emitted
144
- * `[](/url/)`, a link with no clickable text, through every build. That
145
- * is the same drift this module exists to prevent, in the case its own
146
- * {@link ParsedWikilink} docstring calls out. One reading, one place.
128
+ * Read with `??`, which falls through on `null` only, an empty label would
129
+ * emit `[](/url/)`: a link with no clickable text. One reading, one place.
147
130
  *
148
131
  * @param {{display: string|null}} parsed - A parsed wikilink, or anything
149
132
  * carrying its `display`.
@@ -156,7 +139,7 @@ export function authoredLabel({ display }) {
156
139
  /**
157
140
  * What an author writing an unlabelled link is told, in one place.
158
141
  *
159
- * Shared by the link checker, the pack compilers and the web resolver, because
142
+ * Shared by the link checker and the resolver, on every target, because
160
143
  * an author meets whichever runs first and they should read the same. It names
161
144
  * the form to write rather than a value to correct: there is no value that
162
145
  * makes an unlabelled link resolve.
@@ -180,14 +163,12 @@ export function unlabelledLinkMessage(target) {
180
163
  }
181
164
 
182
165
  /**
183
- * Every way a link can fail, named once for all three resolvers.
166
+ * Every way a link can fail, named once for every reader of a link.
184
167
  *
185
- * A link is read in three places — the checker (`content-links.mjs`), the pack
186
- * compilers (`wikilinks.mjs`) and the web resolver (`web-wikilinks.mjs`) — and
187
- * each would otherwise name the failures in its own words. `unknown` in one is
188
- * `unresolved` in another and `broken type/shortcode` in the third, so a
189
- * consumer switching on a `reason` was switching on which build had produced
190
- * it. The set is closed and lives here, beside the syntax the three share.
168
+ * A link is read by the checker (`content-links.mjs`) and by the resolver
169
+ * (`wikilinks.mjs`) for every target, and each names a failure by one of these
170
+ * reasons, so a consumer switching on a `reason` never switches on which build
171
+ * produced it. The set is closed and lives here, beside the syntax they share.
191
172
  *
192
173
  * - `unlabelled` — no `|`, so the link addresses nothing.
193
174
  * - `not-an-address` — labelled, but the target does not parse as an address.
@@ -256,7 +237,7 @@ export function isEventAnchor(hit, anchor) {
256
237
  *
257
238
  * An address naming no note is a typo or an omission: the intent of a
258
239
  * placeholder for a note nobody has written yet is spelled as a `draft`-tagged
259
- * note that exists, resolves and renders marked. Both want fixing, and all three builds say so.
240
+ * note that exists, resolves and renders marked. Both want fixing, and every build says so.
260
241
  *
261
242
  * @param {string} target - The address as authored, named in the message.
262
243
  * @returns {string} The message, unpunctuated at the end as a finding is.
@@ -322,9 +303,9 @@ export function ambiguousAddressMessage(target, packages = []) {
322
303
  /**
323
304
  * The message for one link finding, whichever resolver found it.
324
305
  *
325
- * The single table the checker, the pack compilers and the web resolver all
326
- * report through, so one authored link cannot get three different explanations
327
- * of the same mistake depending on which build the author ran first. Callers
306
+ * The single table the checker and the resolver report through, on every
307
+ * target, so one authored link cannot get different explanations of the same
308
+ * mistake depending on which build the author ran first. Callers
328
309
  * add their own context around it — the note's name, the file it sits in — and
329
310
  * never their own wording for the defect.
330
311
  *
@@ -423,8 +404,9 @@ export function linkFindingMessage({
423
404
  /**
424
405
  * Whether a parsed link addresses a section of the page it is written on.
425
406
  *
426
- * `[[#some-heading]]` — no target, only an anchor. Both resolvers special-case
427
- * it before consulting any index, because there is nothing to look up.
407
+ * `[[#some-heading]]` — no target, only an anchor. The resolver and the checker
408
+ * special-case it before consulting any index, because there is nothing to look
409
+ * up.
428
410
  *
429
411
  * @param {ParsedWikilink} parsed - A parsed wikilink.
430
412
  * @returns {boolean} True when the link is same-page.