@heroiclands/package-build 23.1.0 → 23.2.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 (248) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/bin/content-commands.mjs +68 -149
  3. package/bin/package-build.mjs +56 -10
  4. package/ci/check-banners.mjs +42 -6
  5. package/ci/confirm-npm-version.mjs +135 -0
  6. package/ci/content-format-reference.mjs +14 -5
  7. package/config.mjs +1 -0
  8. package/container.mjs +185 -4
  9. package/content-config.mjs +333 -210
  10. package/deploy.mjs +116 -10
  11. package/docs/api.md +238 -246
  12. package/docs/authoring/assets.md +11 -5
  13. package/docs/authoring/frontmatter.md +1 -1
  14. package/docs/authoring/links-and-markup.md +87 -0
  15. package/docs/commands.md +138 -99
  16. package/docs/configuration.md +277 -125
  17. package/docs/content-format.md +2 -0
  18. package/docs/diagnostics.md +48 -13
  19. package/docs/guides/book.md +341 -21
  20. package/docs/guides/e2e.md +13 -2
  21. package/docs/guides/icons.md +14 -4
  22. package/docs/guides/module-project.md +18 -8
  23. package/docs/guides/release-and-deploy.md +54 -6
  24. package/docs/guides/site.md +5 -2
  25. package/docs/project-setup.md +30 -10
  26. package/docs/reference/format-details.md +373 -170
  27. package/docs/reference/note-types.md +128 -109
  28. package/docs/reference/theme-pages.md +31 -6
  29. package/e2e.mjs +140 -28
  30. package/engine/actor-compiler.mjs +22 -31
  31. package/engine/address-charset.mjs +4 -17
  32. package/engine/address-fields.mjs +1 -1
  33. package/engine/address-render.mjs +12 -1
  34. package/engine/address-values.mjs +6 -0
  35. package/engine/address.mjs +46 -33
  36. package/engine/art-fields.mjs +30 -5
  37. package/engine/art-slots.mjs +10 -1
  38. package/engine/asset-bindings.mjs +12 -1
  39. package/engine/base-compiler.mjs +34 -65
  40. package/engine/being-age.mjs +2 -7
  41. package/engine/being-measurements.mjs +12 -1
  42. package/engine/calendar-artifacts.mjs +6 -0
  43. package/engine/calendar-choice.mjs +12 -1
  44. package/engine/calendar-format.mjs +12 -1
  45. package/engine/calendar-human.mjs +12 -1
  46. package/engine/compile-corpus.mjs +3 -1
  47. package/engine/content-address.mjs +9 -12
  48. package/engine/content-alerts.mjs +13 -1
  49. package/engine/content-blocks.mjs +14 -3
  50. package/engine/content-embeds.mjs +11 -3
  51. package/engine/content-figures.mjs +9 -0
  52. package/engine/content-footnotes.mjs +12 -1
  53. package/engine/content-format-check.mjs +11 -262
  54. package/engine/content-format.yaml +5 -2
  55. package/engine/content-images.mjs +3 -0
  56. package/engine/content-index.mjs +443 -20
  57. package/engine/content-links.mjs +29 -35
  58. package/engine/content-lint.mjs +2 -3
  59. package/engine/content-poetry.mjs +12 -1
  60. package/engine/content-slug.mjs +2 -14
  61. package/engine/content-spans.mjs +13 -1
  62. package/engine/content-tables.mjs +12 -1
  63. package/engine/culture-choice.mjs +210 -13
  64. package/engine/data-keys.mjs +19 -16
  65. package/engine/date-conversion.mjs +12 -1
  66. package/engine/declaration-check.mjs +9 -0
  67. package/engine/derived-fields.mjs +6 -7
  68. package/engine/diagnostics.mjs +34 -4
  69. package/engine/docs-checks.mjs +9 -0
  70. package/engine/document-subtypes.mjs +47 -34
  71. package/engine/event-views.mjs +139 -14
  72. package/engine/exported-scene.mjs +92 -28
  73. package/engine/extension-attributes.mjs +12 -1
  74. package/engine/field-spec.mjs +11 -102
  75. package/engine/folder-notes.mjs +0 -64
  76. package/engine/foreign-catalog.mjs +2 -1
  77. package/engine/format-generated.mjs +9 -0
  78. package/engine/foundry-entries.mjs +10 -28
  79. package/engine/frontmatter-lint.mjs +83 -392
  80. package/engine/frontmatter.mjs +20 -70
  81. package/engine/generate.mjs +10 -15
  82. package/engine/heading-attributes.mjs +12 -1
  83. package/engine/helpers.mjs +41 -102
  84. package/engine/holdings.mjs +1 -1
  85. package/engine/homepage.mjs +3 -10
  86. package/engine/ids.mjs +1 -167
  87. package/engine/index-records.mjs +6 -4
  88. package/engine/index.mjs +0 -3
  89. package/engine/infobox-render.mjs +15 -3
  90. package/engine/infobox.mjs +13 -14
  91. package/engine/item-compiler.mjs +4 -10
  92. package/engine/item-docs.mjs +3 -5
  93. package/engine/item-registry.mjs +2 -10
  94. package/engine/journals.mjs +1 -5
  95. package/engine/literature-notes.mjs +4 -12
  96. package/engine/macros.mjs +1 -2
  97. package/engine/map-graphviz.mjs +12 -1
  98. package/engine/markdown-expressions.mjs +12 -1
  99. package/engine/markdownlint-run.mjs +567 -0
  100. package/engine/metadata-index.mjs +42 -12
  101. package/engine/note-addresses.mjs +143 -35
  102. package/engine/note-claims.mjs +14 -28
  103. package/engine/note-dates.mjs +3 -3
  104. package/engine/note-frontmatter.mjs +6 -0
  105. package/engine/note-ids.mjs +8 -14
  106. package/engine/note-package.mjs +8 -25
  107. package/engine/note-state.mjs +1 -3
  108. package/engine/note-vocabulary.mjs +148 -84
  109. package/engine/number-words.mjs +12 -1
  110. package/engine/pack-config.mjs +15 -25
  111. package/engine/pack-router.mjs +10 -16
  112. package/engine/pathnames.mjs +8 -22
  113. package/engine/pdf-build.mjs +773 -121
  114. package/engine/pdf-coverage.mjs +188 -0
  115. package/engine/pdf-index.mjs +54 -7
  116. package/engine/pdf-pages.mjs +247 -0
  117. package/engine/pdf-render.mjs +114 -4
  118. package/engine/pdf-toc.mjs +29 -32
  119. package/engine/project-init.mjs +46 -6
  120. package/engine/prose-config.mjs +1 -1
  121. package/engine/prose-lint.mjs +52 -88
  122. package/engine/readability-lint.mjs +9 -0
  123. package/engine/reckoning-markers.mjs +12 -1
  124. package/engine/reference-targets.mjs +7 -0
  125. package/engine/runtime-only-fields.mjs +5 -8
  126. package/engine/scene-levels.mjs +2 -2
  127. package/engine/scenes.mjs +1 -1
  128. package/engine/shortcode-references.mjs +7 -0
  129. package/engine/site-build.mjs +30 -16
  130. package/engine/site-index.mjs +10 -16
  131. package/engine/site-preview.mjs +6 -0
  132. package/engine/site-root.mjs +8 -1
  133. package/engine/social-tie-terms.mjs +12 -1
  134. package/engine/social-ties.mjs +3 -0
  135. package/engine/sql-tables.mjs +56 -20
  136. package/engine/standing-terms.mjs +11 -1
  137. package/engine/standings.mjs +11 -27
  138. package/engine/system-block.mjs +39 -141
  139. package/engine/system-vocabulary.mjs +6 -0
  140. package/engine/walk-files.mjs +69 -0
  141. package/engine/web-wikilinks.mjs +23 -3
  142. package/engine/wikilink-syntax.mjs +32 -21
  143. package/engine/wikilinks.mjs +27 -16
  144. package/engine/yaml-lint.mjs +7 -4
  145. package/githooks/attribution.mjs +8 -0
  146. package/githooks/commit-message.mjs +9 -0
  147. package/hm3/actors.mjs +9 -48
  148. package/hm3/default-item-art.mjs +9 -9
  149. package/hm3/item-fields.mjs +2 -9
  150. package/hm3/template-priority.mjs +6 -7
  151. package/hugo-theme/data/banner-kinds.yaml +78 -0
  152. package/hugo-theme/data/banners.yaml +2 -3
  153. package/hugo-theme/layouts/404.html +3 -3
  154. package/hugo-theme/layouts/partials/banner-url.html +31 -7
  155. package/hugo-theme/layouts/partials/hero-banner.html +13 -14
  156. package/hugo-theme/layouts/partials/related.html +1 -1
  157. package/hugo-theme/layouts/partials/warn-missing-banner.html +1 -1
  158. package/hugo-theme/static/css/style.css +1 -1
  159. package/manifest.mjs +11 -4
  160. package/markdownlint-config.mjs +1 -1
  161. package/package.json +6 -3
  162. package/release.mjs +83 -18
  163. package/sohl/actors.mjs +16 -27
  164. package/sohl/default-item-art.mjs +3 -3
  165. package/sohl/document-subtypes.mjs +1 -16
  166. package/sohl/infobox.mjs +2 -3
  167. package/sohl/item-builders.mjs +5 -8
  168. package/sohl/item-fields.mjs +5 -35
  169. package/sohl/items.mjs +0 -4
  170. package/sohl/kb-passes.mjs +3 -3
  171. package/types/container.d.mts +116 -5
  172. package/types/content-config.d.mts +83 -35
  173. package/types/deploy.d.mts +42 -6
  174. package/types/e2e.d.mts +35 -3
  175. package/types/engine/address-charset.d.mts +4 -17
  176. package/types/engine/address.d.mts +17 -8
  177. package/types/engine/art-fields.d.mts +7 -4
  178. package/types/engine/art-slots.d.mts +6 -0
  179. package/types/engine/base-compiler.d.mts +15 -17
  180. package/types/engine/content-address.d.mts +9 -12
  181. package/types/engine/content-embeds.d.mts +4 -2
  182. package/types/engine/content-format-check.d.mts +0 -64
  183. package/types/engine/content-index.d.mts +82 -3
  184. package/types/engine/content-links.d.mts +2 -4
  185. package/types/engine/culture-choice.d.mts +51 -8
  186. package/types/engine/data-keys.d.mts +2 -2
  187. package/types/engine/derived-fields.d.mts +1 -2
  188. package/types/engine/diagnostics.d.mts +27 -4
  189. package/types/engine/document-subtypes.d.mts +12 -31
  190. package/types/engine/event-views.d.mts +14 -2
  191. package/types/engine/exported-scene.d.mts +3 -2
  192. package/types/engine/field-spec.d.mts +13 -122
  193. package/types/engine/folder-notes.d.mts +0 -39
  194. package/types/engine/foreign-catalog.d.mts +2 -1
  195. package/types/engine/foundry-entries.d.mts +4 -5
  196. package/types/engine/frontmatter-lint.d.mts +2 -2
  197. package/types/engine/frontmatter.d.mts +19 -45
  198. package/types/engine/generate.d.mts +1 -1
  199. package/types/engine/helpers.d.mts +25 -42
  200. package/types/engine/homepage.d.mts +3 -10
  201. package/types/engine/ids.d.mts +0 -114
  202. package/types/engine/index-records.d.mts +6 -4
  203. package/types/engine/index.d.mts +0 -1
  204. package/types/engine/infobox-render.d.mts +7 -1
  205. package/types/engine/infobox.d.mts +3 -2
  206. package/types/engine/item-docs.d.mts +1 -1
  207. package/types/engine/item-registry.d.mts +2 -4
  208. package/types/engine/literature-notes.d.mts +1 -1
  209. package/types/engine/markdownlint-run.d.mts +40 -0
  210. package/types/engine/metadata-index.d.mts +9 -1
  211. package/types/engine/note-addresses.d.mts +27 -2
  212. package/types/engine/note-claims.d.mts +3 -5
  213. package/types/engine/note-ids.d.mts +2 -4
  214. package/types/engine/note-package.d.mts +1 -1
  215. package/types/engine/note-state.d.mts +1 -3
  216. package/types/engine/note-vocabulary.d.mts +28 -21
  217. package/types/engine/pack-config.d.mts +2 -6
  218. package/types/engine/pack-router.d.mts +4 -4
  219. package/types/engine/pathnames.d.mts +3 -3
  220. package/types/engine/pdf-build.d.mts +98 -12
  221. package/types/engine/pdf-coverage.d.mts +60 -0
  222. package/types/engine/pdf-index.d.mts +34 -5
  223. package/types/engine/pdf-pages.d.mts +143 -0
  224. package/types/engine/pdf-render.d.mts +45 -1
  225. package/types/engine/pdf-toc.d.mts +12 -14
  226. package/types/engine/prose-config.d.mts +1 -1
  227. package/types/engine/prose-lint.d.mts +11 -10
  228. package/types/engine/runtime-only-fields.d.mts +2 -2
  229. package/types/engine/site-build.d.mts +9 -5
  230. package/types/engine/site-index.d.mts +4 -5
  231. package/types/engine/site-root.d.mts +1 -1
  232. package/types/engine/sql-tables.d.mts +31 -2
  233. package/types/engine/standing-terms.d.mts +2 -1
  234. package/types/engine/standings.d.mts +7 -9
  235. package/types/engine/system-block.d.mts +10 -44
  236. package/types/engine/walk-files.d.mts +15 -0
  237. package/types/engine/wikilink-syntax.d.mts +18 -19
  238. package/types/engine/yaml-lint.d.mts +5 -4
  239. package/types/hm3/actors.d.mts +1 -2
  240. package/types/hm3/item-fields.d.mts +2 -4
  241. package/types/hm3/template-priority.d.mts +6 -7
  242. package/types/manifest.d.mts +3 -2
  243. package/types/release.d.mts +12 -7
  244. package/types/sohl/actors.d.mts +2 -1
  245. package/types/sohl/document-subtypes.d.mts +1 -2
  246. package/types/sohl/item-fields.d.mts +3 -7
  247. package/engine/retired-fields.mjs +0 -631
  248. package/types/engine/retired-fields.d.mts +0 -354
