@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,593 @@
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, written as HTML.** {@link writeSite} draws every page of a site
16
+ * through the layouts of {@link module:engine/site-theme} and writes the files
17
+ * that are deployed: one `index.html` per page, `404.html`, `sitemap.xml`, the
18
+ * stylesheets, scripts and fonts the layouts link, and, where the site asks
19
+ * for them, its listings, its feed, a sitemap index and `robots.txt`.
20
+ * {@link packageSite} turns a package's site build into the pages
21
+ * `writeSite` is given.
22
+ *
23
+ * ## The pages of a site
24
+ *
25
+ * A site is given its home page, its regular pages and any section pages its
26
+ * author wrote. The rest is derived:
27
+ *
28
+ * | Page | Where | Lists |
29
+ * | ---- | ----- | ----- |
30
+ * | a section | `<section>/` | the section's pages |
31
+ * | the tags | `tags/` | every tag, with how many pages carry it |
32
+ * | a tag | `tags/<tag>/` | the pages that carry it |
33
+ *
34
+ * A section is the `section` a regular page names; a page that names none is
35
+ * in no section. A section page an author wrote keeps its title and its body,
36
+ * and lists the section's pages the body does not already reach. The tag pages
37
+ * are written only with `tagPages`, and a page's tag links to its page only
38
+ * then.
39
+ *
40
+ * The pages of a section are in the order a reader walks them, which their
41
+ * previous and next links follow: by `weight`, lightest first and unweighted
42
+ * last, then newest first by `date`, then by title in English order, then by
43
+ * address.
44
+ *
45
+ * ## The files beside the pages
46
+ *
47
+ * | Site states | Written |
48
+ * | ----------- | ------- |
49
+ * | always | `404.html`, `sitemap.xml`, `css/code.css` and the theme's own files |
50
+ * | `feed: {section, title}` | `<section>/atom.xml`, the section's pages, newest first |
51
+ * | `sitemaps: [...]` | `sitemap-index.xml`, naming this site's sitemap and each one listed, and `robots.txt` naming them all |
52
+ *
53
+ * A sitemap index and `robots.txt` belong at the root of an origin, so a site
54
+ * served under a path refuses `sitemaps`.
55
+ *
56
+ * Every page is minified as it is written; see
57
+ * {@link module:engine/html-minify}.
58
+ *
59
+ * @module
60
+ */
61
+
62
+ import fs from "node:fs";
63
+ import path from "node:path";
64
+ import { fileURLToPath } from "node:url";
65
+
66
+ import { createRenderer } from "./content-render.mjs";
67
+ import { resolvePathname } from "./pathnames.mjs";
68
+ import { minifyHtml } from "./html-minify.mjs";
69
+ import { mapWorld } from "./map-places.mjs";
70
+ import {
71
+ checkScriptFonts,
72
+ copyScriptFonts,
73
+ scriptFontFiles,
74
+ siteFontsDestination,
75
+ } from "./script-font-delivery.mjs";
76
+ import { buildSite, gatesFailed } from "./site-build.mjs";
77
+ import { readCachedNavigation, siteSettings } from "./site-config.mjs";
78
+ import { atomFeed, robotsTxt, sitemapIndexXml, sitemapXml } from "./site-files.mjs";
79
+ import { prepareTreeSqlTables } from "./sql-tables.mjs";
80
+ import { placeGeography, searchMetadata } from "./site-search.mjs";
81
+ import {
82
+ createSiteTheme,
83
+ humanize,
84
+ plainText,
85
+ siteThemeAssets,
86
+ subjectTags,
87
+ } from "./site-theme.mjs";
88
+ import { basePath, relUrl, tagUrl } from "./site-theme-urls.mjs";
89
+ import { slugify } from "./content-slug.mjs";
90
+ import { isPost } from "./post-fields.mjs";
91
+ import { siteBase } from "../content-config.mjs";
92
+
93
+ /** This package's root. */
94
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
95
+
96
+ /**
97
+ * The files the layouts link that the theme ships as they are: the site and
98
+ * icon stylesheets, the spoiler script, the icon font and the theme's images.
99
+ */
100
+ export const SITE_STATIC_DIR = path.join(PACKAGE_ROOT, "site-theme", "static");
101
+
102
+ /** The words of a post a feed entry summarises it with. */
103
+ const SUMMARY_WORDS = 70;
104
+
105
+ /** @param {unknown} value */
106
+ const text = (value) => (value === undefined || value === null ? "" : String(value));
107
+
108
+ /** Titles in the order an English reader sorts them. */
109
+ const collator = new Intl.Collator("en", { sensitivity: "variant" });
110
+
111
+ /**
112
+ * A title as it is sorted: an apostrophe typed as `'` sorts before one typed
113
+ * as `’`, rather than level with it. It is read as U+205E, the punctuation
114
+ * mark the collation places just before both.
115
+ */
116
+ const sortKey = (title) => text(title).replaceAll("'", "\u205e");
117
+
118
+ /**
119
+ * Pages in the order a reader walks them: by `weight`, lightest first and an
120
+ * unweighted page last; then newest first by `date`; then by title; then by
121
+ * address. A page's own `weight` and `date` are read before its `params`'.
122
+ *
123
+ * @template {{title?: string, url: string, date?: unknown, weight?: number, params?: object}} T
124
+ * @param {readonly T[]} pages - The pages.
125
+ * @returns {T[]} A sorted copy.
126
+ */
127
+ export function siteOrder(pages) {
128
+ const weight = (page) => {
129
+ const value = Number(page.weight ?? page.params?.weight);
130
+ return value > 0 ? value : Infinity;
131
+ };
132
+ const time = (page) => {
133
+ const value = page.date ?? page.params?.date;
134
+ const date = value ? new Date(String(value)).getTime() : 0;
135
+ return Number.isNaN(date) ? 0 : date;
136
+ };
137
+ return [...pages].sort((a, b) => {
138
+ if (weight(a) !== weight(b)) return weight(a) < weight(b) ? -1 : 1;
139
+ if (time(a) !== time(b)) return time(b) - time(a);
140
+ const titles = collator.compare(sortKey(a.title), sortKey(b.title));
141
+ if (titles) return titles;
142
+ return (
143
+ a.url < b.url ? -1
144
+ : a.url > b.url ? 1
145
+ : 0
146
+ );
147
+ });
148
+ }
149
+
150
+ /** A page as a sibling names it. */
151
+ const siblingOf = (page) => ({
152
+ url: page.url,
153
+ title: page.title,
154
+ type: page.params?.type,
155
+ subType: page.params?.subType,
156
+ category: page.params?.category,
157
+ });
158
+
159
+ /** A page as a listing names it. */
160
+ const memberOf = (page) => ({
161
+ url: page.url,
162
+ title: page.title,
163
+ description: page.params?.description,
164
+ content: page.content,
165
+ });
166
+
167
+ /**
168
+ * Every page of a site: the pages it is given, with their siblings, and the
169
+ * listings derived from them.
170
+ *
171
+ * @param {object} site - The site, as {@link writeSite} takes it.
172
+ * @param {readonly object[]} pages - The home page, the regular pages and any
173
+ * authored section pages.
174
+ * @returns {object[]} Every page, home first, then the regular pages, the
175
+ * sections, and the tag pages.
176
+ */
177
+ export function siteTree(site, pages) {
178
+ const home = pages.filter((page) => page.kind === "home");
179
+ const regular = pages.filter((page) => page.kind === "page");
180
+ const authored = new Map(
181
+ pages.filter((page) => page.kind === "section").map((page) => [page.section, page]),
182
+ );
183
+
184
+ /** @type {Map<string, object[]>} */
185
+ const sections = new Map();
186
+ for (const page of regular) {
187
+ const key = text(page.section);
188
+ if (!sections.has(key)) sections.set(key, []);
189
+ sections.get(key).push(page);
190
+ }
191
+ const listings = [];
192
+ for (const [key, list] of sections) {
193
+ const ordered = siteOrder(list);
194
+ const siblings = ordered.map(siblingOf);
195
+ for (const page of ordered) page.siblings = siblings;
196
+ if (!key) continue;
197
+ const page = authored.get(key) ?? {
198
+ kind: "section",
199
+ section: key,
200
+ title: humanize(key),
201
+ url: relUrl(site, `${key}/`),
202
+ params: {},
203
+ content: "",
204
+ };
205
+ listings.push({ ...page, members: ordered.map(memberOf) });
206
+ }
207
+ for (const [key, page] of authored)
208
+ if (!sections.has(key)) listings.push({ ...page, members: [] });
209
+
210
+ if (!site.tagPages) return [...home, ...regular, ...listings];
211
+
212
+ /** @type {Map<string, {name: string, pages: object[]}>} */
213
+ const tags = new Map();
214
+ for (const page of regular)
215
+ for (const name of subjectTags(page.params)) {
216
+ const key = slugify(name);
217
+ if (!key) continue;
218
+ if (!tags.has(key)) tags.set(key, { name, pages: [] });
219
+ tags.get(key).pages.push(page);
220
+ }
221
+ const terms = [...tags.values()].map((tag) => ({
222
+ kind: "term",
223
+ title: tag.name,
224
+ url: tagUrl(site, tag.name),
225
+ params: {},
226
+ content: "",
227
+ members: siteOrder(tag.pages).map(memberOf),
228
+ count: tag.pages.length,
229
+ date: tag.pages
230
+ .map((page) => text(page.date))
231
+ .filter(Boolean)
232
+ .sort()
233
+ .at(-1),
234
+ }));
235
+ const taxonomy = {
236
+ kind: "taxonomy",
237
+ title: "Tags",
238
+ url: relUrl(site, "tags/"),
239
+ params: {},
240
+ content: "",
241
+ members: siteOrder(terms).map((term) => ({
242
+ url: term.url,
243
+ title: term.title,
244
+ count: term.count,
245
+ })),
246
+ };
247
+ return [...home, ...regular, ...listings, taxonomy, ...terms];
248
+ }
249
+
250
+ /**
251
+ * The file a page is written to.
252
+ *
253
+ * @param {object} site - The site.
254
+ * @param {string} out - The directory the site is written to.
255
+ * @param {string} url - The page's address, under the site's base path.
256
+ * @returns {string} The `index.html` the address serves.
257
+ * @throws {Error} When the address is not under the site's base path.
258
+ */
259
+ function pageFile(site, out, url) {
260
+ const base = basePath(site);
261
+ if (!url.startsWith(base))
262
+ throw new Error(`the page ${url} is not under the site's base path ${base}`);
263
+ return path.join(out, ...url.slice(base.length).split("/").filter(Boolean), "index.html");
264
+ }
265
+
266
+ /**
267
+ * Write the files the layouts link: the theme's own, and the code stylesheet.
268
+ *
269
+ * @param {string} out - The directory the site is written to.
270
+ * @param {string} [staticDir] - The theme's own files.
271
+ * @returns {string[]} The files the theme generates, each written. The theme's
272
+ * own files are copied as a tree and not listed.
273
+ */
274
+ export function writeThemeFiles(out, staticDir = SITE_STATIC_DIR) {
275
+ fs.mkdirSync(out, { recursive: true });
276
+ if (fs.existsSync(staticDir)) fs.cpSync(staticDir, out, { recursive: true });
277
+ return siteThemeAssets().map((asset) => {
278
+ const file = path.join(out, asset.path);
279
+ fs.mkdirSync(path.dirname(file), { recursive: true });
280
+ fs.writeFileSync(file, asset.content);
281
+ return file;
282
+ });
283
+ }
284
+
285
+ /**
286
+ * Draw a site and write it.
287
+ *
288
+ * @param {object} options - The inputs.
289
+ * @param {object} options.site - The site, as
290
+ * {@link module:engine/site-theme.createSiteTheme} takes it, with `tagPages`,
291
+ * `feed` and `sitemaps` as the module description sets out. `pages` and
292
+ * `pageExists` are filled in here.
293
+ * @param {readonly object[]} options.pages - The home page, the regular pages
294
+ * and any authored section pages, each as a layout is given it.
295
+ * @param {string} options.out - The directory written to. Emptied first.
296
+ * @param {(finding: import("./site-theme.mjs").Finding) => void} [options.report] -
297
+ * Receives each finding the layouts make.
298
+ * @param {string} [options.staticDir] - The theme's own files, copied first.
299
+ * @param {ReadonlySet<string>} [options.only] - The addresses of the pages to
300
+ * draw again into a site already written. Nothing else is touched.
301
+ * @returns {{files: string[], pages: string[], findings: object[]}} Every file
302
+ * written, the address of every page, and the layouts' findings.
303
+ * @throws {Error} When a page's address is outside the site, or a site served
304
+ * under a path asks for a sitemap index.
305
+ */
306
+ export function writeSite({ site, pages, out, report, staticDir = SITE_STATIC_DIR, only }) {
307
+ if (site.sitemaps && basePath(site) !== "/")
308
+ throw new Error(
309
+ `a sitemap index and robots.txt belong at the root of an origin, and this site is served under ${basePath(site)}`,
310
+ );
311
+ const tree = siteTree(site, pages);
312
+ const published = new Set(tree.map((page) => page.url));
313
+ const feedUrl = site.feed ? relUrl(site, `${site.feed.section}/atom.xml`) : "";
314
+ const theme = createSiteTheme({
315
+ site: {
316
+ ...site,
317
+ feed:
318
+ site.feed ?
319
+ {
320
+ title: site.feed.title ?? site.title,
321
+ url: feedUrl,
322
+ section: text(site.feed.section),
323
+ }
324
+ : undefined,
325
+ pages: tree
326
+ .filter((page) => page.kind === "page")
327
+ .map((page) => ({
328
+ url: page.url,
329
+ title: page.title,
330
+ section: text(page.section),
331
+ type: page.params?.type,
332
+ subType: page.params?.subType,
333
+ date: page.date ?? page.params?.date,
334
+ content: page.content,
335
+ })),
336
+ pageExists: (url) => published.has(url) || published.has(`${url.replace(/\/?$/, "/")}`),
337
+ },
338
+ report,
339
+ });
340
+
341
+ const files = [];
342
+ const write = (file, content) => {
343
+ fs.mkdirSync(path.dirname(file), { recursive: true });
344
+ fs.writeFileSync(file, content);
345
+ files.push(file);
346
+ };
347
+ const origin = (url) => new URL(url, site.baseURL).href;
348
+ const draw = (page) =>
349
+ write(
350
+ pageFile(site, out, page.url),
351
+ minifyHtml(theme.render({ ...page, permalink: origin(page.url) })),
352
+ );
353
+ if (only) {
354
+ for (const page of tree) if (only.has(page.url)) draw(page);
355
+ return { files, pages: tree.map((page) => page.url), findings: theme.findings };
356
+ }
357
+
358
+ fs.rmSync(out, { recursive: true, force: true });
359
+ files.push(...writeThemeFiles(out, staticDir));
360
+ for (const page of tree) draw(page);
361
+ write(
362
+ path.join(out, "404.html"),
363
+ minifyHtml(
364
+ theme.render({
365
+ kind: "404",
366
+ title: "Page not found",
367
+ url: relUrl(site, "404.html"),
368
+ params: {},
369
+ }),
370
+ ),
371
+ );
372
+
373
+ write(
374
+ path.join(out, "sitemap.xml"),
375
+ sitemapXml(
376
+ tree.map((page) => ({
377
+ loc: origin(page.url),
378
+ lastmod: page.params?.lastmod ?? page.date ?? page.params?.date,
379
+ })),
380
+ ),
381
+ );
382
+
383
+ if (site.feed) {
384
+ const section = text(site.feed.section);
385
+ const time = (page) => new Date(text(page.date ?? page.params?.date)).getTime() || 0;
386
+ const posts = tree
387
+ .filter((page) => page.kind === "page" && text(page.section) === section)
388
+ .sort((a, b) => time(b) - time(a));
389
+ write(
390
+ path.join(out, ...section.split("/").filter(Boolean), "atom.xml"),
391
+ atomFeed({
392
+ title: site.feed.title ?? site.title,
393
+ id: origin(relUrl(site, `${section}/`)),
394
+ self: origin(feedUrl),
395
+ author: site.params?.author,
396
+ entries: posts.map((post) => {
397
+ const words = plainText(post.content).split(" ").filter(Boolean);
398
+ return {
399
+ title: post.title,
400
+ url: origin(post.url),
401
+ date: post.date ?? post.params?.date,
402
+ author: text(post.params?.data?.author) || site.params?.author,
403
+ summary:
404
+ text(post.params?.description) ||
405
+ (words.length > SUMMARY_WORDS ?
406
+ `${words.slice(0, SUMMARY_WORDS).join(" ")}…`
407
+ : words.join(" ")),
408
+ content: post.content,
409
+ };
410
+ }),
411
+ }),
412
+ );
413
+ }
414
+
415
+ if (site.sitemaps) {
416
+ const own = origin(relUrl(site, "sitemap.xml"));
417
+ const index = origin(relUrl(site, "sitemap-index.xml"));
418
+ const all = [own, ...site.sitemaps.filter((loc) => loc !== own)];
419
+ write(path.join(out, "sitemap-index.xml"), sitemapIndexXml(all));
420
+ write(path.join(out, "robots.txt"), robotsTxt([index, ...all]));
421
+ }
422
+
423
+ return { files, pages: tree.map((page) => page.url), findings: theme.findings };
424
+ }
425
+
426
+ /**
427
+ * A package's pages, from its site build.
428
+ *
429
+ * Each body is rendered by the `web` renderer from the markdown the build
430
+ * expanded, and each page carries what it tells the search; see
431
+ * {@link module:engine/site-search}. The site carries what the configuration
432
+ * asks for: `site.tags` as `tagPages`, `site.feed` as the feed's section, each
433
+ * package of `site.sitemaps` as its sitemap's address on the site's origin,
434
+ * and each of `site.mergeIndexes` as the path of its search bundle.
435
+ *
436
+ * @param {object} options - The inputs.
437
+ * @param {object} options.config - The resolved configuration.
438
+ * @param {object} options.built - What `buildSite` returned with `write: false`.
439
+ * @param {{baseURL: string, title: string, params: object}} options.settings -
440
+ * From {@link module:engine/site-config.siteSettings}.
441
+ * @param {readonly {name: string, url: string, children?: object[]}[]} [options.navigation] -
442
+ * The menu.
443
+ * @param {number} [options.year] - The year the footer states.
444
+ * @returns {{site: object, pages: object[], pageOf: (entry: object) => object}}
445
+ * What {@link writeSite} is given, and how one note the build rendered again
446
+ * becomes its page.
447
+ */
448
+ export function packageSite({ config, built, settings, navigation = [], year }) {
449
+ const renderer = createRenderer("web", { icons: config.icons });
450
+ const resolveSrc = (src) => resolvePathname(src, config)?.web ?? null;
451
+ const { site: from } = built;
452
+ const relative = (file) =>
453
+ file ? path.relative(process.cwd(), file).split(path.sep).join("/") : undefined;
454
+ const world = mapWorld({
455
+ records: from.records,
456
+ foreignIndex: from.foreign?.index ?? new Map(),
457
+ contentBase: config.paths.content,
458
+ base: from.base,
459
+ config,
460
+ });
461
+ const geography = placeGeography(world.places);
462
+ const search = (data) => searchMetadata(data, { geography, index: from.index });
463
+
464
+ /** Each note `site.sections` names, by address, with the section it is the page of. */
465
+ const sectionOf = new Map(
466
+ Object.entries(config.site.sections).map(([name, address]) => [address, name]),
467
+ );
468
+ /**
469
+ * A content page, from what the build made of its note: a section's page
470
+ * for a note `site.sections` names, and a page of the feed's section for
471
+ * a post.
472
+ */
473
+ const pageOf = ({ page, markdown, figures, data }) => {
474
+ const section = sectionOf.get(page.slug);
475
+ return {
476
+ kind: section ? "section" : "page",
477
+ title: text(data.title),
478
+ url: page.url,
479
+ section: section ?? (isPost(data) ? config.site.feed : ""),
480
+ params: data,
481
+ date: isPost(data) ? data.data?.date : undefined,
482
+ weight: data.weight,
483
+ content: renderer.render(markdown, { runs: from.runs, figures, resolveSrc }),
484
+ source: relative(page.file),
485
+ search: search(data),
486
+ };
487
+ };
488
+
489
+ const pages = [];
490
+ for (const home of from.homepages)
491
+ pages.push({
492
+ kind: "home",
493
+ title: text(home.data.title),
494
+ url: from.base,
495
+ params: home.data,
496
+ content: renderer.render(home.body, { runs: from.runs, resolveSrc }),
497
+ source: relative(home.file),
498
+ search: search(home.data),
499
+ });
500
+ for (const entry of from.pages) pages.push(pageOf(entry));
501
+
502
+ const site = {
503
+ title: settings.title,
504
+ baseURL: settings.baseURL,
505
+ params: { ...settings.params, search: config.site.search !== false },
506
+ menu: navigation.map((entry) => ({
507
+ name: entry.name,
508
+ url: entry.url,
509
+ children: (entry.children ?? []).map((child) => ({ name: child.name, url: child.url })),
510
+ })),
511
+ year,
512
+ tagPages: config.site.tags,
513
+ ...(config.site.feed ? { feed: { section: config.site.feed } } : {}),
514
+ ...(config.site.sitemaps.length ?
515
+ {
516
+ sitemaps: config.site.sitemaps.map(
517
+ (pkg) => new URL(`/${pkg}/sitemap.xml`, settings.baseURL).href,
518
+ ),
519
+ }
520
+ : {}),
521
+ ...(config.site.mergeIndexes.length ?
522
+ { mergeIndexes: config.site.mergeIndexes.map((pkg) => `/${pkg}/pagefind/`) }
523
+ : {}),
524
+ };
525
+ return { site, pages, pageOf };
526
+ }
527
+
528
+ /**
529
+ * The directory a package's site is written to: its base path under the
530
+ * deployed root, so a site served at `/` is written at the root itself.
531
+ *
532
+ * @param {object} config - The resolved configuration.
533
+ * @param {string} root - The deployed root.
534
+ * @returns {string} The directory.
535
+ */
536
+ export function siteDirectory(config, root) {
537
+ return path.join(root, ...siteBase(config).split("/").filter(Boolean));
538
+ }
539
+
540
+ /**
541
+ * Copy the script fonts the site's pages set their marked text in.
542
+ *
543
+ * @param {object} config - The resolved configuration.
544
+ * @param {string} out - The directory the site is written to.
545
+ * @returns {{findings: object[], count: number, dest?: string}} What checking
546
+ * the installed fonts found, and how many files were copied where. Nothing is
547
+ * copied when there is a finding, or when the package sets no script.
548
+ */
549
+ export function deliverSiteFonts(config, out) {
550
+ const found = scriptFontFiles(config, ["woff2"]);
551
+ if (!found) return { findings: [], count: 0 };
552
+ const findings = checkScriptFonts(config);
553
+ if (findings.length) return { findings, count: 0 };
554
+ const dest = path.join(out, siteFontsDestination(found.version));
555
+ return { findings, count: copyScriptFonts(config, dest, ["woff2"]), dest };
556
+ }
557
+
558
+ /**
559
+ * Build a package's site from its content tree and write it.
560
+ *
561
+ * The site's address, title and menu are read before the tree is, so a site
562
+ * that cannot be described fails before anything is built. A gate that fires
563
+ * stops the build before anything is written; a note in error is written as
564
+ * its page shows it, with the finding in what is returned, which
565
+ * `siteBuildFailed` reads.
566
+ *
567
+ * @param {object} options - The inputs.
568
+ * @param {object} options.config - The resolved configuration.
569
+ * @param {string} options.out - The directory the site is written to, which
570
+ * is the package's own directory under the deployed root.
571
+ * @param {number} [options.year] - The year the footer states.
572
+ * @returns {Promise<{built: object, written: object|null, fonts: object|null,
573
+ * site?: object, pages?: object[], pageOf?: (entry: object) => object}>}
574
+ * What the build found, and, when it found nothing that stops it, what was
575
+ * written, the pages it was written from and how a note rendered again
576
+ * becomes its page. `written` is `null` when a gate fired.
577
+ * @throws {Error} When the site's settings or its cached menu cannot be read.
578
+ */
579
+ export async function buildPackageSite({ config, out, year }) {
580
+ const settings = siteSettings({ config });
581
+ const navigation = readCachedNavigation(config);
582
+ const sqlTables = await prepareTreeSqlTables(config.paths.content, {
583
+ skipDirectories: config.skipDirectories,
584
+ config,
585
+ audience: "public",
586
+ });
587
+ const built = buildSite({ config, sqlTables, write: false });
588
+ if (gatesFailed(built.gates)) return { built, written: null, fonts: null };
589
+ const { site, pages, pageOf } = packageSite({ config, built, settings, navigation, year });
590
+ const written = writeSite({ site, pages, out });
591
+ const fonts = deliverSiteFonts(config, out);
592
+ return { built, written, fonts, site, pages, pageOf };
593
+ }
@@ -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/`
@@ -120,15 +120,19 @@ export function noindexHeaders() {
120
120
  * that names their version, so a year's lifetime never serves a stale file.
121
121
  *
122
122
  * @param {object} [options] - Options.
123
- * @param {string} [options.pkg] - The content package, which is the prefix.
123
+ * @param {string} [options.pkg] - The content package, whose site is served at
124
+ * `/<pkg>/` unless `base` says otherwise.
125
+ * @param {string} [options.base] - The path the site is served at, beginning
126
+ * and ending with `/`.
124
127
  * @param {boolean} [options.fonts] - Whether the site carries the fonts.
125
128
  * @returns {string} The file's contents.
126
129
  */
127
- export function headers({ pkg, fonts = false } = {}) {
130
+ export function headers({ pkg, base, fonts = false } = {}) {
128
131
  const lines = noindexHeaders();
129
- if (fonts && pkg) {
132
+ const prefix = base ?? (pkg ? `/${pkg}/` : "");
133
+ if (fonts && prefix) {
130
134
  lines.push(
131
- `/${pkg}/fonts/heroiclands/*`,
135
+ `${prefix}fonts/heroiclands/*`,
132
136
  " Cache-Control: public, max-age=31536000, immutable",
133
137
  "",
134
138
  );
@@ -245,7 +249,10 @@ export async function indexSite({ site, output }) {
245
249
  *
246
250
  * @param {object} options - Options.
247
251
  * @param {string} options.pkg - The content package name, which is also the
248
- * directory Hugo rendered into.
252
+ * directory the site is rendered into unless `base` says otherwise.
253
+ * @param {string} [options.base] - The path the site is served at, beginning
254
+ * and ending with `/`; the site is rendered into that path under `out`, so
255
+ * a site served at `/` is rendered at `out` itself.
249
256
  * @param {string} options.out - The directory that is deployed.
250
257
  * @param {boolean} [options.search] - Whether to build the search index;
251
258
  * `site.search` in the configuration. Default `true`.
@@ -259,18 +266,20 @@ export async function indexSite({ site, output }) {
259
266
  * under the prefix; or, located at the rendered site, when the indexer
260
267
  * fails.
261
268
  */
