@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
@@ -15,20 +15,20 @@
15
15
  * The files that belong to a deployment's **root** rather than to the rendered
16
16
  * site.
17
17
  *
18
- * Hugo renders into `<out>/<package>/`, because the deployment carries the
18
+ * `package-build site build` renders into `<out>/<package>/`, because the deployment carries the
19
19
  * `/<package>/` prefix physically and the routing layer is a path-preserving
20
20
  * pass-through. The directory that is *uploaded* is its parent, and Cloudflare
21
21
  * Pages reads `_headers` and `_redirects` from there and nowhere else — a copy
22
- * inside the prefix is published as a text file and never applied. Hugo owns
22
+ * inside the prefix is published as a text file and never applied. The render owns
23
23
  * everything under the prefix; this owns what sits beside it.
24
24
  *
25
25
  * **One file, `_headers`.** The prefix root is the homepage — the site build
26
- * writes it as the mount's `_index.md` — so nothing redirects, and no
26
+ * renders it at the package root — so nothing redirects, and no
27
27
  * `_redirects` is written. One left beside the site by an earlier build is
28
28
  * removed rather than left to send every reader somewhere nothing publishes.
29
29
  *
30
30
  * **And one directory inside the prefix, `pagefind/`.** Every package site
31
- * ends the same way — Hugo renders into the output directory, this
31
+ * ends the same way — the render fills the output directory, this
32
32
  * post-processes it there — so the search index is built in this step and
33
33
  * every site gets one without each repository adding a dependency and a
34
34
  * script. Pagefind indexes the rendered pages and writes `<package>/pagefind/`
@@ -51,7 +51,8 @@
51
51
  import fs from "node:fs";
52
52
  import path from "node:path";
53
53
  import * as pagefind from "pagefind";
54
- import { gunzipSync } from "node:zlib";
54
+
55
+ import { feedPaths, reachablePages } from "./site-reach.mjs";
55
56
 
56
57
  import { formatDiagnostic } from "./diagnostics.mjs";
57
58
 
