@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
@@ -12,76 +12,44 @@
12
12
  */
13
13
 
14
14
  /**
15
- * The Hugo configuration a site build generates.
16
- *
17
- * `package-build site` writes the whole Hugo source tree under `build/hugo/`
18
- * — `hugo.toml`, the content mount, Hugo's own cache — as a sibling of the
19
- * deployment root `build/site/`, so nothing Hugo reads lands in what is
20
- * published. The consumer's script runs Hugo over it; the toolchain never
21
- * does.
22
- *
23
- * Every value in the generated file has one source. The package's identity
24
- * (`baseURL`, `title`, `params.description`, `params.author`) is read from
25
- * `package.json` and `package-build.config.yaml`, where it is already stated.
26
- * The organisation's constants — the brand links, the locale — are stated once
27
- * here, because they are the same on every site and a copy per repository is
28
- * a copy that drifts. The navigation is neither: it is the list of packages
29
- * the organisation publishes, which lives in one place, heroiclands-site's
30
- * roster, and reaches every site as the `nav.json` that site publishes.
31
- * package-build carries no package list; `deps fetch` caches the navigation
32
- * and the site build writes `[menu.main]` from the cache.
15
+ * What a site build reads from the configuration: the site's address, title
16
+ * and parameters, and the organisation's navigation.
17
+ *
18
+ * Every value has one source. The package's identity (`baseURL`, `title`,
19
+ * `params.description`, `params.author`) is read from `package.json` and
20
+ * `package-build.config.yaml`, where it is already stated. The organisation's
21
+ * constants — the brand links — are stated once here, because they are the
22
+ * same on every site and a copy per repository is a copy that drifts. The
23
+ * navigation is neither: it is the list of packages the organisation
24
+ * publishes, which lives in one place, heroiclands-site's roster, and reaches
25
+ * every site as the `nav.json` that site publishes. package-build carries no
26
+ * package list; `deps fetch` caches the navigation and the site build reads
27
+ * the cache.
33
28
  *
34
29
  * What a repository still says for itself is the residue that is genuinely
35
- * its own — the wording of its "page not found" page — and, through
36
- * `site.hugo`, the one key nobody anticipated.
37
- * {@link module:content-config.DERIVED_HUGO_KEYS} refuses everything the
38
- * generator writes from being authored there too.
30
+ * its own, such as the wording of its "page not found" page.
39
31
  *
40
32
  * @module
41
33
  */
42
34
 
43
35
  import fs from "node:fs";
44
36
  import path from "node:path";
45
- import { fileURLToPath } from "node:url";
46
- import { stringify as stringifyToml } from "smol-toml";
47
37
 
48
38
  import { checkHomepage, fail } from "../config.mjs";
49
- import { slugify } from "./content-slug.mjs";
50
39
  import { siteFontsParam } from "./script-font-delivery.mjs";
51
40
 
52
- /** The Hugo source directory, relative to the repository root. */
53
- export const HUGO_SOURCE = "build/hugo";
54
-
55
- /** The content mount `package-build site` writes, relative to the repository root. */
56
- export const HUGO_CONTENT = `${HUGO_SOURCE}/content`;
57
-
58
41
  /**
59
42
  * The directory that is deployed, relative to the repository root.
60
43
  *
61
- * Hugo renders into `<DEPLOY_ROOT>/<contentPackage>/`; `package-build
62
- * site-root` writes `_headers` beside it and the search index inside it.
44
+ * `package-build site build` renders into `<DEPLOY_ROOT>/<contentPackage>/`
45
+ * and writes `_headers` beside it and the search index inside it.
63
46
  */
64
47
  export const DEPLOY_ROOT = "build/site";
65
48
 
66
- /**
67
- * This package's own root, which is the directory Hugo reads themes from.
68
- *
69
- * The theme ships inside this package, so the directory holding it is found
70
- * from this module's own location rather than searched for.
71
- */
72
- const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
73
-
74
- /** The theme's directory name under `themesDir`. */
75
- export const THEME = "hugo-theme";
76
-
77
- /** The locale every site renders in. */
78
- export const LOCALE = "en-us";
79
-
80
49
  /**
81
50
  * The brand chrome's own links, the same on every site.
82
51
  *
83
- * `logo` is resolved through the theme's `cdn-url.html`, so it is a path on
84
- * the asset host rather than an address.
52
+ * `logo` is a path on the asset host rather than an address.
85
53
  */
