@heroiclands/package-build 23.13.0 → 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 (233) hide show
  1. package/CHANGELOG.md +36 -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 +244 -78
  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 +112 -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 +259 -314
  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-render.mjs +593 -0
  88. package/engine/site-root.mjs +22 -13
  89. package/engine/site-search.mjs +161 -0
  90. package/engine/site-serve.mjs +273 -0
  91. package/engine/site-theme-banner.mjs +154 -0
  92. package/engine/site-theme-urls.mjs +125 -0
  93. package/engine/site-theme.mjs +926 -0
  94. package/engine/text-maps.mjs +57 -10
  95. package/engine/typographer.mjs +248 -0
  96. package/engine/verse-metre.mjs +357 -0
  97. package/engine/wikilink-syntax.mjs +29 -47
  98. package/engine/wikilinks.mjs +758 -279
  99. package/engine/word-forms.mjs +39 -24
  100. package/engine/word-rows.mjs +12 -2
  101. package/engine/word-terms.mjs +29 -4
  102. package/manifest.mjs +3 -4
  103. package/package.json +10 -7
  104. package/{hugo-theme → site-theme}/data/banners.yaml +6 -10
  105. package/site-theme/layouts/base.hbs +31 -0
  106. package/site-theme/layouts/blog-list.hbs +30 -0
  107. package/site-theme/layouts/blog-post.hbs +40 -0
  108. package/site-theme/layouts/home.hbs +35 -0
  109. package/site-theme/layouts/list.hbs +20 -0
  110. package/site-theme/layouts/not-found.hbs +31 -0
  111. package/site-theme/layouts/page.hbs +7 -0
  112. package/site-theme/partials/copy-buttons.hbs +37 -0
  113. package/site-theme/partials/draft-notice.hbs +5 -0
  114. package/site-theme/partials/exturl.hbs +2 -0
  115. package/{hugo-theme/layouts/partials/footer.html → site-theme/partials/footer.hbs} +10 -7
  116. package/site-theme/partials/header.hbs +44 -0
  117. package/site-theme/partials/hero-banner.hbs +11 -0
  118. package/site-theme/partials/infobox-entry.hbs +2 -0
  119. package/site-theme/partials/infobox-section.hbs +31 -0
  120. package/site-theme/partials/infobox-value.hbs +2 -0
  121. package/site-theme/partials/infobox.hbs +18 -0
  122. package/site-theme/partials/list-members.hbs +9 -0
  123. package/site-theme/partials/nav-links.hbs +5 -0
  124. package/site-theme/partials/page-body.hbs +48 -0
  125. package/site-theme/partials/page-list.hbs +14 -0
  126. package/site-theme/partials/related.hbs +24 -0
  127. package/site-theme/partials/search-meta.hbs +6 -0
  128. package/site-theme/partials/search-names.hbs +2 -0
  129. package/site-theme/partials/search.hbs +52 -0
  130. package/site-theme/partials/tags.hbs +4 -0
  131. package/site-theme/partials/toc.hbs +7 -0
  132. package/{hugo-theme → site-theme}/static/css/style.css +14 -0
  133. package/stage.mjs +1 -2
  134. package/types/config.d.mts +8 -4
  135. package/types/content-config.d.mts +15 -15
  136. package/types/engine/asset-shipping.d.mts +5 -2
  137. package/types/engine/base-compiler.d.mts +20 -2
  138. package/types/engine/content-address.d.mts +1 -1
  139. package/types/engine/content-alerts.d.mts +8 -2
  140. package/types/engine/content-blocks.d.mts +11 -4
  141. package/types/engine/content-expand.d.mts +288 -0
  142. package/types/engine/content-figures.d.mts +11 -3
  143. package/types/engine/content-glyphs.d.mts +85 -0
  144. package/types/engine/content-images.d.mts +6 -7
  145. package/types/engine/content-links.d.mts +1 -1
  146. package/types/engine/content-render.d.mts +199 -0
  147. package/types/engine/content-scripts.d.mts +62 -0
  148. package/types/engine/content-spans.d.mts +1 -1
  149. package/types/engine/content-styles.d.mts +33 -0
  150. package/types/engine/content-symbols.d.mts +35 -0
  151. package/types/engine/core-report.d.mts +45 -0
  152. package/types/engine/core-vocabulary.d.mts +53 -0
  153. package/types/engine/dictionary-html.d.mts +8 -0
  154. package/types/engine/field-reference.d.mts +1 -1
  155. package/types/engine/heading-attributes.d.mts +20 -0
  156. package/types/engine/helpers.d.mts +3 -10
  157. package/types/engine/homepage.d.mts +11 -24
  158. package/types/engine/html-minify.d.mts +9 -0
  159. package/types/engine/index.d.mts +2 -1
  160. package/types/engine/language-terms.d.mts +24 -2
  161. package/types/engine/logographic.d.mts +45 -0
  162. package/types/engine/map-lexicon.d.mts +48 -8
  163. package/types/engine/map-places.d.mts +8 -0
  164. package/types/engine/markdown-expressions.d.mts +0 -2
  165. package/types/engine/metre-declaration.d.mts +39 -0
  166. package/types/engine/pdf-render.d.mts +6 -2
  167. package/types/engine/post-fields.d.mts +44 -0
  168. package/types/engine/preview-index.d.mts +72 -0
  169. package/types/engine/preview-source.d.mts +51 -0
  170. package/types/engine/script-emphasis-terms.d.mts +21 -0
  171. package/types/engine/script-emphasis.d.mts +84 -0
  172. package/types/engine/script-font-delivery.d.mts +4 -4
  173. package/types/engine/site-build.d.mts +71 -113
  174. package/types/engine/site-config.d.mts +14 -134
  175. package/types/engine/site-files.d.mts +73 -0
  176. package/types/engine/site-index.d.mts +11 -3
  177. package/types/engine/site-preview.d.mts +15 -19
  178. package/types/engine/site-render.d.mts +171 -0
  179. package/types/engine/site-root.d.mts +12 -4
  180. package/types/engine/site-search.d.mts +54 -0
  181. package/types/engine/site-serve.d.mts +44 -0
  182. package/types/engine/site-theme-banner.d.mts +51 -0
  183. package/types/engine/site-theme-urls.d.mts +60 -0
  184. package/types/engine/site-theme.d.mts +127 -0
  185. package/types/engine/text-maps.d.mts +17 -3
  186. package/types/engine/typographer.d.mts +11 -0
  187. package/types/engine/verse-metre.d.mts +131 -0
  188. package/types/engine/wikilink-syntax.d.mts +30 -48
  189. package/types/engine/wikilinks.d.mts +330 -14
  190. package/types/engine/word-forms.d.mts +3 -2
  191. package/types/manifest.d.mts +3 -4
  192. package/types/stage.d.mts +1 -2
  193. package/types/vendor/markdownlint.d.mts +8 -0
  194. package/vendor/markdownlint.mjs +20388 -0
  195. package/engine/web-wikilinks.mjs +0 -594
  196. package/hugo-theme/layouts/404.html +0 -85
  197. package/hugo-theme/layouts/_default/baseof.html +0 -67
  198. package/hugo-theme/layouts/_default/list.html +0 -139
  199. package/hugo-theme/layouts/_default/single.html +0 -4
  200. package/hugo-theme/layouts/blog/list.html +0 -54
  201. package/hugo-theme/layouts/blog-post/single.html +0 -36
  202. package/hugo-theme/layouts/index.html +0 -75
  203. package/hugo-theme/layouts/partials/banner-url.html +0 -141
  204. package/hugo-theme/layouts/partials/cdn-url.html +0 -36
  205. package/hugo-theme/layouts/partials/dictionary-lists.html +0 -58
  206. package/hugo-theme/layouts/partials/draft-notice.html +0 -41
  207. package/hugo-theme/layouts/partials/draft-state.html +0 -26
  208. package/hugo-theme/layouts/partials/header.html +0 -35
  209. package/hugo-theme/layouts/partials/hero-banner.html +0 -85
  210. package/hugo-theme/layouts/partials/infobox/entry.html +0 -18
  211. package/hugo-theme/layouts/partials/infobox/items.html +0 -26
  212. package/hugo-theme/layouts/partials/infobox/section.html +0 -56
  213. package/hugo-theme/layouts/partials/infobox/value.html +0 -43
  214. package/hugo-theme/layouts/partials/infobox.html +0 -57
  215. package/hugo-theme/layouts/partials/page-body.html +0 -152
  216. package/hugo-theme/layouts/partials/related.html +0 -138
  217. package/hugo-theme/layouts/partials/search.html +0 -84
  218. package/hugo-theme/layouts/partials/site-url.html +0 -32
  219. package/hugo-theme/layouts/partials/tags.html +0 -30
  220. package/hugo-theme/layouts/partials/toc.html +0 -28
  221. package/hugo-theme/layouts/partials/warn-missing-banner.html +0 -3
  222. package/hugo-theme/layouts/shortcodes/cartouche.html +0 -64
  223. package/hugo-theme/layouts/shortcodes/exturl.html +0 -12
  224. package/hugo-theme/layouts/shortcodes/glyph.html +0 -43
  225. package/hugo-theme/layouts/shortcodes/pagelist.html +0 -63
  226. package/hugo-theme/theme.toml +0 -11
  227. package/types/engine/web-wikilinks.d.mts +0 -107
  228. /package/{hugo-theme → site-theme}/data/banner-kinds.yaml +0 -0
  229. /package/{hugo-theme → site-theme}/static/css/sohl-icons.css +0 -0
  230. /package/{hugo-theme → site-theme}/static/fonts/game-icons-kb.woff2 +0 -0
  231. /package/{hugo-theme → site-theme}/static/img/banners/takheperu-hieroglyphs.svg +0 -0
  232. /package/{hugo-theme → site-theme}/static/img/cartouches/takheperu.svg +0 -0
  233. /package/{hugo-theme → site-theme}/static/js/inline-spoilers.js +0 -0
