@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.
- package/CHANGELOG.md +50 -0
- package/README.md +2 -1
- package/assets/preview/gitea.ini +12 -7
- package/assets/styles/content-base.css +150 -1
- package/bin/content-commands.mjs +356 -210
- package/bin/package-build.mjs +3 -2
- package/ci/check-banners.mjs +7 -7
- package/config.mjs +21 -6
- package/content-config.mjs +199 -80
- package/docs/api.md +93 -72
- package/docs/authoring/links-and-markup.md +316 -78
- package/docs/commands.md +248 -80
- package/docs/configuration.md +210 -102
- package/docs/content-format.md +2 -2
- package/docs/diagnostics.md +2 -2
- package/docs/getting-started.md +1 -1
- package/docs/guides/icons.md +1 -1
- package/docs/guides/module-project.md +5 -8
- package/docs/guides/release-and-deploy.md +2 -2
- package/docs/guides/site.md +155 -23
- package/docs/guides/system-project.md +1 -1
- package/docs/guides/writing-in-invented-scripts.md +1247 -0
- package/docs/index.md +1 -0
- package/docs/project-setup.md +28 -30
- package/docs/reference/format-details.md +589 -308
- package/docs/reference/note-types.md +40 -10
- package/docs/reference/theme-pages.md +127 -108
- package/docs/source-preview.md +15 -1
- package/engine/asset-shipping.mjs +22 -10
- package/engine/base-compiler.mjs +62 -104
- package/engine/changelog-lint.mjs +1 -1
- package/engine/content-address.mjs +1 -1
- package/engine/content-alerts.mjs +8 -4
- package/engine/content-blocks.mjs +26 -11
- package/engine/content-embeds.mjs +1 -1
- package/engine/content-expand.mjs +376 -0
- package/engine/content-figures.mjs +13 -6
- package/engine/content-format.yaml +8 -0
- package/engine/content-glyphs.mjs +213 -0
- package/engine/content-images.mjs +6 -7
- package/engine/content-links.mjs +3 -3
- package/engine/content-lint.mjs +1 -1
- package/engine/content-render.mjs +453 -0
- package/engine/content-scripts.mjs +326 -27
- package/engine/content-spans.mjs +4 -1
- package/engine/content-styles.mjs +91 -0
- package/engine/content-symbols.mjs +186 -0
- package/engine/content-verse.mjs +6 -18
- package/engine/core-report.mjs +169 -0
- package/engine/core-vocabulary.mjs +208 -0
- package/engine/dictionary-html.mjs +119 -0
- package/engine/field-reference.mjs +1 -1
- package/engine/frontmatter-lint.mjs +1 -1
- package/engine/heading-attributes.mjs +89 -0
- package/engine/helpers.mjs +61 -101
- package/engine/homepage.mjs +17 -29
- package/engine/html-minify.mjs +70 -0
- package/engine/index.mjs +8 -5
- package/engine/language-rows.mjs +8 -2
- package/engine/language-terms.mjs +115 -14
- package/engine/language-words.mjs +12 -0
- package/engine/logographic.mjs +255 -0
- package/engine/map-lexicon.mjs +221 -25
- package/engine/map-places.mjs +5 -4
- package/engine/markdown-expressions.mjs +0 -2
- package/engine/markdownlint-run.mjs +9 -3
- package/engine/markup-validation.mjs +21 -0
- package/engine/metre-declaration.mjs +134 -0
- package/engine/note-matter.mjs +2 -2
- package/engine/note-vocabulary.mjs +187 -3
- package/engine/pdf-build.mjs +68 -133
- package/engine/pdf-render.mjs +102 -44
- package/engine/post-fields.mjs +157 -0
- package/engine/preview-index.mjs +130 -0
- package/engine/preview-source.mjs +450 -0
- package/engine/project-init.mjs +5 -12
- package/engine/prose-config.mjs +3 -3
- package/engine/run-words.mjs +3 -2
- package/engine/script-emphasis-terms.mjs +38 -0
- package/engine/script-emphasis.mjs +295 -0
- package/engine/script-font-delivery.mjs +4 -4
- package/engine/site-build.mjs +265 -313
- package/engine/site-config.mjs +26 -238
- package/engine/site-files.mjs +175 -0
- package/engine/site-index.mjs +9 -4
- package/engine/site-preview.mjs +50 -523
- package/engine/site-reach.mjs +213 -0
- package/engine/site-render.mjs +668 -0
- package/engine/site-root.mjs +90 -39
- package/engine/site-search.mjs +161 -0
- package/engine/site-serve.mjs +274 -0
- package/engine/site-theme-banner.mjs +154 -0
- package/engine/site-theme-urls.mjs +125 -0
- package/engine/site-theme.mjs +939 -0
- package/engine/sql-tables.mjs +9 -6
- package/engine/text-maps.mjs +57 -10
- package/engine/typographer.mjs +248 -0
- package/engine/verse-metre.mjs +357 -0
- package/engine/wikilink-syntax.mjs +29 -47
- package/engine/wikilinks.mjs +758 -279
- package/engine/word-forms.mjs +39 -24
- package/engine/word-rows.mjs +12 -2
- package/engine/word-terms.mjs +29 -4
- package/manifest.mjs +3 -4
- package/package.json +10 -7
- package/{hugo-theme → site-theme}/data/banners.yaml +6 -10
- package/site-theme/layouts/base.hbs +31 -0
- package/site-theme/layouts/blog-list.hbs +30 -0
- package/site-theme/layouts/blog-post.hbs +40 -0
- package/site-theme/layouts/home.hbs +35 -0
- package/site-theme/layouts/list.hbs +20 -0
- package/site-theme/layouts/not-found.hbs +31 -0
- package/site-theme/layouts/page.hbs +7 -0
- package/site-theme/partials/copy-buttons.hbs +37 -0
- package/site-theme/partials/draft-notice.hbs +5 -0
- package/site-theme/partials/exturl.hbs +2 -0
- package/{hugo-theme/layouts/partials/footer.html → site-theme/partials/footer.hbs} +10 -7
- package/site-theme/partials/header.hbs +44 -0
- package/site-theme/partials/hero-banner.hbs +11 -0
- package/site-theme/partials/infobox-entry.hbs +2 -0
- package/site-theme/partials/infobox-section.hbs +31 -0
- package/site-theme/partials/infobox-value.hbs +2 -0
- package/site-theme/partials/infobox.hbs +18 -0
- package/site-theme/partials/list-members.hbs +9 -0
- package/site-theme/partials/nav-links.hbs +5 -0
- package/site-theme/partials/page-body.hbs +48 -0
- package/site-theme/partials/page-list.hbs +14 -0
- package/site-theme/partials/related.hbs +24 -0
- package/site-theme/partials/search-meta.hbs +6 -0
- package/site-theme/partials/search-names.hbs +2 -0
- package/site-theme/partials/search.hbs +52 -0
- package/site-theme/partials/tags.hbs +4 -0
- package/site-theme/partials/toc.hbs +7 -0
- package/{hugo-theme → site-theme}/static/css/style.css +14 -0
- package/stage.mjs +1 -2
- package/types/config.d.mts +8 -4
- package/types/content-config.d.mts +15 -15
- package/types/engine/asset-shipping.d.mts +5 -2
- package/types/engine/base-compiler.d.mts +20 -2
- package/types/engine/content-address.d.mts +1 -1
- package/types/engine/content-alerts.d.mts +8 -2
- package/types/engine/content-blocks.d.mts +11 -4
- package/types/engine/content-expand.d.mts +288 -0
- package/types/engine/content-figures.d.mts +11 -3
- package/types/engine/content-glyphs.d.mts +85 -0
- package/types/engine/content-images.d.mts +6 -7
- package/types/engine/content-links.d.mts +1 -1
- package/types/engine/content-render.d.mts +199 -0
- package/types/engine/content-scripts.d.mts +62 -0
- package/types/engine/content-spans.d.mts +1 -1
- package/types/engine/content-styles.d.mts +33 -0
- package/types/engine/content-symbols.d.mts +35 -0
- package/types/engine/core-report.d.mts +45 -0
- package/types/engine/core-vocabulary.d.mts +53 -0
- package/types/engine/dictionary-html.d.mts +8 -0
- package/types/engine/field-reference.d.mts +1 -1
- package/types/engine/heading-attributes.d.mts +20 -0
- package/types/engine/helpers.d.mts +3 -10
- package/types/engine/homepage.d.mts +11 -24
- package/types/engine/html-minify.d.mts +9 -0
- package/types/engine/index.d.mts +2 -1
- package/types/engine/language-terms.d.mts +24 -2
- package/types/engine/logographic.d.mts +45 -0
- package/types/engine/map-lexicon.d.mts +48 -8
- package/types/engine/map-places.d.mts +8 -0
- package/types/engine/markdown-expressions.d.mts +0 -2
- package/types/engine/metre-declaration.d.mts +39 -0
- package/types/engine/pdf-render.d.mts +6 -2
- package/types/engine/post-fields.d.mts +44 -0
- package/types/engine/preview-index.d.mts +72 -0
- package/types/engine/preview-source.d.mts +51 -0
- package/types/engine/script-emphasis-terms.d.mts +21 -0
- package/types/engine/script-emphasis.d.mts +84 -0
- package/types/engine/script-font-delivery.d.mts +4 -4
- package/types/engine/site-build.d.mts +71 -113
- package/types/engine/site-config.d.mts +14 -134
- package/types/engine/site-files.d.mts +73 -0
- package/types/engine/site-index.d.mts +11 -3
- package/types/engine/site-preview.d.mts +15 -19
- package/types/engine/site-reach.d.mts +76 -0
- package/types/engine/site-render.d.mts +173 -0
- package/types/engine/site-root.d.mts +14 -4
- package/types/engine/site-search.d.mts +54 -0
- package/types/engine/site-serve.d.mts +44 -0
- package/types/engine/site-theme-banner.d.mts +51 -0
- package/types/engine/site-theme-urls.d.mts +60 -0
- package/types/engine/site-theme.d.mts +127 -0
- package/types/engine/text-maps.d.mts +17 -3
- package/types/engine/typographer.d.mts +11 -0
- package/types/engine/verse-metre.d.mts +131 -0
- package/types/engine/wikilink-syntax.d.mts +30 -48
- package/types/engine/wikilinks.d.mts +330 -14
- package/types/engine/word-forms.d.mts +3 -2
- package/types/manifest.d.mts +3 -4
- package/types/stage.d.mts +1 -2
- package/types/vendor/markdownlint.d.mts +8 -0
- package/vendor/markdownlint.mjs +20388 -0
- package/engine/web-wikilinks.mjs +0 -594
- package/hugo-theme/layouts/404.html +0 -85
- package/hugo-theme/layouts/_default/baseof.html +0 -67
- package/hugo-theme/layouts/_default/list.html +0 -139
- package/hugo-theme/layouts/_default/single.html +0 -4
- package/hugo-theme/layouts/blog/list.html +0 -54
- package/hugo-theme/layouts/blog-post/single.html +0 -36
- package/hugo-theme/layouts/index.html +0 -75
- package/hugo-theme/layouts/partials/banner-url.html +0 -141
- package/hugo-theme/layouts/partials/cdn-url.html +0 -36
- package/hugo-theme/layouts/partials/dictionary-lists.html +0 -58
- package/hugo-theme/layouts/partials/draft-notice.html +0 -41
- package/hugo-theme/layouts/partials/draft-state.html +0 -26
- package/hugo-theme/layouts/partials/header.html +0 -35
- package/hugo-theme/layouts/partials/hero-banner.html +0 -85
- package/hugo-theme/layouts/partials/infobox/entry.html +0 -18
- package/hugo-theme/layouts/partials/infobox/items.html +0 -26
- package/hugo-theme/layouts/partials/infobox/section.html +0 -56
- package/hugo-theme/layouts/partials/infobox/value.html +0 -43
- package/hugo-theme/layouts/partials/infobox.html +0 -57
- package/hugo-theme/layouts/partials/page-body.html +0 -152
- package/hugo-theme/layouts/partials/related.html +0 -138
- package/hugo-theme/layouts/partials/search.html +0 -84
- package/hugo-theme/layouts/partials/site-url.html +0 -32
- package/hugo-theme/layouts/partials/tags.html +0 -30
- package/hugo-theme/layouts/partials/toc.html +0 -28
- package/hugo-theme/layouts/partials/warn-missing-banner.html +0 -3
- package/hugo-theme/layouts/shortcodes/cartouche.html +0 -64
- package/hugo-theme/layouts/shortcodes/exturl.html +0 -12
- package/hugo-theme/layouts/shortcodes/glyph.html +0 -43
- package/hugo-theme/layouts/shortcodes/pagelist.html +0 -63
- package/hugo-theme/theme.toml +0 -11
- package/types/engine/web-wikilinks.d.mts +0 -107
- /package/{hugo-theme → site-theme}/data/banner-kinds.yaml +0 -0
- /package/{hugo-theme → site-theme}/static/css/sohl-icons.css +0 -0
- /package/{hugo-theme → site-theme}/static/fonts/game-icons-kb.woff2 +0 -0
- /package/{hugo-theme → site-theme}/static/img/banners/takheperu-hieroglyphs.svg +0 -0
- /package/{hugo-theme → site-theme}/static/img/cartouches/takheperu.svg +0 -0
- /package/{hugo-theme → site-theme}/static/js/inline-spoilers.js +0 -0
package/engine/site-root.mjs
CHANGED
|
@@ -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
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
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 —
|
|
31
|
+
* ends the same way — the render fills the output directory, this
|
|
32
32
|
* post-processes it there — so the search index is built in this step and
|
|
33
33
|
* every site gets one without each repository adding a dependency and a
|
|
34
34
|
* script. Pagefind indexes the rendered pages and writes `<package>/pagefind/`
|
|
@@ -51,7 +51,8 @@
|
|
|
51
51
|
import fs from "node:fs";
|
|
52
52
|
import path from "node:path";
|
|
53
53
|
import * as pagefind from "pagefind";
|
|
54
|
-
|
|
54
|
+
|
|
55
|
+
import { feedPaths, reachablePages } from "./site-reach.mjs";
|
|
55
56
|
|
|
56
57
|
import { formatDiagnostic } from "./diagnostics.mjs";
|
|
57
58
|
|
|
@@ -120,15 +121,19 @@ export function noindexHeaders() {
|
|
|
120
121
|
* that names their version, so a year's lifetime never serves a stale file.
|
|
121
122
|
*
|
|
122
123
|
* @param {object} [options] - Options.
|
|
123
|
-
* @param {string} [options.pkg] - The content package,
|
|
124
|
+
* @param {string} [options.pkg] - The content package, whose site is served at
|
|
125
|
+
* `/<pkg>/` unless `base` says otherwise.
|
|
126
|
+
* @param {string} [options.base] - The path the site is served at, beginning
|
|
127
|
+
* and ending with `/`.
|
|
124
128
|
* @param {boolean} [options.fonts] - Whether the site carries the fonts.
|
|
125
129
|
* @returns {string} The file's contents.
|
|
126
130
|
*/
|
|
127
|
-
export function headers({ pkg, fonts = false } = {}) {
|
|
131
|
+
export function headers({ pkg, base, fonts = false } = {}) {
|
|
128
132
|
const lines = noindexHeaders();
|
|
129
|
-
|
|
133
|
+
const prefix = base ?? (pkg ? `/${pkg}/` : "");
|
|
134
|
+
if (fonts && prefix) {
|
|
130
135
|
lines.push(
|
|
131
|
-
|
|
136
|
+
`${prefix}fonts/heroiclands/*`,
|
|
132
137
|
" Cache-Control: public, max-age=31536000, immutable",
|
|
133
138
|
"",
|
|
134
139
|
);
|
|
@@ -145,11 +150,40 @@ export function headers({ pkg, fonts = false } = {}) {
|
|
|
145
150
|
* binary.
|
|
146
151
|
*
|
|
147
152
|
* @callback SiteIndexer
|
|
148
|
-
* @param {{site: string, output: string}} where - The rendered
|
|
149
|
-
* directory inside it the index is written to
|
|
153
|
+
* @param {{site: string, output: string, base?: string}} where - The rendered
|
|
154
|
+
* site, the directory inside it the index is written to, and the path the
|
|
155
|
+
* site is served at. With a `base`, only the pages reachable from the
|
|
156
|
+
* homepage are indexed.
|
|
150
157
|
* @returns {Promise<{pages: number}>} How many pages were indexed.
|
|
151
158
|
*/
|
|
152
159
|
|
|
160
|
+
/**
|
|
161
|
+
* The addresses of the pages reachable from a rendered site's homepage.
|
|
162
|
+
*
|
|
163
|
+
* @param {string} site - The rendered site's directory, served at `base`.
|
|
164
|
+
* @param {string} base - The path the site is served at.
|
|
165
|
+
* @returns {Set<string>} Page addresses, each beginning with `base`.
|
|
166
|
+
*/
|
|
167
|
+
function reachableFromDisk(site, base) {
|
|
168
|
+
const file = (url) => path.join(site, ...url.slice(base.length).split("/").filter(Boolean));
|
|
169
|
+
const read = (target) => {
|
|
170
|
+
try {
|
|
171
|
+
return fs.readFileSync(target, "utf8");
|
|
172
|
+
} catch {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
return reachablePages({
|
|
177
|
+
base,
|
|
178
|
+
html: (url) => read(path.join(file(url), "index.html")),
|
|
179
|
+
expand: (target) => {
|
|
180
|
+
if (!target.endsWith(".xml")) return [];
|
|
181
|
+
const xml = read(file(target));
|
|
182
|
+
return xml === undefined ? [] : feedPaths(xml);
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
153
187
|
/**
|
|
154
188
|
* Build the search index of a rendered site with Pagefind.
|
|
155
189
|
*
|
|
@@ -165,12 +199,17 @@ export function headers({ pkg, fonts = false } = {}) {
|
|
|
165
199
|
* - The binary missing is reported by the package as an install failure of
|
|
166
200
|
* its own; it is rethrown naming the platform package to install.
|
|
167
201
|
*
|
|
202
|
+
* With a `base`, the index covers the pages reachable from the homepage by
|
|
203
|
+
* the links the rendered HTML carries, and no others: an unlinked page is
|
|
204
|
+
* served but not searchable. Each reachable page is handed to Pagefind as its
|
|
205
|
+
* own file, so a page outside the set is never read.
|
|
206
|
+
*
|
|
168
207
|
* The output directory is emptied first, so an index of an earlier build's
|
|
169
208
|
* pages never survives beside the current one.
|
|
170
209
|
*
|
|
171
210
|
* @type {SiteIndexer}
|
|
172
211
|
*/
|
|
173
|
-
export async function indexSite({ site, output }) {
|
|
212
|
+
export async function indexSite({ site, output, base }) {
|
|
174
213
|
if (!fs.statSync(site, { throwIfNoEntry: false })?.isDirectory()) {
|
|
175
214
|
throw new Error(`${site} is not a directory, so there is nothing to index`);
|
|
176
215
|
}
|
|
@@ -197,33 +236,40 @@ export async function indexSite({ site, output }) {
|
|
|
197
236
|
}
|
|
198
237
|
let pages;
|
|
199
238
|
try {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
239
|
+
if (base) {
|
|
240
|
+
pages = 0;
|
|
241
|
+
for (const url of reachableFromDisk(site, base)) {
|
|
242
|
+
const sourcePath = path.posix.join(
|
|
243
|
+
...url.slice(base.length).split("/"),
|
|
244
|
+
"index.html",
|
|
245
|
+
);
|
|
246
|
+
const content = fs.readFileSync(path.join(site, sourcePath), "utf8");
|
|
247
|
+
const added = await index.addHTMLFile({ sourcePath, content });
|
|
248
|
+
if (added.errors.length) throw new Error(added.errors.join("; "));
|
|
249
|
+
pages += 1;
|
|
250
|
+
}
|
|
251
|
+
} else {
|
|
252
|
+
const added = await index.addDirectory({ path: site });
|
|
253
|
+
if (added.errors.length) throw new Error(added.errors.join("; "));
|
|
254
|
+
pages = added.page_count;
|
|
255
|
+
}
|
|
203
256
|
if (!pages) {
|
|
204
257
|
throw new Error(`no page under ${site} was indexed — the site holds no HTML`);
|
|
205
258
|
}
|
|
206
|
-
|
|
207
|
-
|
|
259
|
+
// The files are taken from the index in memory and written here,
|
|
260
|
+
// because `writeFiles` can answer while the binary is still flushing
|
|
261
|
+
// them, which leaves an index that a reader loads half-written.
|
|
262
|
+
const { files, errors } = await index.getFiles();
|
|
263
|
+
if (errors.length) throw new Error(errors.join("; "));
|
|
264
|
+
if (!files.length) throw new Error("the search index holds no files");
|
|
265
|
+
for (const file of files) {
|
|
266
|
+
const target = path.join(output, file.path);
|
|
267
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
268
|
+
fs.writeFileSync(target, file.content);
|
|
269
|
+
}
|
|
208
270
|
} finally {
|
|
209
271
|
await pagefind.close();
|
|
210
272
|
}
|
|
211
|
-
// Pagefind may finish its API call while fragment writes are still visible
|
|
212
|
-
// as incomplete gzip streams under heavy parallel load. A successful site
|
|
213
|
-
// build must hand deployment complete fragments.
|
|
214
|
-
const fragmentDir = path.join(output, "fragment");
|
|
215
|
-
const deadline = Date.now() + 5000;
|
|
216
|
-
for (;;) {
|
|
217
|
-
try {
|
|
218
|
-
const fragments = fs.readdirSync(fragmentDir);
|
|
219
|
-
if (!fragments.length) throw new Error("no search fragments were written");
|
|
220
|
-
for (const name of fragments) gunzipSync(fs.readFileSync(path.join(fragmentDir, name)));
|
|
221
|
-
break;
|
|
222
|
-
} catch (error) {
|
|
223
|
-
if (Date.now() >= deadline) throw error;
|
|
224
|
-
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
273
|
return { pages };
|
|
228
274
|
}
|
|
229
275
|
|
|
@@ -245,7 +291,10 @@ export async function indexSite({ site, output }) {
|
|
|
245
291
|
*
|
|
246
292
|
* @param {object} options - Options.
|
|
247
293
|
* @param {string} options.pkg - The content package name, which is also the
|
|
248
|
-
* directory
|
|
294
|
+
* directory the site is rendered into unless `base` says otherwise.
|
|
295
|
+
* @param {string} [options.base] - The path the site is served at, beginning
|
|
296
|
+
* and ending with `/`; the site is rendered into that path under `out`, so
|
|
297
|
+
* a site served at `/` is rendered at `out` itself.
|
|
249
298
|
* @param {string} options.out - The directory that is deployed.
|
|
250
299
|
* @param {boolean} [options.search] - Whether to build the search index;
|
|
251
300
|
* `site.search` in the configuration. Default `true`.
|
|
@@ -259,18 +308,20 @@ export async function indexSite({ site, output }) {
|
|
|
259
308
|
* under the prefix; or, located at the rendered site, when the indexer
|
|
260
309
|
* fails.
|
|
261
310
|
*/
|
|
262
|
-
export async function writeSiteRoot({ pkg, out, search = true, indexer = indexSite }) {
|
|
311
|
+
export async function writeSiteRoot({ pkg, base, out, search = true, indexer = indexSite }) {
|
|
263
312
|
const root = path.resolve(out);
|
|
264
|
-
const
|
|
313
|
+
const prefix = base ?? `/${pkg}/`;
|
|
314
|
+
const rendered = path.join(root, ...prefix.split("/").filter(Boolean));
|
|
265
315
|
if (!fs.existsSync(path.join(rendered, "index.html"))) {
|
|
266
316
|
throw new Error(
|
|
267
|
-
`${out}
|
|
317
|
+
`${path.join(out, ...prefix.split("/").filter(Boolean))}/ holds no rendered site — ` +
|
|
318
|
+
"build the site before its root files",
|
|
268
319
|
);
|
|
269
320
|
}
|
|
270
321
|
|
|
271
322
|
const file = path.join(root, "_headers");
|
|
272
323
|
const fonts = fs.existsSync(path.join(rendered, "fonts", "heroiclands"));
|
|
273
|
-
fs.writeFileSync(file, headers({ pkg, fonts }));
|
|
324
|
+
fs.writeFileSync(file, headers({ pkg, base: prefix, fonts }));
|
|
274
325
|
fs.rmSync(path.join(root, "_redirects"), { force: true });
|
|
275
326
|
|
|
276
327
|
const dir = path.join(rendered, SEARCH_DIR);
|
|
@@ -280,7 +331,7 @@ export async function writeSiteRoot({ pkg, out, search = true, indexer = indexSi
|
|
|
280
331
|
}
|
|
281
332
|
let pages;
|
|
282
333
|
try {
|
|
283
|
-
({ pages } = await indexer({ site: rendered, output: dir }));
|
|
334
|
+
({ pages } = await indexer({ site: rendered, output: dir, base: prefix }));
|
|
284
335
|
} catch (err) {
|
|
285
336
|
const message = err instanceof Error ? err.message : String(err);
|
|
286
337
|
// A directory, so the locator is the file field alone: there is no
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
|
|
3
|
+
* Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
|
|
4
|
+
*
|
|
5
|
+
* This work is licensed under the GNU General Public License v3.0 (GPLv3).
|
|
6
|
+
* You may copy, modify, and distribute it under the terms of that license.
|
|
7
|
+
*
|
|
8
|
+
* For full terms, see the LICENSE.md file in the project root or visit:
|
|
9
|
+
* https://www.gnu.org/licenses/gpl-3.0.html
|
|
10
|
+
*
|
|
11
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* **What a note's page tells the site's search.** Pagefind reads the filters a
|
|
16
|
+
* page carries, so a reader can narrow a search to a package, a kind of note
|
|
17
|
+
* or a part of the world:
|
|
18
|
+
*
|
|
19
|
+
* | Filter | Value |
|
|
20
|
+
* | ------ | ----- |
|
|
21
|
+
* | `package` | the package that publishes the note |
|
|
22
|
+
* | `type` | the note's `type` |
|
|
23
|
+
* | `subtype` | the note's `subType` |
|
|
24
|
+
* | `culture` | the name of the culture `data.culture` names |
|
|
25
|
+
* | `continent` | the continent the note lies in |
|
|
26
|
+
* | `region` | the outermost region beneath that continent |
|
|
27
|
+
*
|
|
28
|
+
* A place lies where its `parents` lead; a being where its `homes` are; a
|
|
29
|
+
* scenario where its `locations` are. A continent is a region whose parent is
|
|
30
|
+
* the world. A note reached by more than one path carries every continent and
|
|
31
|
+
* region it reaches, so a being with homes in two regions is found under both.
|
|
32
|
+
*
|
|
33
|
+
* The note's name and its aliases are weighted, so a search for a name finds
|
|
34
|
+
* its note first. A note's tags and its draft state are not filters.
|
|
35
|
+
*
|
|
36
|
+
* @module
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { placeShortcodes } from "./map-places.mjs";
|
|
40
|
+
import { resolveInfoboxRef } from "./site-index.mjs";
|
|
41
|
+
|
|
42
|
+
/** The filters a note's page carries, in the order a reader is offered them. */
|
|
43
|
+
export const SEARCH_FILTERS = Object.freeze([
|
|
44
|
+
"package",
|
|
45
|
+
"type",
|
|
46
|
+
"subtype",
|
|
47
|
+
"culture",
|
|
48
|
+
"continent",
|
|
49
|
+
"region",
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {unknown} value - Anything.
|
|
54
|
+
* @returns {boolean} Whether it is a mapping a field may be read out of.
|
|
55
|
+
*/
|
|
56
|
+
const isMapping = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The continents and regions every place lies in.
|
|
60
|
+
*
|
|
61
|
+
* @param {Map<string, {shortcode: string, name: string, subType?: string, parents: string[]}>} places -
|
|
62
|
+
* Every place, by shortcode, as {@link module:engine/map-places.mapWorld} reads them.
|
|
63
|
+
* @returns {(shortcodes: Iterable<string>) => {continents: string[], regions: string[]}}
|
|
64
|
+
* The names of the continents and the outermost regions the given places lie
|
|
65
|
+
* in, each sorted.
|
|
66
|
+
*/
|
|
67
|
+
export function placeGeography(places) {
|
|
68
|
+
const worlds = new Set(
|
|
69
|
+
[...places.values()].filter((p) => p.subType === "world").map((p) => p.shortcode),
|
|
70
|
+
);
|
|
71
|
+
const isContinent = (place) =>
|
|
72
|
+
place?.subType === "region" && place.parents.some((parent) => worlds.has(parent));
|
|
73
|
+
/** @type {Map<string, {continents: Set<string>, regions: Set<string>}>} */
|
|
74
|
+
const known = new Map();
|
|
75
|
+
|
|
76
|
+
const reach = (shortcode, visiting) => {
|
|
77
|
+
if (known.has(shortcode)) return known.get(shortcode);
|
|
78
|
+
const found = { continents: new Set(), regions: new Set() };
|
|
79
|
+
const place = places.get(shortcode);
|
|
80
|
+
if (!place || visiting.has(shortcode) || worlds.has(shortcode)) return found;
|
|
81
|
+
visiting.add(shortcode);
|
|
82
|
+
if (isContinent(place)) found.continents.add(shortcode);
|
|
83
|
+
for (const parent of place.parents) {
|
|
84
|
+
if (place.subType === "region" && isContinent(places.get(parent)))
|
|
85
|
+
found.regions.add(shortcode);
|
|
86
|
+
const above = reach(parent, visiting);
|
|
87
|
+
for (const c of above.continents) found.continents.add(c);
|
|
88
|
+
for (const r of above.regions) found.regions.add(r);
|
|
89
|
+
}
|
|
90
|
+
visiting.delete(shortcode);
|
|
91
|
+
known.set(shortcode, found);
|
|
92
|
+
return found;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const names = (shortcodes) =>
|
|
96
|
+
[...new Set([...shortcodes].map((sc) => places.get(sc)?.name ?? sc))].sort();
|
|
97
|
+
return (shortcodes) => {
|
|
98
|
+
const continents = new Set();
|
|
99
|
+
const regions = new Set();
|
|
100
|
+
for (const shortcode of shortcodes) {
|
|
101
|
+
const found = reach(shortcode, new Set());
|
|
102
|
+
for (const c of found.continents) continents.add(c);
|
|
103
|
+
for (const r of found.regions) regions.add(r);
|
|
104
|
+
}
|
|
105
|
+
return { continents: names(continents), regions: names(regions) };
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The places a note lies in: itself for a place, its `homes` and its
|
|
111
|
+
* `locations` otherwise.
|
|
112
|
+
*
|
|
113
|
+
* @param {object} fm - The note's frontmatter.
|
|
114
|
+
* @returns {string[]} Shortcodes, lower case.
|
|
115
|
+
*/
|
|
116
|
+
export function notePlaces(fm) {
|
|
117
|
+
if (fm?.type === "place") return [String(fm.shortcode ?? "").toLowerCase()].filter(Boolean);
|
|
118
|
+
const data = isMapping(fm?.data) ? fm.data : {};
|
|
119
|
+
return [...placeShortcodes(data.homes), ...placeShortcodes(data.locations)];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* What a note's page tells the search.
|
|
124
|
+
*
|
|
125
|
+
* @param {object} fm - The page's frontmatter: the note's, with its `package`.
|
|
126
|
+
* @param {object} options - What the values resolve against.
|
|
127
|
+
* @param {(shortcodes: Iterable<string>) => {continents: string[], regions: string[]}} options.geography -
|
|
128
|
+
* From {@link placeGeography}.
|
|
129
|
+
* @param {object} [options.index] - The site's address index, which names the
|
|
130
|
+
* culture `data.culture` names.
|
|
131
|
+
* @returns {{filters: Array<{name: string, values: string[]}>, names: string[]}}
|
|
132
|
+
* Each filter with a value, in {@link SEARCH_FILTERS} order, and the names a
|
|
133
|
+
* search weights.
|
|
134
|
+
*/
|
|
135
|
+
export function searchMetadata(fm, { geography, index }) {
|
|
136
|
+
const data = isMapping(fm?.data) ? fm.data : {};
|
|
137
|
+
const text = (value) => (value === undefined || value === null ? "" : String(value).trim());
|
|
138
|
+
const culture =
|
|
139
|
+
data.culture !== undefined && data.culture !== null && data.culture !== "" ?
|
|
140
|
+
index && resolveInfoboxRef(index, data.culture, { type: "lore" })?.name
|
|
141
|
+
: undefined;
|
|
142
|
+
const { continents, regions } = geography(notePlaces(fm));
|
|
143
|
+
const values = {
|
|
144
|
+
package: [text(fm?.package)],
|
|
145
|
+
type: [text(fm?.type)],
|
|
146
|
+
subtype: [text(fm?.subType)],
|
|
147
|
+
culture: [text(culture)],
|
|
148
|
+
continent: continents,
|
|
149
|
+
region: regions,
|
|
150
|
+
};
|
|
151
|
+
const filters = SEARCH_FILTERS.map((name) => ({
|
|
152
|
+
name,
|
|
153
|
+
values: values[name].filter(Boolean),
|
|
154
|
+
})).filter((filter) => filter.values.length);
|
|
155
|
+
const name = isMapping(fm?.name) ? fm.name : {};
|
|
156
|
+
const names = [
|
|
157
|
+
text(name.full),
|
|
158
|
+
...(Array.isArray(name.aliases) ? name.aliases.map(text) : []),
|
|
159
|
+
].filter(Boolean);
|
|
160
|
+
return { filters, names: [...new Set(names)] };
|
|
161
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
|
|
3
|
+
* Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
|
|
4
|
+
*
|
|
5
|
+
* This work is licensed under the GNU General Public License v3.0 (GPLv3).
|
|
6
|
+
* You may copy, modify, and distribute it under the terms of that license.
|
|
7
|
+
*
|
|
8
|
+
* For full terms, see the LICENSE.md file in the project root or visit:
|
|
9
|
+
* https://www.gnu.org/licenses/gpl-3.0.html
|
|
10
|
+
*
|
|
11
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* **A site served while it is written.** {@link serveSite} builds a package's
|
|
16
|
+
* site, serves it, and keeps it current: it watches the content tree and the
|
|
17
|
+
* theme, writes again what a change touches, and tells every open page to
|
|
18
|
+
* reload.
|
|
19
|
+
*
|
|
20
|
+
* | Change | What is written again |
|
|
21
|
+
* | ------ | --------------------- |
|
|
22
|
+
* | a note's body or frontmatter | that note's page |
|
|
23
|
+
* | a note's name, type or shortcode, a new or removed note, a note holding an `sql` table, any other file in the content tree | the whole site |
|
|
24
|
+
* | the theme's layouts, partials or files | every page, and the theme's files |
|
|
25
|
+
*
|
|
26
|
+
* A page that changes because another note changed — a table that lists the
|
|
27
|
+
* note, a link that carries its name — is written again by the next whole
|
|
28
|
+
* build. A note in error is written as its page shows it, and the error is
|
|
29
|
+
* reported; a build whose gates fire writes nothing and leaves the last good
|
|
30
|
+
* site in place.
|
|
31
|
+
*
|
|
32
|
+
* The site is served from the deployed root, so a page is at the address it is
|
|
33
|
+
* deployed at, its base path included. Every HTML page is sent with a script
|
|
34
|
+
* that listens on {@link RELOAD_PATH} for the server's `reload` event; the
|
|
35
|
+
* script is added as the page is sent and is never written to the site. The
|
|
36
|
+
* search index is built once, after the first build.
|
|
37
|
+
*
|
|
38
|
+
* @module
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import fs from "node:fs";
|
|
42
|
+
import http from "node:http";
|
|
43
|
+
import path from "node:path";
|
|
44
|
+
|
|
45
|
+
import { siteBuildFailed } from "./site-build.mjs";
|
|
46
|
+
import { SITE_THEME_DIR } from "./site-theme.mjs";
|
|
47
|
+
import { SITE_STATIC_DIR, buildPackageSite, writeSite, writeThemeFiles } from "./site-render.mjs";
|
|
48
|
+
import { siteBase } from "../content-config.mjs";
|
|
49
|
+
import { SEARCH_DIR, indexSite } from "./site-root.mjs";
|
|
50
|
+
|
|
51
|
+
/** The address a served page listens on for the server's reloads. */
|
|
52
|
+
export const RELOAD_PATH = "/__package-build/reload";
|
|
53
|
+
|
|
54
|
+
/** How long a burst of changes is gathered before it is written, in milliseconds. */
|
|
55
|
+
const SETTLE_MS = 120;
|
|
56
|
+
|
|
57
|
+
/** What a file is sent as, by its extension. */
|
|
58
|
+
const CONTENT_TYPES = Object.freeze({
|
|
59
|
+
".html": "text/html; charset=utf-8",
|
|
60
|
+
".css": "text/css; charset=utf-8",
|
|
61
|
+
".js": "text/javascript; charset=utf-8",
|
|
62
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
63
|
+
".json": "application/json",
|
|
64
|
+
".xml": "application/xml",
|
|
65
|
+
".txt": "text/plain; charset=utf-8",
|
|
66
|
+
".svg": "image/svg+xml",
|
|
67
|
+
".png": "image/png",
|
|
68
|
+
".jpg": "image/jpeg",
|
|
69
|
+
".jpeg": "image/jpeg",
|
|
70
|
+
".webp": "image/webp",
|
|
71
|
+
".gif": "image/gif",
|
|
72
|
+
".woff2": "font/woff2",
|
|
73
|
+
".woff": "font/woff",
|
|
74
|
+
".wasm": "application/wasm",
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/** The script a served page is sent with. */
|
|
78
|
+
const RELOAD_SCRIPT = `<script>new EventSource(${JSON.stringify(RELOAD_PATH)}).addEventListener("reload", () => location.reload());</script>`;
|
|
79
|
+
|
|
80
|
+
/** Whether a note's text holds a query its build prepares ahead of the render. */
|
|
81
|
+
const holdsQuery = (text) => /```\s*sql\b|\{\{[^}]*\bsql\b/.test(text);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Serve a package's site, and keep it current.
|
|
85
|
+
*
|
|
86
|
+
* @param {object} options - The inputs.
|
|
87
|
+
* @param {object} options.config - The resolved configuration.
|
|
88
|
+
* @param {string} options.out - The site's directory under the deployed
|
|
89
|
+
* root, which the site is written to.
|
|
90
|
+
* @param {string} [options.root] - The deployed root, which is served. The
|
|
91
|
+
* directory above `out` by default; `out` itself for a site served at `/`.
|
|
92
|
+
* @param {string} [options.host] - The address to listen on.
|
|
93
|
+
* @param {number} [options.port] - The port; `0` takes any free one.
|
|
94
|
+
* @param {boolean} [options.search] - Build the search index after the first
|
|
95
|
+
* build, where the site has one.
|
|
96
|
+
* @param {number} [options.year] - The year the footer states.
|
|
97
|
+
* @param {(event: {kind: string, built?: object, files?: string[], findings?: object[], error?: unknown}) => void} [options.report] -
|
|
98
|
+
* Told of each build: `built` for a whole build, `page` for one note's page,
|
|
99
|
+
* `theme` for the theme, `failed` with what the build found, `error` with
|
|
100
|
+
* what it threw.
|
|
101
|
+
* @returns {Promise<{url: string, close: () => Promise<void>, settled: () => Promise<void>}>}
|
|
102
|
+
* The address of the site's home page, how to stop serving, and a promise
|
|
103
|
+
* for the moment every change seen so far has been written.
|
|
104
|
+
*/
|
|
105
|
+
export async function serveSite({
|
|
106
|
+
config,
|
|
107
|
+
out,
|
|
108
|
+
root = path.dirname(out),
|
|
109
|
+
host = "127.0.0.1",
|
|
110
|
+
port = 1313,
|
|
111
|
+
search = true,
|
|
112
|
+
year,
|
|
113
|
+
report = () => {},
|
|
114
|
+
}) {
|
|
115
|
+
/** @type {Set<http.ServerResponse>} */
|
|
116
|
+
const listeners = new Set();
|
|
117
|
+
let state = null;
|
|
118
|
+
|
|
119
|
+
const reload = () => {
|
|
120
|
+
for (const res of listeners) res.write("event: reload\ndata: {}\n\n");
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const buildAll = async () => {
|
|
124
|
+
const result = await buildPackageSite({ config, out, year });
|
|
125
|
+
if (!result.written || siteBuildFailed(result.built))
|
|
126
|
+
report({ kind: "failed", built: result.built });
|
|
127
|
+
if (!result.written) return false;
|
|
128
|
+
state = result;
|
|
129
|
+
report({ kind: "built", built: result.built, findings: result.written.findings });
|
|
130
|
+
return true;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const redrawNotes = (files) => {
|
|
134
|
+
if (!state) return false;
|
|
135
|
+
const changed = new Set();
|
|
136
|
+
for (const file of files) {
|
|
137
|
+
const text = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
138
|
+
if (!file.endsWith(".md") || !text || holdsQuery(text)) return false;
|
|
139
|
+
const entry = state.built.site.renderNote(file);
|
|
140
|
+
if (!entry) return false;
|
|
141
|
+
if (entry.findings.length) {
|
|
142
|
+
report({ kind: "failed", findings: entry.findings });
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
const page = state.pageOf(entry);
|
|
146
|
+
state.pages = state.pages.map((old) => (old.url === page.url ? page : old));
|
|
147
|
+
changed.add(page.url);
|
|
148
|
+
}
|
|
149
|
+
const written = writeSite({ site: state.site, pages: state.pages, out, only: changed });
|
|
150
|
+
report({ kind: "page", files: written.files, findings: written.findings });
|
|
151
|
+
return true;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const redrawTheme = () => {
|
|
155
|
+
if (!state) return false;
|
|
156
|
+
writeThemeFiles(out);
|
|
157
|
+
const written = writeSite({
|
|
158
|
+
site: state.site,
|
|
159
|
+
pages: state.pages,
|
|
160
|
+
out,
|
|
161
|
+
only: new Set(state.pages.map((page) => page.url)),
|
|
162
|
+
});
|
|
163
|
+
report({ kind: "theme", files: written.files, findings: written.findings });
|
|
164
|
+
return true;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const indexForSearch = async () => {
|
|
168
|
+
if (!search || config.site.search === false || !state) return;
|
|
169
|
+
await indexSite({ site: out, output: path.join(out, SEARCH_DIR), base: siteBase(config) });
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
await buildAll();
|
|
173
|
+
await indexForSearch();
|
|
174
|
+
|
|
175
|
+
/** @type {{content: Set<string>, theme: boolean}} */
|
|
176
|
+
let pending = { content: new Set(), theme: false };
|
|
177
|
+
let timer = null;
|
|
178
|
+
let work = Promise.resolve();
|
|
179
|
+
const flush = () => {
|
|
180
|
+
timer = null;
|
|
181
|
+
const batch = pending;
|
|
182
|
+
pending = { content: new Set(), theme: false };
|
|
183
|
+
work = work.then(async () => {
|
|
184
|
+
try {
|
|
185
|
+
let done = true;
|
|
186
|
+
if (batch.content.size && !redrawNotes([...batch.content])) {
|
|
187
|
+
done = await buildAll();
|
|
188
|
+
if (done) await indexForSearch();
|
|
189
|
+
} else if (batch.theme) done = redrawTheme();
|
|
190
|
+
if (done) reload();
|
|
191
|
+
} catch (error) {
|
|
192
|
+
report({ kind: "error", error });
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
};
|
|
196
|
+
const queue = (kind, file) => {
|
|
197
|
+
if (kind === "theme") pending.theme = true;
|
|
198
|
+
else pending.content.add(file);
|
|
199
|
+
if (!timer) timer = setTimeout(flush, SETTLE_MS);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const watchers = [
|
|
203
|
+
fs.watch(config.paths.content, { recursive: true }, (_event, name) => {
|
|
204
|
+
if (name) queue("content", path.join(config.paths.content, String(name)));
|
|
205
|
+
}),
|
|
206
|
+
...[SITE_THEME_DIR, SITE_STATIC_DIR]
|
|
207
|
+
.filter((dir) => fs.existsSync(dir))
|
|
208
|
+
.map((dir) => fs.watch(dir, { recursive: true }, () => queue("theme"))),
|
|
209
|
+
];
|
|
210
|
+
|
|
211
|
+
const server = http.createServer((req, res) => {
|
|
212
|
+
const address = decodeURIComponent(
|
|
213
|
+
new URL(req.url ?? "/", "http://serve.invalid").pathname,
|
|
214
|
+
);
|
|
215
|
+
if (address === RELOAD_PATH) {
|
|
216
|
+
res.writeHead(200, {
|
|
217
|
+
"content-type": "text/event-stream",
|
|
218
|
+
"cache-control": "no-cache",
|
|
219
|
+
connection: "keep-alive",
|
|
220
|
+
});
|
|
221
|
+
res.write(": connected\n\n");
|
|
222
|
+
listeners.add(res);
|
|
223
|
+
req.on("close", () => listeners.delete(res));
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
let file = path.join(root, address);
|
|
227
|
+
if (!file.startsWith(root)) {
|
|
228
|
+
res.writeHead(403).end();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (fs.statSync(file, { throwIfNoEntry: false })?.isDirectory())
|
|
232
|
+
file = path.join(file, "index.html");
|
|
233
|
+
let status = 200;
|
|
234
|
+
if (!fs.existsSync(file)) {
|
|
235
|
+
status = 404;
|
|
236
|
+
file = path.join(out, "404.html");
|
|
237
|
+
if (!fs.existsSync(file)) {
|
|
238
|
+
res.writeHead(404).end();
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const type = CONTENT_TYPES[path.extname(file).toLowerCase()] ?? "application/octet-stream";
|
|
243
|
+
let body = fs.readFileSync(file);
|
|
244
|
+
if (type.startsWith("text/html")) {
|
|
245
|
+
const html = body.toString("utf8");
|
|
246
|
+
const at = html.lastIndexOf("</body>");
|
|
247
|
+
body = Buffer.from(
|
|
248
|
+
at < 0 ? html + RELOAD_SCRIPT : html.slice(0, at) + RELOAD_SCRIPT + html.slice(at),
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
res.writeHead(status, { "content-type": type, "cache-control": "no-store" });
|
|
252
|
+
res.end(body);
|
|
253
|
+
});
|
|
254
|
+
await new Promise((resolve) => server.listen(port, host, resolve));
|
|
255
|
+
const listening = /** @type {import("node:net").AddressInfo} */ (server.address());
|
|
256
|
+
const base = new URL(config.homepage).pathname;
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
url: `http://${host}:${listening.port}${base}`,
|
|
260
|
+
settled: async () => {
|
|
261
|
+
while (timer || pending.content.size || pending.theme) {
|
|
262
|
+
await new Promise((resolve) => setTimeout(resolve, SETTLE_MS));
|
|
263
|
+
}
|
|
264
|
+
await work;
|
|
265
|
+
},
|
|
266
|
+
close: async () => {
|
|
267
|
+
if (timer) clearTimeout(timer);
|
|
268
|
+
for (const watcher of watchers) watcher.close();
|
|
269
|
+
for (const res of listeners) res.end();
|
|
270
|
+
await work;
|
|
271
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
}
|