@heroiclands/package-build 20.4.0 → 20.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +288 -0
  2. package/CONTENT.md +213 -20
  3. package/README.md +19 -1
  4. package/bin/content-build.mjs +135 -32
  5. package/bin/package-build.mjs +46 -13
  6. package/content-config.mjs +345 -101
  7. package/docs/api.md +1352 -0
  8. package/docs/commands.md +1609 -0
  9. package/docs/configuration.md +1432 -0
  10. package/docs/content-format.md +16 -6
  11. package/docs/diagnostics.md +356 -0
  12. package/docs/getting-started.md +813 -0
  13. package/docs/project-setup.md +469 -0
  14. package/engine/actor-compiler.mjs +30 -27
  15. package/engine/address-diff.mjs +45 -41
  16. package/engine/base-compiler.mjs +6 -0
  17. package/engine/bundles.mjs +9 -0
  18. package/engine/content-address.mjs +9 -9
  19. package/engine/content-index.mjs +44 -23
  20. package/engine/content-links.mjs +44 -11
  21. package/engine/content-lint.mjs +44 -10
  22. package/engine/content-tables.mjs +32 -27
  23. package/engine/folder-notes.mjs +4 -2
  24. package/engine/frontmatter-lint.mjs +35 -38
  25. package/engine/generate.mjs +5 -0
  26. package/engine/helpers.mjs +86 -32
  27. package/engine/index.mjs +12 -2
  28. package/engine/journals.mjs +9 -0
  29. package/engine/note-claims.mjs +18 -10
  30. package/engine/note-schemas.mjs +0 -5
  31. package/engine/note-vocabulary.mjs +32 -31
  32. package/engine/pack-config.mjs +26 -12
  33. package/engine/pack-router.mjs +0 -0
  34. package/engine/pdf-build.mjs +464 -0
  35. package/engine/pdf-fonts.mjs +420 -0
  36. package/engine/pdf-render.mjs +876 -0
  37. package/engine/pdf-toc.mjs +525 -0
  38. package/engine/scenes.mjs +14 -5
  39. package/engine/schema-check.mjs +1 -1
  40. package/engine/site-build.mjs +21 -3
  41. package/engine/web-wikilinks.mjs +6 -3
  42. package/engine/wikilinks.mjs +2 -4
  43. package/hm3/actors.mjs +8 -0
  44. package/hm3/items.mjs +8 -0
  45. package/package.json +1 -1
  46. package/release.mjs +63 -3
  47. package/sohl/actors.mjs +8 -0
  48. package/sohl/items.mjs +8 -0
  49. package/sohl/note-schemas.mjs +5 -5
  50. package/types/content-config.d.mts +66 -15
  51. package/types/engine/actor-compiler.d.mts +34 -30
  52. package/types/engine/address-diff.d.mts +57 -3
  53. package/types/engine/base-compiler.d.mts +10 -2
  54. package/types/engine/bundles.d.mts +9 -0
  55. package/types/engine/content-address.d.mts +9 -9
  56. package/types/engine/content-index.d.mts +57 -13
  57. package/types/engine/content-lint.d.mts +6 -4
  58. package/types/engine/content-tables.d.mts +49 -18
  59. package/types/engine/frontmatter-lint.d.mts +3 -2
  60. package/types/engine/helpers.d.mts +105 -31
  61. package/types/engine/index.d.mts +4 -0
  62. package/types/engine/journals.d.mts +9 -0
  63. package/types/engine/note-claims.d.mts +17 -10
  64. package/types/engine/note-vocabulary.d.mts +23 -196
  65. package/types/engine/pack-config.d.mts +4 -4
  66. package/types/engine/pdf-build.d.mts +42 -0
  67. package/types/engine/pdf-fonts.d.mts +30 -0
  68. package/types/engine/pdf-render.d.mts +156 -0
  69. package/types/engine/pdf-toc.d.mts +114 -0
  70. package/types/engine/scenes.d.mts +10 -1
  71. package/types/engine/schema-check.d.mts +2 -2
  72. package/types/engine/site-build.d.mts +34 -6
  73. package/types/engine/wikilinks.d.mts +2 -3
  74. package/types/hm3/actors.d.mts +8 -0
  75. package/types/hm3/items.d.mts +8 -0
  76. package/types/release.d.mts +15 -4
  77. package/types/sohl/actors.d.mts +10 -2
  78. package/types/sohl/items.d.mts +8 -0
@@ -22,7 +22,6 @@
22
22
  * packageKind: systems
23
23
  * compatibility: { minimum: "14.359", verified: "14.364" }
24
24
  * stats:
25
- * systemId: sohl
26
25
  * lastModifiedBy: sohlbuilder00000
27
26
  * itemBuilders: sohl
