@heroiclands/package-build 23.12.8 → 24.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (258) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +3 -2
  3. package/assets/preview/gitea.ini +12 -7
  4. package/assets/styles/content-base.css +293 -24
  5. package/bin/content-commands.mjs +370 -213
  6. package/bin/package-build-preview.mjs +2 -2
  7. package/bin/package-build.mjs +3 -2
  8. package/ci/check-banners.mjs +7 -7
  9. package/config.mjs +21 -6
  10. package/content-config.mjs +199 -80
  11. package/docs/api.md +110 -89
  12. package/docs/authoring/links-and-markup.md +469 -97
  13. package/docs/commands.md +259 -78
  14. package/docs/configuration.md +211 -103
  15. package/docs/content-format.md +4 -4
  16. package/docs/diagnostics.md +44 -2
  17. package/docs/getting-started.md +1 -1
  18. package/docs/guides/icons.md +1 -1
  19. package/docs/guides/module-project.md +5 -8
  20. package/docs/guides/release-and-deploy.md +2 -2
  21. package/docs/guides/site.md +112 -23
  22. package/docs/guides/system-project.md +1 -1
  23. package/docs/guides/writing-in-invented-scripts.md +1247 -0
  24. package/docs/index.md +1 -0
  25. package/docs/project-setup.md +28 -30
  26. package/docs/reference/format-details.md +677 -319
  27. package/docs/reference/note-types.md +47 -10
  28. package/docs/reference/theme-pages.md +178 -108
  29. package/docs/source-preview.md +16 -2
  30. package/engine/anchors.mjs +2 -2
  31. package/engine/asset-shipping.mjs +22 -10
  32. package/engine/base-compiler.mjs +62 -104
  33. package/engine/changelog-lint.mjs +1 -1
  34. package/engine/code-fences.mjs +3 -3
  35. package/engine/content-address.mjs +1 -1
  36. package/engine/content-alerts.mjs +8 -4
  37. package/engine/content-blocks.mjs +29 -14
  38. package/engine/content-embeds.mjs +1 -1
  39. package/engine/content-expand.mjs +376 -0
  40. package/engine/content-figures.mjs +18 -11
  41. package/engine/content-format.yaml +8 -0
  42. package/engine/content-glyphs.mjs +213 -0
  43. package/engine/content-images.mjs +6 -7
  44. package/engine/content-index.mjs +1 -1
  45. package/engine/content-links.mjs +3 -3
  46. package/engine/content-lint.mjs +1 -1
  47. package/engine/content-render.mjs +453 -0
  48. package/engine/content-scripts.mjs +376 -43
  49. package/engine/content-spans.mjs +6 -3
  50. package/engine/content-styles.mjs +91 -0
  51. package/engine/content-symbols.mjs +186 -0
  52. package/engine/content-verse.mjs +738 -0
  53. package/engine/core-report.mjs +169 -0
  54. package/engine/core-vocabulary.mjs +208 -0
  55. package/engine/dictionary-html.mjs +119 -0
  56. package/engine/dictionary-rows.mjs +15 -4
  57. package/engine/field-reference.mjs +5 -5
  58. package/engine/first-use.mjs +17 -27
  59. package/engine/frontmatter-lint.mjs +1 -1
  60. package/engine/heading-attributes.mjs +89 -0
  61. package/engine/helpers.mjs +61 -101
  62. package/engine/homepage.mjs +17 -29
  63. package/engine/html-minify.mjs +70 -0
  64. package/engine/index.mjs +8 -5
  65. package/engine/key-case-collisions.mjs +136 -0
  66. package/engine/language-data.mjs +203 -6
  67. package/engine/language-rows.mjs +8 -2
  68. package/engine/language-terms.mjs +186 -16
  69. package/engine/language-words.mjs +12 -0
  70. package/engine/logographic.mjs +255 -0
  71. package/engine/map-lexicon.mjs +262 -47
  72. package/engine/map-places.mjs +5 -4
  73. package/engine/markdown-expressions.mjs +0 -2
  74. package/engine/markdownlint-run.mjs +9 -3
  75. package/engine/markup-validation.mjs +21 -0
  76. package/engine/metre-declaration.mjs +134 -0
  77. package/engine/names.mjs +36 -9
  78. package/engine/note-matter.mjs +66 -0
  79. package/engine/note-vocabulary.mjs +187 -3
  80. package/engine/pdf-build.mjs +69 -133
  81. package/engine/pdf-render.mjs +205 -58
  82. package/engine/post-fields.mjs +157 -0
  83. package/engine/preview-index.mjs +130 -0
  84. package/engine/preview-source.mjs +450 -0
  85. package/engine/project-init.mjs +5 -12
  86. package/engine/prose-config.mjs +3 -3
  87. package/engine/read-stdin.mjs +29 -0
  88. package/engine/run-rows.mjs +2 -2
  89. package/engine/run-words.mjs +6 -112
  90. package/engine/script-emphasis-terms.mjs +38 -0
  91. package/engine/script-emphasis.mjs +295 -0
  92. package/engine/script-font-delivery.mjs +4 -4
  93. package/engine/site-build.mjs +260 -315
  94. package/engine/site-config.mjs +26 -238
  95. package/engine/site-files.mjs +175 -0
  96. package/engine/site-index.mjs +9 -4
  97. package/engine/site-preview.mjs +52 -525
  98. package/engine/site-render.mjs +593 -0
  99. package/engine/site-root.mjs +22 -13
  100. package/engine/site-search.mjs +161 -0
  101. package/engine/site-serve.mjs +273 -0
  102. package/engine/site-theme-banner.mjs +154 -0
  103. package/engine/site-theme-urls.mjs +125 -0
  104. package/engine/site-theme.mjs +926 -0
  105. package/engine/sql-tables.mjs +20 -3
  106. package/engine/text-maps.mjs +57 -10
  107. package/engine/typographer.mjs +248 -0
  108. package/engine/verse-metre.mjs +357 -0
  109. package/engine/wikilink-syntax.mjs +29 -47
  110. package/engine/wikilinks.mjs +758 -279
  111. package/engine/word-forms.mjs +364 -0
  112. package/engine/word-rows.mjs +12 -2
  113. package/engine/word-terms.mjs +38 -4
  114. package/manifest.mjs +3 -4
  115. package/package.json +11 -8
  116. package/{hugo-theme → site-theme}/data/banners.yaml +6 -10
  117. package/site-theme/layouts/base.hbs +31 -0
  118. package/site-theme/layouts/blog-list.hbs +30 -0
  119. package/site-theme/layouts/blog-post.hbs +40 -0
  120. package/site-theme/layouts/home.hbs +35 -0
  121. package/site-theme/layouts/list.hbs +20 -0
  122. package/site-theme/layouts/not-found.hbs +31 -0
  123. package/site-theme/layouts/page.hbs +7 -0
  124. package/site-theme/partials/copy-buttons.hbs +37 -0
  125. package/site-theme/partials/draft-notice.hbs +5 -0
  126. package/site-theme/partials/exturl.hbs +2 -0
  127. package/{hugo-theme/layouts/partials/footer.html → site-theme/partials/footer.hbs} +10 -7
  128. package/site-theme/partials/header.hbs +44 -0
  129. package/site-theme/partials/hero-banner.hbs +11 -0
  130. package/site-theme/partials/infobox-entry.hbs +2 -0
  131. package/site-theme/partials/infobox-section.hbs +31 -0
  132. package/site-theme/partials/infobox-value.hbs +2 -0
  133. package/site-theme/partials/infobox.hbs +18 -0
  134. package/site-theme/partials/list-members.hbs +9 -0
  135. package/site-theme/partials/nav-links.hbs +5 -0
  136. package/site-theme/partials/page-body.hbs +48 -0
  137. package/site-theme/partials/page-list.hbs +14 -0
  138. package/site-theme/partials/related.hbs +24 -0
  139. package/site-theme/partials/search-meta.hbs +6 -0
  140. package/site-theme/partials/search-names.hbs +2 -0
  141. package/site-theme/partials/search.hbs +52 -0
  142. package/site-theme/partials/tags.hbs +4 -0
  143. package/site-theme/partials/toc.hbs +7 -0
  144. package/{hugo-theme → site-theme}/static/css/style.css +106 -14
  145. package/stage.mjs +1 -2
  146. package/types/config.d.mts +8 -4
  147. package/types/content-config.d.mts +15 -15
  148. package/types/engine/asset-shipping.d.mts +5 -2
  149. package/types/engine/base-compiler.d.mts +20 -2
  150. package/types/engine/code-fences.d.mts +3 -3
  151. package/types/engine/content-address.d.mts +1 -1
  152. package/types/engine/content-alerts.d.mts +8 -2
  153. package/types/engine/content-blocks.d.mts +11 -4
  154. package/types/engine/content-expand.d.mts +288 -0
  155. package/types/engine/content-figures.d.mts +11 -3
  156. package/types/engine/content-glyphs.d.mts +85 -0
  157. package/types/engine/content-images.d.mts +6 -7
  158. package/types/engine/content-links.d.mts +1 -1
  159. package/types/engine/content-render.d.mts +199 -0
  160. package/types/engine/content-scripts.d.mts +70 -4
  161. package/types/engine/content-spans.d.mts +1 -1
  162. package/types/engine/content-styles.d.mts +33 -0
  163. package/types/engine/content-symbols.d.mts +35 -0
  164. package/types/engine/content-verse.d.mts +240 -0
  165. package/types/engine/core-report.d.mts +45 -0
  166. package/types/engine/core-vocabulary.d.mts +53 -0
  167. package/types/engine/dictionary-html.d.mts +8 -0
  168. package/types/engine/dictionary-rows.d.mts +10 -0
  169. package/types/engine/field-reference.d.mts +2 -2
  170. package/types/engine/heading-attributes.d.mts +20 -0
  171. package/types/engine/helpers.d.mts +3 -10
  172. package/types/engine/homepage.d.mts +11 -24
  173. package/types/engine/html-minify.d.mts +9 -0
  174. package/types/engine/index.d.mts +2 -1
  175. package/types/engine/key-case-collisions.d.mts +28 -0
  176. package/types/engine/language-data.d.mts +0 -2
  177. package/types/engine/language-terms.d.mts +24 -2
  178. package/types/engine/logographic.d.mts +45 -0
  179. package/types/engine/map-lexicon.d.mts +66 -13
  180. package/types/engine/map-places.d.mts +8 -0
  181. package/types/engine/markdown-expressions.d.mts +0 -2
  182. package/types/engine/metre-declaration.d.mts +39 -0
  183. package/types/engine/names.d.mts +34 -3
  184. package/types/engine/note-matter.d.mts +48 -0
  185. package/types/engine/pdf-render.d.mts +6 -2
  186. package/types/engine/post-fields.d.mts +44 -0
  187. package/types/engine/preview-index.d.mts +72 -0
  188. package/types/engine/preview-source.d.mts +51 -0
  189. package/types/engine/read-stdin.d.mts +11 -0
  190. package/types/engine/run-rows.d.mts +2 -2
  191. package/types/engine/run-words.d.mts +0 -15
  192. package/types/engine/script-emphasis-terms.d.mts +21 -0
  193. package/types/engine/script-emphasis.d.mts +84 -0
  194. package/types/engine/script-font-delivery.d.mts +4 -4
  195. package/types/engine/site-build.d.mts +71 -113
  196. package/types/engine/site-config.d.mts +14 -134
  197. package/types/engine/site-files.d.mts +73 -0
  198. package/types/engine/site-index.d.mts +11 -3
  199. package/types/engine/site-preview.d.mts +15 -19
  200. package/types/engine/site-render.d.mts +171 -0
  201. package/types/engine/site-root.d.mts +12 -4
  202. package/types/engine/site-search.d.mts +54 -0
  203. package/types/engine/site-serve.d.mts +44 -0
  204. package/types/engine/site-theme-banner.d.mts +51 -0
  205. package/types/engine/site-theme-urls.d.mts +60 -0
  206. package/types/engine/site-theme.d.mts +127 -0
  207. package/types/engine/sql-tables.d.mts +4 -1
  208. package/types/engine/text-maps.d.mts +17 -3
  209. package/types/engine/typographer.d.mts +11 -0
  210. package/types/engine/verse-metre.d.mts +131 -0
  211. package/types/engine/wikilink-syntax.d.mts +30 -48
  212. package/types/engine/wikilinks.d.mts +330 -14
  213. package/types/engine/word-forms.d.mts +83 -0
  214. package/types/manifest.d.mts +3 -4
  215. package/types/stage.d.mts +1 -2
  216. package/types/vendor/markdownlint.d.mts +8 -0
  217. package/vendor/markdownlint.mjs +20388 -0
  218. package/engine/content-poetry.mjs +0 -240
  219. package/engine/web-wikilinks.mjs +0 -594
  220. package/hugo-theme/layouts/404.html +0 -85
  221. package/hugo-theme/layouts/_default/baseof.html +0 -67
  222. package/hugo-theme/layouts/_default/list.html +0 -139
  223. package/hugo-theme/layouts/_default/single.html +0 -4
  224. package/hugo-theme/layouts/blog/list.html +0 -54
  225. package/hugo-theme/layouts/blog-post/single.html +0 -36
  226. package/hugo-theme/layouts/index.html +0 -75
  227. package/hugo-theme/layouts/partials/banner-url.html +0 -141
  228. package/hugo-theme/layouts/partials/cdn-url.html +0 -36
  229. package/hugo-theme/layouts/partials/dictionary-lists.html +0 -58
  230. package/hugo-theme/layouts/partials/draft-notice.html +0 -41
  231. package/hugo-theme/layouts/partials/draft-state.html +0 -26
  232. package/hugo-theme/layouts/partials/header.html +0 -35
  233. package/hugo-theme/layouts/partials/hero-banner.html +0 -85
  234. package/hugo-theme/layouts/partials/infobox/entry.html +0 -18
  235. package/hugo-theme/layouts/partials/infobox/items.html +0 -26
  236. package/hugo-theme/layouts/partials/infobox/section.html +0 -56
  237. package/hugo-theme/layouts/partials/infobox/value.html +0 -43
  238. package/hugo-theme/layouts/partials/infobox.html +0 -57
  239. package/hugo-theme/layouts/partials/page-body.html +0 -152
  240. package/hugo-theme/layouts/partials/related.html +0 -138
  241. package/hugo-theme/layouts/partials/search.html +0 -84
  242. package/hugo-theme/layouts/partials/site-url.html +0 -32
  243. package/hugo-theme/layouts/partials/tags.html +0 -30
  244. package/hugo-theme/layouts/partials/toc.html +0 -28
  245. package/hugo-theme/layouts/partials/warn-missing-banner.html +0 -3
  246. package/hugo-theme/layouts/shortcodes/cartouche.html +0 -64
  247. package/hugo-theme/layouts/shortcodes/exturl.html +0 -12
  248. package/hugo-theme/layouts/shortcodes/glyph.html +0 -43
  249. package/hugo-theme/layouts/shortcodes/pagelist.html +0 -63
  250. package/hugo-theme/theme.toml +0 -11
  251. package/types/engine/content-poetry.d.mts +0 -58
  252. package/types/engine/web-wikilinks.d.mts +0 -107
  253. /package/{hugo-theme → site-theme}/data/banner-kinds.yaml +0 -0
  254. /package/{hugo-theme → site-theme}/static/css/sohl-icons.css +0 -0
  255. /package/{hugo-theme → site-theme}/static/fonts/game-icons-kb.woff2 +0 -0
  256. /package/{hugo-theme → site-theme}/static/img/banners/takheperu-hieroglyphs.svg +0 -0
  257. /package/{hugo-theme → site-theme}/static/img/cartouches/takheperu.svg +0 -0
  258. /package/{hugo-theme → site-theme}/static/js/inline-spoilers.js +0 -0