@@ -197,36 +197,6 @@ export const PACK_DOCUMENT_TYPES = /** @type {const} */ ([
197
197
  "Scene",
198
198
  ]);
199
199
 
200
- /**
201
- * Address-scheme keys a configuration may no longer declare.
202
- *
203
- * A retired key has exactly two possible fates, and only one of them is honest
204
- * — the same reasoning `engine/retired-fields.mjs` applies to a retired
205
- * frontmatter field. Left honoured, it keeps doing whatever it did, which is
206
- * why it was retired. Left *ignored*, it reads to its author as though it still
207
- * works: the configuration says one thing and the build does another, and
208
- * nothing says so. This module has no third option, because it has no warning
209
- * channel — every finding goes through `fail()`, which throws. So a retired
210
- * key is **refused**, at the line it was written on, with a message that says
211
- * the mechanism is gone rather than naming a value to correct.
212
- *
213
- * **`landing` is one such key.** It named which note addressed a whole section
214
- * rather than a page within one — a *landing page*, which therefore had no slug
215
- * of its own. There are no sections to address: a section is a Hugo content
216
- * directory the note format does not carry, a page's address names no
217
- * directory, and so no note lands anything.
218
- *
219
- * @type {Readonly<Record<string, string>>}
220
- */
221
- export const RETIRED_ADDRESS_KEYS = Object.freeze({
222
- landing:
223
- "is a retired option — delete it. It named which note addressed a " +
224
- "whole section rather than a page within one, and there are no " +
225
- "sections to address: a section is a Hugo content directory the note " +
226
- "format does not carry, so no note lands one and every page is " +
227
- "addressed `<type>-<shortcode>`. Nothing replaces it",
228
- });
229
-
230
200
  /**
231
201
  * A repository's address scheme, with the defaults an unconfigured one gets.
232
202
  *
@@ -237,9 +207,7 @@ export const RETIRED_ADDRESS_KEYS = Object.freeze({
237
207
  * knowledge, held in `PACKAGE_BASE` (`engine/content-address.mjs`) and prefixed at
238
208
  * resolve time, so it is never recorded here.
239
209
  *
240
- * It is the whole scheme: `landing`, the key that named which note addressed a
241
- * whole section, is retired with the sections themselves — see
242
- * {@link RETIRED_ADDRESS_KEYS}.
210
+ * It is the whole scheme.
243
211
  */