28
27
  * skipDirectories: [Templates]
@@ -42,14 +41,15 @@
42
41
  * a consumer's config is data, and the compilers read it.
43
42
  *
44
43
  * **This module validates; it does not load.** `engine/pack-config.mjs` is what
45
- * finds a repository's configuration and reads it, and it is where the three
44
+ * finds a repository's configuration and reads it, and it is where the four
46
45
  * fields absent from the YAML above are derived: `rootDir` (the directory the
47
- * file sits in), `stats.systemVersion` (the adjacent `package.json`), and the
48
- * `itemBuilders` table the name `sohl` stands for. All three are I/O or code,
49
- * and this module is deliberately neither — which is also why a consumer whose
50
- * item-builder registry is its own writes `package-build.config.mjs`, calling
51
- * `defineConfig` below directly with a `rootDir` of `import.meta.dirname`.
52
- * Both forms end here, so both are validated and frozen identically.
46
+ * file sits in), `foundryPackage` and `stats.systemVersion` (the adjacent
47
+ * `package.json`), and the `itemBuilders` table the name `sohl` stands for. All
48
+ * four are I/O or code, and this module is deliberately neither — which is also
49
+ * why a consumer whose item-builder registry is its own writes
50
+ * `package-build.config.mjs`, calling `defineConfig` below directly with a
51
+ * `rootDir` of `import.meta.dirname`. Both forms end here, so both are
52
+ * validated and frozen identically.
53
53
  *
54
54
  * **`rootDir` anchors every path**, so the build reads the same files whatever
55
55
  * directory it was launched from.
@@ -76,13 +76,70 @@ import { ACTOR_TYPES } from "./engine/subtype-registry.mjs";
76
76
  import { NOTE_VOCABULARY } from "./engine/note-vocabulary.mjs";
77
77
 
78
78
  /**
79
- * The two kinds of Foundry package a content module can be built into. The
80
- * value is also the directory Foundry installs the package under, which is why
81
- * it is plural.
79
+ * What kind of package this is.
80
+ *
81
+ * `systems` and `modules` are the two Foundry answers, and the value is also
82
+ * the directory Foundry installs the package under, which is why they are
83
+ * plural. `documentation` is the answer "not a Foundry package at all": it
84
+ * publishes a site and a book from its notes, installs into no Foundry data
85
+ * directory and compiles no compendium.
82
86
  *
83
87
  * @satisfies {readonly PackageKind[]}
84
88
  */
85
- export const PACKAGE_KINDS = /** @type {const} */ (["systems", "modules"]);
89
+ export const PACKAGE_KINDS = /** @type {const} */ (["systems", "modules", "documentation"]);
90
+
91
+ /**
92
+ * The kind that compiles no Foundry documents.
93
+ *
94
+ * Spelled once and read wherever a pass asks whether it applies, so the
95
+ * validator, the CLI and the compile passes cannot come to disagree about what
96
+ * the value means.
97
+ *
98
+ * @type {string}
99
+ */
100
+ export const DOCUMENTATION_KIND = "documentation";
101
+
102
+ /**
103
+ * Whether this package compiles Foundry documents at all.
104
+ *
105
+ * The one question every Foundry-side reader asks — the manifest writer, to
106
+ * decide whether there is a package for Foundry to install, and the pack
107
+ * compilers, to decide whether there is anything to compile.
108
+ *
109
+ * @param {{packageKind: string}} config - A resolved configuration.
110
+ * @returns {boolean} Whether the package compiles Foundry documents.
111
+ */
112
+ export function compilesFoundryDocuments(config) {
113
+ return config.packageKind !== DOCUMENTATION_KIND;
114
+ }
115
+
116
+ /**
117
+ * Every key a documentation package may not declare, and why.
118
+ *
119
+ * The value of the kind is as much in what it refuses as in what it accepts. A
120
+ * key here cannot mean anything in a package that compiles nothing and installs
121
+ * nowhere, so it fails at load naming the key — the loader resolves that name
122
+ * to a line and a column — rather than being read and ignored, which is the
123
+ * failure this contract exists to prevent.
124
+ *
125
+ * `foundryPackage` is on the list for the same reason as the rest, and is the
126
+ * one the YAML loader would otherwise supply: it derives the id from the
127
+ * adjacent `package.json`, and there is no Foundry package here to carry one.
128
+ *
129
+ * @type {Readonly<Record<string, string>>}
130
+ */
131
+ const DOCUMENTATION_REFUSES = Object.freeze({
132
+ packs: "compiles no compendium, so there are no packs to declare",
133
+ itemBuilders: "compiles no items, so there is no item-type registry to name",
134
+ docs: "compiles no items, so there are no item-field reference pages to frame",
135
+ compatibility:
136
+ "installs into no Foundry data directory, so there is no Foundry core range to support",
137
+ relationships: "is not a Foundry package, so it stands in no relationship to one",
138
+ systems: "compiles no documents, so it ships content for no game system",
139
+ requiresSystem: "compiles no documents, so there is no game system to gate its packs on",
140
+ stats: "compiles no documents, so there is no `_stats` block to stamp",
141
+ foundryPackage: "is not a Foundry package, so it has no Foundry package id",
142
+ });
86
143
 
