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