@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.
Files changed (57) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +7 -0
  3. package/bin/content-commands.mjs +32 -8
  4. package/docs/api.md +57 -16
  5. package/docs/authoring/links-and-markup.md +51 -3
  6. package/docs/commands.md +60 -17
  7. package/docs/configuration.md +3 -2
  8. package/docs/content-format.md +10 -0
  9. package/docs/diagnostics.md +17 -0
  10. package/docs/guides/module-project.md +11 -3
  11. package/docs/guides/site.md +12 -2
  12. package/docs/guides/writing-in-invented-scripts.md +5 -2
  13. package/docs/reference/format-details.md +61 -11
  14. package/docs/reference/note-types.md +32 -32
  15. package/e2e.mjs +1 -1
  16. package/engine/base-compiler.mjs +2 -0
  17. package/engine/compile-corpus.mjs +6 -2
  18. package/engine/content-charset.mjs +1 -1
  19. package/engine/content-render.mjs +14 -2
  20. package/engine/content-scripts.mjs +44 -6
  21. package/engine/content-session.mjs +11 -7
  22. package/engine/content-spans.mjs +3 -4
  23. package/engine/content-verse.mjs +52 -10
  24. package/engine/core-report.mjs +27 -18
  25. package/engine/language-names.mjs +53 -23
  26. package/engine/markdownlint-run.mjs +1 -1
  27. package/engine/markup-validation.mjs +2 -0
  28. package/engine/note-vocabulary.mjs +10 -5
  29. package/engine/npc-generate.mjs +52 -7
  30. package/engine/page-breaks.mjs +115 -0
  31. package/engine/pdf-build.mjs +12 -4
  32. package/engine/pdf-render.mjs +10 -1
  33. package/engine/preview-index.mjs +4 -2
  34. package/engine/site-build.mjs +12 -3
  35. package/engine/site-preview.mjs +3 -0
  36. package/engine/site-render.mjs +18 -4
  37. package/engine/site-root.mjs +10 -1
  38. package/engine/site-serve-http.mjs +151 -0
  39. package/engine/site-serve.mjs +196 -126
  40. package/engine/verse-metre.mjs +50 -5
  41. package/engine/verse-regions.mjs +16 -0
  42. package/package.json +3 -3
  43. package/types/e2e.d.mts +1 -1
  44. package/types/engine/content-scripts.d.mts +9 -1
  45. package/types/engine/content-verse.d.mts +11 -2
  46. package/types/engine/core-report.d.mts +6 -2
  47. package/types/engine/language-names.d.mts +18 -6
  48. package/types/engine/npc-generate.d.mts +3 -0
  49. package/types/engine/page-breaks.d.mts +30 -0
  50. package/types/engine/pdf-render.d.mts +2 -0
  51. package/types/engine/site-build.d.mts +3 -1
  52. package/types/engine/site-render.d.mts +6 -2
  53. package/types/engine/site-root.d.mts +3 -1
  54. package/types/engine/site-serve-http.d.mts +1 -0
  55. package/types/engine/site-serve.d.mts +12 -6
  56. package/types/engine/verse-metre.d.mts +2 -1
  57. package/types/engine/verse-regions.d.mts +8 -0
@@ -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;
@@ -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,
@@ -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,
@@ -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
+ }