@@ -120,15 +121,19 @@ export function noindexHeaders() {
120
121
  * that names their version, so a year's lifetime never serves a stale file.
121
122
  *
122
123
  * @param {object} [options] - Options.
123
- * @param {string} [options.pkg] - The content package, which is the prefix.
124
+ * @param {string} [options.pkg] - The content package, whose site is served at
125
+ * `/<pkg>/` unless `base` says otherwise.
126
+ * @param {string} [options.base] - The path the site is served at, beginning
127
+ * and ending with `/`.
124
128
  * @param {boolean} [options.fonts] - Whether the site carries the fonts.
125
129
  * @returns {string} The file's contents.
126
130
  */
127
- export function headers({ pkg, fonts = false } = {}) {
131
+ export function headers({ pkg, base, fonts = false } = {}) {
128
132
  const lines = noindexHeaders();
129
- if (fonts && pkg) {
133
+ const prefix = base ?? (pkg ? `/${pkg}/` : "");
134
+ if (fonts && prefix) {
130
135
  lines.push(
131
- `/${pkg}/fonts/heroiclands/*`,
136
+ `${prefix}fonts/heroiclands/*`,
132
137
  " Cache-Control: public, max-age=31536000, immutable",
133
138
  "",
134
139
  );
@@ -145,11 +150,40 @@ export function headers({ pkg, fonts = false } = {}) {
145
150
  * binary.
146
151
  *
147
152
  * @callback SiteIndexer
148
- * @param {{site: string, output: string}} where - The rendered site, and the
149
- * directory inside it the index is written to.
153
+ * @param {{site: string, output: string, base?: string}} where - The rendered
154
+ * site, the directory inside it the index is written to, and the path the
155
+ * site is served at. With a `base`, only the pages reachable from the
156
+ * homepage are indexed.
150
157
  * @returns {Promise<{pages: number}>} How many pages were indexed.
151
158
  */
152
159
 
160
+ /**
161
+ * The addresses of the pages reachable from a rendered site's homepage.
162
+ *
163
+ * @param {string} site - The rendered site's directory, served at `base`.
164
+ * @param {string} base - The path the site is served at.
165
+ * @returns {Set<string>} Page addresses, each beginning with `base`.
166
+ */
167
+ function reachableFromDisk(site, base) {
168
+ const file = (url) => path.join(site, ...url.slice(base.length).split("/").filter(Boolean));
169
+ const read = (target) => {
170
+ try {
171
+ return fs.readFileSync(target, "utf8");
172
+ } catch {
173
+ return undefined;
174
+ }
175
+ };
176
+ return reachablePages({
177
+ base,
178
+ html: (url) => read(path.join(file(url), "index.html")),
179
+ expand: (target) => {
180
+ if (!target.endsWith(".xml")) return [];
181
+ const xml = read(file(target));
182
+ return xml === undefined ? [] : feedPaths(xml);
183
+ },
184
+ });
185
+ }
186
+
153
187
  /**
154
188
  * Build the search index of a rendered site with Pagefind.
155
189
  *
@@ -165,12 +199,17 @@ export function headers({ pkg, fonts = false } = {}) {
165
199
  * - The binary missing is reported by the package as an install failure of
166
200
  * its own; it is rethrown naming the platform package to install.
167
201
  *
202
+ * With a `base`, the index covers the pages reachable from the homepage by
203
+ * the links the rendered HTML carries, and no others: an unlinked page is
204
+ * served but not searchable. Each reachable page is handed to Pagefind as its
205
+ * own file, so a page outside the set is never read.
206
+ *
168
207
  * The output directory is emptied first, so an index of an earlier build's
169
208
  * pages never survives beside the current one.
170
209
  *
171
210
  * @type {SiteIndexer}
172
211
  */
173
- export async function indexSite({ site, output }) {
212
+ export async function indexSite({ site, output, base }) {
174
213
  if (!fs.statSync(site, { throwIfNoEntry: false })?.isDirectory()) {
175
214
  throw new Error(`${site} is not a directory, so there is nothing to index`);
176
215
  }
@@ -197,33 +236,40 @@ export async function indexSite({ site, output }) {
197
236
  }
198
237
  let pages;
199
238
  try {
200
- const added = await index.addDirectory({ path: site });
201
- if (added.errors.length) throw new Error(added.errors.join("; "));
202
- pages = added.page_count;
239
+ if (base) {
240
+ pages = 0;
241
+ for (const url of reachableFromDisk(site, base)) {
242
+ const sourcePath = path.posix.join(
243
+ ...url.slice(base.length).split("/"),
244
+ "index.html",
245
+ );
246
+ const content = fs.readFileSync(path.join(site, sourcePath), "utf8");
247
+ const added = await index.addHTMLFile({ sourcePath, content });
248
+ if (added.errors.length) throw new Error(added.errors.join("; "));
249
+ pages += 1;
250
+ }
251
+ } else {
252
+ const added = await index.addDirectory({ path: site });
253
+ if (added.errors.length) throw new Error(added.errors.join("; "));
254
+ pages = added.page_count;
255
+ }
203
256
  if (!pages) {
204
257
  throw new Error(`no page under ${site} was indexed — the site holds no HTML`);
205
258
  }
206
- const written = await index.writeFiles({ outputPath: output });
207
- if (written.errors.length) throw new Error(written.errors.join("; "));
259
+ // The files are taken from the index in memory and written here,
260
+ // because `writeFiles` can answer while the binary is still flushing
261
+ // them, which leaves an index that a reader loads half-written.
262
+ const { files, errors } = await index.getFiles();
263
+ if (errors.length) throw new Error(errors.join("; "));
264
+ if (!files.length) throw new Error("the search index holds no files");
265
+ for (const file of files) {
266
+ const target = path.join(output, file.path);
267
+ fs.mkdirSync(path.dirname(target), { recursive: true });
268
+ fs.writeFileSync(target, file.content);
269
+ }
208
270
  } finally {
209
271
  await pagefind.close();
210
272
  }
211
- // Pagefind may finish its API call while fragment writes are still visible
212
- // as incomplete gzip streams under heavy parallel load. A successful site
213
- // build must hand deployment complete fragments.
214
- const fragmentDir = path.join(output, "fragment");
215
- const deadline = Date.now() + 5000;
216
- for (;;) {
217
- try {
218
- const fragments = fs.readdirSync(fragmentDir);
219
- if (!fragments.length) throw new Error("no search fragments were written");
220
- for (const name of fragments) gunzipSync(fs.readFileSync(path.join(fragmentDir, name)));
221
- break;
222
- } catch (error) {
223
- if (Date.now() >= deadline) throw error;
224
- await new Promise((resolve) => setTimeout(resolve, 25));
225
- }
226
- }
227
273
  return { pages };
228
274
  }
229
275
 
@@ -245,7 +291,10 @@ export async function indexSite({ site, output }) {
245
291
  *
246
292
  * @param {object} options - Options.
247
293
  * @param {string} options.pkg - The content package name, which is also the
248
- * directory Hugo rendered into.
294
+ * directory the site is rendered into unless `base` says otherwise.
295
+ * @param {string} [options.base] - The path the site is served at, beginning
296
+ * and ending with `/`; the site is rendered into that path under `out`, so
297
+ * a site served at `/` is rendered at `out` itself.
249
298
  * @param {string} options.out - The directory that is deployed.
250
299
  * @param {boolean} [options.search] - Whether to build the search index;
251
300
  * `site.search` in the configuration. Default `true`.
@@ -259,18 +308,20 @@ export async function indexSite({ site, output }) {
259
308
  * under the prefix; or, located at the rendered site, when the indexer
260
309
  * fails.
261
310
  */
262
- export async function writeSiteRoot({ pkg, out, search = true, indexer = indexSite }) {
311
+ export async function writeSiteRoot({ pkg, base, out, search = true, indexer = indexSite }) {
263
312
  const root = path.resolve(out);
264
- const rendered = path.join(root, pkg);
313
+ const prefix = base ?? `/${pkg}/`;
314
+ const rendered = path.join(root, ...prefix.split("/").filter(Boolean));
265
315
  if (!fs.existsSync(path.join(rendered, "index.html"))) {
266
316
  throw new Error(
267
- `${out}/${pkg}/ holds no rendered site — build the site before its root files`,
317
+ `${path.join(out, ...prefix.split("/").filter(Boolean))}/ holds no rendered site — ` +
318
+ "build the site before its root files",
268
319
  );
269
320
  }
270
321
 
271
322
  const file = path.join(root, "_headers");
272
323
  const fonts = fs.existsSync(path.join(rendered, "fonts", "heroiclands"));
273
- fs.writeFileSync(file, headers({ pkg, fonts }));
324
+ fs.writeFileSync(file, headers({ pkg, base: prefix, fonts }));
274
325
  fs.rmSync(path.join(root, "_redirects"), { force: true });
275
326
 
276
327
  const dir = path.join(rendered, SEARCH_DIR);
@@ -280,7 +331,7 @@ export async function writeSiteRoot({ pkg, out, search = true, indexer = indexSi
280
331
  }
281
332
  let pages;
282
333
  try {
283
- ({ pages } = await indexer({ site: rendered, output: dir }));
334
+ ({ pages } = await indexer({ site: rendered, output: dir, base: prefix }));
284
335
  } catch (err) {
285
336
  const message = err instanceof Error ? err.message : String(err);
286
337
  // A directory, so the locator is the file field alone: there is no
@@ -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,274 @@
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 { siteBase } from "../content-config.mjs";
49
+ import { SEARCH_DIR, indexSite } from "./site-root.mjs";
50
+
51
+ /** The address a served page listens on for the server's reloads. */
52
+ export const RELOAD_PATH = "/__package-build/reload";
53
+
54
+ /** How long a burst of changes is gathered before it is written, in milliseconds. */
55
+ const SETTLE_MS = 120;
56
+
57
+ /** What a file is sent as, by its extension. */
58
+ const CONTENT_TYPES = Object.freeze({
59
+ ".html": "text/html; charset=utf-8",
60
+ ".css": "text/css; charset=utf-8",
61
+ ".js": "text/javascript; charset=utf-8",
62
+ ".mjs": "text/javascript; charset=utf-8",
63
+ ".json": "application/json",
64
+ ".xml": "application/xml",
65
+ ".txt": "text/plain; charset=utf-8",
66
+ ".svg": "image/svg+xml",
67
+ ".png": "image/png",
68
+ ".jpg": "image/jpeg",
69
+ ".jpeg": "image/jpeg",
70
+ ".webp": "image/webp",
71
+ ".gif": "image/gif",
72
+ ".woff2": "font/woff2",
73
+ ".woff": "font/woff",
74
+ ".wasm": "application/wasm",
75
+ });
76
+
77
+ /** The script a served page is sent with. */
78
+ const RELOAD_SCRIPT = `<script>new EventSource(${JSON.stringify(RELOAD_PATH)}).addEventListener("reload", () => location.reload());</script>`;
79
+
80
+ /** Whether a note's text holds a query its build prepares ahead of the render. */
81
+ const holdsQuery = (text) => /```\s*sql\b|\{\{[^}]*\bsql\b/.test(text);
82
+
83
+ /**
84
+ * Serve a package's site, and keep it current.
85
+ *
86
+ * @param {object} options - The inputs.
87
+ * @param {object} options.config - The resolved configuration.
88
+ * @param {string} options.out - The site's directory under the deployed
89
+ * root, which the site is written to.
90
+ * @param {string} [options.root] - The deployed root, which is served. The
91
+ * directory above `out` by default; `out` itself for a site served at `/`.
92
+ * @param {string} [options.host] - The address to listen on.
93
+ * @param {number} [options.port] - The port; `0` takes any free one.
94
+ * @param {boolean} [options.search] - Build the search index after the first
95
+ * build, where the site has one.
96
+ * @param {number} [options.year] - The year the footer states.
97
+ * @param {(event: {kind: string, built?: object, files?: string[], findings?: object[], error?: unknown}) => void} [options.report] -
98
+ * Told of each build: `built` for a whole build, `page` for one note's page,
99
+ * `theme` for the theme, `failed` with what the build found, `error` with
100
+ * what it threw.
101
+ * @returns {Promise<{url: string, close: () => Promise<void>, settled: () => Promise<void>}>}
102
+ * The address of the site's home page, how to stop serving, and a promise
103
+ * for the moment every change seen so far has been written.
104
+ */
105
+ export async function serveSite({
106
+ config,
107
+ out,
108
+ root = path.dirname(out),
109
+ host = "127.0.0.1",
110
+ port = 1313,
111
+ search = true,
112
+ year,
113
+ report = () => {},
114
+ }) {
115
+ /** @type {Set<http.ServerResponse>} */
116
+ const listeners = new Set();
117
+ let state = null;
118
+
119
+ const reload = () => {
120
+ for (const res of listeners) res.write("event: reload\ndata: {}\n\n");
121
+ };
122
+
123
+ const buildAll = async () => {
124
+ const result = await buildPackageSite({ config, out, year });
125
+ if (!result.written || siteBuildFailed(result.built))
126
+ report({ kind: "failed", built: result.built });
127
+ if (!result.written) return false;
128
+ state = result;
129
+ report({ kind: "built", built: result.built, findings: result.written.findings });
130
+ return true;
131
+ };
132
+
133
+ const redrawNotes = (files) => {
134
+ if (!state) return false;
135
+ const changed = new Set();
136
+ for (const file of files) {
137
+ const text = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
138
+ if (!file.endsWith(".md") || !text || holdsQuery(text)) return false;
139
+ const entry = state.built.site.renderNote(file);
140
+ if (!entry) return false;
141
+ if (entry.findings.length) {
142
+ report({ kind: "failed", findings: entry.findings });
143
+ return true;
144
+ }
145
+ const page = state.pageOf(entry);
146
+ state.pages = state.pages.map((old) => (old.url === page.url ? page : old));
147
+ changed.add(page.url);
148
+ }
149
+ const written = writeSite({ site: state.site, pages: state.pages, out, only: changed });
150
+ report({ kind: "page", files: written.files, findings: written.findings });
151
+ return true;
152
+ };
153
+
154
+ const redrawTheme = () => {
155
+ if (!state) return false;
156
+ writeThemeFiles(out);
157
+ const written = writeSite({
158
+ site: state.site,
159
+ pages: state.pages,
160
+ out,
161
+ only: new Set(state.pages.map((page) => page.url)),
162
+ });
163
+ report({ kind: "theme", files: written.files, findings: written.findings });
164
+ return true;
165
+ };
166
+
167
+ const indexForSearch = async () => {
168
+ if (!search || config.site.search === false || !state) return;
169
+ await indexSite({ site: out, output: path.join(out, SEARCH_DIR), base: siteBase(config) });
170
+ };
171
+
172
+ await buildAll();
173
+ await indexForSearch();
174
+
175
+ /** @type {{content: Set<string>, theme: boolean}} */
176
+ let pending = { content: new Set(), theme: false };
177
+ let timer = null;
178
+ let work = Promise.resolve();
179
+ const flush = () => {
180
+ timer = null;
181
+ const batch = pending;
182
+ pending = { content: new Set(), theme: false };
183
+ work = work.then(async () => {
184
+ try {
185
+ let done = true;
186
+ if (batch.content.size && !redrawNotes([...batch.content])) {
187
+ done = await buildAll();
188
+ if (done) await indexForSearch();
189
+ } else if (batch.theme) done = redrawTheme();
190
+ if (done) reload();
191
+ } catch (error) {
192
+ report({ kind: "error", error });
193
+ }
194
+ });
195
+ };
196
+ const queue = (kind, file) => {
197
+ if (kind === "theme") pending.theme = true;
198
+ else pending.content.add(file);
199
+ if (!timer) timer = setTimeout(flush, SETTLE_MS);
200
+ };
201
+
202
+ const watchers = [
203
+ fs.watch(config.paths.content, { recursive: true }, (_event, name) => {
204
+ if (name) queue("content", path.join(config.paths.content, String(name)));
205
+ }),
206
+ ...[SITE_THEME_DIR, SITE_STATIC_DIR]
207
+ .filter((dir) => fs.existsSync(dir))
208
+ .map((dir) => fs.watch(dir, { recursive: true }, () => queue("theme"))),
209
+ ];
210
+
211
+ const server = http.createServer((req, res) => {
212
+ const address = decodeURIComponent(
213
+ new URL(req.url ?? "/", "http://serve.invalid").pathname,
214
+ );
215
+ if (address === RELOAD_PATH) {
216
+ res.writeHead(200, {
217
+ "content-type": "text/event-stream",
218
+ "cache-control": "no-cache",
219
+ connection: "keep-alive",
220
+ });
221
+ res.write(": connected\n\n");
222
+ listeners.add(res);
223
+ req.on("close", () => listeners.delete(res));
224
+ return;
225
+ }
226
+ let file = path.join(root, address);
227
+ if (!file.startsWith(root)) {
228
+ res.writeHead(403).end();
229
+ return;
230
+ }
231
+ if (fs.statSync(file, { throwIfNoEntry: false })?.isDirectory())
232
+ file = path.join(file, "index.html");
233
+ let status = 200;
234
+ if (!fs.existsSync(file)) {
235
+ status = 404;
236
+ file = path.join(out, "404.html");
237
+ if (!fs.existsSync(file)) {
238
+ res.writeHead(404).end();
239
+ return;
240
+ }
241
+ }
242
+ const type = CONTENT_TYPES[path.extname(file).toLowerCase()] ?? "application/octet-stream";
243
+ let body = fs.readFileSync(file);
244
+ if (type.startsWith("text/html")) {
245
+ const html = body.toString("utf8");
246
+ const at = html.lastIndexOf("</body>");
247
+ body = Buffer.from(
248
+ at < 0 ? html + RELOAD_SCRIPT : html.slice(0, at) + RELOAD_SCRIPT + html.slice(at),
249
+ );
250
+ }
251
+ res.writeHead(status, { "content-type": type, "cache-control": "no-store" });
252
+ res.end(body);
253
+ });
254
+ await new Promise((resolve) => server.listen(port, host, resolve));
255
+ const listening = /** @type {import("node:net").AddressInfo} */ (server.address());
256
+ const base = new URL(config.homepage).pathname;
257
+
258
+ return {
259
+ url: `http://${host}:${listening.port}${base}`,
260
+ settled: async () => {
261
+ while (timer || pending.content.size || pending.theme) {
262
+ await new Promise((resolve) => setTimeout(resolve, SETTLE_MS));
263
+ }
264
+ await work;
265
+ },
266
+ close: async () => {
267
+ if (timer) clearTimeout(timer);
268
+ for (const watcher of watchers) watcher.close();
269
+ for (const res of listeners) res.end();
270
+ await work;
271
+ await new Promise((resolve) => server.close(() => resolve()));
272
+ },
273
+ };
274
+ }