262
- export async function writeSiteRoot({ pkg, out, search = true, indexer = indexSite }) {
269
+ export async function writeSiteRoot({ pkg, base, out, search = true, indexer = indexSite }) {
263
270
  const root = path.resolve(out);
264
- const rendered = path.join(root, pkg);
271
+ const prefix = base ?? `/${pkg}/`;
272
+ const rendered = path.join(root, ...prefix.split("/").filter(Boolean));
265
273
  if (!fs.existsSync(path.join(rendered, "index.html"))) {
266
274
  throw new Error(
267
- `${out}/${pkg}/ holds no rendered site — build the site before its root files`,
275
+ `${path.join(out, ...prefix.split("/").filter(Boolean))}/ holds no rendered site — ` +
276
+ "build the site before its root files",
268
277
  );
269
278
  }
270
279
 
271
280
  const file = path.join(root, "_headers");
272
281
  const fonts = fs.existsSync(path.join(rendered, "fonts", "heroiclands"));
273
- fs.writeFileSync(file, headers({ pkg, fonts }));
282
+ fs.writeFileSync(file, headers({ pkg, base: prefix, fonts }));
274
283
  fs.rmSync(path.join(root, "_redirects"), { force: true });
275
284
 
276
285
  const dir = path.join(rendered, SEARCH_DIR);