@heroiclands/package-build 25.5.4 → 25.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +7 -0
  3. package/assets/styles/content-base.css +1 -1
  4. package/bin/content-commands.mjs +32 -8
  5. package/docs/api.md +81 -32
  6. package/docs/authoring/frontmatter.md +1 -1
  7. package/docs/authoring/links-and-markup.md +59 -10
  8. package/docs/commands.md +60 -17
  9. package/docs/configuration.md +3 -2
  10. package/docs/content-format.md +50 -1
  11. package/docs/diagnostics.md +17 -0
  12. package/docs/guides/module-project.md +11 -3
  13. package/docs/guides/reference-pages.md +4 -1
  14. package/docs/guides/site.md +13 -3
  15. package/docs/guides/writing-in-invented-scripts.md +5 -2
  16. package/docs/reference/format-details.md +233 -138
  17. package/docs/reference/note-types.md +165 -152
  18. package/docs/reference/theme-pages.md +1 -0
  19. package/e2e.mjs +1 -1
  20. package/engine/base-compiler.mjs +2 -0
  21. package/engine/compile-corpus.mjs +6 -2
  22. package/engine/content-charset.mjs +1 -1
  23. package/engine/content-format.yaml +9 -7
  24. package/engine/content-html.mjs +3 -2
  25. package/engine/content-index.mjs +23 -0
  26. package/engine/content-links.mjs +8 -0
  27. package/engine/content-render.mjs +14 -2
  28. package/engine/content-scripts.mjs +44 -6
  29. package/engine/content-session.mjs +11 -7
  30. package/engine/content-spans.mjs +3 -4
  31. package/engine/content-tables.mjs +12 -5
  32. package/engine/content-verse.mjs +52 -10
  33. package/engine/core-report.mjs +27 -18
  34. package/engine/culture-reach.mjs +3 -0
  35. package/engine/declared-sections.mjs +100 -0
  36. package/engine/derived-sections.mjs +36 -2
  37. package/engine/dictionary-rows.mjs +2 -1
  38. package/engine/generated-sections.mjs +2 -0
  39. package/engine/holdings.mjs +107 -3
  40. package/engine/index-records.mjs +3 -1
  41. package/engine/index.mjs +1 -1
  42. package/engine/language-data.mjs +0 -1
  43. package/engine/language-names.mjs +53 -23
  44. package/engine/language-rows.mjs +1 -3
  45. package/engine/language-terms.mjs +7 -10
  46. package/engine/map-dot.mjs +22 -0
  47. package/engine/markdown-blocks.mjs +23 -8
  48. package/engine/markdownlint-run.mjs +1 -1
  49. package/engine/markup-validation.mjs +2 -0
  50. package/engine/metadata-index.mjs +1 -0
  51. package/engine/note-addresses.mjs +16 -0
  52. package/engine/note-vocabulary.mjs +29 -13
  53. package/engine/npc-generate.mjs +52 -7
  54. package/engine/office-holders.mjs +76 -0
  55. package/engine/page-breaks.mjs +143 -0
  56. package/engine/pdf-build.mjs +12 -4
  57. package/engine/pdf-render.mjs +12 -2
  58. package/engine/place-kinds.mjs +106 -0
  59. package/engine/preview-index.mjs +4 -2
  60. package/engine/settlement-tables.mjs +410 -675
  61. package/engine/settlement-terms.mjs +118 -0
  62. package/engine/site-build.mjs +14 -7
  63. package/engine/site-preview.mjs +3 -0
  64. package/engine/site-render.mjs +18 -4
  65. package/engine/site-root.mjs +10 -1
  66. package/engine/site-serve-http.mjs +151 -0
  67. package/engine/site-serve.mjs +196 -126
  68. package/engine/sql-tables.mjs +101 -7
  69. package/engine/standing-terms.mjs +9 -5
  70. package/engine/standings.mjs +83 -31
  71. package/engine/unique-items.mjs +85 -0
  72. package/engine/verse-metre.mjs +50 -5
  73. package/engine/verse-regions.mjs +16 -0
  74. package/package.json +9 -4
  75. package/site-theme/data/banner-kinds.yaml +2 -0
  76. package/sohl/actors.mjs +4 -3
  77. package/types/e2e.d.mts +1 -1
  78. package/types/engine/content-html.d.mts +2 -1
  79. package/types/engine/content-scripts.d.mts +9 -1
  80. package/types/engine/content-verse.d.mts +11 -2
  81. package/types/engine/core-report.d.mts +6 -2
  82. package/types/engine/culture-reach.d.mts +3 -0
  83. package/types/engine/declared-sections.d.mts +30 -0
  84. package/types/engine/derived-sections.d.mts +5 -1
  85. package/types/engine/holdings.d.mts +34 -2
  86. package/types/engine/index-records.d.mts +2 -1
  87. package/types/engine/language-names.d.mts +18 -6
  88. package/types/engine/language-terms.d.mts +5 -1
  89. package/types/engine/markdown-blocks.d.mts +5 -1
  90. package/types/engine/npc-generate.d.mts +3 -0
  91. package/types/engine/office-holders.d.mts +33 -0
  92. package/types/engine/page-breaks.d.mts +38 -0
  93. package/types/engine/pdf-render.d.mts +2 -0
  94. package/types/engine/place-kinds.d.mts +18 -0
  95. package/types/engine/settlement-tables.d.mts +25 -158
  96. package/types/engine/settlement-terms.d.mts +53 -0
  97. package/types/engine/site-build.d.mts +3 -1
  98. package/types/engine/site-render.d.mts +6 -2
  99. package/types/engine/site-root.d.mts +3 -1
  100. package/types/engine/site-serve-http.d.mts +1 -0
  101. package/types/engine/site-serve.d.mts +12 -6
  102. package/types/engine/sql-tables.d.mts +35 -3
  103. package/types/engine/standing-terms.d.mts +3 -2
  104. package/types/engine/standings.d.mts +14 -2
  105. package/types/engine/unique-items.d.mts +12 -0
  106. package/types/engine/verse-metre.d.mts +2 -1
  107. package/types/engine/verse-regions.d.mts +8 -0
  108. package/vendor/markdownlint.mjs +855 -569
