@heroiclands/package-build 24.1.0 → 25.0.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 CHANGED
@@ -1,5 +1,37 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 25.0.0
4
+
5
+ ### Major Changes
6
+
7
+ **Languages and scripts**
8
+
9
+ - Add `data.roman` to every configured language note: an NFC string listing each
10
+ permitted Roman character once, with capitals, accents, digits and punctuation
11
+ explicit.
12
+ - Use `""` for no written characters; omit spaces and line breaks, and declare
13
+ inventories without ranges, automatic case expansion or inheritance. Maps and
14
+ fonts grant no extra Roman characters.
15
+ - Language-tagged source and visible link labels are checked before mapping;
16
+ configured names and their BCP 47 tags select the same inventory.
17
+ - Invalid characters and non-NFC spellings are reported at their original
18
+ positions; link destinations, attributes and verse structure are excluded.
19
+ - Only period maps to Semaron's pause; recognized native signs require explicit
20
+ native presentation and a face that draws them, while mappings never broaden
21
+ Roman inventories.
22
+ - Unassociated external tags such as `en` and `zxx` retain ordinary behavior;
23
+ standalone `package-build markdown` does not load language inventories.
24
+
25
+ ## 24.1.1
26
+
27
+ ### Patch Changes
28
+
29
+ **Content builds**
30
+
31
+ - Build indexes, calendars, assets and packs with
32
+ `package-build content build --calendaria-version 1.4.2`, sharing preparation
33
+ across validation and compilation.
34
+
3
35
  ## 24.1.0
4
36
 
5
37
  ### 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/api.md CHANGED
@@ -1384,17 +1384,29 @@ Linting a content tree's **addresses** — the rules every package's notes are a
1384
1384
 
1385
1385
  The charset authored content is held to, so a book can choose its face. The packs and the website render in whatever font the reader's browser or Foundry supplies, and a glyph nobody has is somebody else's problem. **A book is not that.** A PDF embeds the faces it sets, so every character in the corpus is a claim on the book's typeface — and the claim is silent, which is what makes it expensive.
1386
1386
 
