@heroiclands/package-build 25.5.4 → 25.5.5
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 +22 -0
- package/README.md +7 -0
- package/bin/content-commands.mjs +32 -8
- package/docs/api.md +57 -16
- package/docs/authoring/links-and-markup.md +51 -3
- package/docs/commands.md +60 -17
- package/docs/configuration.md +3 -2
- package/docs/content-format.md +10 -0
- package/docs/diagnostics.md +17 -0
- package/docs/guides/module-project.md +11 -3
- package/docs/guides/site.md +12 -2
- package/docs/guides/writing-in-invented-scripts.md +5 -2
- package/docs/reference/format-details.md +61 -11
- package/docs/reference/note-types.md +32 -32
- package/e2e.mjs +1 -1
- package/engine/base-compiler.mjs +2 -0
- package/engine/compile-corpus.mjs +6 -2
- package/engine/content-charset.mjs +1 -1
- package/engine/content-render.mjs +14 -2
- package/engine/content-scripts.mjs +44 -6
- package/engine/content-session.mjs +11 -7
- package/engine/content-spans.mjs +3 -4
- package/engine/content-verse.mjs +52 -10
- package/engine/core-report.mjs +27 -18
- package/engine/language-names.mjs +53 -23
- package/engine/markdownlint-run.mjs +1 -1
- package/engine/markup-validation.mjs +2 -0
- package/engine/note-vocabulary.mjs +10 -5
- package/engine/npc-generate.mjs +52 -7
- package/engine/page-breaks.mjs +115 -0
- package/engine/pdf-build.mjs +12 -4
- package/engine/pdf-render.mjs +10 -1
- package/engine/preview-index.mjs +4 -2
- package/engine/site-build.mjs +12 -3
- package/engine/site-preview.mjs +3 -0
- package/engine/site-render.mjs +18 -4
- package/engine/site-root.mjs +10 -1
- package/engine/site-serve-http.mjs +151 -0
- package/engine/site-serve.mjs +196 -126
- package/engine/verse-metre.mjs +50 -5
- package/engine/verse-regions.mjs +16 -0
- package/package.json +3 -3
- package/types/e2e.d.mts +1 -1
- package/types/engine/content-scripts.d.mts +9 -1
- package/types/engine/content-verse.d.mts +11 -2
- package/types/engine/core-report.d.mts +6 -2
- package/types/engine/language-names.d.mts +18 -6
- package/types/engine/npc-generate.d.mts +3 -0
- package/types/engine/page-breaks.d.mts +30 -0
- package/types/engine/pdf-render.d.mts +2 -0
- package/types/engine/site-build.d.mts +3 -1
- package/types/engine/site-render.d.mts +6 -2
- package/types/engine/site-root.d.mts +3 -1
- package/types/engine/site-serve-http.d.mts +1 -0
- package/types/engine/site-serve.d.mts +12 -6
- package/types/engine/verse-metre.d.mts +2 -1
- package/types/engine/verse-regions.d.mts +8 -0
package/engine/pdf-render.mjs
CHANGED
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
* @module
|
|
63
63
|
*/
|
|
64
64
|
|
|
65
|
+
import { scanPageBreaks } from "./page-breaks.mjs";
|
|
65
66
|
import { createRenderer } from "./content-render.mjs";
|
|
66
67
|
import { bookDraftNoticePreamble } from "./draft-notice.mjs";
|
|
67
68
|
import { ICON_PATTERN, ICON_SIZES } from "./content-icons.mjs";
|
|
@@ -359,6 +360,7 @@ function reportUnrenderable(source, definitions, opts, original) {
|
|
|
359
360
|
}
|
|
360
361
|
}
|
|
361
362
|
for (const error of [
|
|
363
|
+
...scanPageBreaks(source).errors,
|
|
362
364
|
...scanSpans(source).errors,
|
|
363
365
|
...scanRuns(source, opts.runs).errors,
|
|
364
366
|
...markupAnchorFindings(source),
|
|
@@ -469,6 +471,7 @@ function reportUnrenderable(source, definitions, opts, original) {
|
|
|
469
471
|
* @param {Array<{line: number, generated: boolean}>} [opts.lineMap] - From
|
|
470
472
|
* table expansion, mapping each line handed in back to the line an author
|
|
471
473
|
* wrote.
|
|
474
|
+
* @param {boolean} [opts.pageBreaks] - Internal: whether this slice remains at the note top level.
|
|
472
475
|
* @param {boolean} [opts.prepared] - Whether this body has been through the
|
|
473
476
|
* passes that resolve a wikilink, an embed and an inline expression. A note's
|
|
474
477
|
* has; a front-matter file's and a prose file's have not, and markup of theirs
|
|
@@ -547,6 +550,7 @@ export function markdownToTypst(markdown, opts = {}) {
|
|
|
547
550
|
footnotePrefix: opts.footnotePrefix ?? `footnote-${slugify(anchorPrefix || "body")}`,
|
|
548
551
|
scrolls: marked?.scrolls ?? opts.scrolls ?? new Map(),
|
|
549
552
|
verses: marked?.verses ?? opts.verses ?? new Map(),
|
|
553
|
+
pageBreaks: opts.pageBreaks !== false,
|
|
550
554
|
};
|
|
551
555
|
// A withheld section is set in the box a `:::secret` is set in, and its
|
|
552
556
|
// heading is set inside it: a printed page honours no reader permission, so
|
|
@@ -600,6 +604,7 @@ export function markdownToTypst(markdown, opts = {}) {
|
|
|
600
604
|
if (block.name === "div") {
|
|
601
605
|
const content = markdownToTypst(block.body, {
|
|
602
606
|
...sharedOptions,
|
|
607
|
+
pageBreaks: false,
|
|
603
608
|
insideAdmonition: false,
|
|
604
609
|
});
|
|
605
610
|
const anchor = block.id ? ` <${sectionLabel(anchorPrefix, block.id)}>` : "";
|
|
@@ -620,6 +625,7 @@ export function markdownToTypst(markdown, opts = {}) {
|
|
|
620
625
|
// of its own; a box holds none, and the scan has already said so.
|
|
621
626
|
const content = markdownToTypst(block.body, {
|
|
622
627
|
...sharedOptions,
|
|
628
|
+
pageBreaks: false,
|
|
623
629
|
insideAdmonition: !BLOCK_CONTAINERS.includes(block.name),
|
|
624
630
|
});
|
|
625
631
|
output.push(
|
|
@@ -695,7 +701,7 @@ export function markdownToTypst(markdown, opts = {}) {
|
|
|
695
701
|
/** Render one fragment with the note's footnote definitions available. */
|
|
696
702
|
function renderMarkdownSegment(source, md, ctx, definitions) {
|
|
697
703
|
const input = definitions ? `${source}\n\n${definitions}` : source;
|
|
698
|
-
const tokens = md.parse(input, { verses: ctx.verses });
|
|
704
|
+
const tokens = md.parse(input, { verses: ctx.verses, pageBreaks: ctx.pageBreaks });
|
|
699
705
|
const footnoteStart = tokens.findIndex((token) => token.type === "footnote_block_open");
|
|
700
706
|
const visible = footnoteStart < 0 ? tokens : tokens.slice(0, footnoteStart);
|
|
701
707
|
const footnotes = new Map();
|
|
@@ -791,6 +797,9 @@ function renderBlock(tokens, i, out, ctx) {
|
|
|
791
797
|
out.push(`\n#block(width: 100%)[${content}]${anchor}\n`);
|
|
792
798
|
return 1;
|
|
793
799
|
}
|
|
800
|
+
case "page_break":
|
|
801
|
+
out.push("\n#pagebreak(weak: true)\n\n");
|
|
802
|
+
return 1;
|
|
794
803
|
case "hr":
|
|
795
804
|
out.push("\n#line(length: 100%, stroke: 0.4pt)\n\n");
|
|
796
805
|
return 1;
|
package/engine/preview-index.mjs
CHANGED
|
@@ -37,7 +37,7 @@ import { noContentIndexPackages } from "./metadata-index.mjs";
|
|
|
37
37
|
import { isGmNote } from "./note-vocabulary.mjs";
|
|
38
38
|
import { assetAddressIndex } from "./art-fields.mjs";
|
|
39
39
|
import { assetPathnameKey, pathnameProblem, resolvePathname } from "./pathnames.mjs";
|
|
40
|
-
import { buildSiteIndex } from "./site-index.mjs";
|
|
40
|
+
import { buildSiteIndex, resolveInfoboxRef } from "./site-index.mjs";
|
|
41
41
|
import { siteBase } from "../content-config.mjs";
|
|
42
42
|
|
|
43
43
|
/**
|
|
@@ -107,7 +107,9 @@ export function previewIndex({ config, records, foreign }) {
|
|
|
107
107
|
return key ? assets?.byPath?.get(key) : undefined;
|
|
108
108
|
};
|
|
109
109
|
return {
|
|
110
|
-
runs: runContext(config, records
|
|
110
|
+
runs: runContext(config, records, {
|
|
111
|
+
referenceName: (target) => resolveInfoboxRef(site, target)?.name,
|
|
112
|
+
}),
|
|
111
113
|
icons: config.icons,
|
|
112
114
|
lookupAsset,
|
|
113
115
|
resolveSrc: webSrc,
|
package/engine/site-build.mjs
CHANGED
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
* @module
|
|
52
52
|
*/
|
|
53
53
|
|
|
54
|
+
import { scanPageBreaks } from "./page-breaks.mjs";
|
|
54
55
|
import { scanAlerts } from "./content-alerts.mjs";
|
|
55
56
|
import { positionOfYamlPath } from "./diagnostics.mjs";
|
|
56
57
|
import { decodeNoteAddresses, noteAddressContext } from "./note-addresses.mjs";
|
|
@@ -695,7 +696,9 @@ export function renderSitePage(
|
|
|
695
696
|
figuresByAddress = new Map(),
|
|
696
697
|
events,
|
|
697
698
|
drawings = new Map(),
|
|
698
|
-
runs = runContext(config
|
|
699
|
+
runs = runContext(config, undefined, {
|
|
700
|
+
referenceName: (target) => resolveInfoboxRef(index, target)?.name,
|
|
701
|
+
}),
|
|
699
702
|
},
|
|
700
703
|
) {
|
|
701
704
|
const secretErrors = [];
|
|
@@ -797,6 +800,7 @@ export function renderSitePage(
|
|
|
797
800
|
for (const error of [
|
|
798
801
|
...scanBlocks(page.body).errors,
|
|
799
802
|
...scanAlerts(page.body).errors,
|
|
803
|
+
...scanPageBreaks(page.body).errors,
|
|
800
804
|
...scanSpans(page.body).errors,
|
|
801
805
|
...scanRuns(page.body, runs).errors,
|
|
802
806
|
...markupAnchorFindings(page.body),
|
|
@@ -985,7 +989,9 @@ export function renderPages(pages, options) {
|
|
|
985
989
|
// What each page renders as, held until the graph is whole.
|
|
986
990
|
/** @type {Array<{page: object, markdown: string, figures: object[], data: object}>} */
|
|
987
991
|
const rendered = [];
|
|
988
|
-
const runs = runContext(config, records
|
|
992
|
+
const runs = runContext(config, records, {
|
|
993
|
+
referenceName: (target) => resolveInfoboxRef(index, target)?.name,
|
|
994
|
+
});
|
|
989
995
|
|
|
990
996
|
const renderOne = (page) =>
|
|
991
997
|
renderSitePage(page, {
|
|
@@ -1141,6 +1147,7 @@ export function sitePageDecorator(config, index) {
|
|
|
1141
1147
|
* @param {object} [options] - Options.
|
|
1142
1148
|
* @param {object} [options.config] - A resolved configuration; loaded when
|
|
1143
1149
|
* omitted.
|
|
1150
|
+
* @param {(message: string) => void} [options.progress] - Build phase summaries.
|
|
1144
1151
|
* @param {Map<string, object[]>} [options.sqlTables] - Prepared `sql` results,
|
|
1145
1152
|
* keyed by the note's absolute file, from
|
|
1146
1153
|
* {@link module:engine/sql-tables.prepareSqlTables}. A page authoring an
|
|
@@ -1157,7 +1164,7 @@ export function sitePageDecorator(config, index) {
|
|
|
1157
1164
|
* against the rest of the build, or returns `null` when its address, name
|
|
1158
1165
|
* or type changed and the whole site must be built again.
|
|
1159
1166
|
*/
|
|
1160
|
-
export function buildSite({ config, sqlTables } = {}) {
|
|
1167
|
+
export function buildSite({ config, sqlTables, progress = () => {} } = {}) {
|
|
1161
1168
|
const resolved = config ?? loadPackConfig();
|
|
1162
1169
|
const site = resolved.site;
|
|
1163
1170
|
const scheme = resolved.publish.address;
|
|
@@ -1247,6 +1254,7 @@ export function buildSite({ config, sqlTables } = {}) {
|
|
|
1247
1254
|
// the content tree for pages, so `sohl-kethira-basic` and `harn-adventures`
|
|
1248
1255
|
// cannot publish one whatever else their `site:` block declares.
|
|
1249
1256
|
if (!publishesContent) {
|
|
1257
|
+
progress(`rendering ${homepages.length} page(s)`);
|
|
1250
1258
|
return {
|
|
1251
1259
|
gates: emptyGates(),
|
|
1252
1260
|
manifests: null,
|
|
@@ -1322,6 +1330,7 @@ export function buildSite({ config, sqlTables } = {}) {
|
|
|
1322
1330
|
};
|
|
1323
1331
|
}
|
|
1324
1332
|
|
|
1333
|
+
progress(`rendering ${pages.length + homepages.length} page(s)`);
|
|
1325
1334
|
const pass = resolveSitePass(site.pass, {
|
|
1326
1335
|
...site.passOptions,
|
|
1327
1336
|
repoRoot: resolved.rootDir,
|
package/engine/site-preview.mjs
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
/** Buffer-only HTML preview shared by editors and external renderers. @module */
|
|
15
15
|
|
|
16
|
+
import { scanPageBreaks } from "./page-breaks.mjs";
|
|
16
17
|
import { sharedContentRules } from "./content-styles.mjs";
|
|
17
18
|
import { readMatter } from "./note-matter.mjs";
|
|
18
19
|
import { scanFigures } from "./content-figures.mjs";
|
|
@@ -99,6 +100,8 @@ export function renderPreview(
|
|
|
99
100
|
} catch (error) {
|
|
100
101
|
findings.push({ file, severity: "warning", message: String(error.message) });
|
|
101
102
|
}
|
|
103
|
+
for (const error of scanPageBreaks(body).errors)
|
|
104
|
+
findings.push({ file, severity: "error", ...error, line: bodyLine + error.line - 1 });
|
|
102
105
|
applyComputedBeingAge(frontmatter, date);
|
|
103
106
|
if (String(frontmatter.type ?? "").toLowerCase() === "being") {
|
|
104
107
|
frontmatter.data ??= {};
|
package/engine/site-render.mjs
CHANGED
|
@@ -312,18 +312,30 @@ export function writeThemeFiles(out, staticDir = SITE_STATIC_DIR) {
|
|
|
312
312
|
* @param {string} [options.staticDir] - The theme's own files, copied first.
|
|
313
313
|
* @param {ReadonlySet<string>} [options.only] - The addresses of the pages to
|
|
314
314
|
* draw again into a site already written. Nothing else is touched.
|
|
315
|
+
* @param {(message: string) => void} [options.progress] - Build phase summaries.
|
|
315
316
|
* @returns {{files: string[], pages: string[], reachable: string[],
|
|
316
317
|
* findings: object[]}} Every file written, the address of every page, the
|
|
317
318
|
* address of each page reachable from the homepage, and the layouts' findings.
|
|
318
319
|
* @throws {Error} When a page's address is outside the site, or a site served
|
|
319
320
|
* under a path asks for a sitemap index.
|
|
320
321
|
*/
|
|
321
|
-
export function writeSite({
|
|
322
|
+
export function writeSite({
|
|
323
|
+
site,
|
|
324
|
+
pages,
|
|
325
|
+
out,
|
|
326
|
+
report,
|
|
327
|
+
staticDir = SITE_STATIC_DIR,
|
|
328
|
+
only,
|
|
329
|
+
progress = () => {},
|
|
330
|
+
}) {
|
|
322
331
|
if (site.sitemaps && basePath(site) !== "/")
|
|
323
332
|
throw new Error(
|
|
324
333
|
`a sitemap index and robots.txt belong at the root of an origin, and this site is served under ${basePath(site)}`,
|
|
325
334
|
);
|
|
326
335
|
const tree = siteTree(site, pages);
|
|
336
|
+
progress(
|
|
337
|
+
`writing ${only ? tree.filter((page) => only.has(page.url)).length : tree.length} page(s)`,
|
|
338
|
+
);
|
|
327
339
|
const published = new Set(tree.map((page) => page.url));
|
|
328
340
|
const feedUrl = site.feed ? relUrl(site, `${site.feed.section}/atom.xml`) : "";
|
|
329
341
|
const base = basePath(site);
|
|
@@ -659,6 +671,7 @@ export function deliverSiteFonts(config, out) {
|
|
|
659
671
|
* @param {string} options.out - The directory the site is written to, which
|
|
660
672
|
* is the package's own directory under the deployed root.
|
|
661
673
|
* @param {number} [options.year] - The year the footer states.
|
|
674
|
+
* @param {(message: string) => void} [options.progress] - Build phase summaries.
|
|
662
675
|
* @returns {Promise<{built: object, written: object|null, fonts: object|null,
|
|
663
676
|
* site?: object, pages?: object[], pageOf?: (entry: object) => object}>}
|
|
664
677
|
* What the build found, and, when it found nothing that stops it, what was
|
|
@@ -666,18 +679,19 @@ export function deliverSiteFonts(config, out) {
|
|
|
666
679
|
* becomes its page. `written` is `null` when a gate fired.
|
|
667
680
|
* @throws {Error} When the site's settings or its cached menu cannot be read.
|
|
668
681
|
*/
|
|
669
|
-
export async function buildPackageSite({ config, out, year }) {
|
|
682
|
+
export async function buildPackageSite({ config, out, year, progress = () => {} }) {
|
|
670
683
|
const settings = siteSettings({ config });
|
|
671
684
|
const navigation = readCachedNavigation(config);
|
|
685
|
+
progress("indexing content");
|
|
672
686
|
const sqlTables = await prepareTreeSqlTables(config.paths.content, {
|
|
673
687
|
skipDirectories: config.skipDirectories,
|
|
674
688
|
config,
|
|
675
689
|
audience: "public",
|
|
676
690
|
});
|
|
677
|
-
const built = buildSite({ config, sqlTables,
|
|
691
|
+
const built = buildSite({ config, sqlTables, progress });
|
|
678
692
|
if (gatesFailed(built.gates)) return { built, written: null, fonts: null };
|
|
679
693
|
const { site, pages, pageOf } = packageSite({ config, built, settings, navigation, year });
|
|
680
|
-
const written = writeSite({ site, pages, out });
|
|
694
|
+
const written = writeSite({ site, pages, out, progress });
|
|
681
695
|
const fonts = deliverSiteFonts(config, out);
|
|
682
696
|
return { built, written, fonts, site, pages, pageOf };
|
|
683
697
|
}
|
package/engine/site-root.mjs
CHANGED
|
@@ -300,6 +300,7 @@ export async function indexSite({ site, output, base }) {
|
|
|
300
300
|
* `site.search` in the configuration. Default `true`.
|
|
301
301
|
* @param {SiteIndexer} [options.indexer] - What builds it. Default
|
|
302
302
|
* {@link indexSite}.
|
|
303
|
+
* @param {(message: string) => void} [options.progress] - Build phase summaries.
|
|
303
304
|
* @returns {Promise<{files: string[], search: {dir: string, pages: number}|null}>}
|
|
304
305
|
* The files written, and the index — where it is and how many pages it
|
|
305
306
|
* holds — or `null` when none was built.
|
|
@@ -308,7 +309,14 @@ export async function indexSite({ site, output, base }) {
|
|
|
308
309
|
* under the prefix; or, located at the rendered site, when the indexer
|
|
309
310
|
* fails.
|
|
310
311
|
*/
|
|
311
|
-
export async function writeSiteRoot({
|
|
312
|
+
export async function writeSiteRoot({
|
|
313
|
+
pkg,
|
|
314
|
+
base,
|
|
315
|
+
out,
|
|
316
|
+
search = true,
|
|
317
|
+
indexer = indexSite,
|
|
318
|
+
progress = () => {},
|
|
319
|
+
}) {
|
|
312
320
|
const root = path.resolve(out);
|
|
313
321
|
const prefix = base ?? `/${pkg}/`;
|
|
314
322
|
const rendered = path.join(root, ...prefix.split("/").filter(Boolean));
|
|
@@ -331,6 +339,7 @@ export async function writeSiteRoot({ pkg, base, out, search = true, indexer = i
|
|
|
331
339
|
}
|
|
332
340
|
let pages;
|
|
333
341
|
try {
|
|
342
|
+
progress("building the search index");
|
|
334
343
|
({ pages } = await indexer({ site: rendered, output: dir, base: prefix }));
|
|
335
344
|
} catch (err) {
|
|
336
345
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -0,0 +1,151 @@
|
|
|
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
|
+
* HTTP stays responsive while the calling thread renders a large site.
|
|
16
|
+
* Only filesystem and listening addresses cross this boundary; the resolved
|
|
17
|
+
* configuration and the build's render closures remain with their caller.
|
|
18
|
+
* @module
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import http from "node:http";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { parentPort, workerData } from "node:worker_threads";
|
|
25
|
+
|
|
26
|
+
/** What a file is sent as, by its extension. */
|
|
27
|
+
const CONTENT_TYPES = Object.freeze({
|
|
28
|
+
".html": "text/html; charset=utf-8",
|
|
29
|
+
".css": "text/css; charset=utf-8",
|
|
30
|
+
".js": "text/javascript; charset=utf-8",
|
|
31
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
32
|
+
".json": "application/json",
|
|
33
|
+
".xml": "application/xml",
|
|
34
|
+
".txt": "text/plain; charset=utf-8",
|
|
35
|
+
".svg": "image/svg+xml",
|
|
36
|
+
".png": "image/png",
|
|
37
|
+
".jpg": "image/jpeg",
|
|
38
|
+
".jpeg": "image/jpeg",
|
|
39
|
+
".webp": "image/webp",
|
|
40
|
+
".gif": "image/gif",
|
|
41
|
+
".woff2": "font/woff2",
|
|
42
|
+
".woff": "font/woff",
|
|
43
|
+
".wasm": "application/wasm",
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** The script a served page is sent with. */
|
|
47
|
+
const RELOAD_SCRIPT = `<script>new EventSource(${JSON.stringify(workerData?.reloadPath)}).addEventListener("reload", () => location.reload());</script>`;
|
|
48
|
+
|
|
49
|
+
if (parentPort) {
|
|
50
|
+
const { root, out, host, port, reloadPath: RELOAD_PATH } = workerData;
|
|
51
|
+
const listeners = new Set();
|
|
52
|
+
const server = http.createServer((req, res) => {
|
|
53
|
+
let address;
|
|
54
|
+
try {
|
|
55
|
+
address = decodeURIComponent(new URL(req.url ?? "/", "http://serve.invalid").pathname);
|
|
56
|
+
} catch {
|
|
57
|
+
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
|
|
58
|
+
res.end("Bad request.\n");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (address === RELOAD_PATH) {
|
|
62
|
+
res.writeHead(200, {
|
|
63
|
+
"content-type": "text/event-stream",
|
|
64
|
+
"cache-control": "no-cache",
|
|
65
|
+
connection: "keep-alive",
|
|
66
|
+
});
|
|
67
|
+
res.write(": connected\n\n");
|
|
68
|
+
listeners.add(res);
|
|
69
|
+
req.on("close", () => listeners.delete(res));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (!fs.existsSync(path.join(out, "index.html"))) {
|
|
73
|
+
res.writeHead(503, { "content-type": "text/plain; charset=utf-8", "retry-after": "1" });
|
|
74
|
+
res.end(
|
|
75
|
+
"No site is available yet. See build progress and diagnostics in the terminal.\n",
|
|
76
|
+
);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
let file = path.join(root, address);
|
|
80
|
+
if (file !== root && !file.startsWith(root + path.sep)) {
|
|
81
|
+
res.writeHead(403).end();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (fs.statSync(file, { throwIfNoEntry: false })?.isDirectory())
|
|
85
|
+
file = path.join(file, "index.html");
|
|
86
|
+
let status = 200;
|
|
87
|
+
if (!fs.existsSync(file)) {
|
|
88
|
+
status = 404;
|
|
89
|
+
file = path.join(out, "404.html");
|
|
90
|
+
if (!fs.existsSync(file)) {
|
|
91
|
+
res.writeHead(404).end();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const type = CONTENT_TYPES[path.extname(file).toLowerCase()] ?? "application/octet-stream";
|
|
96
|
+
let body = fs.readFileSync(file);
|
|
97
|
+
if (type.startsWith("text/html")) {
|
|
98
|
+
const html = body.toString("utf8");
|
|
99
|
+
const at = html.lastIndexOf("</body>");
|
|
100
|
+
body = Buffer.from(
|
|
101
|
+
at < 0 ? html + RELOAD_SCRIPT : html.slice(0, at) + RELOAD_SCRIPT + html.slice(at),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
res.writeHead(status, { "content-type": type, "cache-control": "no-store" });
|
|
105
|
+
res.end(body);
|
|
106
|
+
});
|
|
107
|
+
await new Promise((resolve, reject) => {
|
|
108
|
+
server.once("error", reject);
|
|
109
|
+
server.listen(port, host, resolve);
|
|
110
|
+
});
|
|
111
|
+
const listening = /** @type {import("node:net").AddressInfo} */ (server.address());
|
|
112
|
+
parentPort.postMessage({ kind: "listening", port: listening.port });
|
|
113
|
+
|
|
114
|
+
parentPort.on("message", (message) => {
|
|
115
|
+
if (message.kind === "reload") {
|
|
116
|
+
for (const res of listeners) res.write("event: reload\ndata: {}\n\n");
|
|
117
|
+
} else if (message.kind === "install") {
|
|
118
|
+
// Requests and these renames run on one event loop, so no request
|
|
119
|
+
// observes the gap between moving the old tree and installing it.
|
|
120
|
+
let moved = false;
|
|
121
|
+
try {
|
|
122
|
+
if (fs.existsSync(out)) {
|
|
123
|
+
fs.renameSync(out, message.previous);
|
|
124
|
+
moved = true;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
fs.renameSync(message.staged, out);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (moved) {
|
|
130
|
+
try {
|
|
131
|
+
fs.renameSync(message.previous, out);
|
|
132
|
+
} catch (rollback) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`the new site could not be installed or the previous one restored; the previous site remains at ${message.previous}`,
|
|
135
|
+
{ cause: rollback },
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
parentPort.postMessage({ kind: "installed" });
|
|
142
|
+
} catch (error) {
|
|
143
|
+
parentPort.postMessage({ kind: "installed", error });
|
|
144
|
+
}
|
|
145
|
+
} else if (message.kind === "close") {
|
|
146
|
+
for (const res of listeners) res.end();
|
|
147
|
+
server.close(() => parentPort.close());
|
|
148
|
+
server.closeAllConnections();
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
}
|