87
144
  /**
88
145
  * The directories the build reads from and writes to, relative to `rootDir`,
@@ -234,7 +291,7 @@ export function publishesContentPages(config) {
234
291
  }
235
292
 
236
293
  /**
237
- * @typedef {"systems" | "modules"} PackageKind
294
+ * @typedef {"systems" | "modules" | "documentation"} PackageKind
238
295
  */
239
296
 
240
297
  /**
@@ -500,10 +557,20 @@ export function publishesContentPages(config) {
500
557
  * @property {string} contentPackage Content package name — the address
501
558
  * namespace every note in this
502
559
  * repository is published under.
503
- * @property {string} foundryPackage Foundry package id, as it appears in
560
+ * @property {string} [foundryPackage] Foundry package id, as it appears in
504
561
  * `system.json` / `module.json`.
505
- * @property {PackageKind} packageKind Whether the package is a system or a module.
506
- * @property {StatsSpec} stats Identity stamped into every document's `_stats`.
562
+ * Refused by a `documentation`
563
+ * package, which ships no Foundry
564
+ * package.
565
+ * @property {PackageKind} packageKind Whether the package is a system, a
566
+ * module, or documentation — the kind
567
+ * that publishes a site and a book
568
+ * while compiling nothing.
569
+ * @property {StatsSpec} [stats] Identity stamped into every
570
+ * document's `_stats`. Required of a
571
+ * package that compiles documents, and
572
+ * refused by a `documentation` one,
573
+ * which compiles none.
507
574
  * @property {Record<string, ItemBuilderEntry>|readonly ItemRegistrySpec[]} [itemBuilders]
508
575
  * The consumer's
509
576
  * item-type registry: each content `type`
@@ -552,12 +619,20 @@ export function publishesContentPages(config) {
552
619
  * @typedef {object} ContentBuildConfig
553
620
  * @property {string} rootDir
554
621
  * @property {string} contentPackage
555
- * @property {string} foundryPackage
622
+ * @property {string|null} foundryPackage `null` for a `documentation`
623
+ * package, which ships no Foundry package.
556
624
  * @property {PackageKind} packageKind
557
- * @property {string} assetRoot Derived: the served Foundry asset root,
558
- * `<packageKind>/<foundryPackage>/assets`.
625
+ * @property {string|null} assetRoot Derived, and **conditional**: the served
626
+ * Foundry asset root,
627
+ * `<packageKind>/<foundryPackage>/assets`,
628
+ * for a package Foundry installs — and
629
+ * `null` for a `documentation` package,
630
+ * which Foundry serves no files for. See
631
+ * {@link module:engine/helpers.resolveImg},
632
+ * the one reader of it.
559
633
  * @property {Readonly<ResolvedPaths>} paths
560
- * @property {Readonly<StatsSpec>} stats
634
+ * @property {Readonly<StatsSpec>|null} stats `null` for a `documentation`
635
+ * package, which stamps no `_stats`.
561
636
  * @property {Readonly<Record<string, Function>>} itemBuilders Derived: the
562
637
  * `system` builder of each entry, whichever
563
638
  * of the two spellings declared it.
@@ -623,6 +698,7 @@ const CONFIG_KEYS = [
623
698
  "packs",
624
699
  "docs",
625
700
  "site",
701
+ "pdf",
626
702
  "compatibility",
627
703
  "relationships",
628
704
  "systems",
@@ -646,6 +722,9 @@ const SITE_KEYS = [
646
722
  "backfillSections",
647
723
  ];
648
724
  const SITE_TREE_KEYS = ["from", "section"];
725
+ const PDF_KEYS = ["title", "subtitle", "document", "out", "front", "fonts", "iconFonts", "binary"];
726
+ const PDF_FONT_KEYS = ["serif", "sans", "mono", "path"];
727
+ const EMPTY_PDF_FONTS = Object.freeze({ serif: "", sans: "", mono: "", path: "" });
649
728
  const SECTION_META_KEYS = ["title", "banner", "description", "listType", "listSubType"];
650
729
  const DOC_PAGE_KEYS = ["title", "out", "preamble"];
651
730
  const RELATIONSHIP_KINDS = ["systems", "requires", "recommends", "conflicts"];
@@ -746,9 +825,10 @@ function requireNonEmptyString(value, field) {
746
825
  * whose addresses are simply unreadable, reported nowhere and discovered as
747
826
  * links that resolve to nothing.
748
827
  *
749
- * 1. _Alphanumeric_, so the hyphen stays purely a separator. `harn-adventures`
750
- * was the one violator, and its keys read as one segment too many and failed
751
- * as a `null` return from `readCanonicalKey` a silence, not an error.
828
+ * 1. _Lowercase alphanumeric_ (`ADDRESS_SEGMENT_PATTERN`), so the hyphen stays
829
+ * purely a separator. `harn-adventures` was the one violator, and its keys
830
+ * read as one segment too many and failed as a `null` return from
831
+ * `readCanonicalKey` — a silence, not an error.
752
832
  * 2. _Not a note type_, because a written address is a **partial** one: the
753
833
  * shorter forms drop segments from the left, so `skill-clmb` and
754
834
  * `sohl-skill-clmb` are both addresses and position alone no longer says
@@ -777,11 +857,12 @@ function requireContentPackage(value, docEntryTypes) {
777
857
  if (!isAddressSegment(pkg)) {
778
858
  fail(
779
859
  "contentPackage",
780
- `is \`${pkg}\`, which is not alphanumeric. It is the first ` +
860
+ `is \`${pkg}\`, which is not lowercase alphanumeric ` +
861
+ `(${ADDRESS_SEGMENT_PATTERN.source}). It is the first ` +
781
862
  `segment of every address this package publishes ` +
782
863
  `(\`${pkg}-<system>-<type>-<shortcode>\`), and an address is read by ` +
783
864
  `counting hyphen-separated segments — so anything outside ` +
784
- "`[A-Za-z0-9]` here makes those addresses unreadable rather " +
865
+ "that here makes those addresses unreadable rather " +
785
866
  "than merely ugly. `harn-adventures` became `harnadventures`",
786
867
  );
787
868
  }
@@ -957,18 +1038,6 @@ function normalizePack(value, where, nested = false) {
957
1038
  return Object.freeze(normalized);
958
1039
  }
959
1040
 
960
- /**
961
- * Resolve the layout a consumer supplies against its `rootDir`, filling every
962
- * unnamed directory from {@link DEFAULT_PATHS}.
963
- *
964
- * Configured paths are **relative by contract**: an absolute one would escape
965
- * the repository the config anchors, which is never what a consumer means and
966
- * is what made these paths working-directory-dependent in the first place.
967
- *
968
- * @param {unknown} value
969
- * @param {string} rootDir
970
- * @returns {Readonly<ResolvedPaths>}
971
- */
972
1041
  /**
973
1042
  * A package's icon registry — the fonts it ships and the names it draws from
974
1043
  * them.
@@ -1091,6 +1160,18 @@ function normalizeIcons(value, rootDir) {
1091
1160
  });
1092
1161
  }
1093
1162
 
1163
+ /**
1164
+ * Resolve the layout a consumer supplies against its `rootDir`, filling every
1165
+ * unnamed directory from {@link DEFAULT_PATHS}.
1166
+ *
1167
+ * Configured paths are **relative by contract**: an absolute one would escape
1168
+ * the repository the config anchors, which is never what a consumer means and
1169
+ * is what made these paths working-directory-dependent in the first place.
1170
+ *
1171
+ * @param {unknown} value
1172
+ * @param {string} rootDir
1173
+ * @returns {Readonly<ResolvedPaths>}
1174
+ */
1094
1175
  function normalizePaths(value, rootDir) {
1095
1176
  if (value !== undefined && !isPlainObject(value)) {
1096
1177
  fail("paths", "must be an object");
@@ -1118,7 +1199,9 @@ function normalizePaths(value, rootDir) {
1118
1199
  }
1119
1200
 
1120
1201
  /**
1121
- * @param {unknown} value
1202
+ * @param {unknown} value - The authored `stats:` block.
1203
+ * @param {{systemId: string, systemVersion: string}} derived - The package-wide
1204
+ * system and the version it stamps against, both derived by the caller.
1122
1205
  * @returns {Readonly<StatsSpec>}
1123
1206
  */
1124
1207
  function normalizeStats(value, derived) {
@@ -1308,7 +1391,7 @@ function normalizeSectionMeta(value, where) {
1308
1391
  if (!isAddressSegment(segment)) {
1309
1392
  fail(
1310
1393
  `${where}.${key}`,
1311
- `is \`${segment}\`, which is not alphanumeric. It names a ` +
1394
+ `is \`${segment}\`, which is not lowercase alphanumeric. It names a ` +
1312
1395
  "content type or subType, and those are address segments " +
1313
1396
  `(${ADDRESS_SEGMENT_PATTERN.source}) — not the section's ` +
1314
1397
  "own name, which is a URL this site chose and need not " +
@@ -1433,6 +1516,114 @@ function normalizeSite(value) {
1433
1516
  });
1434
1517
  }
1435
1518
 
1519
+ /**
1520
+ * The `pdf` section — the book the content tree is published as.
1521
+ *
1522
+ * A third surface beside the packs and the website, and the one that is a
1523
+ * **selection** rather than a rendering of everything: `document:` names the
1524
+ * tree that says which notes the volume carries and in what order, because a
1525
+ * book is an editorial act where a site is an index. That file is the
1526
+ * consumer's, parsed by {@link module:engine/pdf-toc.parseDocumentTree}, and
1527
+ * nothing about its shape is validated here — this block says only where it is.
1528
+ *
1529
+ * **Nothing here is an address or a brand.** The title, the subtitle, the front
1530
+ * matter and the faces are every one of them the publishing repository's to
1531
+ * choose, which is the whole reason they are configuration: the engine that
1532
+ * sets the book must be able to set somebody else's book.
1533
+ *
1534
+ * **Declaring the block is not the switch.** Whether a PDF is built at all is
1535
+ * `publish.site` — `content` builds one, `homepage` does not — so a package
1536
+ * cannot end up with two switches that disagree about whether it publishes its
1537
+ * content tree. See {@link publishesContentPages}.
1538
+ *
1539
+ * @param {unknown} value - The `pdf` block, or `undefined`.
1540
+ * @param {string} rootDir - The repository root configured paths resolve against.
1541
+ * @returns {Readonly<object>|null} It, frozen; `null` when the block is absent.
1542
+ */
1543
+ function normalizePdf(value, rootDir) {
1544
+ if (value === undefined) return null;
1545
+ if (!isPlainObject(value)) fail("pdf", "must be a mapping");
1546
+ const input = /** @type {Record<string, unknown>} */ (value);
1547
+ rejectUnknownKeys(input, PDF_KEYS, "pdf.");
1548
+
1549
+ // Both required, and required together: a document with no tree has nothing
1550
+ // to print, and a tree with no title produces a file whose name and cover
1551
+ // say nothing about what a reader downloaded.
1552
+ const title = requireNonEmptyString(input.title, "pdf.title");
1553
+ const document = requireNonEmptyString(input.document, "pdf.document");
1554
+
1555
+ const front = [];
1556
+ if (input.front !== undefined) {
1557
+ if (!Array.isArray(input.front)) fail("pdf.front", "must be a list of markdown files");
1558
+ input.front.forEach((entry, i) => {
1559
+ front.push(requireNonEmptyString(entry, `pdf.front[${i}]`));
1560
+ });
1561
+ }
1562
+
1563
+ let fonts = EMPTY_PDF_FONTS;
1564
+ if (input.fonts !== undefined) {
1565
+ if (!isPlainObject(input.fonts)) fail("pdf.fonts", "must be a mapping");
1566
+ const declared = /** @type {Record<string, unknown>} */ (input.fonts);
1567
+ rejectUnknownKeys(declared, PDF_FONT_KEYS, "pdf.fonts.");
1568
+ fonts = Object.freeze({
1569
+ // Family *names*, not files: the renderer asks the font stack for a
1570
+ // family, and `path` is where it may look beyond the system's own.
1571
+ serif:
1572
+ declared.serif === undefined ?
1573
+ ""
1574
+ : requireNonEmptyString(declared.serif, "pdf.fonts.serif"),
1575
+ sans:
1576
+ declared.sans === undefined ?
1577
+ ""
1578
+ : requireNonEmptyString(declared.sans, "pdf.fonts.sans"),
1579
+ mono:
1580
+ declared.mono === undefined ?
1581
+ ""
1582
+ : requireNonEmptyString(declared.mono, "pdf.fonts.mono"),
1583
+ path:
1584
+ declared.path === undefined ?
1585
+ ""
1586
+ : path.resolve(rootDir, requireNonEmptyString(declared.path, "pdf.fonts.path")),
1587
+ });
1588
+ }
1589
+
1590
+ // Family name to the font file carrying its glyphs, for `:icon-…:`. A file
1591
+ // rather than a codepoint, because the font's own tables are the only
1592
+ // trustworthy source of which glyph a name resolves to — see
1593
+ // {@link module:engine/content-icons}, which states the style and the name
1594
+ // and deliberately holds no codepoints.
1595
+ const iconFonts = {};
1596
+ if (input.iconFonts !== undefined) {
1597
+ if (!isPlainObject(input.iconFonts)) {
1598
+ fail("pdf.iconFonts", "must be a mapping of icon family to font file");
1599
+ }
1600
+ for (const [family, file] of Object.entries(input.iconFonts)) {
1601
+ iconFonts[family] = path.resolve(
1602
+ rootDir,
1603
+ requireNonEmptyString(file, `pdf.iconFonts.${family}`),
1604
+ );
1605
+ }
1606
+ }
1607
+
1608
+ return Object.freeze({
1609
+ title,
1610
+ subtitle:
1611
+ input.subtitle === undefined ?
1612
+ ""
1613
+ : requireNonEmptyString(input.subtitle, "pdf.subtitle"),
1614
+ document: path.resolve(rootDir, document),
1615
+ out: input.out === undefined ? "" : requireNonEmptyString(input.out, "pdf.out"),
1616
+ front: Object.freeze(front.map((f) => path.resolve(rootDir, f))),
1617
+ fonts,
1618
+ iconFonts: Object.freeze(iconFonts),
1619
+ // Where the Typst binary is, when it is not simply `typst` on PATH.
1620
+ // Named rather than bundled: a native compiler would put a
1621
+ // platform-specific binary in the dependency tree of three repositories
1622
+ // that mostly do not build books.
1623
+ binary: input.binary === undefined ? "" : requireNonEmptyString(input.binary, "pdf.binary"),
1624
+ });
1625
+ }
1626
+
1436
1627
  /**
1437
1628
  * Validate a Foundry version range.
1438
1629
  *
@@ -1462,38 +1653,6 @@ function normalizeCompatibility(value, where, requireMinimum = true) {
1462
1653
  return Object.freeze(out);
1463
1654
  }
1464
1655
 
1465
- /**
1466
- * Validate the declared relationships.
1467
- *
1468
- * Only as far as this package needs to read them: enough that a system
1469
- * relationship can be found and its `verified` version trusted. The rest is
1470
- * passed through for the manifest generator to emit.
1471
- *
1472
- * @param {unknown} value - The `relationships` block, or `undefined`.
1473
- * @returns {Readonly<Relationships>} It, frozen; `{}` when absent.
1474
- */
1475
- /**
1476
- * The systems this package can stamp content against — declaration only.
1477
- *
1478
- * **Declaring is not requiring, and that separation is the whole point.** The
1479
- * only other place to state a system version is `relationships.systems`, and
1480
- * that list is a *restriction*: Foundry's `supportsSystem` drops a module from
1481
- * any world whose system it does not name. So a module shipping content for two
1482
- * systems — `harn-ensemble` ships an HM3 pack, a SoHL pack and a system-neutral
1483
- * journals pack — had to choose between naming its systems and remaining
1484
- * loadable, and choosing the second meant stamping no system version at all on
1485
- * content that certainly has one.
1486
- *
1487
- * Naming a system here restricts nothing. {@link normalizeRequiresSystem} is
1488
- * what restricts, and it is separate and optional.
1489
- *
1490
- * Each entry carries the same `compatibility` shape a relationship does, and
1491
- * `verified` is what a pack stamps: `_stats.systemVersion` records what the
1492
- * content was *built against*, not the floor it tolerates.
1493
- *
1494
- * @param {unknown} value - The declared `systems:` mapping.
1495
- * @returns {Readonly<Record<string, Readonly<object>>>} Frozen; `{}` when absent.
1496
- */
1497
1656
  /**
1498
1657
  * The **package-wide** system, or `null` where the configuration names none.
1499
1658
  *
@@ -1516,7 +1675,7 @@ function normalizeCompatibility(value, where, requireMinimum = true) {
1516
1675
  * answer.
1517
1676
  *
1518
1677
  * @param {object} parts - The resolved pieces of the configuration.
1519
- * @param {string} parts.packageKind - `systems` or `modules`.
1678
+ * @param {string} parts.packageKind - One of {@link PACKAGE_KINDS}.
1520
1679
  * @param {unknown} parts.foundryPackage - The package id.
1521
1680
  * @param {string|null} parts.requiresSystem - The declared gate, if any.
1522
1681
  * @param {Readonly<Record<string, object>>} parts.systems - The `systems:` block.
@@ -1539,6 +1698,28 @@ function packageWideSystemId({
1539
1698
  return null;
1540
1699
  }
1541
1700
 
1701
+ /**
1702
+ * The systems this package can stamp content against — declaration only.
1703
+ *
1704
+ * **Declaring is not requiring, and that separation is the whole point.** The
1705
+ * only other place to state a system version is `relationships.systems`, and
1706
+ * that list is a *restriction*: Foundry's `supportsSystem` drops a module from
1707
+ * any world whose system it does not name. So a module shipping content for two
1708
+ * systems — `harn-ensemble` ships an HM3 pack, a SoHL pack and a system-neutral
1709
+ * journals pack — had to choose between naming its systems and remaining
1710
+ * loadable, and choosing the second meant stamping no system version at all on
1711
+ * content that certainly has one.
1712
+ *
1713
+ * Naming a system here restricts nothing. {@link normalizeRequiresSystem} is
1714
+ * what restricts, and it is separate and optional.
1715
+ *
1716
+ * Each entry carries the same `compatibility` shape a relationship does, and
1717
+ * `verified` is what a pack stamps: `_stats.systemVersion` records what the
1718
+ * content was *built against*, not the floor it tolerates.
1719
+ *
1720
+ * @param {unknown} value - The declared `systems:` mapping.
1721
+ * @returns {Readonly<Record<string, Readonly<object>>>} Frozen; `{}` when absent.
1722
+ */
1542
1723
  function normalizeSystems(value) {
1543
1724
  if (value === undefined || value === null) return Object.freeze({});
1544
1725
  if (!isPlainObject(value)) fail("systems", "must be a mapping of id to spec");
@@ -1602,6 +1783,16 @@ function normalizeRequiresSystem(value) {
1602
1783
  return requireNonEmptyString(value, "requiresSystem");
1603
1784
  }
1604
1785
 
1786
+ /**
1787
+ * Validate the declared relationships.
1788
+ *
1789
+ * Only as far as this package needs to read them: enough that a system
1790
+ * relationship can be found and its `verified` version trusted. The rest is
1791
+ * passed through for the manifest generator to emit.
1792
+ *
1793
+ * @param {unknown} value - The `relationships` block, or `undefined`.
1794
+ * @returns {Readonly<Relationships>} It, frozen; `{}` when absent.
1795
+ */
1605
1796
  function normalizeRelationships(value) {
1606
1797
  if (value === undefined) return Object.freeze({});
1607
1798
  if (!isPlainObject(value)) fail("relationships", "must be a mapping");
@@ -2017,9 +2208,45 @@ export function defineConfig(config) {
2017
2208
  fail("packageKind", `must be one of: ${PACKAGE_KINDS.join(", ")}`);
2018
2209
  }
2019
2210
 
2020
- if (!Array.isArray(input.packs)) fail("packs", "must be an array");
2021
- if (input.packs.length === 0) fail("packs", "must declare at least one pack");
2022
- const packs = input.packs.map((pack, index) => normalizePack(pack, `packs[${index}]`));
2211
+ // A documentation package compiles nothing and installs nowhere, so every
2212
+ // key that describes a Foundry package is refused by name — ahead of the
2213
+ // checks below, which each assume a Foundry package is being described.
2214
+ const documentation = packageKind === DOCUMENTATION_KIND;
2215
+ if (documentation) {
2216
+ for (const [key, why] of Object.entries(DOCUMENTATION_REFUSES)) {
2217
+ if (input[key] === undefined) continue;
2218
+ fail(key, `is refused in a \`${DOCUMENTATION_KIND}\` package, which ${why}`);
2219
+ }
2220
+ // Publishing is what a documentation package is *for*, so the floor
2221
+ // every other package may sit at is not available to it: `homepage`
2222
+ // would leave a package that publishes one authored page, builds no
2223
+ // book, and compiles nothing at all.
2224
+ if (!isPlainObject(input.publish)) {
2225
+ fail(
2226
+ "publish",
2227
+ `is required in a \`${DOCUMENTATION_KIND}\` package: publishing ` +
2228
+ "the content tree is the whole of what it does. Write " +
2229
+ "`publish: {site: content}`",
2230
+ );
2231
+ }
2232
+ const mode = /** @type {Record<string, unknown>} */ (input.publish).site;
2233
+ if (mode !== "content") {
2234
+ fail(
2235
+ "publish.site",
2236
+ `must be \`content\` in a \`${DOCUMENTATION_KIND}\` package — ` +
2237
+ "`homepage` fences the content surfaces off, and a package " +
2238
+ "that compiles nothing and publishes nothing from its tree " +
2239
+ "would produce a single authored page and no book",
2240
+ );
2241
+ }
2242
+ }
2243
+
2244
+ if (!documentation) {
2245
+ if (!Array.isArray(input.packs)) fail("packs", "must be an array");
2246
+ if (input.packs.length === 0) fail("packs", "must declare at least one pack");
2247
+ }
2248
+ const declaredPacks = Array.isArray(input.packs) ? input.packs : [];
2249
+ const packs = declaredPacks.map((pack, index) => normalizePack(pack, `packs[${index}]`));
2023
2250
 
2024
2251
  // One list, so the compile order and the directory list cannot disagree —
2025
2252
  // as `PACK_CONFIGS` and `SOURCE_PACKS` they would be maintained apart.
@@ -2140,7 +2367,10 @@ export function defineConfig(config) {
2140
2367
  requireNonEmptyString(name, `skipDirectories[${index}]`),
2141
2368
  );
2142
2369
 
2143
- const foundryPackage = requireNonEmptyString(input.foundryPackage, "foundryPackage");
2370
+ // Refused above for a documentation package, so there is nothing to read
2371
+ // and nothing to derive an asset root or a package-wide system from.
2372
+ const foundryPackage =
2373
+ documentation ? null : requireNonEmptyString(input.foundryPackage, "foundryPackage");
2144
2374
 
2145
2375
  const {
2146
2376
  itemBuilders,
@@ -2172,37 +2402,50 @@ export function defineConfig(config) {
2172
2402
  packageKind: /** @type {PackageKind} */ (packageKind),
2173
2403
  // Foundry serves a package's files from `<kind>/<id>/`, so this is the
2174
2404
  // one place `systems/sohl` (or `modules/sohl-thalorna`) is spelled.
2175
- assetRoot: `${packageKind}/${foundryPackage}/assets`,
2405
+ //
2406
+ // **Conditional on the kind.** `documentation` names no directory
2407
+ // Foundry serves, and there is no package id to put under one either, so
2408
+ // the derivation would read `documentation/null/assets` — an address
2409
+ // that resolves nowhere and would be written into every compiled `img`.
2410
+ // `null` says the package has no asset root instead, and
2411
+ // {@link module:engine/helpers.resolveImg} — the only reader — refuses
2412
+ // rather than rooting a path against nothing.
2413
+ assetRoot: documentation ? null : `${packageKind}/${foundryPackage}/assets`,
2176
2414
  paths: normalizePaths(input.paths, rootDir),
2177
2415
  // The package-wide system, derived. A **system** package is its
2178
2416
  // own system, which is true by construction and needs no declaration. A
2179
2417
  // **module** takes the one it requires, or the one system it declares
2180
2418
  // when there is exactly one; with several and no gate there is no
2181
2419
  // package-wide answer, and each pack carries its own.
2182
- stats: normalizeStats(input.stats, {
2183
- systemId: packageWideSystemId({
2184
- packageKind,
2185
- foundryPackage,
2186
- requiresSystem,
2187
- systems,
2188
- relationshipSystems,
2189
- }),
2190
- // Derived here where the answer is pure data — the `verified` of
2191
- // whichever system the package-wide block takes — and supplied by
2192
- // the loader otherwise. The loader is the half that may do I/O, and
2193
- // the two cases needing it are a *system* package (its own
2194
- // `package.json` version) and a module still deriving from
2195
- // `relationships.systems`.
2196
- systemVersion:
2197
- (() => {
2198
- const id =
2199
- requiresSystem ??
2200
- (Object.keys(systems).length === 1 ? Object.keys(systems)[0] : null);
2201
- return id ? (systems[id]?.compatibility?.verified ?? null) : null;
2202
- })() ??
2203
- (isPlainObject(input.stats) ? input.stats[DERIVED_SYSTEM_VERSION] : null) ??
2204
- null,
2205
- }),
2420
+ stats:
2421
+ documentation ? null : (
2422
+ normalizeStats(input.stats, {
2423
+ systemId: packageWideSystemId({
2424
+ packageKind,
2425
+ foundryPackage,
2426
+ requiresSystem,
2427
+ systems,
2428
+ relationshipSystems,
2429
+ }),
2430
+ // Derived here where the answer is pure data the `verified` of
2431
+ // whichever system the package-wide block takes and supplied by
2432
+ // the loader otherwise. The loader is the half that may do I/O, and
2433
+ // the two cases needing it are a *system* package (its own
2434
+ // `package.json` version) and a module still deriving from
2435
+ // `relationships.systems`.
2436
+ systemVersion:
2437
+ (() => {
2438
+ const id =
2439
+ requiresSystem ??
2440
+ (Object.keys(systems).length === 1 ?
2441
+ Object.keys(systems)[0]
2442
+ : null);
2443
+ return id ? (systems[id]?.compatibility?.verified ?? null) : null;
2444
+ })() ??
2445
+ (isPlainObject(input.stats) ? input.stats[DERIVED_SYSTEM_VERSION] : null) ??
2446
+ null,
2447
+ })
2448
+ ),
2206
2449
  itemBuilders,
2207
2450
  itemArt,
2208
2451
  itemFields,
@@ -2224,6 +2467,7 @@ export function defineConfig(config) {
2224
2467
  packDirectories: Object.freeze(packDirectories),
2225
2468
  docs: normalizeDocs(input.docs),
2226
2469
  site: normalizeSite(input.site),
2470
+ pdf: normalizePdf(input.pdf, rootDir),
2227
2471
  compatibility: normalizeCompatibility(input.compatibility, "compatibility"),
2228
2472
  relationships: normalizeRelationships(input.relationships),
2229
2473
  systems,