1387
- | Export | Signature | Returns | Use it when |
1388
- | -------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- |
1389
- | `isLetterTier` | `function isLetterTier(cp)` | {boolean} Whether Tier 1 admits it. | Tier 1 — the letters, and the two whitespace characters a file is made of. |
1390
- | `TYPOGRAPHY` | `const TYPOGRAPHY` | — | Tier 2 — typography, enumerated one codepoint at a time. |
1391
- | `NOTATION` | `const NOTATION` | — | Tier 3 — the notation the rules and price tables are written in. |
1392
- | `isAllowedCodePoint` | `function isAllowedCodePoint(cp)` | {boolean} Whether it is allowed outside a code fence. | Whether the charset admits a code point anywhere in a note. |
1393
- | `isDiagramCodePoint` | `function isDiagramCodePoint(cp)` | {boolean} Whether a fence may carry it. | Whether a code point is diagram furniture, admitted inside a fence only. |
1394
- | `refusalFor` | `function refusalFor(cp)` | {string} A clause naming what it is and what to do instead. | The reason a code point is refused. |
1395
- | `decomposedRuns` | `function decomposedRuns(text)` | {Array<{sequence: string, composed: string, index: number}>} Each offending run, in the order it appears. | Every non-NFC run in a string, with the composed form it should have been. |
1396
- | `checkText` | `function checkText(text, file)` | {Array<{file: string, line: number, column: number, severity: "warning", message: string}>} What is wrong, in file order. | Check one file's text against the charset and the normalization rule. |
1397
- | `lintContentCharset` | `function lintContentCharset(contentBase,` | `{{findings: Array<{file: string, line: number, column: number, severity: "warning", message: string}>, files: number}}` The findings, and how many files produced them. | Walk a content tree and check every authored file in it. |
1387
+ | Export | Signature | Returns | Use it when |
1388
+ | -------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- |
1389
+ | `isLetterTier` | `function isLetterTier(cp)` | {boolean} Whether Tier 1 admits it. | Tier 1 — the letters, and the two whitespace characters a file is made of. |
1390
+ | `TYPOGRAPHY` | `const TYPOGRAPHY` | — | Tier 2 — typography, enumerated one codepoint at a time. |
1391
+ | `NOTATION` | `const NOTATION` | — | Tier 3 — the notation the rules and price tables are written in. |
1392
+ | `isSettingScriptCodePoint` | `isSettingScriptCodePoint(cp)` | `boolean` | recognizing native setting-script input before the selected face checks the individual glyph |
1393
+ | `isAllowedCodePoint` | `function isAllowedCodePoint(cp)` | {boolean} Whether it is allowed outside a code fence. | Whether the charset admits a code point anywhere in a note. |
1394
+ | `isDiagramCodePoint` | `function isDiagramCodePoint(cp)` | {boolean} Whether a fence may carry it. | Whether a code point is diagram furniture, admitted inside a fence only. |
1395
+ | `refusalFor` | `function refusalFor(cp)` | {string} A clause naming what it is and what to do instead. | The reason a code point is refused. |
1396
+ | `decomposedRuns` | `function decomposedRuns(text)` | {Array<{sequence: string, composed: string, index: number}>} Each offending run, in the order it appears. | Every non-NFC run in a string, with the composed form it should have been. |
1397
+ | `checkText` | `function checkText(text, file)` | {Array<{file: string, line: number, column: number, severity: "warning", message: string}>} What is wrong, in file order. | Check one file's text against the charset and the normalization rule. |
1398
+ | `lintContentCharset` | `function lintContentCharset(contentBase,` | `{{findings: Array<{file: string, line: number, column: number, severity: "warning", message: string}>, files: number}}` The findings, and how many files produced them. | Walk a content tree and check every authored file in it. |
1399
+
1400
+ ### `engine.romanCharacters`
1401
+
1402
+ Exact permitted Roman source characters for configured languages, before script
1403
+ mapping. Inventories come from `data.roman` on each language's own note.
1404
+
1405
+ | Export | Signature | Returns | Use it when |
1406
+ | ------------------------ | --------------------------------------------- | ----------------------------- | --------------------------------------------------------------------------------------------- |
1407
+ | `romanInventoryProblems` | `romanInventoryProblems(value)` | `string[]` | checking NFC, repeated characters and forbidden whitespace or controls in a literal inventory |
1408
+ | `romanInventoriesOf` | `romanInventoriesOf(records, languages)` | `Map<string, RomanInventory>` | looking up a configured language's own inventory by name or BCP 47 tag |
1409
+ | `romanTextProblems` | `romanTextProblems(text, inventory, native?)` | `{offset, message}[]` | validating visible unescaped source with exact offsets and an optional native-sign predicate |
1398
1410
 
1399
1411
  ### `engine.pathCharset`
1400
1412
 
@@ -644,6 +644,14 @@ has an error is set exactly as written.
644
644
 
645
645
  ## Marking language, font and mapping
646
646
 
647
+ Each configured language note declares `data.roman`, a literal NFC string of
648
+ permitted source characters. Include capitals, accents, digits and punctuation
649
+ explicitly; an empty string declares no written characters. Spaces and line
650
+ breaks are structural. `package-build lint` checks Roman source before mapping,
651
+ including language-only runs and visible link labels. The configured name and
652
+ its BCP 47 tag select the same inventory; external tags without a configured
653
+ language note keep their ordinary behavior.
654
+
647
655
  `lang=` identifies the language. It preserves the authored spelling and the
648
656
  surrounding prose font. Add **`.nativefont`** to request native script:
649
657
 
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**
@@ -2132,6 +2215,14 @@ dependencies; exact Item UUID validation remains part of compilation. Reads
2132
2215
  the content tree named by `root`, defaulting to
2133
2216
  `paths.content`; writes nothing.
2134
2217
 
2218
+ Every configured language note declares `data.roman`, its exact NFC Roman
2219
+ source characters. Lint checks language-tagged text before mapping, using the
2220
+ same inventory for a configured name and its BCP 47 tag. Visible linked labels
2221
+ are checked; destinations, attributes and verse structure are excluded. An
2222
+ explicit empty inventory declares no written characters. External tags without
2223
+ a language-note association retain their ordinary behavior. Standalone
2224
+ `package-build markdown` does not load these inventories.
2225
+
2135
2226
  Name marking is advisory: lint checks explicit `.name` spans and authored link
2136
2227
  labels against the [name-marking
2137
2228
  conventions](authoring/links-and-markup.md#marking-names-in-prose). Plain prose