@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,91 @@
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 shared rules of the content stylesheet**, rescoped for a surface that
16
+ * is not Foundry.
17
+ *
18
+ * `assets/styles/content-base.css` styles what the renderer emits, scoped to
19
+ * the roots a Foundry journal and its editors use. Two other surfaces reuse its
20
+ * rules without a second copy: the editor preview takes the stretches marked
21
+ * `preview:shared`, and a site takes the highlighted-code rules. Both read the
22
+ * one file and replace the scope, so a colour changed there changes everywhere.
23
+ *
24
+ * @module
25
+ */
26
+
27
+ import fs from "node:fs";
28
+
29
+ import { baseStylesheetSource } from "./base-styles.mjs";
30
+
31
+ /** The roots the shipped content stylesheet scopes its rules to. */
32
+ export const CONTENT_ROOTS =
33
+ ":is(.journal-entry-page, .journal-entry-content, .editor-content, .prosemirror)";
34
+
35
+ /** The stretches of the shipped content stylesheet other surfaces share. */
36
+ const SHARED_RULES = /\/\* preview:shared [^]*?\*\/([^]*?)\/\* \/preview:shared \*\//g;
37
+
38
+ /**
39
+ * The rules the Foundry content stylesheet shares with other surfaces, scoped
40
+ * to a selector.
41
+ *
42
+ * @param {string} scope - The selector that replaces the Foundry roots.
43
+ * @returns {string} The rules.
44
+ * @throws {Error} When the stylesheet marks no shared rules.
45
+ */
46
+ export function sharedContentRules(scope) {
47
+ const css = fs.readFileSync(baseStylesheetSource(), "utf8");
48
+ const shared = [...css.matchAll(SHARED_RULES)].map((match) => match[1]);
49
+ if (!shared.length) throw new Error("the content stylesheet marks no rules for the preview");
50
+ return shared.join("").replaceAll(CONTENT_ROOTS, scope);
51
+ }
52
+
53
+ /**
54
+ * The rules that colour highlighted code, scoped to a selector.
55
+ *
56
+ * A rule belongs to the set when one of its selectors names a `hljs-` token
57
+ * class. The rules are taken from the shared stretches, so the colours, their
58
+ * custom properties and their `light-dark()` pairs are the stylesheet's own.
59
+ *
60
+ * @param {string} scope - The selector that replaces the Foundry roots.
61
+ * @returns {string} The token rules, one per block.
62
+ */
63
+ export function highlightRules(scope) {
64
+ const shared = sharedContentRules(scope)
65
+ .replace(/\/\*[^]*?\*\//g, "")
66
+ .replace(/\s*\n\s+(?=\.hljs-)/g, " ");
67
+ return (
68
+ shared
69
+ .split("}")
70
+ .map((rule) => rule.trim())
71
+ .filter(
72
+ (rule) => rule.includes("{") && /\.hljs-/.test(rule.slice(0, rule.indexOf("{"))),
73
+ )
74
+ .map((rule) => `${rule}\n}`)
75
+ .join("\n\n") + "\n"
76
+ );
77
+ }
78
+
79
+ /**
80
+ * The stylesheet a site links for highlighted code.
81
+ *
82
+ * A site sets no colour scheme, and the token colours are `light-dark()` pairs
83
+ * that read the scheme in force, so code is given the dark one its pages are
84
+ * drawn in.
85
+ *
86
+ * @param {string} [scope] - The element the page body sits in.
87
+ * @returns {string} The stylesheet.
88
+ */
89
+ export function siteCodeStylesheet(scope = ".single-body") {
90
+ return `${scope} :is(pre, code) {\n color-scheme: dark;\n}\n\n${highlightRules(scope)}`;
91
+ }
@@ -0,0 +1,186 @@
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 glyph and cartouche spans of a note, read in one place for the stages
16
+ * that need their pictures: the renderer that draws them and the book that
17
+ * stages their files.
18
+ *
19
+ * @module
20
+ */
21
+
22
+ import {
23
+ glyphHtml,
24
+ glyphPathname,
25
+ cartoucheHtml,
26
+ cartouchePathname,
27
+ cartoucheProblems,
28
+ glyphProblems,
29
+ } from "./content-glyphs.mjs";
30
+ import { scanSpans } from "./content-spans.mjs";
31
+
32
+ /**
33
+ * A span's attributes as the construct's checker reads them.
34
+ * @param {Array<[string, string]>} attrs - The span's attribute pairs.
35
+ * @returns {{id: string, classes: string[], values: Record<string, string>}} Parsed form.
36
+ */
37
+ function parsedAttributes(attrs) {
38
+ const find = (name) => attrs.find(([key]) => key === name)?.[1] ?? "";
39
+ return {
40
+ id: find("id"),
41
+ classes: find("class").split(/\s+/).filter(Boolean),
42
+ values: Object.fromEntries(attrs.filter(([key]) => !["id", "class"].includes(key))),
43
+ };
44
+ }
45
+
46
+ /**
47
+ * The well-formed glyph and cartouche spans of a note, outermost only, outside
48
+ * code.
49
+ * @param {string} source - Authored Markdown.
50
+ * @returns {Array<{kind: "glyph"|"cartouche", start: number, end: number, slug: string, alt: string, frame?: string, size?: string, align?: string, height?: string, caption?: string}>}
51
+ * One entry per sign, in source order. A sign with a finding is left out: it
52
+ * is reported and draws as an ordinary span.
53
+ */
54
+ function symbolSpans(source) {
55
+ if (!/\.(glyph|cartouche)\b/.test(source)) return [];
56
+ const { spans } = scanSpans(source);
57
+ const signs = spans
58
+ .map((span) => ({ span, parsed: parsedAttributes(span.attrs) }))
59
+ .flatMap(({ span, parsed }) => {
60
+ const kind = ["glyph", "cartouche"].find((name) => parsed.classes.includes(name));
61
+ const problems = { glyph: glyphProblems, cartouche: cartoucheProblems }[kind];
62
+ if (!problems || problems(parsed).length) return [];
63
+ const { slug, frame, size, align, height, caption } = parsed.values;
64
+ return [
65
+ {
66
+ kind,
67
+ start: span.start,
68
+ end: span.end,
69
+ slug,
70
+ alt: span.visible.trim(),
71
+ ...(frame ? { frame } : {}),
72
+ ...(size ? { size } : {}),
73
+ ...(align ? { align } : {}),
74
+ ...(height ? { height } : {}),
75
+ ...(caption ? { caption } : {}),
76
+ },
77
+ ];
78
+ });
79
+ return signs.filter(
80
+ (sign) => !signs.some((outer) => outer.start < sign.start && outer.end >= sign.end),
81
+ );
82
+ }
83
+
84
+ const OPEN = "\uE000",
85
+ CLOSE = "\uE001";
86
+ const PLACEHOLDER = new RegExp(`${OPEN}([0-9a-f]+)${CLOSE}`, "g");
87
+
88
+ /**
89
+ * A cartouche as the inert text a Markdown parser passes through untouched.
90
+ * A figure written as HTML at the start of a line would open a CommonMark HTML
91
+ * block and leave the rest of its paragraph unparsed, so the figure is written
92
+ * as inline HTML by {@link cartoucheFigures} after the paragraph is parsed.
93
+ * @param {object} cartouche - The figure's attributes, with `src` the address of its picture.
94
+ * @returns {string} Private-use delimiters around the hex of the attributes' JSON.
95
+ */
96
+ function cartouchePlaceholder(cartouche) {
97
+ const hex = [...new TextEncoder().encode(JSON.stringify(cartouche))]
98
+ .map((byte) => byte.toString(16).padStart(2, "0"))
99
+ .join("");
100
+ return `${OPEN}${hex}${CLOSE}`;
101
+ }
102
+
103
+ /**
104
+ * Markdown-it plugin: write each cartouche placeholder as the inline HTML of
105
+ * its figure, in whatever paragraph, cell or title it stands.
106
+ * @param {import("markdown-it")} md - Parser to extend.
107
+ */
108
+ export function cartoucheFigures(md) {
109
+ md.core.ruler.after("inline", "cartouche_figures", (state) => {
110
+ for (const token of state.tokens) {
111
+ if (token.type !== "inline" || !token.children) continue;
112
+ token.children = token.children.flatMap((child) => {
113
+ if (child.type !== "text" || !child.content.includes(OPEN)) return [child];
114
+ const pieces = [];
115
+ let last = 0;
116
+ const piece = (type, content) => {
117
+ const made = new state.Token(type, "", 0);
118
+ made.content = content;
119
+ made.level = child.level;
120
+ pieces.push(made);
121
+ };
122
+ for (const match of child.content.matchAll(PLACEHOLDER)) {
123
+ if (match.index > last) piece("text", child.content.slice(last, match.index));
124
+ const { src, ...cartouche } = JSON.parse(
125
+ new TextDecoder().decode(
126
+ Uint8Array.from(match[1].match(/../g), (pair) => parseInt(pair, 16)),
127
+ ),
128
+ );
129
+ piece("html_inline", cartoucheHtml(cartouche, src));
130
+ last = match.index + match[0].length;
131
+ }
132
+ if (last < child.content.length) piece("text", child.content.slice(last));
133
+ return pieces;
134
+ });
135
+ }
136
+ });
137
+ }
138
+
139
+ /**
140
+ * Write each glyph and cartouche span as the HTML that draws its picture. A
141
+ * cartouche is written as a placeholder that {@link cartoucheFigures} turns
142
+ * into its figure.
143
+ *
144
+ * @param {string} source - Markdown that may contain glyph and cartouche spans.
145
+ * @param {(src: string) => string} [resolveSrc] - From a sign's authored
146
+ * pathname to the address the target serves; omitted, the pathname.
147
+ * @param {object} [options]
148
+ * @param {boolean} [options.glyphs] - Write glyph spans; `false` leaves them
149
+ * as the source they were written in. Default `true`.
150
+ * @returns {string} The Markdown with each well-formed sign written as HTML or a placeholder.
151
+ */
152
+ export function renderSymbolSpans(source, resolveSrc, { glyphs = true } = {}) {
153
+ let markdown = String(source ?? "");
154
+ for (const sign of symbolSpans(markdown).sort((a, b) => b.start - a.start)) {
155
+ if (sign.kind === "glyph" && !glyphs) continue;
156
+ const picture = (sign.kind === "glyph" ? glyphPathname : cartouchePathname)(sign.slug);
157
+ const src = resolveSrc?.(picture) ?? picture;
158
+ const html =
159
+ sign.kind === "glyph" ?
160
+ glyphHtml(sign, src)
161
+ : cartouchePlaceholder({ ...sign, alt: sign.alt || undefined, src });
162
+ markdown = markdown.slice(0, sign.start) + html + markdown.slice(sign.end);
163
+ }
164
+ return markdown;
165
+ }
166
+
167
+ /**
168
+ * Every picture the glyphs and cartouches of a body name, for a stage that has
169
+ * to place the files before a compiler reads them.
170
+ *
171
+ * @param {string} body - The note's Markdown.
172
+ * @returns {Array<{src: string, locate: string}>} The authored pathname of each
173
+ * picture and the slug that locates it in the note's text, in document order
174
+ * within glyphs and then cartouches.
175
+ */
176
+ export function symbolImagesIn(body) {
177
+ const signs = symbolSpans(String(body ?? ""));
178
+ return ["glyph", "cartouche"].flatMap((kind) =>
179
+ signs
180
+ .filter((sign) => sign.kind === kind)
181
+ .map(({ slug }) => ({
182
+ src: (kind === "glyph" ? glyphPathname : cartouchePathname)(slug),
183
+ locate: slug,
184
+ })),
185
+ );
186
+ }
@@ -164,9 +164,7 @@ export const COLUMNS_CLASS = "verse-columns";
164
164
  */
165
165
  export const VERSE_KEYS = Object.freeze([
166
166
  "form",
167
- "meter",
168
- "rhyme",
169
- "syllables",
167
+ "scan",
170
168
  "lang",
171
169
  "font",
172
170
  "map",
@@ -222,15 +220,6 @@ export function scanVerse(source) {
222
220
  columned ?
223
221
  verseGroups(block, lines).filter((group) => !group.stanzaBreak).length
224
222
  : verseLines(body).length;
225
- if (
226
- opening.attributes.syllables &&
227
- opening.attributes.syllables.split(",").length !== count
228
- )
229
- errors.push({
230
- line: opening.start + 1,
231
- column: 1,
232
- message: `verse syllables= gives ${opening.attributes.syllables.split(",").length} counts for ${count} verse lines`,
233
- });
234
223
  if (columned) errors.push(...columnFindings(block, lines));
235
224
  blocks.push(block);
236
225
  }
@@ -270,17 +259,16 @@ export function scanVerse(source) {
270
259
  message: `\`${key}=\` is not a column: a verse has at most two, \`col1=\` and \`col2=\``,
271
260
  });
272
261
  else if (!VERSE_KEYS.includes(key))
273
- errors.push({ ...at, message: `${key}= is not a verse attribute` });
274
- else if (key === "numstep" && !/^\d+$/.test(value))
275
262
  errors.push({
276
263
  line: i + 1,
277
264
  column: keyColumn(lines[i], key),
278
- message: "verse numstep= numbers every Nth line: a whole number, 0 for none",
265
+ message: `${key}= is not a verse attribute`,
279
266
  });
280
- else if (key === "syllables" && !/^[1-9]\d*(?:,[1-9]\d*)*$/.test(value))
267
+ else if (key === "numstep" && !/^\d+$/.test(value))
281
268
  errors.push({
282
- ...at,
283
- message: "verse syllables= needs positive comma-separated counts",
269
+ line: i + 1,
270
+ column: keyColumn(lines[i], key),
271
+ message: "verse numstep= numbers every Nth line: a whole number, 0 for none",
284
272
  });
285
273
  }
286
274
  }
@@ -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
+ }