@@ -0,0 +1,118 @@
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
+ /** Dependency-free settlement row declarations. @module */
15
+ /** The generated heading. */
16
+ export const SETTLEMENTS_HEADING = "Settlements";
17
+
18
+ /** Rendered columns, in their fixed order; Held by and For appear when a row states them. */
19
+ export const SETTLEMENT_COLUMNS = Object.freeze([
20
+ Object.freeze({ name: "Name", required: true }),
21
+ Object.freeze({ name: "Market", required: true }),
22
+ Object.freeze({ name: "People", required: true }),
23
+ Object.freeze({ name: "Held by", required: false }),
24
+ Object.freeze({ name: "For", required: false }),
25
+ ]);
26
+
27
+ /** The closed row fields; existing subjects use native Addresses. */
28
+ export const SETTLEMENT_ROW_FIELDS = Object.freeze(
29
+ [
30
+ {
31
+ name: "place",
32
+ kind: "address",
33
+ shape: "a place Address",
34
+ describe: "The existing settlement note; exclusive with name.",
35
+ },
36
+ {
37
+ name: "name",
38
+ kind: "string",
39
+ shape: "plain text",
40
+ describe: "A settlement without a note; exclusive with place.",
41
+ },
42
+ {
43
+ name: "market",
44
+ kind: "number",
45
+ required: true,
46
+ shape: "a market class, 1 to 6",
47
+ describe: "What trade the settlement supports.",
48
+ },
49
+ {
50
+ name: "population",
51
+ kind: "number",
52
+ required: true,
53
+ shape: "a positive whole number",
54
+ describe: "How many people live here.",
55
+ },
56
+ {
57
+ name: "heldBy",
58
+ kind: "address",
59
+ shape: "an affiliation or being Address",
60
+ describe:
61
+ "What holds the settlement's land, where its culture holds land; what the holder is says how it is held.",
62
+ },
63
+ {
64
+ name: "for",
65
+ kind: "string",
66
+ shape: "plain text",
67
+ describe: "Optional reason the settlement exists.",
68
+ },
69
+ ].map(Object.freeze),
70
+ );
71
+
72
+ /** The place vocabulary's single settlement declaration. */
73
+ export const SETTLEMENTS_FIELD = Object.freeze({
74
+ name: "settlements",
75
+ kind: "map",
76
+ shape: "{ rows, tail }",
77
+ fields: Object.freeze([
78
+ {
79
+ name: "rows",
80
+ kind: "list",
81
+ required: true,
82
+ shape: "a list of settlement rows",
83
+ entries: { kind: "map", shape: "a settlement row", fields: SETTLEMENT_ROW_FIELDS },
84
+ describe: "One row per settlement.",
85
+ },
86
+ {
87
+ name: "tail",
88
+ kind: "string",
89
+ required: true,
90
+ shape: "a nonempty sentence",
91
+ describe: "What the table leaves out.",
92
+ },
93
+ ]),
94
+ describe:
95
+ "A region's settlement rows and the sentence stating what they leave out; the table is generated from this declaration.",
96
+ });
97
+
98
+ /** Every semantic finding, named once for the reference and its guards. */
99
+ export const SETTLEMENT_TABLE_RULES = Object.freeze(
100
+ [
101
+ [
102
+ "body settlements table",
103
+ "A body table under Settlements is refused; declare data.settlements.",
104
+ ],
105
+ ["settlements shape", "A region declares rows and tail, with one name or place per row."],
106
+ ["unresolved settlement reference", "A place or heldBy Address does not resolve."],
107
+ ["market class", "A row's market is not an integer class from 1 to 6."],
108
+ ["people figure", "A row's population is not a positive whole number."],
109
+ ["not a holder", "A heldBy Address names neither an affiliation nor a being."],
110
+ ["not a settlement", "A place Address names a subject that is not a place."],
111
+ ["figure disagrees", "A linked population differs from its note, or the note states none."],
112
+ ["market disagrees", "A linked market differs from its note, or the note states none."],
113
+ ["unlinked settlement", "A plain name has an existing settlement note in this region."],
114
+ ["missing row", "A covered settlement note has no row."],
115
+ ["duplicate settlement", "A settlement has another row here or in another region."],
116
+ ["no tail", "The declaration has no sentence stating what it leaves out."],
117
+ ].map(([name, describe]) => Object.freeze({ name, describe })),
118
+ );
@@ -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";
@@ -274,11 +275,9 @@ export function collectContentPages(contentBase, ctx) {
274
275
  // not use it: a note is addressed as `type/shortcode` wherever it
275
276
  // is filed.
276
277
  tld: rel.split(path.sep)[0],
277
- // Location below the content root, POSIX-separated — what a
278
- // generated table reads as `file.path` and scopes on with `FROM`.
278
+ // Location below the content root, POSIX-separated.
279
279
  relPath: rel.split(path.sep).join("/"),
280
- // The immediate source subfolder — what a content table reads as
281
- // `file.folder`, and the page's `kbfolder`.
280
+ // The immediate source subfolder — the page's `kbfolder`.
282
281
  folder: path.basename(path.dirname(file)),
283
282
  // Every page is addressed by `(type, shortcode)` at the package
284
283
  // root, which takes no content mount.
@@ -695,7 +694,9 @@ export function renderSitePage(
695
694
  figuresByAddress = new Map(),
696
695
  events,
697
696
  drawings = new Map(),
698
- runs = runContext(config),
697
+ runs = runContext(config, undefined, {
698
+ referenceName: (target) => resolveInfoboxRef(index, target)?.name,
699
+ }),
699
700
  },
700
701
  ) {
701
702
  const secretErrors = [];
@@ -797,6 +798,7 @@ export function renderSitePage(
797
798
  for (const error of [
798
799
  ...scanBlocks(page.body).errors,
799
800
  ...scanAlerts(page.body).errors,
801
+ ...scanPageBreaks(page.body).errors,
800
802
  ...scanSpans(page.body).errors,
801
803
  ...scanRuns(page.body, runs).errors,
802
804
  ...markupAnchorFindings(page.body),
@@ -985,7 +987,9 @@ export function renderPages(pages, options) {
985
987
  // What each page renders as, held until the graph is whole.
986
988
  /** @type {Array<{page: object, markdown: string, figures: object[], data: object}>} */
987
989
  const rendered = [];
988
- const runs = runContext(config, records);
990
+ const runs = runContext(config, records, {
991
+ referenceName: (target) => resolveInfoboxRef(index, target)?.name,
992
+ });
989
993
 
990
994
  const renderOne = (page) =>
991
995
  renderSitePage(page, {
@@ -1141,6 +1145,7 @@ export function sitePageDecorator(config, index) {
1141
1145
  * @param {object} [options] - Options.
1142
1146
  * @param {object} [options.config] - A resolved configuration; loaded when
1143
1147
  * omitted.
1148
+ * @param {(message: string) => void} [options.progress] - Build phase summaries.
1144
1149
  * @param {Map<string, object[]>} [options.sqlTables] - Prepared `sql` results,
1145
1150
  * keyed by the note's absolute file, from
1146
1151
  * {@link module:engine/sql-tables.prepareSqlTables}. A page authoring an
@@ -1157,7 +1162,7 @@ export function sitePageDecorator(config, index) {
1157
1162
  * against the rest of the build, or returns `null` when its address, name
1158
1163
  * or type changed and the whole site must be built again.
1159
1164
  */
1160
- export function buildSite({ config, sqlTables } = {}) {
1165
+ export function buildSite({ config, sqlTables, progress = () => {} } = {}) {
1161
1166
  const resolved = config ?? loadPackConfig();
1162
1167
  const site = resolved.site;
1163
1168
  const scheme = resolved.publish.address;
@@ -1247,6 +1252,7 @@ export function buildSite({ config, sqlTables } = {}) {
1247
1252
  // the content tree for pages, so `sohl-kethira-basic` and `harn-adventures`
1248
1253
  // cannot publish one whatever else their `site:` block declares.
1249
1254
  if (!publishesContent) {
1255
+ progress(`rendering ${homepages.length} page(s)`);
1250
1256
  return {
1251
1257
  gates: emptyGates(),
1252
1258
  manifests: null,
@@ -1322,6 +1328,7 @@ export function buildSite({ config, sqlTables } = {}) {
1322
1328
  };
1323
1329
  }
1324
1330
 
1331
+ progress(`rendering ${pages.length + homepages.length} page(s)`);
1325
1332
  const pass = resolveSitePass(site.pass, {
1326
1333
  ...site.passOptions,
1327
1334
  repoRoot: resolved.rootDir,
@@ -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 ??= {};
@@ -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({ site, pages, out, report, staticDir = SITE_STATIC_DIR, only }) {
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, write: false });
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
  }
@@ -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({ pkg, base, out, search = true, indexer = indexSite }) {
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
+ }