@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.
- package/CHANGELOG.md +40 -0
- package/README.md +7 -0
- package/assets/styles/content-base.css +1 -1
- package/bin/content-commands.mjs +32 -8
- package/docs/api.md +81 -32
- package/docs/authoring/frontmatter.md +1 -1
- package/docs/authoring/links-and-markup.md +59 -10
- package/docs/commands.md +60 -17
- package/docs/configuration.md +3 -2
- package/docs/content-format.md +50 -1
- package/docs/diagnostics.md +17 -0
- package/docs/guides/module-project.md +11 -3
- package/docs/guides/reference-pages.md +4 -1
- package/docs/guides/site.md +13 -3
- package/docs/guides/writing-in-invented-scripts.md +5 -2
- package/docs/reference/format-details.md +233 -138
- package/docs/reference/note-types.md +165 -152
- package/docs/reference/theme-pages.md +1 -0
- 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-format.yaml +9 -7
- package/engine/content-html.mjs +3 -2
- package/engine/content-index.mjs +23 -0
- package/engine/content-links.mjs +8 -0
- 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-tables.mjs +12 -5
- package/engine/content-verse.mjs +52 -10
- package/engine/core-report.mjs +27 -18
- package/engine/culture-reach.mjs +3 -0
- package/engine/declared-sections.mjs +100 -0
- package/engine/derived-sections.mjs +36 -2
- package/engine/dictionary-rows.mjs +2 -1
- package/engine/generated-sections.mjs +2 -0
- package/engine/holdings.mjs +107 -3
- package/engine/index-records.mjs +3 -1
- package/engine/index.mjs +1 -1
- package/engine/language-data.mjs +0 -1
- package/engine/language-names.mjs +53 -23
- package/engine/language-rows.mjs +1 -3
- package/engine/language-terms.mjs +7 -10
- package/engine/map-dot.mjs +22 -0
- package/engine/markdown-blocks.mjs +23 -8
- package/engine/markdownlint-run.mjs +1 -1
- package/engine/markup-validation.mjs +2 -0
- package/engine/metadata-index.mjs +1 -0
- package/engine/note-addresses.mjs +16 -0
- package/engine/note-vocabulary.mjs +29 -13
- package/engine/npc-generate.mjs +52 -7
- package/engine/office-holders.mjs +76 -0
- package/engine/page-breaks.mjs +143 -0
- package/engine/pdf-build.mjs +12 -4
- package/engine/pdf-render.mjs +12 -2
- package/engine/place-kinds.mjs +106 -0
- package/engine/preview-index.mjs +4 -2
- package/engine/settlement-tables.mjs +410 -675
- package/engine/settlement-terms.mjs +118 -0
- package/engine/site-build.mjs +14 -7
- 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/sql-tables.mjs +101 -7
- package/engine/standing-terms.mjs +9 -5
- package/engine/standings.mjs +83 -31
- package/engine/unique-items.mjs +85 -0
- package/engine/verse-metre.mjs +50 -5
- package/engine/verse-regions.mjs +16 -0
- package/package.json +9 -4
- package/site-theme/data/banner-kinds.yaml +2 -0
- package/sohl/actors.mjs +4 -3
- package/types/e2e.d.mts +1 -1
- package/types/engine/content-html.d.mts +2 -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/culture-reach.d.mts +3 -0
- package/types/engine/declared-sections.d.mts +30 -0
- package/types/engine/derived-sections.d.mts +5 -1
- package/types/engine/holdings.d.mts +34 -2
- package/types/engine/index-records.d.mts +2 -1
- package/types/engine/language-names.d.mts +18 -6
- package/types/engine/language-terms.d.mts +5 -1
- package/types/engine/markdown-blocks.d.mts +5 -1
- package/types/engine/npc-generate.d.mts +3 -0
- package/types/engine/office-holders.d.mts +33 -0
- package/types/engine/page-breaks.d.mts +38 -0
- package/types/engine/pdf-render.d.mts +2 -0
- package/types/engine/place-kinds.d.mts +18 -0
- package/types/engine/settlement-tables.d.mts +25 -158
- package/types/engine/settlement-terms.d.mts +53 -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/sql-tables.d.mts +35 -3
- package/types/engine/standing-terms.d.mts +3 -2
- package/types/engine/standings.d.mts +14 -2
- package/types/engine/unique-items.d.mts +12 -0
- package/types/engine/verse-metre.d.mts +2 -1
- package/types/engine/verse-regions.d.mts +8 -0
- 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
|
+
);
|
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";
|
|
@@ -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
|
|
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 —
|
|
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,
|
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
|
+
}
|