@heroiclands/package-build 24.1.0 → 24.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 24.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ **Content builds**
8
+
9
+ - Build indexes, calendars, assets and packs with
10
+ `package-build content build --calendaria-version 1.4.2`, sharing preparation
11
+ across validation and compilation.
12
+
3
13
  ## 24.1.0
4
14
 
5
15
  ### Minor Changes
package/README.md CHANGED
@@ -16,6 +16,8 @@ npm install -D @heroiclands/package-build
16
16
 
17
17
  The generated project includes a homepage, website and book targets, npm scripts, and a local `.gitignore`. [Getting started](docs/getting-started.md) routes you to a [system](docs/guides/system-project.md), [module](docs/guides/module-project.md), or [content authoring](docs/authoring/first-note.md) path. The [content-language-server](https://github.com/HeroicLands/content-language-server) provides editor navigation and completion.
18
18
 
19
+ Build indexes, calendars, staged assets and packs together with `package-build content build --calendaria-version 1.4.2`. The [complete content build](docs/commands.md#package-build-content-build) shares one source capture across publication, lint, links and compilation and reports each build stage's time.
20
+
19
21
  Run selected content operations together with `package-build content run index lint links compile`. The [shared content command](docs/commands.md#package-build-content-run-targets) captures one source tree for the selected checks and outputs; standalone commands remain available.
20
22
 
21
23
  Language annotations keep authored spelling: `[Zaron]{lang=vyl}` stays Roman.
@@ -135,6 +135,10 @@ import { serveSite } from "../engine/site-serve.mjs";
135
135
  import { writeSiteRoot } from "../engine/site-root.mjs";
136
136
  import { auditLinks, buildLinkIndex, walkReachability } from "../engine/content-links.mjs";
137
137
  import { prepareLinkSqlTables, prepareTreeSqlTables } from "../engine/sql-tables.mjs";
138
+ import { createContentTimings } from "../engine/content-timings.mjs";
139
+ import { emitCalendarArtifacts } from "../engine/calendar-artifacts.mjs";
140
+ import { stagePackageBuildAssets } from "../engine/build-assets.mjs";
141
+ import { DEPLOYED_ASSET_TYPES } from "../engine/asset-types.mjs";
138
142
  import { createContentSession, CONTENT_TARGETS } from "../engine/content-session.mjs";
139
143
  import { createFirstUseChecker } from "../engine/first-use.mjs";
140
144
  import { createNameCatalog, createNamesReport, namesReportFinding } from "../engine/names.mjs";
@@ -297,7 +301,7 @@ export function registerContentCommands(cli) {
297
301
  .command(proseCommand())
298
302
  .command(contentFormatCommand())
299
303
  .command(withIndexPreflight(linksCommand()))
300
- .command(withIndexPreflight(contentCommand()))
304
+ .command(contentCommand())
301
305
  .command(formatCommand())
302
306
  .command(markdownCommand())
303
307
  .command(
@@ -1494,25 +1498,187 @@ function markdownCommand() {
1494
1498
  };
1495
1499
  }
1496
1500
 
1497
- /**
1498
- * `package-build links` — check that every link in a content tree lands.
1499
- *
1500
- * Reports a dead `#anchor`, a dead qualified address, and a wikilink authored
1501
- * in frontmatter, plus a vendored manifest that has drifted out of reach. All
1502
- * of it is package-agnostic, so a consumer needs no script of its own: the
1503
- * manifest directory is the only thing it might name, and that comes from its
1504
- * configuration.
1505
- *
1506
- * @returns {object} The yargs command module.
1507
- */
1508
- // eslint-disable-next-line
1501
+ /** Content operations with a shared invocation-local capture. */
1509
1502
  function contentCommand() {
1510
1503
  return {
1511
- command: "content <action> <targets..>",
1504
+ command: "content",
1505
+ describe: "Run content operations in one in-memory session",
1506
+ builder: (yargs) =>
1507
+ yargs
1508
+ .command(withIndexPreflight(contentRunCommand()))
1509
+ .command(contentBuildCommand())
1510
+ .demandCommand(1, "Choose content run or content build."),
1511
+ handler: () => {},
1512
+ };
1513
+ }
1514
+
1515
+ /** The complete build writes only outside the captured input roots. */
1516
+ function contentBuildOutputs(config, packaging) {
1517
+ const roots = [
1518
+ config.paths.content,
1519
+ ...DEPLOYED_ASSET_TYPES.map((entry) => path.resolve(config.paths.assets, entry.root)),
1520
+ ];
1521
+ const stage = path.resolve(packaging.rootDir, packaging.stageDir);
1522
+ const outputs = [
1523
+ config.paths.contentIndex,
1524
+ config.paths.packJson,
1525
+ config.paths.stage,
1526
+ stage,
1527
+ path.join(config.rootDir, "build/calendars"),
1528
+ ];
1529
+ const contains = (parent, child) => {
1530
+ const relative = path.relative(path.resolve(parent), path.resolve(child));
1531
+ return (
1532
+ relative === "" ||
1533
+ (!relative.startsWith(`..${path.sep}`) &&
1534
+ relative !== ".." &&
1535
+ !path.isAbsolute(relative))
1536
+ );
1537
+ };
1538
+ for (const output of [...outputs, ...packaging.assets.map(({ to }) => path.resolve(stage, to))])
1539
+ for (const root of roots)
1540
+ if (contains(root, output) || contains(output, root))
1541
+ throw new Error(
1542
+ `content build output ${output} overlaps captured input ${root}; keep build outputs outside source roots`,
1543
+ );
1544
+ return [
1545
+ ...new Set([
1546
+ config.paths.contentIndex,
1547
+ config.paths.packJson,
1548
+ path.join(config.rootDir, "build/calendars"),
1549
+ ...config.packDirectories.map((name) => path.join(config.paths.stage, name)),
1550
+ ]),
1551
+ ];
1552
+ }
1553
+
1554
+ /** Publish, generate calendars, stage assets and check/compile one live session. */
1555
+ function contentBuildCommand() {
1556
+ return {
1557
+ command: "build",
1558
+ describe:
1559
+ "Publish fresh indexes, emit calendars, stage assets, lint, check links and compile",
1560
+ builder: (yargs) =>
1561
+ yargs
1562
+ .option("calendaria-version", {
1563
+ type: "string",
1564
+ demandOption: true,
1565
+ describe: "Calendaria import format version",
1566
+ })
1567
+ .check((argv) => {
1568
+ if (!/^\d+\.\d+\.\d+$/.test(argv.calendariaVersion))
1569
+ throw new Error("--calendaria-version requires a version such as 1.4.2");
1570
+ return true;
1571
+ }),
1572
+ handler: async (argv) => {
1573
+ const timings = createContentTimings();
1574
+ let session;
1575
+ const shared = {
1576
+ ...argv,
1577
+ references: true,
1578
+ [PREPARATION_REPORTED]: true,
1579
+ [REPORTED_FAILURES]: new Set(),
1580
+ };
1581
+ const fail = (error) => {
1582
+ reportContentFailure(error, shared);
1583
+ process.exitCode = 1;
1584
+ };
1585
+ try {
1586
+ const config = loadPackConfig();
1587
+ const marker = path.join(config.paths.contentIndex, ".publication-ready");
1588
+ fs.rmSync(marker, { force: true });
1589
+ const packaging = loadPackageBuildConfig();
1590
+ const outputs = contentBuildOutputs(config, packaging);
1591
+ for (const output of outputs) fs.rmSync(output, { recursive: true, force: true });
1592
+ cachedMetadataIndexes(config);
1593
+ session = await createContentSession({ config, timings, trackChanges: true });
1594
+ shared[CONTENT_SESSION] = session;
1595
+ let prepared = false;
1596
+ const reportedProblems = new Set();
1597
+ try {
1598
+ assertCompendiumOperation(config, "compile");
1599
+ await session.prepare(["index"]);
1600
+ prepared = true;
1601
+ } catch (error) {
1602
+ if (error.findings) {
1603
+ for (const finding of error.findings) {
1604
+ emitDiagnostic(finding);
1605
+ reportedProblems.add(finding);
1606
+ }
1607
+ shared[REPORTED_FAILURES].add(error);
1608
+ process.exitCode = 1;
1609
+ } else fail(error);
1610
+ }
1611
+ for (const problem of session.problems) {
1612
+ if (!reportedProblems.has(problem)) emitDiagnostic(problem);
1613
+ process.exitCode = 1;
1614
+ }
1615
+ let staged = false;
1616
+ if (
1617
+ prepared &&
1618
+ (await timings.measure("index publication", () =>
1619
+ contentIndexCommand().handler(shared),
1620
+ ))
1621
+ ) {
1622
+ fs.writeFileSync(marker, `${config.contentPackage}\n`);
1623
+ try {
1624
+ await timings.measure("calendars", () => {
1625
+ const result = emitCalendarArtifacts({
1626
+ config,
1627
+ calendariaVersion: argv.calendariaVersion,
1628
+ sources: session.sources,
1629
+ });
1630
+ log.info(
1631
+ `${result.calendars} calendars emitted (${result.files} files).`,
1632
+ );
1633
+ });
1634
+ await timings.measure("assets", () =>
1635
+ stagePackageBuildAssets({
1636
+ config: packaging,
1637
+ packConfig: config,
1638
+ session,
1639
+ }),
1640
+ );
1641
+ staged = true;
1642
+ } catch (error) {
1643
+ fail(error);
1644
+ }
1645
+ try {
1646
+ session.assertUnchanged();
1647
+ } catch (error) {
1648
+ fs.rmSync(marker, { force: true });
1649
+ throw error;
1650
+ }
1651
+ }
1652
+ await timings.measure("validation", () => lintCommand().handler(shared));
1653
+ await timings.measure("links", () => linksCommand().handler(shared));
1654
+ if (staged)
1655
+ await timings.measure("compile", () =>
1656
+ packageCommand().handler({ ...shared, action: "compile" }),
1657
+ );
1658
+ } catch (error) {
1659
+ fail(error);
1660
+ } finally {
1661
+ const summary = timings.snapshot();
1662
+ log.info(
1663
+ `Content timings (exclusive): ${Object.entries(summary.stages)
1664
+ .map(([name, ms]) => `${name} ${Number(ms).toFixed(1)} ms`)
1665
+ .join("; ")}; total ${summary.totalMs.toFixed(1)} ms.`,
1666
+ );
1667
+ log.info(
1668
+ `Content work: ${session?.sources.size ?? 0} sources; ${session?.parsed.size ?? 0} parsed notes; ${session?.records.length ?? 0} records; reuse ${JSON.stringify(summary.hits)}; peak RSS ${(process.resourceUsage().maxRSS / 1024).toFixed(1)} MiB.`,
1669
+ );
1670
+ session?.dispose();
1671
+ }
1672
+ },
1673
+ };
1674
+ }
1675
+
1676
+ function contentRunCommand() {
1677
+ return {
1678
+ command: "run <targets..>",
1512
1679
  describe: "Run selected content operations with one in-memory source capture",
1513
1680
  builder: (yargs) =>
1514
1681
  yargs
1515
- .positional("action", { type: "string", choices: ["run"] })
1516
1682
  .positional("targets", { type: "string", array: true, choices: CONTENT_TARGETS })
1517
1683
  .option("references", {
1518
1684
  type: "boolean",
@@ -1801,9 +1967,11 @@ function contentIndexCommand() {
1801
1967
  `${config.contentPackage} → ${path.relative(process.cwd(), written.file)} ` +
1802
1968
  `(${written.rows} ${relation} rows)`,
1803
1969
  );
1970
+ return true;
1804
1971
  } catch (err) {
1805
1972
  reportContentFailure(err, argv);
1806
1973
  process.exitCode = 1;
1974
+ return false;
1807
1975
  } finally {
1808
1976
  if (!argv[CONTENT_SESSION]) session?.dispose();
1809
1977
  }
@@ -87,20 +87,13 @@ import yargs from "yargs";
87
87
  import { hideBin } from "yargs/helpers";
88
88
 
89
89
  import { loadPackageBuildConfig } from "../config.mjs";
90
- import { checkForeignAssetBindings } from "../engine/asset-bindings.mjs";
91
- import { emitDiagnostic, formatDiagnostic, positionOfYamlPath } from "../engine/diagnostics.mjs";
90
+ import { loadAssetTransform, stagePackageBuildAssets } from "../engine/build-assets.mjs";
91
+ import { emitDiagnostic, formatDiagnostic } from "../engine/diagnostics.mjs";
92
92
  import { writeSiteRoot } from "../engine/site-root.mjs";
93
93
  import { DEPLOY_ROOT } from "../engine/site-config.mjs";
94
94
  import { compilesFoundryDocuments, siteBase } from "../content-config.mjs";
95
95
  import { loadPackConfig, packConfigPath, resolveConfigFile } from "../engine/pack-config.mjs";
96
- import { cleanBuildArtifacts, resetStage, stageAssets } from "../stage.mjs";
97
- import { rowsStagingPictureRoots, stagePackageAssets } from "../engine/asset-shipping.mjs";
98
- import { baseStyleStageEntry } from "../engine/base-styles.mjs";
99
- import {
100
- checkScriptFonts,
101
- copyScriptFonts,
102
- STAGED_FONTS_DIR,
103
- } from "../engine/script-font-delivery.mjs";
96
+ import { cleanBuildArtifacts, resetStage } from "../stage.mjs";
104
97
  import {
105
98
  CDN_ENV,
106
99
  cdnCredentials,
@@ -438,171 +431,13 @@ function cleanCommand() {
438
431
  };
439
432
  }
440
433
 
441
- /**
442
- * A finding for each `packageBuild.assets` row that would stage a picture root
443
- * itself, located at the row's `from` when the configuration is YAML.
444
- *
445
- * @param {readonly {from: string, to: string}[]} rows - The configured rows.
446
- * @param {{rootDir: string, assetsBase: string}} opts - Where they resolve.
447
- * @returns {object[]} The findings.
448
- */
449
- function pictureRootRowFindings(rows, { rootDir, assetsBase }) {
450
- const offending = rowsStagingPictureRoots(rows, { rootDir, assetsBase });
451
- if (!offending.length) return [];
452
- const file = packConfigPath();
453
- const text =
454
- file.endsWith(".yaml") || file.endsWith(".yml") ? fs.readFileSync(file, "utf8") : "";
455
- return offending.map(({ index, from, root }) => ({
456
- file,
457
- ...(text ? positionOfYamlPath(text, ["packageBuild", "assets", index, "from"]) : {}),
458
- severity: "error",
459
- message:
460
- `\`packageBuild.assets[${index}]\` copies \`${from}\`, which reaches ${root}/ — ` +
461
- "the stage copies the icon and image roots itself, flat and filtered by " +
462
- "each file's provenance, so a row copying them would ship every file " +
463
- "unfiltered. Remove the row",
464
- }));
465
- }
466
-
467
- /**
468
- * The repository's `packageBuild.assetTransform`, loaded.
469
- *
470
- * One loader for every command that writes a picture, so the stage and the CDN
471
- * hold the same bytes for the same file.
472
- *
473
- * @param {object} config - The resolved package-build configuration.
474
- * @returns {Promise<((sourcePath: string) => string|null|undefined)|undefined>}
475
- * The transform, or `undefined` when the repository names none.
476
- */
477
- async function loadAssetTransform(config) {
478
- if (!config.assetTransform) return undefined;
479
- const module = await import(`file://${config.assetTransform}`).catch((err) =>
480
- die(
481
- `cannot load \`packageBuild.assetTransform\` ` +
482
- `(${config.assetTransform}): ${err.message}`,
483
- ),
484
- );
485
- const transform = module.transform;
486
- if (typeof transform !== "function") {
487
- die(
488
- `\`packageBuild.assetTransform\` ` +
489
- `(${config.assetTransform}) exports no \`transform\` ` +
490
- `function. It must export ` +
491
- `\`transform(sourcePath) -> string | null\`.`,
492
- );
493
- }
494
- return transform;
495
- }
496
-
497
- /**
498
- * `assets` — stage the repository's static files into the package root.
499
- *
500
- * The table is data (`packageBuild.assets`). A repository that has to *change*
501
- * a file on the way — SoHL rewrites each SVG's hard-coded fill so icons follow
502
- * the Foundry theme — names a module in `packageBuild.assetTransform`, whose
503
- * `transform(sourcePath)` returns replacement text or `null` to copy verbatim.
504
- * That is the one genuine piece of code in the job, and it stays the
505
- * repository's.
506
- *
507
- * @returns {object} The yargs command module.
508
- */
434
+ /** Stage the configured package assets. */
509
435
  function assetsCommand() {
510
436
  return {
511
437
  command: "assets",
512
438
  describe: "Stage static assets into the package root",
513
439
  builder: (y) => y,
514
- handler: handler(async () => {
515
- const config = loadPackageBuildConfig();
516
- const packConfig = loadPackConfig();
517
- const assetsBase = packConfig.paths.assets;
518
- const findings = [
519
- ...pictureRootRowFindings(config.assets, {
520
- rootDir: config.rootDir,
521
- assetsBase,
522
- }),
523
- ...checkForeignAssetBindings(packConfig, { assets: config.assets }),
524
- ];
525
- if (findings.length) {
526
- const error = new Error(findings.map(formatDiagnostic).join("\n"));
527
- error.located = true;
528
- die(error);
529
- }
530
-
531
- const transform = await loadAssetTransform(config);
532
-
533
- // `to:` is relative to the staged package root, so a
534
- // repository's table reads `lang`, not `build/stage/lang`.
535
- const entries = config.assets.map(({ from, to }) => [
536
- from,
537
- path.join(config.stageDir, to),
538
- ]);
539
- let { entries: count, files } = stageAssets(entries, {
540
- cwd: config.rootDir,
541
- transform,
542
- });
543
-
544
- // The picture roots, staged by the toolchain rather than by a row:
545
- // flat, filtered by each file's provenance, and listed in the
546
- // package's asset set.
547
- const pictures = stagePackageAssets({
548
- assetsBase,
549
- contentPackage: packConfig.contentPackage,
550
- stageDir: path.resolve(config.rootDir, config.stageDir),
551
- // The manifest's title names the set; a package with none is
552
- // named by its content package.
553
- title:
554
- typeof config.manifest?.title === "string" && config.manifest.title.trim() ?
555
- config.manifest.title
556
- : packConfig.contentPackage,
557
- transform,
558
- shippingLicenses: config.shippingLicenses,
559
- });
560
- if (pictures.findings.length) {
561
- const error = new Error(pictures.findings.map(formatDiagnostic).join("\n"));
562
- error.located = true;
563
- die(error);
564
- }
565
- files += pictures.staged.length;
566
- if (pictures.staged.length || pictures.excluded.length) {
567
- console.log(
568
- `Pictures: ${pictures.staged.length} staged, ` +
569
- `${pictures.excluded.length} left out by provenance.`,
570
- );
571
- }
572
-
573
- // Staged in its own pass, without the repository's transform: the
574
- // sheet is this toolchain's file rather than one of the
575
- // repository's assets, and a `transform` written to retheme a
576
- // package's own SVGs has no business being handed it.
577
- if (config.baseStyles) {
578
- const staged = stageAssets([baseStyleStageEntry(config.stageDir)], {
579
- cwd: config.rootDir,
580
- });
581
- count += staged.entries;
582
- files += staged.files;
583
- }
584
-
585
- // The installed fonts, when the package has installed the fonts
586
- // package and a note sets text in one of its faces: the
587
- // stylesheet, the WOFF2 files and the licence, which the
588
- // manifest's `styles` names.
589
- const fontFindings = checkScriptFonts(packConfig);
590
- if (fontFindings.length) {
591
- const error = new Error(fontFindings.map(formatDiagnostic).join("\n"));
592
- error.located = true;
593
- die(error);
594
- }
595
- const fonts = copyScriptFonts(
596
- packConfig,
597
- path.resolve(config.rootDir, config.stageDir, STAGED_FONTS_DIR),
598
- ["woff2"],
599
- );
600
- if (fonts) {
601
- count += 1;
602
- files += fonts;
603
- }
604
- console.log(`✅ Static assets staged (${count} entries, ${files} files).`);
605
- }),
440
+ handler: handler(() => stagePackageBuildAssets()),
606
441
  };
607
442
  }
608
443
 
package/docs/commands.md CHANGED
@@ -1556,6 +1556,89 @@ package-build: This repository declares no end-to-end suite to `run`. Name one u
1556
1556
 
1557
1557
  ## Content operations
1558
1558
 
1559
+ ### `package-build content build`
1560
+
1561
+ **NAME**
1562
+
1563
+ Build indexes, calendars, assets and packs from one captured content tree.
1564
+
1565
+ **SYNOPSIS**
1566
+
1567
+ ```text
1568
+ package-build content build --calendaria-version <version>
1569
+ ```
1570
+
1571
+ **DESCRIPTION**
1572
+
1573
+ The complete content build runs these existing operations in order:
1574
+
1575
+ 1. Capture authored sources and prepare and publish fresh metadata, dictionary
1576
+ and terms indexes.
1577
+ 2. Emit calendar definitions and Calendaria imports from the published index.
1578
+ 3. Stage the configured assets, including calendars, provenance-filtered
1579
+ pictures, base styles and fonts.
1580
+ 4. Run content lint, link checks and pack compilation with the same live session.
1581
+
1582
+ Finish dependency fetching and source generation before invoking the command.
1583
+ Public and complete SQL preparations remain separate and each is reused by
1584
+ operations needing that audience. No resolved index or source cache survives
1585
+ into another invocation. Standalone commands and `content run` remain available.
1586
+
1587
+ The command clears its index directory, generated calendar directory, pack JSON
1588
+ and configured compiled pack directories before preparing the new build. Other
1589
+ stage files, including a manifest written by a parallel packaging task, remain.
1590
+ Use `package-build stage reset` before starting the complete packaging workflow
1591
+ when the entire stage must start empty. Build output paths must be outside the
1592
+ captured content and picture roots. A phase that changes those inputs fails the
1593
+ build, invalidates publication readiness and stops further use of the capture;
1594
+ source generation belongs before this command.
1595
+
1596
+ After all three index files are published successfully, the command writes
1597
+ `<paths.contentIndex>/.publication-ready` (normally
1598
+ `build/content-index/.publication-ready`). It removes that marker at startup.
1599
+ The marker means this invocation published a complete fresh index, even when
1600
+ calendar emission, asset staging, lint, link checks or compilation later fails.
1601
+ A subsequent index consumer can check it to report independent findings after a
1602
+ build failure. Clear the marker before a surrounding workflow starts as well,
1603
+ so failures before the command starts cannot expose an earlier run's index.
1604
+ Never restore the marker or resolved indexes from a cache.
1605
+
1606
+ Preparation failure blocks publication and compilation but preserves useful
1607
+ independent lint and link findings. Calendar or asset failure blocks compilation;
1608
+ ordinary lint or link findings do not block an otherwise valid compile. Pack
1609
+ generation still refuses to compile incomplete output. An advisory warning does
1610
+ not fail the command.
1611
+
1612
+ The final timing summary includes eager capture and index derivation, SQL by
1613
+ audience, publication, calendars, assets, validation, rendering and native pack
1614
+ compilation. Stage times are exclusive: a nested SQL preparation is charged to
1615
+ SQL rather than its calling stage. Counts, preparation reuse and peak process
1616
+ RSS accompany the timings; importing the CLI and validating its arguments happen
1617
+ before the measured build interval.
1618
+
1619
+ **OPTIONS**
1620
+
1621
+ | Option | Type | Default | Description |
1622
+ | ---------------------- | ------ | -------- | -------------------------------------- |
1623
+ | `--calendaria-version` | string | required | Calendaria import version, e.g. 1.4.2. |
1624
+
1625
+ **EXIT STATUS**
1626
+
1627
+ 0 when every operation succeeds. 1 when arguments, preparation or any build
1628
+ operation fails. Publication readiness alone does not mean the build passed.
1629
+
1630
+ **EXAMPLES**
1631
+
1632
+ ```bash
1633
+ package-build deps fetch
1634
+ package-build content build --calendaria-version 1.4.2
1635
+ ```
1636
+
1637
+ **SEE ALSO**
1638
+
1639
+ `package-build content run`, `package-build content-index`, `package-build calendars`,
1640
+ `package-build assets`, `package-build package compile`.
1641
+
1559
1642
  ### `package-build content run <targets..>`
1560
1643
 
1561
1644
  **NAME**
@@ -0,0 +1,179 @@
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
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { pathToFileURL } from "node:url";
17
+ import { loadPackageBuildConfig } from "../config.mjs";
18
+ import { loadPackConfig, packConfigPath } from "./pack-config.mjs";
19
+ import { checkForeignAssetBindings } from "./asset-bindings.mjs";
20
+ import { formatDiagnostic, positionOfYamlPath } from "./diagnostics.mjs";
21
+ import { stageAssets } from "../stage.mjs";
22
+ import { rowsStagingPictureRoots, stagePackageAssets } from "./asset-shipping.mjs";
23
+ import { baseStyleStageEntry } from "./base-styles.mjs";
24
+ import { checkScriptFonts, copyScriptFonts, STAGED_FONTS_DIR } from "./script-font-delivery.mjs";
25
+
26
+ /**
27
+ * A finding for each `packageBuild.assets` row that would stage a picture root
28
+ * itself, located at the row's `from` when the configuration is YAML.
29
+ *
30
+ * @param {readonly {from: string, to: string}[]} rows - The configured rows.
31
+ * @param {{rootDir: string, assetsBase: string}} opts - Where they resolve.
32
+ * @returns {object[]} The findings.
33
+ */
34
+ function pictureRootRowFindings(rows, { rootDir, assetsBase }) {
35
+ const offending = rowsStagingPictureRoots(rows, { rootDir, assetsBase });
36
+ if (!offending.length) return [];
37
+ const file = packConfigPath();
38
+ const text =
39
+ file.endsWith(".yaml") || file.endsWith(".yml") ? fs.readFileSync(file, "utf8") : "";
40
+ return offending.map(({ index, from, root }) => ({
41
+ file,
42
+ ...(text ? positionOfYamlPath(text, ["packageBuild", "assets", index, "from"]) : {}),
43
+ severity: "error",
44
+ message:
45
+ `\`packageBuild.assets[${index}]\` copies \`${from}\`, which reaches ${root}/ — ` +
46
+ "the stage copies the icon and image roots itself, flat and filtered by " +
47
+ "each file's provenance, so a row copying them would ship every file " +
48
+ "unfiltered. Remove the row",
49
+ }));
50
+ }
51
+
52
+ /**
53
+ * The repository's `packageBuild.assetTransform`, loaded.
54
+ *
55
+ * One loader for every command that writes a picture, so the stage and the CDN
56
+ * hold the same bytes for the same file.
57
+ *
58
+ * @param {object} config - The resolved package-build configuration.
59
+ * @returns {Promise<((sourcePath: string) => string|null|undefined)|undefined>}
60
+ * The transform, or `undefined` when the repository names none.
61
+ */
62
+ export async function loadAssetTransform(config) {
63
+ if (!config.assetTransform) return undefined;
64
+ const module = await import(pathToFileURL(config.assetTransform).href).catch((err) => {
65
+ throw new Error(
66
+ `cannot load \`packageBuild.assetTransform\` (${config.assetTransform}): ${err.message}`,
67
+ );
68
+ });
69
+ const transform = module.transform;
70
+ if (typeof transform !== "function") {
71
+ throw new Error(
72
+ `\`packageBuild.assetTransform\` ` +
73
+ `(${config.assetTransform}) exports no \`transform\` ` +
74
+ `function. It must export ` +
75
+ `\`transform(sourcePath) -> string | null\`.`,
76
+ );
77
+ }
78
+ return transform;
79
+ }
80
+
81
+ /**
82
+ * Stage static files, provenance-filtered pictures, base styles and fonts.
83
+ * @param {object} [options]
84
+ * @param {ReturnType<typeof loadPackageBuildConfig>} [options.config]
85
+ * @param {import("../content-config.mjs").ContentBuildConfig} [options.packConfig]
86
+ * @param {import("./content-session.mjs").ContentSession} [options.session]
87
+ */
88
+ export async function stagePackageBuildAssets({
89
+ config = loadPackageBuildConfig(),
90
+ packConfig = loadPackConfig(),
91
+ session,
92
+ } = {}) {
93
+ const assetsBase = packConfig.paths.assets;
94
+ const findings = [
95
+ ...pictureRootRowFindings(config.assets, {
96
+ rootDir: config.rootDir,
97
+ assetsBase,
98
+ }),
99
+ ...checkForeignAssetBindings(packConfig, { assets: config.assets }),
100
+ ];
101
+ if (findings.length) {
102
+ const error = new Error(findings.map(formatDiagnostic).join("\n"));
103
+ error.located = true;
104
+ throw error;
105
+ }
106
+
107
+ const transform = await loadAssetTransform(config);
108
+
109
+ // `to:` is relative to the staged package root, so a
110
+ // repository's table reads `lang`, not `build/stage/lang`.
111
+ const entries = config.assets.map(({ from, to }) => [from, path.join(config.stageDir, to)]);
112
+ let { entries: count, files } = stageAssets(entries, {
113
+ cwd: config.rootDir,
114
+ transform,
115
+ });
116
+
117
+ // The picture roots, staged by the toolchain rather than by a row:
118
+ // flat, filtered by each file's provenance, and listed in the
119
+ // package's asset set.
120
+ const pictures = stagePackageAssets({
121
+ assetsBase,
122
+ contentPackage: packConfig.contentPackage,
123
+ stageDir: path.resolve(config.rootDir, config.stageDir),
124
+ // The manifest's title names the set; a package with none is
125
+ // named by its content package.
126
+ title:
127
+ typeof config.manifest?.title === "string" && config.manifest.title.trim() ?
128
+ config.manifest.title
129
+ : packConfig.contentPackage,
130
+ transform,
131
+ shippingLicenses: config.shippingLicenses,
132
+ });
133
+ if (pictures.findings.length) {
134
+ const error = new Error(pictures.findings.map(formatDiagnostic).join("\n"));
135
+ error.located = true;
136
+ throw error;
137
+ }
138
+ files += pictures.staged.length;
139
+ if (pictures.staged.length || pictures.excluded.length) {
140
+ console.log(
141
+ `Pictures: ${pictures.staged.length} staged, ` +
142
+ `${pictures.excluded.length} left out by provenance.`,
143
+ );
144
+ }
145
+
146
+ // Staged in its own pass, without the repository's transform: the
147
+ // sheet is this toolchain's file rather than one of the
148
+ // repository's assets, and a `transform` written to retheme a
149
+ // package's own SVGs has no business being handed it.
150
+ if (config.baseStyles) {
151
+ const staged = stageAssets([baseStyleStageEntry(config.stageDir)], {
152
+ cwd: config.rootDir,
153
+ });
154
+ count += staged.entries;
155
+ files += staged.files;
156
+ }
157
+
158
+ // The installed fonts, when the package has installed the fonts
159
+ // package and a note sets text in one of its faces: the
160
+ // stylesheet, the WOFF2 files and the licence, which the
161
+ // manifest's `styles` names.
162
+ const fontFindings = checkScriptFonts(packConfig, session);
163
+ if (fontFindings.length) {
164
+ const error = new Error(fontFindings.map(formatDiagnostic).join("\n"));
165
+ error.located = true;
166
+ throw error;
167
+ }
168
+ const fonts = copyScriptFonts(
169
+ packConfig,
170
+ path.resolve(config.rootDir, config.stageDir, STAGED_FONTS_DIR),
171
+ ["woff2"],
172
+ session,
173
+ );
174
+ if (fonts) {
175
+ count += 1;
176
+ files += fonts;
177
+ }
178
+ console.log(`✅ Static assets staged (${count} entries, ${files} files).`);
179
+ }
@@ -77,16 +77,20 @@ function readIndex(file) {
77
77
  *
78
78
  * @param {object} opts
79
79
  * @param {object} opts.config - Resolved content configuration.
80
+ * @param {Map<string, {raw: string}>} [opts.sources] - Captured authored text for diagnostics.
80
81
  * @param {string} opts.calendariaVersion - Version whose import format is targeted.
81
82
  * @returns {{directory: string, calendars: number, files: number}}
82
83
  */
83
- export function emitCalendarArtifacts({ config, calendariaVersion } = {}) {
84
+ export function emitCalendarArtifacts({ config, calendariaVersion, sources } = {}) {
84
85
  if (!config) throw new Error("calendar emission requires a content configuration");
85
86
  if (!/^\d+\.\d+\.\d+$/.test(calendariaVersion ?? "")) {
86
87
  throw new Error("calendar emission requires a Calendaria version such as 1.4.2");
87
88
  }
88
89
 
89
90
  const root = config.rootDir;
91
+ const rawSource = (file) =>
92
+ sources?.get(path.resolve(root, file))?.raw ??
93
+ fs.readFileSync(path.resolve(root, file), "utf8");
90
94
  const contentBase = path.resolve(root, config.paths.content);
91
95
  const indexFile = path.resolve(
92
96
  root,
@@ -108,7 +112,7 @@ export function emitCalendarArtifacts({ config, calendariaVersion } = {}) {
108
112
  );
109
113
  const names = new Map();
110
114
  for (const note of calendars) {
111
- const raw = fs.readFileSync(path.resolve(root, note.file), "utf8");
115
+ const raw = rawSource(note.file);
112
116
  const shortcode = note.fm.shortcode;
113
117
  if (typeof shortcode !== "string" || !/^[a-z0-9]+$/.test(shortcode)) {
114
118
  failAt(
@@ -135,7 +139,7 @@ export function emitCalendarArtifacts({ config, calendariaVersion } = {}) {
135
139
  failAt(
136
140
  note.file,
137
141
  "calendar notes require a world note declaring data.year",
138
- fs.readFileSync(path.resolve(root, note.file), "utf8"),
142
+ rawSource(note.file),
139
143
  ["subType"],
140
144
  );
141
145
  }
@@ -96,6 +96,7 @@ export async function compilePacks({
96
96
  contentBase: config.paths.content,
97
97
  skipDirectories: config.skipDirectories,
98
98
  });
99
+ const measured = (name, work) => (session ? session.timings.measure(name, work) : work());
99
100
  const packNames = sourcePacks.filter((name) => !packName || name === packName);
100
101
 
101
102
  // A prebuilt pack's JSON is checked in rather than generated, so it is
@@ -114,12 +115,14 @@ export async function compilePacks({
114
115
  // content tree, and a package that ships only prebuilt packs has none at
115
116
  // all — so running it would fail the build over a tree nothing reads.
116
117
  if (needsGeneration) {
117
- const errors = await generatePacksJson({
118
- only: packName,
119
- config,
120
- session,
121
- reportPreparation,
122
- });
118
+ const errors = await measured("rendering/documents", () =>
119
+ generatePacksJson({
120
+ only: packName,
121
+ config,
122
+ session,
123
+ reportPreparation,
124
+ }),
125
+ );
123
126
  if (errors > 0) {
124
127
  throw new Error(
125
128
  `Pack JSON generation reported ${errors} error(s); refusing to ` +
@@ -141,11 +144,13 @@ export async function compilePacks({
141
144
 
142
145
  const stage = path.join(stageDest, name);
143
146
  log.info(`Pack ${name}: compiling to LevelDB at ${stage}`);
144
- await compilePack(source, stage, {
145
- recursive: true,
146
- log: false,
147
- transformEntry: cleanPackEntry,
148
- });
147
+ await measured("pack compilation", () =>
148
+ compilePack(source, stage, {
149
+ recursive: true,
150
+ log: false,
151
+ transformEntry: cleanPackEntry,
152
+ }),
153
+ );
149
154
 
150
155
  // A Scene's map image lives on an embedded Level, stored under its own
151
156
  // LevelDB key. Nothing in Foundry ties the two together on read: a
@@ -13,6 +13,9 @@
13
13
 
14
14
  /** One invocation's authored sources and context-specific prepared views. */
15
15
  import path from "node:path";
16
+ import fs from "node:fs";
17
+ import { DEPLOYED_ASSET_TYPES } from "./asset-types.mjs";
18
+ import { createContentTimings } from "./content-timings.mjs";
16
19
  import * as contentIndex from "./content-index.mjs";
17
20
  import * as blocks from "./content-blocks.mjs";
18
21
  import * as alerts from "./content-alerts.mjs";
@@ -52,6 +55,8 @@ import { lintNoteStates } from "./stub-lint.mjs";
52
55
  * @property {() => Promise<import("./content-index.mjs").PreparedContentIndex>} publication
53
56
  * @property {() => ReturnType<import("./compile-corpus.mjs").buildCompileCorpus>} compileCorpus
54
57
  * @property {(targets: readonly ContentTarget[]) => Promise<ContentSession>} prepare
58
+ * @property {ReturnType<typeof createContentTimings>} timings
59
+ * @property {() => void} assertUnchanged
55
60
  * @property {() => void} dispose
56
61
  */
57
62
 
@@ -71,6 +76,8 @@ export const CONTENT_TARGETS = Object.freeze(["index", "lint", "links", "compile
71
76
  * @param {string} [options.assetsBase] - Assets root, defaulting to configuration.
72
77
  * @param {readonly string[]} [options.skipDirectories] - Resolved content scope.
73
78
  * @param {ReturnType<typeof routerFor>} [options.router] - One router shared by this session's compile.
79
+ * @param {boolean} [options.trackChanges] - Snapshot physical input metadata for build phase guards.
80
+ * @param {ReturnType<typeof createContentTimings>} [options.timings] - Invocation stage accounting.
74
81
  * @returns {Promise<ContentSession>} Captured sources and lazy prepared views; dispose after use.
75
82
  */
76
83
  export async function createContentSession({
@@ -79,14 +86,36 @@ export async function createContentSession({
79
86
  assetsBase = config.paths.assets,
80
87
  skipDirectories = config.skipDirectories,
81
88
  router = routerFor(config),
89
+ timings = createContentTimings(),
90
+ trackChanges = false,
82
91
  } = {}) {
83
92
  const tree = path.resolve(contentBase);
84
93
  const assets = path.resolve(assetsBase);
85
94
  const scope = [...skipDirectories];
86
- const inventory = captureContentSources(tree, assets, scope);
95
+ const inventory = timings.sync("source capture", () =>
96
+ captureContentSources(tree, assets, scope),
97
+ );
98
+ const fingerprint = (file) => {
99
+ const stat = fs.statSync(file, { throwIfNoEntry: false });
100
+ return stat ? `${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}` : "absent";
101
+ };
102
+ const fingerprints =
103
+ trackChanges ?
104
+ new Map(
105
+ [
106
+ ...new Set([
107
+ tree,
108
+ ...DEPLOYED_ASSET_TYPES.map((entry) => path.resolve(assets, entry.root)),
109
+ ...inventory.paths.map((entry) => entry.file),
110
+ ]),
111
+ ].map((file) => [file, fingerprint(file)]),
112
+ )
113
+ : undefined;
87
114
  const parsed = new Map();
88
115
  const problems = [];
89
- const foreign = loadForeignIndexes(config, [config.contentPackage]);
116
+ const foreign = timings.sync("dependency indexes", () =>
117
+ loadForeignIndexes(config, [config.contentPackage]),
118
+ );
90
119
  const syntaxCache = new Map();
91
120
  const prepared = new Map();
92
121
  let disposed = false;
@@ -104,23 +133,31 @@ export async function createContentSession({
104
133
  });
105
134
  return syntaxCache.get(text);
106
135
  };
107
- const records = contentIndex.indexRecordsFor({
108
- config,
109
- contentBase: tree,
110
- assetsBase: assets,
111
- skipDirectories: scope,
112
- sources: inventory.indexSources,
113
- parsed,
114
- problems,
115
- foreignIndex: foreign.index,
116
- syntax,
117
- });
136
+ const records = timings.sync("index derivation", () =>
137
+ contentIndex.indexRecordsFor({
138
+ config,
139
+ contentBase: tree,
140
+ assetsBase: assets,
141
+ skipDirectories: scope,
142
+ sources: inventory.indexSources,
143
+ parsed,
144
+ problems,
145
+ foreignIndex: foreign.index,
146
+ syntax,
147
+ }),
148
+ );
118
149
  const memo = (key, make) => {
119
150
  active();
120
- if (!prepared.has(key)) prepared.set(key, Promise.resolve().then(make));
151
+ if (!prepared.has(key))
152
+ prepared.set(
153
+ key,
154
+ Promise.resolve().then(() => timings.measure(key, make)),
155
+ );
156
+ else timings.hit(key);
121
157
  return prepared.get(key);
122
158
  };
123
159
  const session = {
160
+ timings,
124
161
  config,
125
162
  contentBase: tree,
126
163
  assetsBase: assets,
@@ -133,6 +170,17 @@ export async function createContentSession({
133
170
  problems,
134
171
  foreign,
135
172
  syntax,
173
+ /** Refuse a stale capture after any build phase changes an input. */
174
+ assertUnchanged() {
175
+ active();
176
+ if (!fingerprints)
177
+ throw new Error("Content change tracking was not enabled for this session");
178
+ for (const [file, before] of fingerprints)
179
+ if (fingerprint(file) !== before)
180
+ throw new Error(
181
+ `Content sources changed during the build: ${file}. Finish source generation before content build.`,
182
+ );
183
+ },
136
184
  /** Refuse use with a different configuration, root, scope or router. */
137
185
  assertContext(options = {}) {
138
186
  active();
@@ -0,0 +1,67 @@
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
+ /** Sequential, nested stage accounting for one content invocation. */
15
+ export function createContentTimings() {
16
+ const started = performance.now();
17
+ let changed = started;
18
+ const stack = ["orchestration"];
19
+ const stages = new Map();
20
+ const hits = new Map();
21
+ const account = () => {
22
+ const now = performance.now();
23
+ const name = stack.at(-1);
24
+ stages.set(name, (stages.get(name) ?? 0) + now - changed);
25
+ changed = now;
26
+ };
27
+ const begin = (name) => {
28
+ account();
29
+ stack.push(name);
30
+ return () => {
31
+ account();
32
+ stack.pop();
33
+ };
34
+ };
35
+ return {
36
+ /** @template T @param {string} name @param {() => T} work @returns {T} */
37
+ sync(name, work) {
38
+ const stop = begin(name);
39
+ try {
40
+ return work();
41
+ } finally {
42
+ stop();
43
+ }
44
+ },
45
+ /** @template T @param {string} name @param {() => Promise<T>|T} work @returns {Promise<T>} */
46
+ async measure(name, work) {
47
+ const stop = begin(name);
48
+ try {
49
+ return await work();
50
+ } finally {
51
+ stop();
52
+ }
53
+ },
54
+ /** @param {string} name */
55
+ hit(name) {
56
+ hits.set(name, (hits.get(name) ?? 0) + 1);
57
+ },
58
+ snapshot() {
59
+ account();
60
+ return {
61
+ totalMs: changed - started,
62
+ stages: Object.fromEntries(stages),
63
+ hits: Object.fromEntries(hits),
64
+ };
65
+ },
66
+ };
67
+ }
@@ -76,14 +76,15 @@ const FRONTMATTER = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
76
76
  *
77
77
  * @param {string} source - The note's file.
78
78
  * @param {import("./script-fonts.mjs").ScriptFonts} installed - The fonts package.
79
+ * @param {import("./content-session.mjs").ContentNoteSource} [parsed] - Already parsed authored note.
79
80
  * @returns {string|undefined} The face's slug.
80
81
  */
81
- function writtenInFace(source, installed) {
82
+ function writtenInFace(source, installed, parsed) {
82
83
  const yaml = FRONTMATTER.exec(source)?.[1];
83
84
  if (yaml === undefined) return undefined;
84
85
  let fm;
85
86
  try {
86
- fm = YAML.parse(yaml);
87
+ fm = parsed ? parsed.frontmatter : YAML.parse(yaml);
87
88
  } catch {
88
89
  return undefined;
89
90
  }
@@ -102,10 +103,12 @@ function writtenInFace(source, installed) {
102
103
  *
103
104
  * @param {DeliveryConfig} config - The resolved configuration.
104
105
  * @param {import("./script-fonts.mjs").ScriptFonts} installed - The fonts package.
106
+ * @param {import("./content-session.mjs").ContentSession} [session] - Captured sources when staging within a content build.
105
107
  * @returns {ReadonlySet<string>} The slugs of the faces used.
106
108
  */
107
- export function facesUsed(config, installed) {
108
- const known = usedFaces.get(config);
109
+ export function facesUsed(config, installed, session) {
110
+ const cacheKey = session ?? config;
111
+ const known = usedFaces.get(cacheKey);
109
112
  if (known) return known;
110
113
  const used = new Set();
111
114
  const content =
@@ -119,9 +122,9 @@ export function facesUsed(config, installed) {
119
122
  const full = path.join(dir, entry.name);
120
123
  if (entry.isDirectory()) walk(full);
121
124
  else if (entry.isFile() && entry.name.endsWith(".md")) {
122
- const source = fs.readFileSync(full, "utf8");
125
+ const source = session?.sources.get(full)?.raw ?? fs.readFileSync(full, "utf8");
123
126
  if (/^\s*writtenIn:/m.test(source)) {
124
- const face = writtenInFace(source, installed);
127
+ const face = writtenInFace(source, installed, session?.parsed.get(full));
125
128
  if (face) used.add(face);
126
129
  }
127
130
  if (!/\bfont=/.test(source)) continue;
@@ -135,7 +138,7 @@ export function facesUsed(config, installed) {
135
138
  }
136
139
  };
137
140
  if (content && fs.existsSync(content)) walk(content);
138
- usedFaces.set(config, used);
141
+ usedFaces.set(cacheKey, used);
139
142
  return used;
140
143
  }
141
144
 
@@ -143,15 +146,16 @@ export function facesUsed(config, installed) {
143
146
  * The installed package, when this consumer is to ship it.
144
147
  *
145
148
  * @param {DeliveryConfig} config - The resolved configuration.
149
+ * @param {import("./content-session.mjs").ContentSession} [session] - Captured sources.
146
150
  * @returns {import("./script-fonts.mjs").ScriptFonts|undefined} The package, or
147
151
  * `undefined` without a root, an install, a manifest, or a note that sets
148
152
  * text in one of its faces.
149
153
  */
150
- function delivered(config) {
154
+ function delivered(config, session) {
151
155
  if (!config?.rootDir) return undefined;
152
156
  const installed = resolveScriptFonts(config.rootDir);
153
157
  if (!installed?.manifest) return undefined;
154
- return facesUsed(config, installed).size ? installed : undefined;
158
+ return facesUsed(config, installed, session).size ? installed : undefined;
155
159
  }
156
160
 
157
161
  /**
@@ -159,12 +163,13 @@ function delivered(config) {
159
163
  *
160
164
  * @param {DeliveryConfig} config - The resolved configuration.
161
165
  * @param {string[]} formats - The font formats the target uses: `woff2`, `ttf`.
166
+ * @param {import("./content-session.mjs").ContentSession} [session] - Captured sources when staging within a content build.
162
167
  * @returns {{root: string, version: string, files: string[]}|undefined} The
163
168
  * files, sorted: `fonts.css`, `LICENSE` and every file in `fonts/` of a
164
169
  * listed format. `undefined` when the consumer ships none.
165
170
  */
166
- export function scriptFontFiles(config, formats) {
167
- const installed = delivered(config);
171
+ export function scriptFontFiles(config, formats, session) {
172
+ const installed = delivered(config, session);
168
173
  if (!installed) return undefined;
169
174
  const fontsDir = path.join(installed.root, "fonts");
170
175
  const fonts =
@@ -250,10 +255,11 @@ export function stagedStylesheet(css, staged) {
250
255
  * @param {string} dest - The directory to fill; created when there is
251
256
  * something to put in it.
252
257
  * @param {string[]} formats - The font formats the target uses.
258
+ * @param {import("./content-session.mjs").ContentSession} [session] - Captured sources when staging within a content build.
253
259
  * @returns {number} How many files were written; 0 when the consumer ships none.
254
260
  */
255
- export function copyScriptFonts(config, dest, formats) {
256
- const found = scriptFontFiles(config, formats);
261
+ export function copyScriptFonts(config, dest, formats, session) {
262
+ const found = scriptFontFiles(config, formats, session);
257
263
  if (!found) return 0;
258
264
  const staged = new Set(found.files);
259
265
  for (const file of found.files) {
@@ -274,11 +280,12 @@ export function copyScriptFonts(config, dest, formats) {
274
280
  * stylesheet, the licence and every file the manifest lists.
275
281
  *
276
282
  * @param {DeliveryConfig} config - The resolved configuration.
283
+ * @param {import("./content-session.mjs").ContentSession} [session] - Captured sources when staging within a content build.
277
284
  * @returns {{file: string, severity: "error", message: string}[]} One finding
278
285
  * per missing file, located at the manifest; empty when the consumer ships none.
279
286
  */
280
- export function checkScriptFonts(config) {
281
- const installed = delivered(config);
287
+ export function checkScriptFonts(config, session) {
288
+ const installed = delivered(config, session);
282
289
  if (!installed) return [];
283
290
  const manifest = path.join(installed.root, FONTS_MANIFEST);
284
291
  const wanted = new Set([STYLESHEET, LICENSE]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "24.1.0",
3
+ "version": "24.1.1",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The repository's `packageBuild.assetTransform`, loaded.
3
+ *
4
+ * One loader for every command that writes a picture, so the stage and the CDN
5
+ * hold the same bytes for the same file.
6
+ *
7
+ * @param {object} config - The resolved package-build configuration.
8
+ * @returns {Promise<((sourcePath: string) => string|null|undefined)|undefined>}
9
+ * The transform, or `undefined` when the repository names none.
10
+ */
11
+ export function loadAssetTransform(config: object): Promise<((sourcePath: string) => string | null | undefined) | undefined>;
12
+ /**
13
+ * Stage static files, provenance-filtered pictures, base styles and fonts.
14
+ * @param {object} [options]
15
+ * @param {ReturnType<typeof loadPackageBuildConfig>} [options.config]
16
+ * @param {import("../content-config.mjs").ContentBuildConfig} [options.packConfig]
17
+ * @param {import("./content-session.mjs").ContentSession} [options.session]
18
+ */
19
+ export function stagePackageBuildAssets({ config, packConfig, session, }?: {
20
+ config?: Readonly<import("../config.mjs").PackageBuildConfig> | undefined;
21
+ packConfig?: import("../content-config.mjs").ContentBuildConfig | undefined;
22
+ session?: import("./content-session.mjs").ContentSession | undefined;
23
+ }): Promise<void>;
@@ -3,11 +3,15 @@
3
3
  *
4
4
  * @param {object} opts
5
5
  * @param {object} opts.config - Resolved content configuration.
6
+ * @param {Map<string, {raw: string}>} [opts.sources] - Captured authored text for diagnostics.
6
7
  * @param {string} opts.calendariaVersion - Version whose import format is targeted.
7
8
  * @returns {{directory: string, calendars: number, files: number}}
8
9
  */
9
- export function emitCalendarArtifacts({ config, calendariaVersion }?: {
10
+ export function emitCalendarArtifacts({ config, calendariaVersion, sources }?: {
10
11
  config: object;
12
+ sources?: Map<string, {
13
+ raw: string;
14
+ }> | undefined;
11
15
  calendariaVersion: string;
12
16
  }): {
13
17
  directory: string;
@@ -8,9 +8,11 @@
8
8
  * @param {string} [options.assetsBase] - Assets root, defaulting to configuration.
9
9
  * @param {readonly string[]} [options.skipDirectories] - Resolved content scope.
10
10
  * @param {ReturnType<typeof routerFor>} [options.router] - One router shared by this session's compile.
11
+ * @param {boolean} [options.trackChanges] - Snapshot physical input metadata for build phase guards.
12
+ * @param {ReturnType<typeof createContentTimings>} [options.timings] - Invocation stage accounting.
11
13
  * @returns {Promise<ContentSession>} Captured sources and lazy prepared views; dispose after use.
12
14
  */
13
- export function createContentSession({ config, contentBase, assetsBase, skipDirectories, router, }?: {
15
+ export function createContentSession({ config, contentBase, assetsBase, skipDirectories, router, timings, trackChanges, }?: {
14
16
  config?: import("../content-config.mjs").ContentBuildConfig | undefined;
15
17
  contentBase?: string | undefined;
16
18
  assetsBase?: string | undefined;
@@ -21,6 +23,17 @@ export function createContentSession({ config, contentBase, assetsBase, skipDire
21
23
  packsOfType: (docType: string) => string[];
22
24
  defaultOf: (docType: string) => string | undefined;
23
25
  } | undefined;
26
+ trackChanges?: boolean | undefined;
27
+ timings?: {
28
+ sync<T>(name: string, work: () => T): T;
29
+ measure<T>(name: string, work: () => Promise<T> | T): Promise<T>;
30
+ hit(name: string): void;
31
+ snapshot(): {
32
+ totalMs: number;
33
+ stages: any;
34
+ hits: any;
35
+ };
36
+ } | undefined;
24
37
  }): Promise<ContentSession>;
25
38
  /**
26
39
  * @typedef {"index"|"lint"|"links"|"compile"} ContentTarget
@@ -47,6 +60,8 @@ export function createContentSession({ config, contentBase, assetsBase, skipDire
47
60
  * @property {() => Promise<import("./content-index.mjs").PreparedContentIndex>} publication
48
61
  * @property {() => ReturnType<import("./compile-corpus.mjs").buildCompileCorpus>} compileCorpus
49
62
  * @property {(targets: readonly ContentTarget[]) => Promise<ContentSession>} prepare
63
+ * @property {ReturnType<typeof createContentTimings>} timings
64
+ * @property {() => void} assertUnchanged
50
65
  * @property {() => void} dispose
51
66
  */
52
67
  /**
@@ -96,6 +111,8 @@ export type ContentSession = {
96
111
  publication: () => Promise<import("./content-index.mjs").PreparedContentIndex>;
97
112
  compileCorpus: () => ReturnType<typeof import("./compile-corpus.mjs").buildCompileCorpus>;
98
113
  prepare: (targets: readonly ContentTarget[]) => Promise<ContentSession>;
114
+ timings: ReturnType<typeof createContentTimings>;
115
+ assertUnchanged: () => void;
99
116
  dispose: () => void;
100
117
  };
101
118
  import * as blocks from "./content-blocks.mjs";
@@ -106,3 +123,4 @@ import { captureContentSources } from "./content-sources.mjs";
106
123
  import { loadForeignIndexes } from "./metadata-index.mjs";
107
124
  import * as sql from "./sql-tables.mjs";
108
125
  import { buildLinkIndex } from "./content-links.mjs";
126
+ import { createContentTimings } from "./content-timings.mjs";
@@ -0,0 +1,14 @@
1
+ /** Sequential, nested stage accounting for one content invocation. */
2
+ export function createContentTimings(): {
3
+ /** @template T @param {string} name @param {() => T} work @returns {T} */
4
+ sync<T>(name: string, work: () => T): T;
5
+ /** @template T @param {string} name @param {() => Promise<T>|T} work @returns {Promise<T>} */
6
+ measure<T>(name: string, work: () => Promise<T> | T): Promise<T>;
7
+ /** @param {string} name */
8
+ hit(name: string): void;
9
+ snapshot(): {
10
+ totalMs: number;
11
+ stages: any;
12
+ hits: any;
13
+ };
14
+ };
@@ -7,19 +7,21 @@
7
7
  *
8
8
  * @param {DeliveryConfig} config - The resolved configuration.
9
9
  * @param {import("./script-fonts.mjs").ScriptFonts} installed - The fonts package.
10
+ * @param {import("./content-session.mjs").ContentSession} [session] - Captured sources when staging within a content build.
10
11
  * @returns {ReadonlySet<string>} The slugs of the faces used.
11
12
  */
12
- export function facesUsed(config: DeliveryConfig, installed: import("./script-fonts.mjs").ScriptFonts): ReadonlySet<string>;
13
+ export function facesUsed(config: DeliveryConfig, installed: import("./script-fonts.mjs").ScriptFonts, session?: import("./content-session.mjs").ContentSession): ReadonlySet<string>;
13
14
  /**
14
15
  * The files a target receives, as paths relative to the fonts package root.
15
16
  *
16
17
  * @param {DeliveryConfig} config - The resolved configuration.
17
18
  * @param {string[]} formats - The font formats the target uses: `woff2`, `ttf`.
19
+ * @param {import("./content-session.mjs").ContentSession} [session] - Captured sources when staging within a content build.
18
20
  * @returns {{root: string, version: string, files: string[]}|undefined} The
19
21
  * files, sorted: `fonts.css`, `LICENSE` and every file in `fonts/` of a
20
22
  * listed format. `undefined` when the consumer ships none.
21
23
  */
22
- export function scriptFontFiles(config: DeliveryConfig, formats: string[]): {
24
+ export function scriptFontFiles(config: DeliveryConfig, formats: string[], session?: import("./content-session.mjs").ContentSession): {
23
25
  root: string;
24
26
  version: string;
25
27
  files: string[];
@@ -46,18 +48,20 @@ export function stagedStylesheet(css: string, staged: ReadonlySet<string>): stri
46
48
  * @param {string} dest - The directory to fill; created when there is
47
49
  * something to put in it.
48
50
  * @param {string[]} formats - The font formats the target uses.
51
+ * @param {import("./content-session.mjs").ContentSession} [session] - Captured sources when staging within a content build.
49
52
  * @returns {number} How many files were written; 0 when the consumer ships none.
50
53
  */
51
- export function copyScriptFonts(config: DeliveryConfig, dest: string, formats: string[]): number;
54
+ export function copyScriptFonts(config: DeliveryConfig, dest: string, formats: string[], session?: import("./content-session.mjs").ContentSession): number;
52
55
  /**
53
56
  * Whether the package ships what its manifest and stylesheet promise: the
54
57
  * stylesheet, the licence and every file the manifest lists.
55
58
  *
56
59
  * @param {DeliveryConfig} config - The resolved configuration.
60
+ * @param {import("./content-session.mjs").ContentSession} [session] - Captured sources when staging within a content build.
57
61
  * @returns {{file: string, severity: "error", message: string}[]} One finding
58
62
  * per missing file, located at the manifest; empty when the consumer ships none.
59
63
  */
60
- export function checkScriptFonts(config: DeliveryConfig): {
64
+ export function checkScriptFonts(config: DeliveryConfig, session?: import("./content-session.mjs").ContentSession): {
61
65
  file: string;
62
66
  severity: "error";
63
67
  message: string;