244
212
  export const DEFAULT_ADDRESS_SCHEME = Object.freeze({
245
213
  prefix: "",
@@ -581,6 +549,18 @@ export function publishesContentPages(config) {
581
549
  * @property {Record<string, ItemBuilderEntry>} builders The registry itself.
582
550
  */
583
551
 
552
+ /**
553
+ * One person in the top-level `authors` list.
554
+ *
555
+ * @typedef {object} AuthorSpec
556
+ * @property {string} name As printed.
557
+ * @property {string} [email]
558
+ * @property {string} [url]
559
+ * @property {string} [discord] A Discord handle, as Foundry's author schema
560
+ * carries one.
561
+ * @property {string} [role] One of {@link AUTHOR_ROLES}.
562
+ */
563
+
584
564
  /**
585
565
  * The configuration a consumer writes.
586
566
  *
@@ -601,9 +581,10 @@ export function publishesContentPages(config) {
601
581
  * the site build's `baseURL`. Checked
602
582
  * by `checkHomepage` in
603
583
  * `config.mjs`.
604
- * @property {string|{name: string, email?: string, url?: string}} [author]
605
- * `package.json`'s own `author`, in
606
- * either of npm's forms.
584
+ * @property {AuthorSpec[]} [authors] The people who made the
585
+ * package. The Foundry manifest's
586
+ * `authors` and the site's byline
587
+ * are generated from it.
607
588
  * @property {PackageKind} packageKind Whether the package is a system, a
608
589
  * module, or documentation — the kind
609
590
  * that publishes a site and a book
@@ -669,10 +650,13 @@ export function publishesContentPages(config) {
669
650
  * @property {string|null} homepage `package.json`'s own `homepage`,
670
651
  * checked by `checkHomepage` in
671
652
  * `config.mjs`.
653
+ * @property {readonly Readonly<AuthorSpec>[]} authors The people who made
654
+ * the package, in the order declared; `[]`
655
+ * when none are.
672
656
  * @property {Readonly<{name: string, email?: string, url?: string}>|null} author
673
- * `package.json`'s own `author`, normalised
674
- * from either of npm's forms; `null` when
675
- * the package declares none.
657
+ * Derived: the first of `authors`, in npm's
658
+ * `author` shape — the site's byline.
659
+ * `null` when no author is declared.
676
660
  * @property {PackageKind} packageKind
677
661
  * @property {string|null} assetRoot Derived, and **conditional**: the served
678
662
  * Foundry asset root,
@@ -744,7 +728,7 @@ const CONFIG_KEYS = [
744
728
  "contentPackage",
745
729
  "foundryPackage",
746
730
  "homepage",
747
- "author",
731
+ "authors",
748
732
  "packageKind",
749
733
  "stats",
750
734
  "itemBuilders",
@@ -783,7 +767,42 @@ const SITE_KEYS = [
783
767
  ];
784
768
  const SITE_NOTFOUND_KEYS = ["tagline", "sitenoun", "heroimage", "links"];
785
769
  const SITE_NOTFOUND_LINK_KEYS = ["title", "url", "text"];
786
- const PDF_KEYS = ["title", "subtitle", "document", "out", "front", "fonts", "iconFonts", "binary"];
770
+ const PDF_KEYS = [
771
+ "books",
772
+ "out",
773
+ "fonts",
774
+ "iconFonts",
775
+ "binary",
776
+ "unprinted",
777
+ "coverage",
778
+ "indexSeeAlso",
779
+ "copyright",
780
+ "license",
781
+ ];
782
+ const PDF_BOOK_KEYS = [
783
+ "id",
784
+ "title",
785
+ "subtitle",
786
+ "coverImage",
787
+ "front",
788
+ "out",
789
+ "contents",
790
+ "document",
791
+ ];
792
+ /**
793
+ * How a printable note no book selects is reported: `pdf.coverage`.
794
+ *
795
+ * @type {readonly string[]}
796
+ */
797
+ const PDF_COVERAGE = Object.freeze(["warning", "error", "off"]);
798
+ /**
799
+ * What a book `id` may be. It is written on the command line as `--book <id>`
800
+ * and is the tail of the book's default file stem, so it takes the charset a
801
+ * file name and an address segment share.
802
+ */
803
+ const PDF_BOOK_ID = /^[a-z0-9][a-z0-9-]*$/;
804
+ /** What a book's file stem may be: one path segment, no directory. */
805
+ const PDF_BOOK_STEM = /^[\w.-]+$/;
787
806
  const PDF_FONT_KEYS = ["serif", "sans", "mono", "path"];
788
807
  const EMPTY_PDF_FONTS = Object.freeze({ serif: "", sans: "", mono: "", path: "" });
789
808
  const DOC_PAGE_KEYS = ["title", "out", "preamble", "frontmatter"];
@@ -808,7 +827,28 @@ export const RELATIONSHIP_KEYS = [
808
827
  "contentIndex",
809
828
  "assetReplacement",
810
829
  ];
811
- const AUTHOR_KEYS = ["name", "email", "url"];
830
+ const AUTHOR_KEYS = ["name", "email", "url", "discord", "role"];
831
+ /**
832
+ * The closed list a person's `role` in `authors` is drawn from.
833
+ *
834
+ * One role per entry: a person with two roles is listed twice. Printed in
835
+ * title case after the name on a book's title page; the Foundry manifest,
836
+ * whose author schema has no role, never receives it.
837
+ *
838
+ * @type {readonly string[]}
839
+ */
840
+ export const AUTHOR_ROLES = Object.freeze([
841
+ "author",
842
+ "designer",
843
+ "developer",
844
+ "editor",
845
+ "proofreader",
846
+ "artist",
847
+ "cartographer",
848
+ "layout",
849
+ "playtester",
850
+ "translator",
851
+ ]);
812
852
  const ITEM_BUILDER_KEYS = ["system", "img", "fields"];
813
853
  const ITEM_REGISTRY_KEYS = ["system", "builders"];
814
854
  const PACK_KEYS = [
@@ -1029,52 +1069,60 @@ function optionalString(value, field) {
1029
1069
  }
1030
1070
 
1031
1071
  /**
1032
- * npm's `author` field, in either of its two forms.
1033
- *
1034
- * `package.json` accepts a single string — `"Name <email> (url)"`, with the
1035
- * email and the URL both optional — or an object carrying the same three
1036
- * parts. Both normalise to one shape, so the site build reads one field
1037
- * instead of branching on which form a repository happened to write.
1038
- *
1039
- * @type {RegExp}
1040
- */
1041
- const AUTHOR_STRING = /^([^<(]*?)\s*(?:<([^>]*)>)?\s*(?:\(([^)]*)\))?\s*$/;
1072
+ * The people who made the package: the top-level `authors` list.
1073
+ *
1074
+ * The one place a package names its people. The Foundry manifest's `authors`
1075
+ * is generated from it with `role` dropped, because Foundry's author schema
1076
+ * has none; the byline the site build reads is its first entry's name, email
1077
+ * and URL. Each entry is `{name, email?, url?, discord?, role?}`, and `role`
1078
+ * is drawn from {@link AUTHOR_ROLES}.
1079
+ *
1080
+ * @param {unknown} value - The declared `authors`, or `undefined`.
1081
+ * @returns {readonly Readonly<AuthorSpec>[]} The entries, frozen; `[]` when
1082
+ * none are declared.
1083
+ */
1084
+ function normalizeAuthors(value) {
1085
+ if (value === undefined) return Object.freeze([]);
1086
+ if (!Array.isArray(value)) {
1087
+ fail("authors", "must be a list of people, each a mapping with at least a `name`");
1088
+ }
1089
+ return Object.freeze(
1090
+ value.map((entry, i) => {
1091
+ const where = `authors[${i}]`;
1092
+ if (!isPlainObject(entry)) fail(where, "must be a mapping with at least a `name`");
1093
+ const person = /** @type {Record<string, unknown>} */ (entry);
1094
+ rejectUnknownKeys(person, AUTHOR_KEYS, `${where}.`);
1095
+ const out = { name: requireNonEmptyString(person.name, `${where}.name`) };
1096
+ for (const key of ["email", "url", "discord"]) {
1097
+ if (person[key] !== undefined) {
1098
+ out[key] = requireNonEmptyString(person[key], `${where}.${key}`);
1099
+ }
1100
+ }
1101
+ if (person.role !== undefined) {
1102
+ if (typeof person.role !== "string" || !AUTHOR_ROLES.includes(person.role)) {
1103
+ fail(`${where}.role`, `must be one of: ${AUTHOR_ROLES.join(", ")}`);
1104
+ }
1105
+ out.role = person.role;
1106
+ }
1107
+ return Object.freeze(out);
1108
+ }),
1109
+ );
1110
+ }
1042
1111
 
1043
1112
  /**
1044
- * @param {unknown} value - The declared `author`, or `undefined`.
1045
- * @returns {Readonly<{name: string, email?: string, url?: string}>|null}
1046
- * `null` when the package declares none.
1113
+ * The package's byline: the first of `authors`, as npm's `author` shape.
1114
+ *
1115
+ * @param {readonly Readonly<AuthorSpec>[]} authors - From {@link normalizeAuthors}.
1116
+ * @returns {Readonly<{name: string, email?: string, url?: string}>|null} `null`
1117
+ * when no author is declared.
1047
1118
  */
1048
- function normalizeAuthor(value) {
1049
- if (value === undefined) return null;
1050
- if (typeof value === "string") {
1051
- const match = AUTHOR_STRING.exec(value.trim());
1052
- const name = match?.[1]?.trim();
1053
- if (!match || !name) {
1054
- fail(
1055
- "author",
1056
- 'must be `"Name"`, `"Name <email>"`, `"Name (url)"` or ' +
1057
- '`"Name <email> (url)"` — npm\'s own `author` forms',
1058
- );
1059
- }
1060
- return Object.freeze({
1061
- name: /** @type {string} */ (name),
1062
- ...(match[2] ? { email: match[2] } : {}),
1063
- ...(match[3] ? { url: match[3] } : {}),
1064
- });
1065
- }
1066
- if (!isPlainObject(value)) {
1067
- fail("author", "must be a string or an object with `name`, `email` and `url`");
1068
- }
1069
- const author = /** @type {Record<string, unknown>} */ (value);
1070
- rejectUnknownKeys(author, AUTHOR_KEYS, "author.");
1071
- const name = requireNonEmptyString(author.name, "author.name");
1119
+ function bylineOf(authors) {
1120
+ const [first] = authors;
1121
+ if (!first) return null;
1072
1122
  return Object.freeze({
1073
- name,
1074
- ...(author.email !== undefined ?
1075
- { email: optionalString(author.email, "author.email") }
1076
- : {}),
1077
- ...(author.url !== undefined ? { url: optionalString(author.url, "author.url") } : {}),
1123
+ name: first.name,
1124
+ ...(first.email !== undefined ? { email: first.email } : {}),
1125
+ ...(first.url !== undefined ? { url: first.url } : {}),
1078
1126
  });
1079
1127
  }
1080
1128
 
@@ -1088,19 +1136,6 @@ function normalizeAuthor(value) {
1088
1136
  function normalizePack(value, where, nested = false) {
1089
1137
  if (!isPlainObject(value)) fail(where, "must be an object");
1090
1138
  const pack = /** @type {Record<string, unknown>} */ (value);
1091
- // Retired with the YAML it named. Refused explicitly rather than
1092
- // left to the unknown-key check, because the useful thing to say is not
1093
- // "no such key" but where the folders went: they are notes, and a pack
1094
- // materialises the ones its documents reference.
1095
- if (pack.folders !== undefined) {
1096
- fail(
1097
- `${where}.folders`,
1098
- "is retired — delete it. A folder is a note (`type: folder`) now, " +
1099
- "and a pack materialises the folders its documents reference " +
1100
- "through `packFolder`, so there is no per-pack hierarchy file " +
1101
- "to name",
1102
- );
1103
- }
1104
1139
  rejectUnknownKeys(pack, PACK_KEYS, `${where}.`);
1105
1140
 
1106
1141
  const name = requireNonEmptyString(pack.name, `${where}.name`);
@@ -1609,7 +1644,7 @@ export const DERIVED_HUGO_KEYS = Object.freeze({
1609
1644
  "the toolchain, which renders a site as its homepage and its pages: no " +
1610
1645
  "output format is declared, because every listing kind is disabled",
1611
1646
  "params.description": "`site.description`",
1612
- "params.author": "package.json `author`",
1647
+ "params.author": "the first entry of `authors`",
1613
1648
  "params.cdnBaseURL": "`site.assets`",
1614
1649
  "params.brand": "the organisation's brand links, in `engine/site-config.mjs`",
1615
1650
  "params.notfound": "`site.notfound`",
@@ -1695,38 +1730,6 @@ function normalizeSiteHugo(value) {
1695
1730
  return Object.freeze(structuredClone(input));
1696
1731
  }
1697
1732
 
1698
- /**
1699
- * The keys that asked the site build to write an index between the homepage
1700
- * and the pages, each refused with the one message.
1701
- *
1702
- * A site is its homepage and its pages. Each of these keys asks for a
1703
- * generated listing between them — `sections` (with `listType` and
1704
- * `listSubType` inside an entry) a type-wide alphabetical one per entry,
1705
- * `backfillSections` one for every other directory under the mount, `landing`
1706
- * the mount's own, `list` how such a listing renders. None of those is a page
1707
- * anyone chose the contents of, and every one of them is authored instead: a
1708
- * `doc` note, addressed by its shortcode, carrying a content table over the
1709
- * notes it introduces, with the columns it chooses.
1710
- *
1711
- * Refused rather than ignored, for the reason {@link RETIRED_ADDRESS_KEYS}
1712
- * gives: a key left ignored reads to its author as though it still works.
1713
- *
1714
- * @type {readonly string[]}
1715
- */
1716
- const SITE_INDEX_KEYS = Object.freeze(["sections", "landing", "backfillSections", "list"]);
1717
-
1718
- /**
1719
- * The message every key in {@link SITE_INDEX_KEYS} is refused with.
1720
- *
1721
- * @type {string}
1722
- */
1723
- const SITE_INDEX_MESSAGE =
1724
- "is retired — a site is its homepage and its pages, and any index between " +
1725
- "them is a `doc` note: write one with `type: doc`, a `shortcode` and " +
1726
- "`pack: none`, carrying a content table over the notes it lists, and link " +
1727
- "it from the homepage. Nothing is generated between the homepage and the " +
1728
- "pages, so delete the key";
1729
-
1730
1733
  /**
1731
1734
  * The `site` section — how this repository frames the website it publishes.
1732
1735
  *
@@ -1734,7 +1737,7 @@ const SITE_INDEX_MESSAGE =
1734
1737
  * repository's own body rewrites, and the residue of the generated Hugo
1735
1738
  * configuration that is genuinely this repository's own. Where the Hugo tree
1736
1739
  * is written is not a choice: `package-build site` writes it under
1737
- * `build/hugo/`, and a `site.out` is refused by name. How a page gets its
1740
+ * `build/hugo/`. How a page gets its
1738
1741
  * **address** is deliberately not here either — that is `publish.address`,
1739
1742
  * shared with the link manifest so the two cannot disagree about where a
1740
1743
  * page is.
@@ -1748,12 +1751,8 @@ const SITE_INDEX_MESSAGE =
1748
1751
  * **What the site publishes is the homepage and the content tree, and nothing
1749
1752
  * beside them.** A page of documentation is a note — `type: doc`, addressed
1750
1753
  * by its shortcode, compiling into no document where it says `pack: none` —
1751
- * so there is no second mechanism for mounting a directory of markdown, and a
1752
- * configuration that names one (`site.trees`, and the `site.readmeSections`
1753
- * that titled such a tree's landing) is refused with a message saying where
1754
- * the page goes instead. An index between the homepage and the pages is a
1755
- * `doc` note too, so the keys that asked the build to generate one
1756
- * ({@link SITE_INDEX_KEYS}) are refused the same way.
1754
+ * so there is no second mechanism for mounting a directory of markdown. An
1755
+ * index between the homepage and the pages is a `doc` note too.
1757
1756
  *
1758
1757
  * @param {unknown} value - The `site` block, or `undefined`.
1759
1758
  * @returns {Readonly<object>} It, frozen, with every default filled.
@@ -1775,35 +1774,6 @@ function normalizeSite(value) {
1775
1774
  if (value === undefined) return empty;
1776
1775
  if (!isPlainObject(value)) fail("site", "must be a mapping");
1777
1776
  const input = /** @type {Record<string, unknown>} */ (value);
1778
- // Refused by name, ahead of the vocabulary check: the useful thing to say
1779
- // is not "no such key" but that the location is fixed.
1780
- if (input.out !== undefined) {
1781
- fail(
1782
- "site.out",
1783
- "is retired — the site build writes its content mount at " +
1784
- "`build/hugo/content`, beside the generated `hugo.toml`, and " +
1785
- "the location is not configurable. Remove the key",
1786
- );
1787
- }
1788
- // Refused by name too, and ahead of the vocabulary check for the same
1789
- // reason: a page is a note in the content tree, and the useful thing to
1790
- // say is where it goes rather than that the key is unknown.
1791
- for (const key of ["trees", "readmeSections"]) {
1792
- if (input[key] === undefined) continue;
1793
- fail(
1794
- `site.${key}`,
1795
- "is retired — a page is a note in the content tree. Give each page " +
1796
- "`type: doc`, a `shortcode` and `pack: none`, file it under " +
1797
- "`assets/content/`, and link it from the homepage or from a " +
1798
- "`doc` note that indexes it",
1799
- );
1800
- }
1801
- // And by name, for the same reason: an index between the homepage and
1802
- // the pages is a `doc` note, and the useful thing to say is that.
1803
- for (const key of SITE_INDEX_KEYS) {
1804
- if (input[key] === undefined) continue;
1805
- fail(`site.${key}`, SITE_INDEX_MESSAGE);
1806
- }
1807
1777
  rejectUnknownKeys(input, SITE_KEYS, "site.");
1808
1778
 
1809
1779
  let packages = [];
@@ -1833,46 +1803,65 @@ function normalizeSite(value) {
1833
1803
  }
1834
1804
 
1835
1805
  /**
1836
- * The `pdf` section — the book the content tree is published as.
1806
+ * One declared book, resolved.
1837
1807
  *
1838
- * A third surface beside the packs and the website, and the one that is a
1839
- * **selection** rather than a rendering of everything: `document:` names the
1840
- * tree that says which notes the volume carries and in what order, because a
1841
- * book is an editorial act where a site is an index. That file is the
1842
- * consumer's, parsed by {@link module:engine/pdf-toc.parseDocumentTree}, and
1843
- * nothing about its shape is validated here — this block says only where it is.
1808
+ * @typedef {object} ResolvedPdfBook
1809
+ * @property {string} id Its name on the command line, `--book <id>`.
1810
+ * @property {string} title
1811
+ * @property {string} subtitle `""` when none is declared.
1812
+ * @property {string} coverImage Absolute; `""` when none is declared.
1813
+ * Whether the file exists is checked when the
1814
+ * book is built, not here.
1815
+ * @property {readonly string[]} front Absolute paths of the front matter.
1816
+ * @property {string} out The file stem; the version is appended.
1817
+ * @property {readonly object[]|null} contents The tree written inline, or
1818
+ * `null` when `document` names a file.
1819
+ * @property {string} document Absolute path of the tree's file, or `""`
1820
+ * when the tree is written inline.
1821
+ */
1822
+
1823
+ /**
1824
+ * The `pdf` section — the books the content tree is published as.
1844
1825
  *
1845
- * **Nothing here is an address or a brand.** The title, the subtitle, the front
1846
- * matter and the faces are every one of them the publishing repository's to
1847
- * choose, which is the whole reason they are configuration: the engine that
1848
- * sets the book must be able to set somebody else's book.
1826
+ * A third surface beside the packs and the website, and the one that is a
1827
+ * **selection** rather than a rendering of everything: each book's tree says
1828
+ * which notes the volume carries and in what order, because a book is an
1829
+ * editorial act where a site is an index. A tree is written inline as
1830
+ * `contents:` or kept in the file `document:` names, and is parsed by
1831
+ * {@link module:engine/pdf-toc.parseDocumentTree} when the book is built;
1832
+ * nothing about its shape is validated here.
1833
+ *
1834
+ * `books` is the one required key. The rest of the block — faces, icon fonts,
1835
+ * the compiler, the output directory, what no book prints, how an unprinted
1836
+ * note is reported, and the copyright and licence statements — is shared by
1837
+ * every book. Each book's `out` is a file stem (default
1838
+ * `<contentPackage>-<id>`), and two books resolving to one stem is refused,
1839
+ * since the second would overwrite the first.
1840
+ *
1841
+ * Paths resolve against `rootDir` here; whether `coverImage`, `document` and
1842
+ * each `front` file exist is checked when a book is built, so a command that
1843
+ * builds no book never fails for a missing cover.
1844
+ *
1845
+ * **Nothing here is an address or a brand.** The titles, the front matter and
1846
+ * the faces are every one of them the publishing repository's to choose, which
1847
+ * is the whole reason they are configuration: the engine that sets the book
1848
+ * must be able to set somebody else's book.
1849
1849
  *
1850
1850
  * A PDF is built when the authored tree contains content pages and the `pdf`
1851
- * block names a document tree. See {@link publishesContentPages}.
1851
+ * block is present. See {@link publishesContentPages}.
1852
1852
  *
1853
1853
  * @param {unknown} value - The `pdf` block, or `undefined`.
1854
1854
  * @param {string} rootDir - The repository root configured paths resolve against.
1855
+ * @param {string} contentPackage - The package name a default stem starts with.
1855
1856
  * @returns {Readonly<object>|null} It, frozen; `null` when the block is absent.
1856
1857
  */
1857
- function normalizePdf(value, rootDir) {
1858
+ function normalizePdf(value, rootDir, contentPackage) {
1858
1859
  if (value === undefined) return null;
1859
1860
  if (!isPlainObject(value)) fail("pdf", "must be a mapping");
1860
1861
  const input = /** @type {Record<string, unknown>} */ (value);
1861
1862
  rejectUnknownKeys(input, PDF_KEYS, "pdf.");
1862
1863
 
1863
- // Both required, and required together: a document with no tree has nothing
1864
- // to print, and a tree with no title produces a file whose name and cover
1865
- // say nothing about what a reader downloaded.
1866
- const title = requireNonEmptyString(input.title, "pdf.title");
1867
- const document = requireNonEmptyString(input.document, "pdf.document");
1868
-
1869
- const front = [];
1870
- if (input.front !== undefined) {
1871
- if (!Array.isArray(input.front)) fail("pdf.front", "must be a list of markdown files");
1872
- input.front.forEach((entry, i) => {
1873
- front.push(requireNonEmptyString(entry, `pdf.front[${i}]`));
1874
- });
1875
- }
1864
+ const books = normalizePdfBooks(input.books, rootDir, contentPackage);
1876
1865
 
1877
1866
  let fonts = EMPTY_PDF_FONTS;
1878
1867
  if (input.fonts !== undefined) {
@@ -1919,15 +1908,21 @@ function normalizePdf(value, rootDir) {
1919
1908
  }
1920
1909
  }
1921
1910
 
1911
+ const coverage = input.coverage === undefined ? "warning" : input.coverage;
1912
+ if (typeof coverage !== "string" || !PDF_COVERAGE.includes(coverage)) {
1913
+ fail("pdf.coverage", `must be one of: ${PDF_COVERAGE.join(", ")}`);
1914
+ }
1915
+ const copyright =
1916
+ input.copyright === undefined ?
1917
+ ""
1918
+ : requireNonEmptyString(input.copyright, "pdf.copyright");
1919
+ if (/[\r\n]/.test(copyright.trim())) {
1920
+ fail("pdf.copyright", "must be one line — the licence statement is the one that runs on");
1921
+ }
1922
+
1922
1923
  return Object.freeze({
1923
- title,
1924
- subtitle:
1925
- input.subtitle === undefined ?
1926
- ""
1927
- : requireNonEmptyString(input.subtitle, "pdf.subtitle"),
1928
- document: path.resolve(rootDir, document),
1924
+ books,
1929
1925
  out: input.out === undefined ? "" : requireNonEmptyString(input.out, "pdf.out"),
1930
- front: Object.freeze(front.map((f) => path.resolve(rootDir, f))),
1931
1926
  fonts,
1932
1927
  iconFonts: Object.freeze(iconFonts),
1933
1928
  // Where the Typst binary is, when it is not simply `typst` on PATH.
@@ -1935,9 +1930,142 @@ function normalizePdf(value, rootDir) {
1935
1930
  // platform-specific binary in the dependency tree of three repositories
1936
1931
  // that mostly do not build books.
1937
1932
  binary: input.binary === undefined ? "" : requireNonEmptyString(input.binary, "pdf.binary"),
1933
+ // A `WHERE` clause over `notes` naming what belongs in no book.
1934
+ unprinted:
1935
+ input.unprinted === undefined ?
1936
+ ""
1937
+ : requireNonEmptyString(input.unprinted, "pdf.unprinted"),
1938
+ coverage,
1939
+ // Whether an index names a note another book prints, as
1940
+ // `Name: Book title, p. N`.
1941
+ indexSeeAlso: optionalBoolean(input.indexSeeAlso, "pdf.indexSeeAlso", false),
1942
+ copyright: copyright.trim(),
1943
+ license:
1944
+ input.license === undefined ?
1945
+ ""
1946
+ : requireNonEmptyString(input.license, "pdf.license").trim(),
1938
1947
  });
1939
1948
  }
1940
1949
 
1950
+ /**
1951
+ * The `pdf.books` list, each entry resolved.
1952
+ *
1953
+ * @param {unknown} value - The declared `books`.
1954
+ * @param {string} rootDir - What a path resolves against.
1955
+ * @param {string} contentPackage - The head of a default file stem.
1956
+ * @returns {readonly Readonly<ResolvedPdfBook>[]} The books, frozen.
1957
+ */
1958
+ function normalizePdfBooks(value, rootDir, contentPackage) {
1959
+ if (value === undefined) {
1960
+ fail("pdf.books", "is required — list the books this package publishes, one entry each");
1961
+ }
1962
+ if (!Array.isArray(value) || value.length === 0) {
1963
+ fail("pdf.books", "must be a non-empty list of books");
1964
+ }
1965
+ const ids = new Set();
1966
+ /** @type {Map<string, number>} Stem → the book that took it. */
1967
+ const stems = new Map();
1968
+ return Object.freeze(
1969
+ value.map((entry, i) => {
1970
+ const where = `pdf.books[${i}]`;
1971
+ if (!isPlainObject(entry)) fail(where, "must be a mapping");
1972
+ const book = /** @type {Record<string, unknown>} */ (entry);
1973
+ rejectUnknownKeys(book, PDF_BOOK_KEYS, `${where}.`);
1974
+
1975
+ const id = requireNonEmptyString(book.id, `${where}.id`);
1976
+ if (!PDF_BOOK_ID.test(id)) {
1977
+ fail(
1978
+ `${where}.id`,
1979
+ `is \`${id}\`, which is not lowercase letters, digits and hyphens ` +
1980
+ `starting with a letter or digit (${PDF_BOOK_ID.source}) — it is ` +
1981
+ "written as `--book <id>` and ends the book's default file name",
1982
+ );
1983
+ }
1984
+ if (ids.has(id)) fail(`${where}.id`, `is \`${id}\`, which names a book more than once`);
1985
+ ids.add(id);
1986
+ const title = requireNonEmptyString(book.title, `${where}.title`);
1987
+
1988
+ // One tree, from exactly one place: two would leave the reader to
1989
+ // guess which is printed, and none prints nothing.
1990
+ if ((book.contents === undefined) === (book.document === undefined)) {
1991
+ fail(
1992
+ where,
1993
+ "must declare exactly one of `contents` and `document` — the tree " +
1994
+ "written inline, or the file holding it",
1995
+ );
1996
+ }
1997
+ let contents = null;
1998
+ if (book.contents !== undefined) {
1999
+ if (!Array.isArray(book.contents)) {
2000
+ fail(`${where}.contents`, "must be a list of sections");
2001
+ }
2002
+ contents = Object.freeze(structuredClone(book.contents));
2003
+ }
2004
+
2005
+ const front = [];
2006
+ if (book.front !== undefined) {
2007
+ if (!Array.isArray(book.front)) {
2008
+ fail(`${where}.front`, "must be a list of markdown files");
2009
+ }
2010
+ book.front.forEach((file, j) => {
2011
+ front.push(
2012
+ path.resolve(rootDir, requireNonEmptyString(file, `${where}.front[${j}]`)),
2013
+ );
2014
+ });
2015
+ }
2016
+
2017
+ let out = `${contentPackage}-${id}`;
2018
+ let stemAt = `${where}.id`;
2019
+ if (book.out !== undefined) {
2020
+ out = requireNonEmptyString(book.out, `${where}.out`);
2021
+ stemAt = `${where}.out`;
2022
+ if (!PDF_BOOK_STEM.test(out)) {
2023
+ fail(
2024
+ `${where}.out`,
2025
+ `is \`${out}\`, which is not a file stem — letters, digits, ` +
2026
+ "`.`, `_` and `-`, with no directory; the directory is `pdf.out`",
2027
+ );
2028
+ }
2029
+ }
2030
+ const taken = stems.get(out);
2031
+ if (taken !== undefined) {
2032
+ fail(
2033
+ stemAt,
2034
+ `gives the file stem \`${out}\`, which \`pdf.books[${taken}]\` already ` +
2035
+ "uses — the second book would overwrite the first",
2036
+ );
2037
+ }
2038
+ stems.set(out, i);
2039
+
2040
+ return Object.freeze({
2041
+ id,
2042
+ title,
2043
+ subtitle:
2044
+ book.subtitle === undefined ?
2045
+ ""
2046
+ : requireNonEmptyString(book.subtitle, `${where}.subtitle`),
2047
+ coverImage:
2048
+ book.coverImage === undefined ?
2049
+ ""
2050
+ : path.resolve(
2051
+ rootDir,
2052
+ requireNonEmptyString(book.coverImage, `${where}.coverImage`),
2053
+ ),
2054
+ front: Object.freeze(front),
2055
+ out,
2056
+ contents,
2057
+ document:
2058
+ book.document === undefined ?
2059
+ ""
2060
+ : path.resolve(
2061
+ rootDir,
2062
+ requireNonEmptyString(book.document, `${where}.document`),
2063
+ ),
2064
+ });
2065
+ }),
2066
+ );
2067
+ }
2068
+
1941
2069
  /**
1942
2070
  * A dotted version string as the three-element tuple Foundry compares.
1943
2071
  *
@@ -2551,15 +2679,6 @@ function normalizePublish(value) {
2551
2679
  fail("publish.address", "must be an object");
2552
2680
  }
2553
2681
  const address = /** @type {Record<string, unknown>} */ (addressInput ?? {});
2554
- // A retired key is refused by name, ahead of the vocabulary check: reported
2555
- // as merely unrecognized it would read as a misspelling of the one key that
2556
- // survives, and the author would correct the spelling rather than learn
2557
- // that the mechanism is gone.
2558
- for (const key of Object.keys(address)) {
2559
- if (Object.hasOwn(RETIRED_ADDRESS_KEYS, key)) {
2560
- fail(`publish.address.${key}`, RETIRED_ADDRESS_KEYS[key]);
2561
- }
2562
- }
2563
2682
  rejectUnknownKeys(address, ADDRESS_KEYS, "publish.address.");
2564
2683
 
2565
2684
  const prefix =
@@ -2799,6 +2918,7 @@ export function defineConfig(config) {
2799
2918
  new Set([...itemTypes, ...ACTOR_TYPES, "macro", ...MAP_TYPES]),
2800
2919
  );
2801
2920
  const compatibility = normalizeCompatibility(input.compatibility, "compatibility");
2921
+ const authors = normalizeAuthors(input.authors);
2802
2922
  if (!documentation && compatibility === null)
2803
2923
  fail("compatibility.minimum", "is required for a Foundry package");
2804
2924
 
@@ -2813,7 +2933,8 @@ export function defineConfig(config) {
2813
2933
  // `checkHomepage` in `config.mjs`.
2814
2934
  homepage:
2815
2935
  input.homepage === undefined ? null : requireNonEmptyString(input.homepage, "homepage"),
2816
- author: normalizeAuthor(input.author),
2936
+ authors,
2937
+ author: bylineOf(authors),
2817
2938
  packageKind: /** @type {PackageKind} */ (packageKind),
2818
2939
  // Foundry serves a package's files from `<kind>/<id>/`, so this is the
2819
2940
  // one place `systems/sohl` (or `modules/harn-ensemble`) is spelled.
@@ -2883,7 +3004,9 @@ export function defineConfig(config) {
2883
3004
  packDirectories: Object.freeze(packDirectories),
2884
3005
  docs: normalizeDocs(input.docs),
2885
3006
  site: normalizeSite(input.site),
2886
- pdf: normalizePdf(input.pdf, rootDir),
3007
+ // Validated above, in `contentPackage`: a book's default file stem is
3008
+ // `<contentPackage>-<id>`.
3009
+ pdf: normalizePdf(input.pdf, rootDir, /** @type {string} */ (input.contentPackage)),
2887
3010
  compatibility,
2888
3011
  relationships: normalizeRelationships(input.relationships),
2889
3012
  systems,