@@ -0,0 +1,376 @@
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 expansion step.** {@link expandContent} turns a note's authored body
16
+ * into markdown with every construct that reads the content index resolved,
17
+ * before a target renders it. In order:
18
+ *
19
+ * 1. **Tables.** Each prepared `sql` fence and page list is replaced by the
20
+ * markdown its query returned, and the note's generated sections are
21
+ * appended — see {@link module:engine/content-tables.expandContentTables}.
22
+ * This runs outside code protection, because a table is authored as a
23
+ * fence.
24
+ * 2. **Figure numbers.** The expanded body's captions are numbered by
25
+ * {@link module:engine/content-figures.scanFigures}; a `ref` and a
26
+ * `[[#id]]` link read the label.
27
+ * 3. **Expressions.** `{{…}}` is evaluated — `sql`, `ref`, dates and the
28
+ * helpers — see {@link module:engine/markdown-expressions.renderMarkdownExpressions}.
29
+ * 4. **Wikilinks.** Each `[[…]]` outside code is resolved against the index by
30
+ * {@link module:engine/wikilinks.resolveWikilinks}, and written in the
31
+ * target's form.
32
+ *
33
+ * Steps 1 and 2 are {@link expandTables}, which a caller numbering figures
34
+ * across more than one note — the book — runs on its own first.
35
+ *
36
+ * **What the target decides.** Every step reads the same on each target except
37
+ * where the target's own medium differs:
38
+ *
39
+ * | | `foundry` | `web` | `book` |
40
+ * | --- | --- | --- | --- |
41
+ * | a resolved link | an `@UUID[…]{label}` content link | a markdown link to the page's URL | a markdown link to the page's URL, which the book sets as a cross-reference to the entry it prints |
42
+ * | an unresolved link | the error that fails the note, as `linkError` | a `sohl-unresolved-link` span, and a finding | the label, then `(unresolved link)`, and a finding |
43
+ * | a `ref` | a `[[#anchor\|label]]` link, which the link pass addresses by its journal page | a markdown link to the anchor, or to another note's page | as on the web |
44
+ * | the generated sections | part of the one body | resolved apart, and returned as `generated` | resolved with the body, and returned as `generated` |
45
+ * | the date context | the compile's link index | the site index's `dateContext`, which carries the present | as on the web |
46
+ * | a figure's number | counted on the note | counted on the note | counted across the book, by the caller |
47
+ *
48
+ * Expansion reports and never throws on what it finds: each step's findings
49
+ * are returned for the caller to report in its own form.
50
+ *
51
+ * @module
52
+ */
53
+
54
+ import { collectAnchors, eventAnchors } from "./anchors.mjs";
55
+ import { protectCode } from "./code-fences.mjs";
56
+ import { scanFigures } from "./content-figures.mjs";
57
+ import { expandContentTables } from "./content-tables.mjs";
58
+ import { markGenerated, splitGenerated } from "./generated-sections.mjs";
59
+ import { convertNoteWikilinks } from "./helpers.mjs";
60
+ import { renderMarkdownExpressions } from "./markdown-expressions.mjs";
61
+ import { reckoningContext } from "./reckoning-markers.mjs";
62
+ import { resolveCrossNoteFigures, wikiContext } from "./site-index.mjs";
63
+ import { resolveWebWikilinks } from "./wikilinks.mjs";
64
+
65
+ /** The targets a body is expanded for. */
66
+ export const EXPANSION_TARGETS = Object.freeze(["foundry", "web", "book"]);
67
+
68
+ /**
69
+ * A `ref` call's Foundry rendering: a same-page wikilink rather than a
70
+ * Markdown link, so the wikilink pass that runs after expressions render
71
+ * addresses it exactly as an authored `[[#anchor|Text]]` is — by the
72
+ * JournalEntryPage the figure's own fence became, whether that page holds
73
+ * only the figure or carries it alongside other prose. A Markdown link's
74
+ * `#anchor` fragment resolves nowhere in Foundry, which addresses a page by
75
+ * UUID and an anchor within a page by that page's own anchor.
76
+ *
77
+ * @param {{anchor: string}} target - The figure's own anchor.
78
+ * @param {string} label - The link's text, which may itself carry Markdown.
79
+ * @returns {string} `[[#anchor|label]]`, with a literal `|` in the label
80
+ * escaped so it cannot be read as the wikilink's own separator.
81
+ */
82
+ function foundryRefLink({ anchor }, label) {
83
+ return `[[#${anchor}|${label.replace(/\|/g, "\\|")}]]`;
84
+ }
85
+
86
+ /**
87
+ * The note a body belongs to.
88
+ *
89
+ * @typedef {object} ExpandedNote
90
+ * @property {Record<string, any>} fm - Its frontmatter.
91
+ * @property {string} [file] - Its file, which findings name.
92
+ * @property {string} source - What a table's or a link's finding names the
93
+ * note by: its path on the web, its name in Foundry.
94
+ * @property {number} [bodyLine] - The file line its body starts on.
95
+ * @property {number} [bodyColumn] - The column its body starts at on that line.
96
+ */
97
+
98
+ /**
99
+ * How a target resolves links, beyond the index.
100
+ *
101
+ * @typedef {object} ExpandLinks
102
+ * @property {string|null} [pack] - `foundry`: the pack the note's own document
103
+ * lands in, which a `[[#slug]]` self-link addresses.
104
+ * @property {string|null} [docPack] - `foundry`: the pack its journal lands in.
105
+ * @property {Map<string, object>} [foreign] - `web` and `book`: the merged
106
+ * foreign index.
107
+ * @property {object} [assets] - `web` and `book`: the address space an embed
108
+ * resolves against, from {@link module:engine/art-fields.assetAddressIndex}.
109
+ * @property {(text: string) => string} [beforeLinks] - `web` and `book`: a pass
110
+ * run on the text outside code before its links resolve.
111
+ * @property {(text: string) => string} [afterLinks] - `web` and `book`: a pass
112
+ * run on the text outside code once its links resolve.
113
+ */
114
+
115
+ /**
116
+ * The first two steps of an expansion: the tables, and the figures of the
117
+ * table-expanded body.
118
+ *
119
+ * @typedef {object} ExpandedTables
120
+ * @property {{markdown: string, errors: object[], lineMap: object[],
121
+ * generatedFrom: number|undefined}} tables - From
122
+ * {@link module:engine/content-tables.expandContentTables}.
123
+ * @property {{figures: object[], errors: object[]}} figures - The figure scan
124
+ * of `tables.markdown`. A caller numbering figures across notes replaces
125
+ * `figures.figures` with its numbering before expanding the rest.
126
+ */
127
+
128
+ /**
129
+ * Expand a note's tables and scan the result for figures — steps 1 and 2 of
130
+ * {@link expandContent}.
131
+ *
132
+ * @param {string} markdown - The body as authored.
133
+ * @param {object} options
134
+ * @param {string} options.source - What a table's finding names the note by.
135
+ * @param {{sqlTables?: object[], pageLists?: object[]}} [options.prepared] -
136
+ * This note's prepared SQL tables and page lists.
137
+ * @param {(address: string) => string|undefined} [options.resolveRole] - The
138
+ * role an embedded picture takes, which numbers a map apart from a figure.
139
+ * @returns {ExpandedTables} The two steps' results.
140
+ */
141
+ export function expandTables(markdown, { source, prepared = {}, resolveRole }) {
142
+ const tables = expandContentTables(markdown ?? "", {
143
+ source,
144
+ sqlTables: prepared.sqlTables,
145
+ pageLists: prepared.pageLists,
146
+ });
147
+ return { tables, figures: scanFigures(tables.markdown, { resolveRole }) };
148
+ }
149
+
150
+ /**
151
+ * The expanded body, and what each step found.
152
+ *
153
+ * @typedef {object} Expansion
154
+ * @property {string} markdown - The whole body, expanded.
155
+ * @property {string} authored - The author's text, expanded. In Foundry it is
156
+ * the whole body.
157
+ * @property {string} generated - The generated sections, expanded apart from
158
+ * the author's text on the web; `""` in Foundry, and wherever there are none.
159
+ * @property {Array<{line: number, generated: boolean}>} lineMap - The authored
160
+ * line, 0-based, each line of the table-expanded body came from.
161
+ * @property {number|undefined} generatedFrom - The 0-based line of the
162
+ * table-expanded body the generated sections start on.
163
+ * @property {{figures: object[], errors: object[]}} figures - The figure scan
164
+ * of the table-expanded body.
165
+ * @property {{tables: object[], captions: object[], expressions: object[], links: object[]}} findings -
166
+ * Each step's findings, as the step reports them: a table's and a caption's
167
+ * by expanded body line, an expression's in file coordinates, a link's by
168
+ * occurrence of its literal.
169
+ * @property {Error|undefined} linkError - `foundry`: the error
170
+ * {@link module:engine/helpers.convertNoteWikilinks} fails a note with,
171
+ * carrying `file` and `position`.
172
+ * @property {object[]} unresolved - `foundry`: the links that did not resolve.
173
+ * @property {object[]} resolved - `web` and `book`: every link that resolved,
174
+ * in order.
175
+ * @property {number} authoredLinks - `web`: how many of `resolved` the
176
+ * author's text made, before the generated sections'.
177
+ */
178
+
179
+ /**
180
+ * Expand a note's body for one target.
181
+ *
182
+ * @param {string} markdown - The body as authored.
183
+ * @param {object} options
184
+ * @param {"foundry"|"web"|"book"} options.target - What the body is expanded
185
+ * for.
186
+ * @param {object} options.index - What links resolve against: the compile's
187
+ * link index for `foundry`, the {@link module:engine/site-index.buildSiteIndex}
188
+ * index for `web` and `book`.
189
+ * @param {ExpandedNote} options.note - The note.
190
+ * @param {{sqlTables?: object[], pageLists?: object[], inline?: object[]}} [options.prepared] -
191
+ * This note's prepared SQL tables, page lists and inline query results.
192
+ * @param {object} [options.events] - Every note's events, from
193
+ * {@link module:engine/event-fields.eventNoteIndex}.
194
+ * @param {(address: string) => string|undefined} [options.resolveRole] - The
195
+ * role an embedded picture takes, which numbers a map apart from a figure.
196
+ * @param {Map<string, Map<string, object>>} [options.figuresByAddress] - `web`
197
+ * and `book`: every page's figures, by the address a cross-note `ref` writes.
198
+ * @param {ExpandedTables} [options.tables] - The note's {@link expandTables},
199
+ * already run — with the figures numbered, for the book. Run here when
200
+ * omitted.
201
+ * @param {ExpandLinks} [options.links] - How the target resolves links.
202
+ * @returns {Expansion} The expanded body and the findings.
203
+ * @throws {Error} When `target` is not one of {@link EXPANSION_TARGETS}.
204
+ */
205
+ export function expandContent(
206
+ markdown,
207
+ {
208
+ target,
209
+ index,
210
+ note,
211
+ prepared = {},
212
+ events,
213
+ resolveRole,
214
+ figuresByAddress = new Map(),
215
+ tables: staged,
216
+ links = {},
217
+ },
218
+ ) {
219
+ if (!EXPANSION_TARGETS.includes(target))
220
+ throw new Error(
221
+ `\`${target}\` is not an expansion target; expected ` +
222
+ EXPANSION_TARGETS.slice(0, -1)
223
+ .map((name) => `\`${name}\``)
224
+ .join(", ") +
225
+ ` or \`${EXPANSION_TARGETS.at(-1)}\``,
226
+ );
227
+ const { fm, file, source, bodyLine, bodyColumn } = note;
228
+ // The website and the book read the site index, and resolve the same way.
229
+ const web = target !== "foundry";
230
+
231
+ const { tables, figures } = staged ?? expandTables(markdown, { source, prepared, resolveRole });
232
+ const { lineMap, generatedFrom } = tables;
233
+ const figuresById = new Map(
234
+ figures.figures
235
+ .filter((figure) => figure.id)
236
+ .map((figure) => [
237
+ figure.id,
238
+ { label: figure.label, caption: figure.caption, hasCaption: figure.hasCaption },
239
+ ]),
240
+ );
241
+
242
+ const expressions = renderMarkdownExpressions(
243
+ web ? markGenerated(tables.markdown, generatedFrom) : tables.markdown,
244
+ {
245
+ fm,
246
+ dates: web ? index.dateContext : reckoningContext(index),
247
+ file,
248
+ bodyLine,
249
+ sqlResults: prepared.inline,
250
+ events,
251
+ figures:
252
+ web ?
253
+ {
254
+ get: (id) => figuresById.get(id),
255
+ // A cross-note `ref` resolves the written address
256
+ // exactly as a wikilink does, against every page's
257
+ // figures scanned before any page's expressions render.
258
+ note: (address) =>
259
+ resolveCrossNoteFigures(address, {
260
+ contentTypes: index.contentTypes,
261
+ packages: index.packages,
262
+ noIndexPackages: index.noIndexPackages,
263
+ contentPackage: index.contentPackage,
264
+ siteIndexMap: index.index,
265
+ figuresByAddress,
266
+ }),
267
+ }
268
+ : { get: (id) => figuresById.get(id), link: foundryRefLink },
269
+ },
270
+ );
271
+
272
+ const findings = {
273
+ tables: tables.errors,
274
+ captions: figures.errors,
275
+ expressions: expressions.findings,
276
+ links: [],
277
+ };
278
+
279
+ if (!web) {
280
+ let converted;
281
+ let linkError;
282
+ try {
283
+ converted = convertNoteWikilinks(expressions.markdown, {
284
+ type: fm.type,
285
+ id: fm.id,
286
+ file,
287
+ bodyLine,
288
+ bodyColumn,
289
+ lineMap,
290
+ pack: links.pack,
291
+ docPack: links.docPack,
292
+ index,
293
+ name: source,
294
+ });
295
+ } catch (error) {
296
+ linkError = error;
297
+ }
298
+ const body = converted?.markdown ?? expressions.markdown;
299
+ return {
300
+ markdown: body,
301
+ authored: body,
302
+ generated: "",
303
+ lineMap,
304
+ generatedFrom,
305
+ figures,
306
+ findings,
307
+ linkError,
308
+ unresolved: converted?.unresolved ?? [],
309
+ resolved: [],
310
+ authoredLinks: 0,
311
+ };
312
+ }
313
+
314
+ const resolved = [];
315
+ const book = target === "book";
316
+ const ctx = wikiContext(index, {
317
+ src: source,
318
+ file,
319
+ type: fm.type ?? null,
320
+ errors: findings.links,
321
+ foreignIndex: links.foreign,
322
+ assets: links.assets,
323
+ resolved,
324
+ });
325
+ ctx.captionLabels = new Map(
326
+ figures.figures.filter((figure) => figure.id).map((figure) => [figure.id, figure.label]),
327
+ );
328
+ // This page's own anchors, so a `[[#slug]]` self-link is checked against
329
+ // what the page actually declares rather than trusted unconditionally.
330
+ ctx.anchors = new Set(collectAnchors(tables.markdown).map((anchor) => anchor.slug));
331
+ ctx.eventAnchors = new Set(eventAnchors(fm).map((anchor) => anchor.slug));
332
+ if (book) ctx.output = "book";
333
+ const resolve = (text) => {
334
+ const before = links.beforeLinks ? links.beforeLinks(text) : text;
335
+ const linked = resolveWebWikilinks(before, ctx);
336
+ return links.afterLinks ? links.afterLinks(linked) : linked;
337
+ };
338
+ // The book resolves the body whole, so a link's occurrence counts across
339
+ // the author's text and the generated sections it prints after it.
340
+ if (book) {
341
+ const split = splitGenerated(protectCode(expressions.markdown, resolve));
342
+ return {
343
+ markdown: split.generated ? `${split.authored}\n${split.generated}` : split.authored,
344
+ authored: split.authored,
345
+ generated: split.generated ?? "",
346
+ lineMap,
347
+ generatedFrom,
348
+ figures,
349
+ findings,
350
+ linkError: undefined,
351
+ unresolved: [],
352
+ resolved,
353
+ authoredLinks: 0,
354
+ };
355
+ }
356
+ // The author's text and the generated sections are resolved apart, so a
357
+ // caller can tell the links the author wrote from the ones a section
358
+ // derives.
359
+ const split = splitGenerated(expressions.markdown);
360
+ const authored = protectCode(split.authored, resolve);
361
+ const authoredLinks = resolved.length;
362
+ const generated = split.generated ? protectCode(split.generated, resolve) : "";
363
+ return {
364
+ markdown: split.generated ? `${authored}\n${generated}` : authored,
365
+ authored,
366
+ generated,
367
+ lineMap,
368
+ generatedFrom,
369
+ figures,
370
+ findings,
371
+ linkError: undefined,
372
+ unresolved: [],
373
+ resolved,
374
+ authoredLinks,
375
+ };
376
+ }
@@ -374,18 +374,24 @@ export function scanFigures(source, { resolveRole = () => undefined } = {}) {
374
374
  * @param {string} source - A note body with expanded tables.
375
375
  * @param {(markdown: string) => string} [renderMarkdown] - How to render a
376
376
  * figure's own contents.
377
- * @param {Array<{id: string, label: string}>} [numbers] - Labels assigned by a
378
- * surface that counts across documents, matched by id.
377
+ * @param {Array<{id: string, kind: string, label: string}>} [numbers] - The
378
+ * figures as {@link scanFigures} numbered them, with roles, where the
379
+ * surface counted them: across a note's pages, or in expansion, where a
380
+ * `ref` reads the same label. They are matched in order when they are as many
381
+ * as the text holds, by id otherwise, and the caption takes its number, kind
382
+ * and label from them. Omitted, the text is numbered as it stands.
379
383
  * @param {object} [options] - Options.
380
384
  * @param {(address: string) => string|undefined} [options.resolveRole] - See
381
385
  * {@link scanFigures}.
386
+ * @param {(markdown: string) => string} [options.renderInline] - How a caption
387
+ * is rendered; omitted, a caption is plain inline Markdown.
382
388
  * @returns {{markdown: string, errors: Array<{line: number, column: number, message: string}>}}
383
389
  */
384
390
  export function renderFigureBlocks(
385
391
  source,
386
392
  renderMarkdown = parser.render.bind(parser),
387
393
  numbers,
388
- { resolveRole } = {},
394
+ { resolveRole, renderInline = parser.renderInline.bind(parser) } = {},
389
395
  ) {
390
396
  const { figures, errors } = scanFigures(source, { resolveRole });
391
397
  if (errors.length) return { markdown: source, errors };
@@ -393,12 +399,13 @@ export function renderFigureBlocks(
393
399
  numbers ?
394
400
  new Map(numbers.filter((entry) => entry.id).map((entry) => [entry.id, entry]))
395
401
  : new Map();
402
+ const inOrder = numbers?.length === figures.length ? numbers : undefined;
396
403
  const lines = String(source ?? "").split("\n");
397
404
  const output = [];
398
405
  let cursor = 0;
399
- for (const figure of figures) {
406
+ for (const [at, figure] of figures.entries()) {
400
407
  output.push(...lines.slice(cursor, figure.line - 1));
401
- const numbered = (figure.id ? byId.get(figure.id) : null) ?? figure;
408
+ const numbered = inOrder?.[at] ?? (figure.id ? byId.get(figure.id) : null) ?? figure;
402
409
  const contents = lines.slice(figure.bodyStart, figure.bodyEnd).join("\n");
403
410
  const classes = ["content-figure", `content-figure-${numbered.kind}`, ...figure.classes];
404
411
  const id = figure.slug ? ` id="${escapeHtml(figure.slug)}"` : "";
@@ -416,7 +423,7 @@ export function renderFigureBlocks(
416
423
  output.push(
417
424
  `<p class="content-figure-label">${
418
425
  figure.hasCaption ?
419
- `${label ? `${label}: ` : ""}${parser.renderInline(figure.caption)}`
426
+ `${label ? `${label}: ` : ""}${renderInline(figure.caption)}`
420
427
  : label
421
428
  }</p>`,
422
429
  "</div>",
@@ -207,6 +207,7 @@ types:
207
207
  - names
208
208
  - family
209
209
  - abbreviation
210
+ - core
210
211
  - classModifiers
211
212
  - fields
212
213
  - words
@@ -224,6 +225,8 @@ types:
224
225
  - older
225
226
  - negation
226
227
  - naming
228
+ - emphasis
229
+ - script
227
230
  subTypes:
228
231
  - social
229
232
  - nature
@@ -363,7 +366,11 @@ types:
363
366
  doc:
364
367
  data:
365
368
  - culture
369
+ - concepts
370
+ - date
371
+ - author
366
372
  - map
373
+ - metres
367
374
  subTypes:
368
375
  - rules
369
376
  - userguide
@@ -371,6 +378,7 @@ types:
371
378
  - howto
372
379
  - concept
373
380
  - settingguide
381
+ - post
374
382
  macro:
375
383
  data: []
376
384
  subTypes: []
@@ -0,0 +1,213 @@
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 vocabulary of the two picture constructs a note writes for a sign.
16
+ *
17
+ * Both are spans, set in a line of text:
18
+ *
19
+ * ```
20
+ * The name [Ra]{.glyph slug=ra} is written with one sign, and
21
+ * [Au'Khelathu]{.cartouche slug=aukhelathu height=10rem align=right caption="Au'Khelathu"}
22
+ * is sealed in a frame.
23
+ * ```
24
+ *
25
+ * A span's label is its alt text; with an empty label the humanized slug
26
+ * stands in. A cartouche's caption is an attribute.
27
+ *
28
+ * The picture is a file the package ships, addressed by the one pathname rule
29
+ * every picture follows, so each target serves the address it can: `icons/<slug>.svg`
30
+ * for a glyph and `images/cartouches/<slug>.svg` for a cartouche. Both
31
+ * closed vocabularies below are refused when departed from, because an
32
+ * attribute that cannot mean anything must not read as one that worked.
33
+ *
34
+ * @module
35
+ */
36
+
37
+ /**
38
+ * Text safe inside a double-quoted attribute or an element.
39
+ * @param {string} value - Authored text.
40
+ * @returns {string} The text with markup characters escaped.
41
+ */
42
+ function escapeHtml(value) {
43
+ return String(value)
44
+ .replace(/&/g, "&amp;")
45
+ .replace(/</g, "&lt;")
46
+ .replace(/>/g, "&gt;")
47
+ .replace(/"/g, "&quot;");
48
+ }
49
+
50
+ /** The frames a glyph may sit in. */
51
+ export const GLYPH_FRAMES = Object.freeze(["tablet"]);
52
+
53
+ /** The sizes a glyph may take. */
54
+ export const GLYPH_SIZES = Object.freeze(["inline", "block"]);
55
+
56
+ /** The sides a cartouche may float to; absent, it is centred. */
57
+ export const CARTOUCHE_ALIGNS = Object.freeze(["left", "right"]);
58
+
59
+ /** The attribute keys a glyph takes. */
60
+ export const GLYPH_KEYS = Object.freeze(["slug", "frame", "size"]);
61
+ /** The attribute keys a cartouche takes. */
62
+ export const CARTOUCHE_KEYS = Object.freeze(["slug", "align", "height", "caption"]);
63
+
64
+ /**
65
+ * The units a cartouche height may be written in: every one a printed page
66
+ * can honour as well as a screen.
67
+ */
68
+ export const HEIGHT_UNITS = Object.freeze(["rem", "em", "px", "pt", "cm", "mm", "in"]);
69
+
70
+ const SLUG = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
71
+ const HEIGHT = new RegExp(`^(\\d+(?:\\.\\d+)?)(${HEIGHT_UNITS.join("|")})$`);
72
+
73
+ /**
74
+ * The pathname of a glyph's picture.
75
+ * @param {string} slug - The glyph's slug.
76
+ * @returns {string} The authored pathname.
77
+ */
78
+ export function glyphPathname(slug) {
79
+ return `icons/${slug}.svg`;
80
+ }
81
+
82
+ /**
83
+ * The pathname of a cartouche's picture.
84
+ * @param {string} slug - The cartouche's slug.
85
+ * @returns {string} The authored pathname.
86
+ */
87
+ export function cartouchePathname(slug) {
88
+ return `images/cartouches/${slug}.svg`;
89
+ }
90
+
91
+ /**
92
+ * A slug as words, for the alt text of a sign whose author wrote none.
93
+ * @param {string} slug - The slug.
94
+ * @returns {string} Title-cased words.
95
+ */
96
+ export function humanizeSlug(slug) {
97
+ return String(slug)
98
+ .split(/[-_]+/)
99
+ .filter(Boolean)
100
+ .map((word) => word[0].toUpperCase() + word.slice(1))
101
+ .join(" ");
102
+ }
103
+
104
+ /**
105
+ * What is wrong with an attribute list common to both constructs.
106
+ * @param {{id: string, classes: string[], values: Record<string, string>}} parsed - The parsed attributes.
107
+ * @param {string} noun - `glyph` or `cartouche`.
108
+ * @param {readonly string[]} keys - The keys the construct takes.
109
+ * @returns {string[]} One message per problem.
110
+ */
111
+ function sharedProblems({ id, classes, values }, noun, keys) {
112
+ const problems = [];
113
+ if (id) problems.push(`a ${noun} takes no #id`);
114
+ for (const name of classes.filter((name) => name !== noun))
115
+ problems.push(`a ${noun} takes no class besides .${noun}; .${name} is not one`);
116
+ for (const key of Object.keys(values))
117
+ if (!keys.includes(key) && !/^on/i.test(key) && !["id", "class"].includes(key))
118
+ problems.push(`a ${noun} takes ${keys.join(", ")}; \`${key}\` is not one of them`);
119
+ if (!Object.hasOwn(values, "slug"))
120
+ problems.push(`a ${noun} needs slug="…" naming its picture`);
121
+ else if (!SLUG.test(values.slug))
122
+ problems.push(`slug \`${values.slug}\` must be letters, digits, \`-\` and \`_\``);
123
+ return problems;
124
+ }
125
+
126
+ /**
127
+ * What is wrong with a glyph's attributes.
128
+ * @param {{id: string, classes: string[], values: Record<string, string>}} parsed - The parsed attributes.
129
+ * @returns {string[]} One message per problem.
130
+ */
131
+ export function glyphProblems(parsed) {
132
+ const problems = sharedProblems(parsed, "glyph", GLYPH_KEYS);
133
+ const { values } = parsed;
134
+ if (Object.hasOwn(values, "alt"))
135
+ problems.splice(
136
+ problems.findIndex((message) => message.includes("`alt`")),
137
+ 1,
138
+ "write a glyph's alt text as its label: [alt text]{.glyph slug=…}",
139
+ );
140
+ if (Object.hasOwn(values, "frame") && !GLYPH_FRAMES.includes(values.frame))
141
+ problems.push(`frame \`${values.frame}\` is not one of ${GLYPH_FRAMES.join(", ")}`);
142
+ if (Object.hasOwn(values, "size") && !GLYPH_SIZES.includes(values.size))
143
+ problems.push(`size \`${values.size}\` is not one of ${GLYPH_SIZES.join(", ")}`);
144
+ return problems;
145
+ }
146
+
147
+ /**
148
+ * What is wrong with a cartouche's attributes.
149
+ * @param {{id: string, classes: string[], values: Record<string, string>}} parsed - The parsed attributes.
150
+ * @returns {string[]} One message per problem.
151
+ */
152
+ export function cartoucheProblems(parsed) {
153
+ const { values } = parsed;
154
+ const problems = sharedProblems(parsed, "cartouche", CARTOUCHE_KEYS);
155
+ if (Object.hasOwn(values, "alt"))
156
+ problems.splice(
157
+ problems.findIndex((message) => message.includes("`alt`")),
158
+ 1,
159
+ "write a cartouche's alt text as its label: [alt text]{.cartouche slug=…}",
160
+ );
161
+ if (Object.hasOwn(values, "align") && !CARTOUCHE_ALIGNS.includes(values.align))
162
+ problems.push(`align \`${values.align}\` is not one of ${CARTOUCHE_ALIGNS.join(", ")}`);
163
+ if (Object.hasOwn(values, "height") && !HEIGHT.test(values.height))
164
+ problems.push(
165
+ `height \`${values.height}\` must be a number in ${HEIGHT_UNITS.join(", ")}, such as 10rem`,
166
+ );
167
+ return problems;
168
+ }
169
+
170
+ /**
171
+ * A cartouche's height as Typst writes a length.
172
+ * @param {string} height - A validated CSS length.
173
+ * @returns {string} The same length in a unit Typst reads.
174
+ */
175
+ export function typstLength(height) {
176
+ const [, number, unit] = HEIGHT.exec(height);
177
+ if (unit === "rem") return `${number}em`;
178
+ if (unit === "px") return `${Number(number) * 0.75}pt`;
179
+ return `${number}${unit}`;
180
+ }
181
+
182
+ /**
183
+ * A glyph as HTML.
184
+ * @param {{slug: string, alt: string, frame?: string, size?: string}} glyph - Its attributes.
185
+ * @param {string} src - The address the target serves its picture at.
186
+ * @returns {string} A span holding an image.
187
+ */
188
+ export function glyphHtml({ slug, alt, frame, size }, src) {
189
+ const classes = [
190
+ "glyph",
191
+ ...(frame ? [`glyph-${frame}`] : []),
192
+ ...(size ? [`glyph-${size}`] : []),
193
+ ];
194
+ return `<span class="${classes.join(" ")}"><img src="${escapeHtml(src)}" alt="${escapeHtml(alt || humanizeSlug(slug))}" loading="lazy" /></span>`;
195
+ }
196
+
197
+ /**
198
+ * A cartouche as HTML.
199
+ * @param {{slug: string, alt?: string, align?: string, height?: string, caption?: string}} cartouche - Its attributes.
200
+ * @param {string} src - The address the target serves its picture at.
201
+ * @returns {string} A figure holding an image and an optional caption, on one
202
+ * line so that it sits inside a paragraph without ending it.
203
+ */
204
+ export function cartoucheHtml({ slug, alt, align, height, caption }, src) {
205
+ const style =
206
+ height ? ` style="height: ${escapeHtml(height)}; width: auto; max-width: none;"` : "";
207
+ return [
208
+ `<figure class="cartouche${align ? ` cartouche-${align}` : ""}">`,
209
+ `<img src="${escapeHtml(src)}" alt="${escapeHtml(alt || humanizeSlug(slug))}" loading="lazy"${style} />`,
210
+ ...(caption ? [`<figcaption>${escapeHtml(caption)}</figcaption>`] : []),
211
+ "</figure>",
212
+ ].join("");
213
+ }