86
54
  export const BRAND = Object.freeze({
87
55
  logo: "images/brand/sohl-icon-white.webp",
@@ -89,40 +57,6 @@ export const BRAND = Object.freeze({
89
57
  discordURL: "https://discord.gg/EwMfkNd3az",
90
58
  });
91
59
 
92
- /**
93
- * The kinds no site renders.
94
- *
95
- * A site is its homepage and its pages: `home` and `page` are the only kinds,
96
- * for every package and whatever its notes carry. A `section` is a listing
97
- * Hugo would generate from a content directory; `taxonomy` and `term` are
98
- * listings it would generate from `tags:`; `RSS` is a feed. Every structure
99
- * between the homepage and the pages is authored instead, as a `doc` note
100
- * carrying a content table — a tag is a field such a table filters on, not a
101
- * page of its own.
102
- */
103
- export const DISABLE_KINDS = Object.freeze(["section", "taxonomy", "term", "RSS"]);
104
-
105
- /**
106
- * The markup settings the toolchain's own output requires.
107
- *
108
- * Pages are written with raw HTML in them — a `<figure>` for every image, a
109
- * `<span>` marking an unresolved link — and Goldmark drops raw HTML unless
110
- * told otherwise. A theme cannot supply this: Hugo does not merge a theme's
111
- * `markup` block.
112
- *
113
- * `extensions.taskList` is off because the format has no checkbox: Goldmark
114
- * enables it by default, which is the only reason a list item opened with
115
- * `[ ]` renders a `<input type="checkbox">` here and the same two characters as
116
- * plain text in the pack and the book. Off, a list item's text begins right
117
- * after its marker on every surface.
118
- */
119
- export const MARKUP = Object.freeze({
120
- goldmark: Object.freeze({
121
- renderer: Object.freeze({ unsafe: true }),
122
- extensions: Object.freeze({ taskList: false }),
123
- }),
124
- });
125
-
126
60
  /** Where the navigation is published. */
127
61
  export const NAVIGATION_URL = "https://www.heroiclands.org/nav.json";
128
62
 
@@ -144,17 +78,6 @@ const STAMP = ".complete";
144
78
  * @property {NavigationEntry[]} [children] - A dropdown's entries.
145
79
  */
146
80
 
147
- /**
148
- * A Hugo menu entry, as `[[menu.main]]` states one.
149
- *
150
- * @typedef {object} MenuEntry
151
- * @property {string} name
152
- * @property {string} url
153
- * @property {number} weight
154
- * @property {string} [identifier] - Set on an entry that has children.
155
- * @property {string} [parent] - Set on a child, naming its parent's identifier.
156
- */
157
-
158
81
  /**
159
82
  * @param {unknown} value - Anything.
160
83
  * @returns {value is Record<string, unknown>} Whether it is a plain mapping.
@@ -180,7 +103,7 @@ function requireNonEmptyString(value, where) {
180
103
  *
181
104
  * `[{name, url, children?: [{name, url}]}]`, every `url` absolute. Checked
182
105
  * on fetch and again on read: a file that is not a navigation would otherwise
183
- * reach the generated menu as `undefined` labels and links.
106
+ * reach the menu as `undefined` labels and links.
184
107
  *
185
108
  * @param {unknown} value - The parsed document.
186
109
  * @param {string} where - What is being checked, for the error.
@@ -214,35 +137,6 @@ export function checkNavigation(value, where = "the navigation") {
214
137
  return value.map((entry, i) => check(entry, `${where}[${i}]`, false));
215
138
  }
216
139
 
217
- /**
218
- * The `[[menu.main]]` entries a navigation renders as.
219
- *
220
- * Entry for entry, in order; a dropdown is an entry with an `identifier` and
221
- * its children are entries naming it as `parent`, which is how the theme's
222
- * header partial draws one. Weights count from one within each level, so the
223
- * order is the navigation's and not Hugo's alphabetical fallback.
224
- *
225
- * @param {readonly NavigationEntry[]} navigation - The navigation.
226
- * @returns {MenuEntry[]} The menu entries.
227
- */
228
- export function menuEntries(navigation) {
229
- /** @type {MenuEntry[]} */
230
- const out = [];
231
- navigation.forEach((entry, index) => {
232
- const children = entry.children ?? [];
233
- if (!children.length) {
234
- out.push({ name: entry.name, url: entry.url, weight: index + 1 });
235
- return;
236
- }
237
- const identifier = slugify(entry.name);
238
- out.push({ name: entry.name, url: entry.url, weight: index + 1, identifier });
239
- children.forEach((child, i) => {
240
- out.push({ name: child.name, url: child.url, weight: i + 1, parent: identifier });
241
- });
242
- });
243
- return out;
244
- }
245
-
246
140
  /**
247
141
  * Where the fetched navigation sits.
248
142
  *
@@ -314,66 +208,23 @@ export async function fetchNavigation(
314
208
  }
315
209
 
316
210
  /**
317
- * The `themesDir` for a repository, as the path from `build/hugo/` to the
318
- * directory holding the theme.
319
- *
320
- * The theme ships inside this package at `hugo-theme/`, so the directory Hugo
321
- * reads themes from is {@link PACKAGE_ROOT}. `theme` stays {@link THEME}, so
322
- * Hugo opens `<themesDir>/hugo-theme/layouts`.
323
- *
324
- * Written as a path rather than assumed: `build/hugo/` and the installed
325
- * package sit at whatever distance the install put them, and a worktree
326
- * resolving its parent's install says so in the generated file.
327
- *
328
- * @param {string} rootDir - The repository root.
329
- * @returns {string} The relative path, POSIX-separated.
330
- */
331
- export function resolveThemesDir(rootDir) {
332
- const from = path.resolve(rootDir, HUGO_SOURCE);
333
- return path.relative(from, PACKAGE_ROOT).split(path.sep).join("/");
334
- }
335
-
336
- /**
337
- * Deep-merge `overrides` over `base`, arrays replaced whole.
338
- *
339
- * @param {Record<string, unknown>} base - The generated configuration.
340
- * @param {Record<string, unknown>} overrides - What `site.hugo` declares.
341
- * @returns {Record<string, unknown>} A new object.
342
- */
343
- function deepMerge(base, overrides) {
344
- /** @type {Record<string, unknown>} */
345
- const out = { ...base };
346
- for (const [key, value] of Object.entries(overrides)) {
347
- const current = out[key];
348
- out[key] =
349
- isPlainObject(current) && isPlainObject(value) ?
350
- deepMerge(current, value)
351
- : structuredClone(value);
352
- }
353
- return out;
354
- }
355
-
356
- /**
357
- * The Hugo configuration, as an object.
358
- *
359
- * Pure: every input is handed in, so a test can describe the generated shape
360
- * without a repository on disk. {@link generateHugoConfig} is the same function
361
- * with the reading put back.
211
+ * What a site is, read from the configuration: its address, its title and the
212
+ * parameters its layouts read.
362
213
  *
363
214
  * `checkHomepage` runs first, before any value is composed — a missing or
364
215
  * mismatched `package.json` `homepage` is a finding on every site build.
365
216
  *
366
217
  * @param {object} options - The sources.
367
218
  * @param {object} options.config - The resolved build configuration.
368
- * @param {readonly NavigationEntry[]} options.navigation - The navigation.
369
- * @param {string} options.themesDir - From {@link resolveThemesDir}.
370
- * @returns {Record<string, any>} The configuration Hugo reads.
219
+ * @returns {{baseURL: string, title: string, params: Record<string, unknown>}}
220
+ * The site. `params` holds `description`, `author`, `cdnBaseURL`, `brand`,
221
+ * `notfound` and `scriptFonts`, each where the configuration states it.
371
222
  * @throws {TypeError} When `homepage` fails `checkHomepage`, or the
372
223
  * configuration declares no `site.title` or `packageBuild.manifest.title`, no
373
224
  * `site.description`, or no `site.assets`.
374
225
  */
375
- export function hugoConfig({ config, navigation, themesDir }) {
376
- checkHomepage(config.homepage, config.contentPackage);
226
+ export function siteSettings({ config }) {
227
+ checkHomepage(config.homepage, config.contentPackage, config.site.base);
377
228
 
378
229
  const title = config.site.title || config.packageBuild?.manifest?.title;
379
230
  if (typeof title !== "string" || !title.trim()) {
@@ -407,68 +258,5 @@ export function hugoConfig({ config, navigation, themesDir }) {
407
258
  // it does not ship.
408
259
  const scriptFonts = siteFontsParam(config);
409
260
  if (scriptFonts) params.scriptFonts = scriptFonts;
410
-
411
- const generated = {
412
- baseURL: config.homepage,
413
- title,
414
- locale: LOCALE,
415
- publishDir: path.posix.relative(HUGO_SOURCE, `${DEPLOY_ROOT}/${config.contentPackage}`),
416
- themesDir,
417
- theme: THEME,
418
- contentDir: path.posix.relative(HUGO_SOURCE, HUGO_CONTENT),
419
- disableKinds: [...DISABLE_KINDS],
420
- params,
421
- markup: structuredClone(MARKUP),
422
- menu: { main: menuEntries(navigation) },
423
- };
424
- return deepMerge(generated, config.site.hugo);
425
- }
426
-
427
- /**
428
- * The configuration as the TOML Hugo reads.
429
- *
430
- * @param {Record<string, unknown>} generated - From {@link hugoConfig}.
431
- * @returns {string} The file's contents.
432
- */
433
- export function hugoToml(generated) {
434
- return (
435
- "# Generated by `package-build site` from package.json and " +
436
- "package-build.config.yaml.\n# Every value here has a source there; edit " +
437
- "the source, not this file.\n\n" +
438
- `${stringifyToml(generated)}\n`
439
- );
440
- }
441
-
442
- /**
443
- * The Hugo configuration, every source read from the repository.
444
- *
445
- * Reads the cached navigation and the installed theme's location, and
446
- * composes them with {@link hugoConfig}. Nothing is written, so a caller can
447
- * run this before touching the output tree and fail with it intact.
448
- *
449
- * @param {object} config - The resolved build configuration.
450
- * @returns {Record<string, any>} The configuration Hugo reads.
451
- * @throws {Error} When any source is missing or wrong.
452
- */
453
- export function generateHugoConfig(config) {
454
- return hugoConfig({
455
- config,
456
- navigation: readCachedNavigation(config),
457
- themesDir: resolveThemesDir(config.rootDir),
458
- });
459
- }
460
-
461
- /**
462
- * Write `build/hugo/hugo.toml`.
463
- *
464
- * @param {object} config - The resolved build configuration.
465
- * @param {Record<string, unknown>} [generated] - The configuration to write,
466
- * when the caller already generated it. Generated here otherwise.
467
- * @returns {{file: string}} The file written.
468
- */
469
- export function writeHugoConfig(config, generated = generateHugoConfig(config)) {
470
- const file = path.join(config.rootDir, HUGO_SOURCE, "hugo.toml");
471
- fs.mkdirSync(path.dirname(file), { recursive: true });
472
- fs.writeFileSync(file, hugoToml(generated));
473
- return { file };
261
+ return { baseURL: config.homepage, title, params };
474
262
  }
@@ -0,0 +1,175 @@
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 files a site writes beside its pages**, for search engines and feed
16
+ * readers:
17
+ *
18
+ * | File | Holds |
19
+ * | ---- | ----- |
20
+ * | `sitemap.xml` | every page of the site, by its full address ({@link sitemapXml}) |
21
+ * | a sitemap index | every site's sitemap ({@link sitemapIndexXml}) |
22
+ * | `robots.txt` | where every sitemap is ({@link robotsTxt}) |
23
+ * | an Atom feed | a section's posts, newest first ({@link atomFeed}) |
24
+ *
25
+ * Each is a pure function of what it is given, and states no time of its own:
26
+ * a date is a page's date, so a build of the same content writes the same
27
+ * files.
28
+ *
29
+ * @module
30
+ */
31
+
32
+ /** @param {unknown} value */
33
+ const text = (value) => (value === undefined || value === null ? "" : String(value));
34
+
35
+ /**
36
+ * Text escaped for XML content and attribute values.
37
+ *
38
+ * @param {unknown} value - The text.
39
+ * @returns {string} The escaped text.
40
+ */
41
+ export function escapeXml(value) {
42
+ return text(value)
43
+ .replace(/&/g, "&amp;")
44
+ .replace(/</g, "&lt;")
45
+ .replace(/>/g, "&gt;")
46
+ .replace(/"/g, "&quot;")
47
+ .replace(/'/g, "&apos;");
48
+ }
49
+
50
+ /**
51
+ * A date as an RFC 3339 timestamp, or `""` for a value that is no date.
52
+ *
53
+ * @param {unknown} value - A date, or a string a `Date` reads.
54
+ * @returns {string} The timestamp, in UTC.
55
+ */
56
+ export function timestamp(value) {
57
+ if (value === undefined || value === null || value === "") return "";
58
+ const date = value instanceof Date ? value : new Date(String(value));
59
+ return Number.isNaN(date.getTime()) ? "" : date.toISOString().replace(/\.000Z$/, "Z");
60
+ }
61
+
62
+ /**
63
+ * A site's sitemap.
64
+ *
65
+ * @param {Array<{loc: string, lastmod?: unknown}>} entries - Each page's full
66
+ * address, and the date it states where it states one.
67
+ * @returns {string} The document.
68
+ */
69
+ export function sitemapXml(entries) {
70
+ const urls = entries.map((entry) => {
71
+ const lastmod = timestamp(entry.lastmod);
72
+ return [
73
+ " <url>",
74
+ ` <loc>${escapeXml(entry.loc)}</loc>`,
75
+ ...(lastmod ? [` <lastmod>${lastmod}</lastmod>`] : []),
76
+ " </url>",
77
+ ].join("\n");
78
+ });
79
+ return [
80
+ '<?xml version="1.0" encoding="utf-8"?>',
81
+ '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
82
+ ...urls,
83
+ "</urlset>",
84
+ "",
85
+ ].join("\n");
86
+ }
87
+
88
+ /**
89
+ * A sitemap index naming other sitemaps.
90
+ *
91
+ * @param {readonly string[]} sitemaps - Each sitemap's full address.
92
+ * @returns {string} The document.
93
+ */
94
+ export function sitemapIndexXml(sitemaps) {
95
+ return [
96
+ '<?xml version="1.0" encoding="utf-8"?>',
97
+ '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
98
+ ...sitemaps.map((loc) => ` <sitemap>\n <loc>${escapeXml(loc)}</loc>\n </sitemap>`),
99
+ "</sitemapindex>",
100
+ "",
101
+ ].join("\n");
102
+ }
103
+
104
+ /**
105
+ * The `robots.txt` of a site's root: every agent may read every page, and
106
+ * every sitemap is named.
107
+ *
108
+ * @param {readonly string[]} sitemaps - Each sitemap's full address, the
109
+ * sitemap index first.
110
+ * @returns {string} The file.
111
+ */
112
+ export function robotsTxt(sitemaps) {
113
+ return ["User-agent: *", "Allow: /", "", ...sitemaps.map((loc) => `Sitemap: ${loc}`), ""].join(
114
+ "\n",
115
+ );
116
+ }
117
+
118
+ /**
119
+ * An Atom feed.
120
+ *
121
+ * The feed is dated by its newest entry, and an entry by its own date, so the
122
+ * feed changes only when a post does.
123
+ *
124
+ * @param {object} feed - The feed.
125
+ * @param {string} feed.title - The feed's title.
126
+ * @param {string} feed.id - The full address of the page the feed follows,
127
+ * which is also its id.
128
+ * @param {string} feed.self - The feed's own full address.
129
+ * @param {string} [feed.author] - Who writes the posts, where an entry names
130
+ * no author of its own.
131
+ * @param {Array<{title: string, url: string, date: unknown, author?: string,
132
+ * summary?: string, content?: string}>} feed.entries - The posts, each with
133
+ * its full address and its HTML `content`.
134
+ * @returns {string} The document.
135
+ */
136
+ export function atomFeed({ title, id, self, author, entries }) {
137
+ const dated = entries
138
+ .map((entry) => ({ ...entry, updated: timestamp(entry.date) }))
139
+ .filter((entry) => entry.updated)
140
+ .sort((a, b) =>
141
+ a.updated < b.updated ? 1
142
+ : a.updated > b.updated ? -1
143
+ : a.url < b.url ? -1
144
+ : 1,
145
+ );
146
+ const name = (who) => `<author><name>${escapeXml(who)}</name></author>`;
147
+ const lines = [
148
+ '<?xml version="1.0" encoding="utf-8"?>',
149
+ '<feed xmlns="http://www.w3.org/2005/Atom">',
150
+ ` <title>${escapeXml(title)}</title>`,
151
+ ` <id>${escapeXml(id)}</id>`,
152
+ ` <link rel="self" type="application/atom+xml" href="${escapeXml(self)}"/>`,
153
+ ` <link rel="alternate" type="text/html" href="${escapeXml(id)}"/>`,
154
+ ...(dated.length ? [` <updated>${dated[0].updated}</updated>`] : []),
155
+ ...(author ? [` ${name(author)}`] : []),
156
+ ];
157
+ for (const entry of dated) {
158
+ lines.push(
159
+ " <entry>",
160
+ ` <title>${escapeXml(entry.title)}</title>`,
161
+ ` <id>${escapeXml(entry.url)}</id>`,
162
+ ` <link rel="alternate" type="text/html" href="${escapeXml(entry.url)}"/>`,
163
+ ` <published>${entry.updated}</published>`,
164
+ ` <updated>${entry.updated}</updated>`,
165
+ ...(entry.author ? [` ${name(entry.author)}`] : []),
166
+ ...(entry.summary ? [` <summary>${escapeXml(entry.summary)}</summary>`] : []),
167
+ ...(entry.content ?
168
+ [` <content type="html">${escapeXml(entry.content)}</content>`]
169
+ : []),
170
+ " </entry>",
171
+ );
172
+ }
173
+ lines.push("</feed>", "");
174
+ return lines.join("\n");
175
+ }
@@ -82,12 +82,15 @@ import { isGmNote, isDraftNote } from "./note-vocabulary.mjs";
82
82
  * through unindexed.
83
83
  * @property {object} fm The note's frontmatter.
84
84
  * @property {string} name Display name.
85
- * @property {string} slug URL segment.
86
- * @property {string} base Source file's basename, e.g. `Climbing.md`.
85
+ * @property {string} [slug] URL segment.
86
+ * @property {string} [base] Source file's basename, e.g. `Climbing.md`.
87
87
  * @property {string} url The page's published address.
88
88
  * @property {string} [body] The note's markdown body, for its anchors. A
89
89
  * content entry always carries one; absent, the
90
90
  * page is indexed as declaring none.
91
+ * @property {object[]} [anchors] The anchors the body declares, when the
92
+ * caller holds them already — a content-index
93
+ * record's — in place of reading `body`.
91
94
  */
92
95
 
93
96
  /**
@@ -163,7 +166,9 @@ function mergeForeign(index, foreignIndex) {
163
166
  * Build the address index a site's wikilink resolver reads.
164
167
  *
165
168
  * @param {readonly SiteEntry[]} entries - Every page the site will publish,
166
- * each already knowing its own `url`.
169
+ * each already knowing its own `url`. An entry may carry the `anchors` its
170
+ * body declares, from a content-index record, in place of the body they are
171
+ * read from.
167
172
  * @param {object} [options] - Cross-package inputs.
168
173
  * @param {string} [options.package] - The builder's own content package.
169
174
  * @param {string} [options.currentDate="720.1"] - Setting-wide neutral date for recurring events.
@@ -254,7 +259,7 @@ export function buildSiteIndex(
254
259
  // `subType` is: a being states a rank as a number, and what that
255
260
  // number is called is declared on the body.
256
261
  const standings = standingsDigest(e.fm);
257
- const noteAnchors = collectAnchors(e.body ?? "", 1, undefined, syntax);
262
+ const noteAnchors = e.anchors ?? collectAnchors(e.body ?? "", 1, undefined, syntax);
258
263
  const value = {
259
264
  url: e.url,
260
265
  name: e.name,