@@ -0,0 +1,161 @@
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
+ * **What a note's page tells the site's search.** Pagefind reads the filters a
16
+ * page carries, so a reader can narrow a search to a package, a kind of note
17
+ * or a part of the world:
18
+ *
19
+ * | Filter | Value |
20
+ * | ------ | ----- |
21
+ * | `package` | the package that publishes the note |
22
+ * | `type` | the note's `type` |
23
+ * | `subtype` | the note's `subType` |
24
+ * | `culture` | the name of the culture `data.culture` names |
25
+ * | `continent` | the continent the note lies in |
26
+ * | `region` | the outermost region beneath that continent |
27
+ *
28
+ * A place lies where its `parents` lead; a being where its `homes` are; a
29
+ * scenario where its `locations` are. A continent is a region whose parent is
30
+ * the world. A note reached by more than one path carries every continent and
31
+ * region it reaches, so a being with homes in two regions is found under both.
32
+ *
33
+ * The note's name and its aliases are weighted, so a search for a name finds
34
+ * its note first. A note's tags and its draft state are not filters.
35
+ *
36
+ * @module
37
+ */
38
+
39
+ import { placeShortcodes } from "./map-places.mjs";
40
+ import { resolveInfoboxRef } from "./site-index.mjs";
41
+
42
+ /** The filters a note's page carries, in the order a reader is offered them. */
43
+ export const SEARCH_FILTERS = Object.freeze([
44
+ "package",
45
+ "type",
46
+ "subtype",
47
+ "culture",
48
+ "continent",
49
+ "region",
50
+ ]);
51
+
52
+ /**
53
+ * @param {unknown} value - Anything.
54
+ * @returns {boolean} Whether it is a mapping a field may be read out of.
55
+ */
56
+ const isMapping = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
57
+
58
+ /**
59
+ * The continents and regions every place lies in.
60
+ *
61
+ * @param {Map<string, {shortcode: string, name: string, subType?: string, parents: string[]}>} places -
62
+ * Every place, by shortcode, as {@link module:engine/map-places.mapWorld} reads them.
63
+ * @returns {(shortcodes: Iterable<string>) => {continents: string[], regions: string[]}}
64
+ * The names of the continents and the outermost regions the given places lie
65
+ * in, each sorted.
66
+ */
67
+ export function placeGeography(places) {
68
+ const worlds = new Set(
69
+ [...places.values()].filter((p) => p.subType === "world").map((p) => p.shortcode),
70
+ );
71
+ const isContinent = (place) =>
72
+ place?.subType === "region" && place.parents.some((parent) => worlds.has(parent));
73
+ /** @type {Map<string, {continents: Set<string>, regions: Set<string>}>} */
74
+ const known = new Map();
75
+
76
+ const reach = (shortcode, visiting) => {
77
+ if (known.has(shortcode)) return known.get(shortcode);
78
+ const found = { continents: new Set(), regions: new Set() };
79
+ const place = places.get(shortcode);
80
+ if (!place || visiting.has(shortcode) || worlds.has(shortcode)) return found;
81
+ visiting.add(shortcode);
82
+ if (isContinent(place)) found.continents.add(shortcode);
83
+ for (const parent of place.parents) {
84
+ if (place.subType === "region" && isContinent(places.get(parent)))
85
+ found.regions.add(shortcode);
86
+ const above = reach(parent, visiting);
87
+ for (const c of above.continents) found.continents.add(c);
88
+ for (const r of above.regions) found.regions.add(r);
89
+ }
90
+ visiting.delete(shortcode);
91
+ known.set(shortcode, found);
92
+ return found;
93
+ };
94
+
95
+ const names = (shortcodes) =>
96
+ [...new Set([...shortcodes].map((sc) => places.get(sc)?.name ?? sc))].sort();
97
+ return (shortcodes) => {
98
+ const continents = new Set();
99
+ const regions = new Set();
100
+ for (const shortcode of shortcodes) {
101
+ const found = reach(shortcode, new Set());
102
+ for (const c of found.continents) continents.add(c);
103
+ for (const r of found.regions) regions.add(r);
104
+ }
105
+ return { continents: names(continents), regions: names(regions) };
106
+ };
107
+ }
108
+
109
+ /**
110
+ * The places a note lies in: itself for a place, its `homes` and its
111
+ * `locations` otherwise.
112
+ *
113
+ * @param {object} fm - The note's frontmatter.
114
+ * @returns {string[]} Shortcodes, lower case.
115
+ */
116
+ export function notePlaces(fm) {
117
+ if (fm?.type === "place") return [String(fm.shortcode ?? "").toLowerCase()].filter(Boolean);
118
+ const data = isMapping(fm?.data) ? fm.data : {};
119
+ return [...placeShortcodes(data.homes), ...placeShortcodes(data.locations)];
120
+ }
121
+
122
+ /**
123
+ * What a note's page tells the search.
124
+ *
125
+ * @param {object} fm - The page's frontmatter: the note's, with its `package`.
126
+ * @param {object} options - What the values resolve against.
127
+ * @param {(shortcodes: Iterable<string>) => {continents: string[], regions: string[]}} options.geography -
128
+ * From {@link placeGeography}.
129
+ * @param {object} [options.index] - The site's address index, which names the
130
+ * culture `data.culture` names.
131
+ * @returns {{filters: Array<{name: string, values: string[]}>, names: string[]}}
132
+ * Each filter with a value, in {@link SEARCH_FILTERS} order, and the names a
133
+ * search weights.
134
+ */
135
+ export function searchMetadata(fm, { geography, index }) {
136
+ const data = isMapping(fm?.data) ? fm.data : {};
137
+ const text = (value) => (value === undefined || value === null ? "" : String(value).trim());
138
+ const culture =
139
+ data.culture !== undefined && data.culture !== null && data.culture !== "" ?
140
+ index && resolveInfoboxRef(index, data.culture, { type: "lore" })?.name
141
+ : undefined;
142
+ const { continents, regions } = geography(notePlaces(fm));
143
+ const values = {
144
+ package: [text(fm?.package)],
145
+ type: [text(fm?.type)],
146
+ subtype: [text(fm?.subType)],
147
+ culture: [text(culture)],
148
+ continent: continents,
149
+ region: regions,
150
+ };
151
+ const filters = SEARCH_FILTERS.map((name) => ({
152
+ name,
153
+ values: values[name].filter(Boolean),
154
+ })).filter((filter) => filter.values.length);
155
+ const name = isMapping(fm?.name) ? fm.name : {};
156
+ const names = [
157
+ text(name.full),
158
+ ...(Array.isArray(name.aliases) ? name.aliases.map(text) : []),
159
+ ].filter(Boolean);
160
+ return { filters, names: [...new Set(names)] };
161
+ }
@@ -0,0 +1,273 @@
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 site served while it is written.** {@link serveSite} builds a package's
16
+ * site, serves it, and keeps it current: it watches the content tree and the
17
+ * theme, writes again what a change touches, and tells every open page to
18
+ * reload.
19
+ *
20
+ * | Change | What is written again |
21
+ * | ------ | --------------------- |
22
+ * | a note's body or frontmatter | that note's page |
23
+ * | a note's name, type or shortcode, a new or removed note, a note holding an `sql` table, any other file in the content tree | the whole site |
24
+ * | the theme's layouts, partials or files | every page, and the theme's files |
25
+ *
26
+ * A page that changes because another note changed — a table that lists the
27
+ * note, a link that carries its name — is written again by the next whole
28
+ * build. A note in error is written as its page shows it, and the error is
29
+ * reported; a build whose gates fire writes nothing and leaves the last good
30
+ * site in place.
31
+ *
32
+ * The site is served from the deployed root, so a page is at the address it is
33
+ * deployed at, its base path included. Every HTML page is sent with a script
34
+ * that listens on {@link RELOAD_PATH} for the server's `reload` event; the
35
+ * script is added as the page is sent and is never written to the site. The
36
+ * search index is built once, after the first build.
37
+ *
38
+ * @module
39
+ */
40
+
41
+ import fs from "node:fs";
42
+ import http from "node:http";
43
+ import path from "node:path";
44
+
45
+ import { siteBuildFailed } from "./site-build.mjs";
46
+ import { SITE_THEME_DIR } from "./site-theme.mjs";
47
+ import { SITE_STATIC_DIR, buildPackageSite, writeSite, writeThemeFiles } from "./site-render.mjs";
48
+ import { SEARCH_DIR, indexSite } from "./site-root.mjs";
49
+
50
+ /** The address a served page listens on for the server's reloads. */
51
+ export const RELOAD_PATH = "/__package-build/reload";
52
+
53
+ /** How long a burst of changes is gathered before it is written, in milliseconds. */
54
+ const SETTLE_MS = 120;
55
+
56
+ /** What a file is sent as, by its extension. */
57
+ const CONTENT_TYPES = Object.freeze({
58
+ ".html": "text/html; charset=utf-8",
59
+ ".css": "text/css; charset=utf-8",
60
+ ".js": "text/javascript; charset=utf-8",
61
+ ".mjs": "text/javascript; charset=utf-8",
62
+ ".json": "application/json",
63
+ ".xml": "application/xml",
64
+ ".txt": "text/plain; charset=utf-8",
65
+ ".svg": "image/svg+xml",
66
+ ".png": "image/png",
67
+ ".jpg": "image/jpeg",
68
+ ".jpeg": "image/jpeg",
69
+ ".webp": "image/webp",
70
+ ".gif": "image/gif",
71
+ ".woff2": "font/woff2",
72
+ ".woff": "font/woff",
73
+ ".wasm": "application/wasm",
74
+ });
75
+
76
+ /** The script a served page is sent with. */
77
+ const RELOAD_SCRIPT = `<script>new EventSource(${JSON.stringify(RELOAD_PATH)}).addEventListener("reload", () => location.reload());</script>`;
78
+
79
+ /** Whether a note's text holds a query its build prepares ahead of the render. */
80
+ const holdsQuery = (text) => /```\s*sql\b|\{\{[^}]*\bsql\b/.test(text);
81
+
82
+ /**
83
+ * Serve a package's site, and keep it current.
84
+ *
85
+ * @param {object} options - The inputs.
86
+ * @param {object} options.config - The resolved configuration.
87
+ * @param {string} options.out - The site's directory under the deployed
88
+ * root, which the site is written to.
89
+ * @param {string} [options.root] - The deployed root, which is served. The
90
+ * directory above `out` by default; `out` itself for a site served at `/`.
91
+ * @param {string} [options.host] - The address to listen on.
92
+ * @param {number} [options.port] - The port; `0` takes any free one.
93
+ * @param {boolean} [options.search] - Build the search index after the first
94
+ * build, where the site has one.
95
+ * @param {number} [options.year] - The year the footer states.
96
+ * @param {(event: {kind: string, built?: object, files?: string[], findings?: object[], error?: unknown}) => void} [options.report] -
97
+ * Told of each build: `built` for a whole build, `page` for one note's page,
98
+ * `theme` for the theme, `failed` with what the build found, `error` with
99
+ * what it threw.
100
+ * @returns {Promise<{url: string, close: () => Promise<void>, settled: () => Promise<void>}>}
101
+ * The address of the site's home page, how to stop serving, and a promise
102
+ * for the moment every change seen so far has been written.
103
+ */
104
+ export async function serveSite({
105
+ config,
106
+ out,
107
+ root = path.dirname(out),
108
+ host = "127.0.0.1",
109
+ port = 1313,
110
+ search = true,
111
+ year,
112
+ report = () => {},
113
+ }) {
114
+ /** @type {Set<http.ServerResponse>} */
115
+ const listeners = new Set();
116
+ let state = null;
117
+
118
+ const reload = () => {
119
+ for (const res of listeners) res.write("event: reload\ndata: {}\n\n");
120
+ };
121
+
122
+ const buildAll = async () => {
123
+ const result = await buildPackageSite({ config, out, year });
124
+ if (!result.written || siteBuildFailed(result.built))
125
+ report({ kind: "failed", built: result.built });
126
+ if (!result.written) return false;
127
+ state = result;
128
+ report({ kind: "built", built: result.built, findings: result.written.findings });
129
+ return true;
130
+ };
131
+
132
+ const redrawNotes = (files) => {
133
+ if (!state) return false;
134
+ const changed = new Set();
135
+ for (const file of files) {
136
+ const text = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
137
+ if (!file.endsWith(".md") || !text || holdsQuery(text)) return false;
138
+ const entry = state.built.site.renderNote(file);
139
+ if (!entry) return false;
140
+ if (entry.findings.length) {
141
+ report({ kind: "failed", findings: entry.findings });
142
+ return true;
143
+ }
144
+ const page = state.pageOf(entry);
145
+ state.pages = state.pages.map((old) => (old.url === page.url ? page : old));
146
+ changed.add(page.url);
147
+ }
148
+ const written = writeSite({ site: state.site, pages: state.pages, out, only: changed });
149
+ report({ kind: "page", files: written.files, findings: written.findings });
150
+ return true;
151
+ };
152
+
153
+ const redrawTheme = () => {
154
+ if (!state) return false;
155
+ writeThemeFiles(out);
156
+ const written = writeSite({
157
+ site: state.site,
158
+ pages: state.pages,
159
+ out,
160
+ only: new Set(state.pages.map((page) => page.url)),
161
+ });
162
+ report({ kind: "theme", files: written.files, findings: written.findings });
163
+ return true;
164
+ };
165
+
166
+ const indexForSearch = async () => {
167
+ if (!search || config.site.search === false || !state) return;
168
+ await indexSite({ site: out, output: path.join(out, SEARCH_DIR) });
169
+ };
170
+
171
+ await buildAll();
172
+ await indexForSearch();
173
+
174
+ /** @type {{content: Set<string>, theme: boolean}} */
175
+ let pending = { content: new Set(), theme: false };
176
+ let timer = null;
177
+ let work = Promise.resolve();
178
+ const flush = () => {
179
+ timer = null;
180
+ const batch = pending;
181
+ pending = { content: new Set(), theme: false };
182
+ work = work.then(async () => {
183
+ try {
184
+ let done = true;
185
+ if (batch.content.size && !redrawNotes([...batch.content])) {
186
+ done = await buildAll();
187
+ if (done) await indexForSearch();
188
+ } else if (batch.theme) done = redrawTheme();
189
+ if (done) reload();
190
+ } catch (error) {
191
+ report({ kind: "error", error });
192
+ }
193
+ });
194
+ };
195
+ const queue = (kind, file) => {
196
+ if (kind === "theme") pending.theme = true;
197
+ else pending.content.add(file);
198
+ if (!timer) timer = setTimeout(flush, SETTLE_MS);
199
+ };
200
+
201
+ const watchers = [
202
+ fs.watch(config.paths.content, { recursive: true }, (_event, name) => {
203
+ if (name) queue("content", path.join(config.paths.content, String(name)));
204
+ }),
205
+ ...[SITE_THEME_DIR, SITE_STATIC_DIR]
206
+ .filter((dir) => fs.existsSync(dir))
207
+ .map((dir) => fs.watch(dir, { recursive: true }, () => queue("theme"))),
208
+ ];
209
+
210
+ const server = http.createServer((req, res) => {
211
+ const address = decodeURIComponent(
212
+ new URL(req.url ?? "/", "http://serve.invalid").pathname,
213
+ );
214
+ if (address === RELOAD_PATH) {
215
+ res.writeHead(200, {
216
+ "content-type": "text/event-stream",
217
+ "cache-control": "no-cache",
218
+ connection: "keep-alive",
219
+ });
220
+ res.write(": connected\n\n");
221
+ listeners.add(res);
222
+ req.on("close", () => listeners.delete(res));
223
+ return;
224
+ }
225
+ let file = path.join(root, address);
226
+ if (!file.startsWith(root)) {
227
+ res.writeHead(403).end();
228
+ return;
229
+ }
230
+ if (fs.statSync(file, { throwIfNoEntry: false })?.isDirectory())
231
+ file = path.join(file, "index.html");
232
+ let status = 200;
233
+ if (!fs.existsSync(file)) {
234
+ status = 404;
235
+ file = path.join(out, "404.html");
236
+ if (!fs.existsSync(file)) {
237
+ res.writeHead(404).end();
238
+ return;
239
+ }
240
+ }
241
+ const type = CONTENT_TYPES[path.extname(file).toLowerCase()] ?? "application/octet-stream";
242
+ let body = fs.readFileSync(file);
243
+ if (type.startsWith("text/html")) {
244
+ const html = body.toString("utf8");
245
+ const at = html.lastIndexOf("</body>");
246
+ body = Buffer.from(
247
+ at < 0 ? html + RELOAD_SCRIPT : html.slice(0, at) + RELOAD_SCRIPT + html.slice(at),
248
+ );
249
+ }
250
+ res.writeHead(status, { "content-type": type, "cache-control": "no-store" });
251
+ res.end(body);
252
+ });
253
+ await new Promise((resolve) => server.listen(port, host, resolve));
254
+ const listening = /** @type {import("node:net").AddressInfo} */ (server.address());
255
+ const base = new URL(config.homepage).pathname;
256
+
257
+ return {
258
+ url: `http://${host}:${listening.port}${base}`,
259
+ settled: async () => {
260
+ while (timer || pending.content.size || pending.theme) {
261
+ await new Promise((resolve) => setTimeout(resolve, SETTLE_MS));
262
+ }
263
+ await work;
264
+ },
265
+ close: async () => {
266
+ if (timer) clearTimeout(timer);
267
+ for (const watcher of watchers) watcher.close();
268
+ for (const res of listeners) res.end();
269
+ await work;
270
+ await new Promise((resolve) => server.close(() => resolve()));
271
+ },
272
+ };
273
+ }
@@ -0,0 +1,154 @@
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 page's banner image.** {@link bannerUrl} turns a note's banner fields into
16
+ * the address of the hero image, or into `""` when the page has none.
17
+ *
18
+ * Resolution, in order:
19
+ *
20
+ * 1. `data.banner` — a note's own hero image, which the content build resolved
21
+ * from an asset address. It is used as given; a blank one declines an image.
22
+ * 2. `banner` — a hand-authored page's value. `none` or `false` declines an
23
+ * image, a full URL is used as written, and anything else is a fragment
24
+ * under `images/`.
25
+ * 3. `<dir>/<name>.webp` — the default for the page's kind: the banner
26
+ * `banner-kinds.yaml` maps its `type` and `subType` to, or its `category`
27
+ * for the types the mapping lists as taking theirs from there.
28
+ * 4. `<dir>/<fallback>` — whenever the name step 2 or 3 picked is not in the
29
+ * declared inventory of `banners.yaml`. An asset host cannot be asked
30
+ * whether a name exists, so the inventory stands in for the test, and each
31
+ * name that misses is reported once.
32
+ *
33
+ * The inventory is checked only for a relative path directly inside the banner
34
+ * directory; an absolute URL, a step 1 address and a fragment elsewhere under
35
+ * `images/` pass through.
36
+ *
37
+ * @module
38
+ */
39
+
40
+ import fs from "node:fs";
41
+ import path from "node:path";
42
+ import { fileURLToPath } from "node:url";
43
+ import YAML from "yaml";
44
+
45
+ import { cdnUrl } from "./site-theme-urls.mjs";
46
+
47
+ /** The directory holding the banner inventory and the kind mapping. */
48
+ export const BANNER_DATA_DIR = path.resolve(
49
+ path.dirname(fileURLToPath(import.meta.url)),
50
+ "..",
51
+ "site-theme",
52
+ "data",
53
+ );
54
+
55
+ /** @type {{banners: object, kinds: object}|undefined} */
56
+ let shipped;
57
+
58
+ /**
59
+ * The banner data the theme ships.
60
+ *
61
+ * @returns {{banners: object, kinds: object}} The inventory and the kind mapping.
62
+ */
63
+ export function shippedBannerData() {
64
+ shipped ??= {
65
+ banners: YAML.parse(fs.readFileSync(path.join(BANNER_DATA_DIR, "banners.yaml"), "utf8")),
66
+ kinds: YAML.parse(fs.readFileSync(path.join(BANNER_DATA_DIR, "banner-kinds.yaml"), "utf8")),
67
+ };
68
+ return shipped;
69
+ }
70
+
71
+ /** The text of a value as a template prints it, with an unset value as `<nil>`. */
72
+ const printed = (value) => (value === undefined || value === null ? "<nil>" : String(value));
73
+
74
+ /**
75
+ * The address of a page's banner.
76
+ *
77
+ * @param {object} site - The site: `params.cdnBaseURL` and `basePath`.
78
+ * @param {object} fields - What the page states.
79
+ * @param {object} [fields.data] - The page's `data`, read for the resolved `banner`.
80
+ * @param {unknown} [fields.banner] - The hand-authored `banner` value.
81
+ * @param {string} [fields.type] - The note `type`.
82
+ * @param {string} [fields.subType] - The note `subType`.
83
+ * @param {string} [fields.category] - The page's `category`.
84
+ * @param {string} [fields.subtype] - The name to default to when no `type` is given.
85
+ * @param {string} [fields.context] - The page address, named in a report.
86
+ * @param {object} [options] - Options.
87
+ * @param {{banners: object, kinds: object}} [options.data] - The inventory and the
88
+ * kind mapping. The shipped ones by default.
89
+ * @param {(finding: {severity: string, message: string, page?: string}) => void} [options.report] -
90
+ * Receives each name that misses the inventory, once.
91
+ * @param {Set<string>} [options.reported] - The names already reported.
92
+ * @returns {string} The address, or `""` when the page has no banner.
93
+ */
94
+ export function bannerUrl(site, fields, { data = shippedBannerData(), report, reported } = {}) {
95
+ const banners = data.banners ?? {};
96
+ const kinds = data.kinds ?? {};
97
+ const dir = banners.dir || "images/banners";
98
+ const fallback = banners.fallback || "default.webp";
99
+ const available = banners.available ?? [];
100
+
101
+ let subtype = fields.subtype ?? "";
102
+ if (fields.type) {
103
+ const entry = (kinds.kinds ?? {})[fields.type];
104
+ if ((kinds.categoryTypes ?? []).includes(fields.type)) {
105
+ subtype = fields.category || "default";
106
+ } else if (entry && typeof entry === "object") {
107
+ subtype = entry[String(fields.subType ?? "").toLowerCase()] || fields.type;
108
+ } else {
109
+ subtype = entry || fields.type;
110
+ }
111
+ }
112
+
113
+ const raw = printed(fields.banner);
114
+ const declined = raw.toLowerCase() === "none" || raw === "false";
115
+ const resolved = fields.data ? fields.data.banner : undefined;
116
+ const fromBuild = resolved !== undefined && resolved !== null;
117
+
118
+ let banner = "";
119
+ let absolute = false;
120
+ if (fromBuild) {
121
+ banner = String(resolved);
122
+ absolute = true;
123
+ } else if (!declined) {
124
+ if (fields.banner) {
125
+ const value = String(fields.banner);
126
+ banner = `images/${value.replace(/^\//, "")}`;
127
+ if (/^(?:https?:)?\/\//.test(value)) {
128
+ banner = value;
129
+ absolute = true;
130
+ }
131
+ } else if (subtype) {
132
+ banner = `${dir}/${subtype}.webp`;
133
+ }
134
+ if (banner && !absolute && path.posix.dirname(banner) === dir) {
135
+ const name = path.posix.basename(banner, path.posix.extname(banner));
136
+ if (!available.includes(name)) {
137
+ if (!reported?.has(name)) {
138
+ reported?.add(name);
139
+ report?.({
140
+ severity: "warning",
141
+ page: fields.context || undefined,
142
+ message:
143
+ `hero-banner: no banner named "${name}" in the shared artwork set; ` +
144
+ `falling back to ${dir}/${fallback}. Draw it and add "${name}" to ` +
145
+ "data/banners.yaml, or name a hero image with `data.banner`. " +
146
+ "Write `data.banner: none` to decline a hero image.",
147
+ });
148
+ }
149
+ banner = `${dir}/${fallback}`;
150
+ }
151
+ }
152
+ }
153
+ return banner ? cdnUrl(site, banner) : "";
154
+ }