@heroiclands/package-build 22.1.1 → 22.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 22.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 241451e: **`schema.json` is a release asset, not a committed file.** `package-build schema` writes `build/schema.json` instead of the repository root, and `--check` is gone — there is no committed copy left to compare against. `package-build release` publishes `schema.json` beside the archive and the manifest, the way it already publishes the content index, whenever the staged tree carries one.
8
+
9
+ To keep publishing a schema: drop the committed `schema.json` and any lint step that runs `package-build schema --check`; add `{ from: build/schema.json, to: schema.json }` to `packageBuild.assets`, and run `package-build schema` before `package-build assets` in the build chain so the release never ships one older than the source it was cut from.
10
+ - 483b630: **`content-build site` refuses to generate a configuration with no `site.assets`.** The theme resolves every relative asset — the brand logo, the 404 hero, every CDN-resolved image — against `site.assets`, and there is no defensible default: a package that built a site with the key absent published every one of those as a broken relative path. Declare `site.assets` — the `https://` host every package's imagery is served from — in `package-build.config.yaml` for any package that publishes a site.
11
+ - 985f599: **A package now describes itself once for Foundry and once for the site.** Foundry's package browser wants a pitch — HTML, any length — and a site's `<meta name="description">` wants one plain sentence; deriving both from `package.json`'s `description` forced one string onto both. Declare `packageBuild.manifest.descriptionHtml` in `package-build.config.yaml` for the Foundry pitch (HTML allowed, emitted as the manifest's `description`) and `site.description` for the site's meta description (plain text, required for `content-build site`). `package.json`'s own `description` is read by neither any more — a warning names both keys when one is still declared — and the field can be deleted.
12
+
3
13
  ## 22.1.1
4
14
 
5
15
  ### Patch Changes
@@ -334,9 +334,11 @@ async function formatGenerated(text, filepath) {
334
334
  * so the system publishes the field sets as data — the same shape the link
335
335
  * manifest already uses for addresses.
336
336
  *
337
- * `--check` fails when the committed copy disagrees with what the source would
338
- * produce now, because a generated file nothing checks drifts from its
339
- * generator silently and this one is read by other repositories.
337
+ * Writes under `build/`, alongside every other generated artifact: a release
338
+ * publishes it beside the archive and the manifest (see `package-build
339
+ * release`), and `packageBuild.assets` carries it into the staged tree from
340
+ * there. There is nothing committed to compare it against, so there is no
341
+ * `--check`.
340
342
  *
341
343
  * @returns {object} The yargs command module.
342
344
  */
@@ -344,15 +346,8 @@ function schemaCommand() {
344
346
  return {
345
347
  command: "schema",
346
348
  describe: "Publish this package's DataModel field sets as schema.json",
347
- builder: (y) =>
348
- y.option("check", {
349
- type: "boolean",
350
- default: false,
351
- describe:
352
- "Fail when the committed schema.json is out of date " +
353
- "rather than rewriting it",
354
- }),
355
- handler: handler(async (argv) => {
349
+ builder: (y) => y,
350
+ handler: handler(async () => {
356
351
  const config = loadPackageBuildConfig();
357
352
  if (!config.schema.length) {
358
353
  console.log(
@@ -369,25 +364,13 @@ function schemaCommand() {
369
364
  version: pkg.version,
370
365
  });
371
366
 
372
- const out = path.join(config.rootDir, SCHEMA_ARTIFACT_FILE);
367
+ const out = path.join(config.rootDir, "build", SCHEMA_ARTIFACT_FILE);
373
368
  const text = await formatGenerated(JSON.stringify(artifact), out);
374
369
  const counts = Object.entries(artifact.documents)
375
370
  .map(([kind, subtypes]) => `${Object.keys(subtypes).length} ${kind}`)
376
371
  .join(", ");
377
372
 
378
- if (argv.check) {
379
- const current = fs.existsSync(out) ? fs.readFileSync(out, "utf8") : null;
380
- if (current !== text) {
381
- die(
382
- `${SCHEMA_ARTIFACT_FILE} does not match what this ` +
383
- `package's data models would produce — regenerate ` +
384
- `it with \`package-build schema\`.`,
385
- );
386
- }
387
- console.log(`✅ ${SCHEMA_ARTIFACT_FILE} is up to date ` + `(${counts} subtypes).`);
388
- return;
389
- }
390
-
373
+ fs.mkdirSync(path.dirname(out), { recursive: true });
391
374
  fs.writeFileSync(out, text, "utf8");
392
375
  console.log(
393
376
  `✅ Wrote ${SCHEMA_ARTIFACT_FILE} for ${artifact.system} ` +
package/config.mjs CHANGED
@@ -91,7 +91,7 @@ const SECTION_KEYS = [
91
91
  export const DERIVED_MANIFEST_KEYS = Object.freeze({
92
92
  id: "`foundryPackage`, itself derived from package.json `name`",
93
93
  version: "package.json `version`",
94
- description: "package.json `description`",
94
+ description: "`packageBuild.manifest.descriptionHtml`",
95
95
  url: "package.json `repository`",
96
96
  bugs: "package.json `repository`",
97
97
  manifest: "package.json `repository` and the release tag",
@@ -152,11 +152,16 @@ const ARTIFACT_OF_KIND = Object.freeze({
152
152
  * message, so {@link loadPackageBuildConfig} — the half that knows which file
153
153
  * was read — can resolve it to a line and column. This half stays pure.
154
154
  *
155
+ * Exported so a sibling module composing a configuration value this module
156
+ * does not itself validate — {@link module:engine/site-config}'s
157
+ * `hugoConfig`, checking `site.assets` — reports through the one helper
158
+ * rather than a second copy.
159
+ *
155
160
  * @param {string} where - Dotted path of the offending key.
156
161
  * @param {string} problem - What is wrong with it.
157
162
  * @returns {never}
158
163
  */
159
- function fail(where, problem) {
164
+ export function fail(where, problem) {
160
165
  throw Object.assign(new TypeError(`package-build config: \`${where}\` ${problem}.`), {
161
166
  field: where,
162
167
  });
@@ -759,6 +759,7 @@ const DOCS_KEYS = ["itemFields"];
759
759
  const SITE_KEYS = [
760
760
  "base",
761
761
  "assets",
762
+ "description",
762
763
  "packages",
763
764
  "sections",
764
765
  "readmeSections",
@@ -1575,6 +1576,33 @@ function normalizeSiteAssets(value) {
1575
1576
  return assets.replace(/\/+$/, "");
1576
1577
  }
1577
1578
 
1579
+ /**
1580
+ * The site's `<meta name="description">`, plain text.
1581
+ *
1582
+ * Foundry's package browser wants a pitch — HTML, any length,
1583
+ * `packageBuild.manifest.descriptionHtml` — and a `<meta>` tag wants one
1584
+ * plain sentence. The two audiences are different enough that one string
1585
+ * cannot serve both, so this is the site's own, checked for markup rather
1586
+ * than trusted to carry none: a value with a `<` in it is refused, naming
1587
+ * `descriptionHtml` as where markup belongs.
1588
+ *
1589
+ * @param {unknown} value - The configured value, or `undefined`.
1590
+ * @returns {string} The description; `""` when unset.
1591
+ */
1592
+ function normalizeSiteDescription(value) {
1593
+ if (value === undefined) return "";
1594
+ const description = requireNonEmptyString(value, "site.description");
1595
+ if (description.includes("<")) {
1596
+ fail(
1597
+ "site.description",
1598
+ "contains `<` — this is plain text for the site's " +
1599
+ '`<meta name="description">`; markup belongs in ' +
1600
+ "`packageBuild.manifest.descriptionHtml`",
1601
+ );
1602
+ }
1603
+ return description;
1604
+ }
1605
+
1578
1606
  /**
1579
1607
  * A map of section name → landing metadata.
1580
1608
  *
@@ -1616,7 +1644,7 @@ export const DERIVED_HUGO_KEYS = Object.freeze({
1616
1644
  disableKinds: "whether any note in the tree carries `tags:`, which the site walk discovers",
1617
1645
  taxonomies: "whether any note in the tree carries `tags:`, which the site walk discovers",
1618
1646
  outputs: "whether any note in the tree carries `tags:`, which the site walk discovers",
1619
- "params.description": "package.json `description`",
1647
+ "params.description": "`site.description`",
1620
1648
  "params.author": "package.json `author`",
1621
1649
  "params.cdnBaseURL": "`site.assets`",
1622
1650
  "params.brand": "the organisation's brand links, in `engine/site-config.mjs`",
@@ -1739,6 +1767,7 @@ function normalizeSite(value) {
1739
1767
  const empty = Object.freeze({
1740
1768
  base: "",
1741
1769
  assets: "",
1770
+ description: "",
1742
1771
  packages: Object.freeze([]),
1743
1772
  sections: Object.freeze({}),
1744
1773
  readmeSections: Object.freeze({}),
@@ -1807,6 +1836,7 @@ function normalizeSite(value) {
1807
1836
  return Object.freeze({
1808
1837
  base: input.base === undefined ? "" : requireNonEmptyString(input.base, "site.base"),
1809
1838
  assets: normalizeSiteAssets(input.assets),
1839
+ description: normalizeSiteDescription(input.description),
1810
1840
  packages: Object.freeze(packages),
1811
1841
  sections: normalizeSectionMap(input.sections, "site.sections"),
1812
1842
  readmeSections: normalizeSectionMap(input.readmeSections, "site.readmeSections"),
package/docs/api.md CHANGED
@@ -1290,6 +1290,7 @@ console.log(config.stageDir);
1290
1290
  | `resolvePackageBuildConfig` | `resolvePackageBuildConfig(shared)` | `Readonly<PackageBuildConfig>` | validating the `packageBuild:` section from an already-loaded shared configuration — the pure half, usable without touching disk |
1291
1291
  | `loadPackageBuildConfig` | `loadPackageBuildConfig()` | `Readonly<PackageBuildConfig>` | reading and validating the repository's resolved package-build configuration from disk, read fresh on each call rather than cached at import |
1292
1292
  | `checkHomepage` | `checkHomepage(homepage, contentPackage)` | `void` | validating a resolved `homepage` against `contentPackage` — called by `content-build site` before the generated `baseURL` is written, not by `resolvePackageBuildConfig` itself |
1293
+ | `fail` | `fail(where, problem)` | `never` | rejecting a configured value from a sibling module, naming the dotted key path it was written under, so the loader can resolve it to a line and column |
1293
1294
 
1294
1295
  ## `./prettier`
1295
1296
 
package/docs/commands.md CHANGED
@@ -166,7 +166,7 @@ Publish this package's DataModel field sets as `schema.json`.
166
166
  **SYNOPSIS**
167
167
 
168
168
  ```
169
- package-build schema [--check]
169
+ package-build schema
170
170
  ```
171
171
 
172
172
  **DESCRIPTION**
@@ -174,23 +174,20 @@ package-build schema [--check]
174
174
  Publishes the registries named in `packageBuild.schema` as `schema.json`, read
175
175
  by `content-build content-format schema` and, in a consuming package, by
176
176
  `content-build lint`'s emitted-versus-declared check. A repository that
177
- declares no registries has nothing to publish. `--check` compares the
178
- committed file against what the source would produce now instead of
179
- rewriting it, so CI can gate on the file being current. Reads the registries
180
- `packageBuild.schema` names; writes (or checks) `schema.json` at the
181
- repository root.
177
+ declares no registries has nothing to publish. Reads the registries
178
+ `packageBuild.schema` names; writes `build/schema.json`. A release publishes
179
+ it beside the archive and the manifest when `packageBuild.assets` names
180
+ `build/schema.json` (see `package-build release`); a module's
181
+ `content-build deps fetch` keeps the copy from the archive it downloads.
182
182
 
183
183
  **OPTIONS**
184
184
 
185
- | Option | Type | Default | Description |
186
- | --------- | ------- | ------- | ------------------------------------------------------------------------------ |
187
- | `--check` | boolean | `false` | Fail when the committed `schema.json` is out of date rather than rewriting it. |
185
+ None.
188
186
 
189
187
  **EXIT STATUS**
190
188
 
191
- 1 when `--check` finds the committed file does not match what the source
192
- would produce. 1 on any other thrown error. Otherwise 0 — including when
193
- `packageBuild.schema` is empty, which logs and publishes nothing.
189
+ 1 on any thrown error. Otherwise 0 including when `packageBuild.schema` is
190
+ empty, which logs and publishes nothing.
194
191
 
195
192
  **EXAMPLES**
196
193
 
@@ -202,7 +199,7 @@ package-build: no `packageBuild.schema` declared; nothing to publish.
202
199
  **SEE ALSO**
203
200
 
204
201
  `content-build content-format schema`, `content-build lint [root]`,
205
- [Configuration](configuration.md).
202
+ `package-build release`, [Configuration](configuration.md).
206
203
 
207
204
  ### `package-build manifest`
208
205
 
@@ -670,14 +667,17 @@ package-build release [--no-pdf]
670
667
 
671
668
  The artifact name comes from `packageKind` — a system ships as
672
669
  `system.json`'s sibling, a module as `module.json`'s — so no repository
673
- states it a second time. When the package publishes content
674
- (`publish.site: content`), it also builds the content-tree book (see
675
- `content-build pdf`) and reports it alongside the archive; `--no-pdf` skips
676
- that step for a release that has a tree but does not want the book this
677
- time. A book that fails to build is reported, never fatal — the archive
678
- above is the release regardless. Reads the staged package and (for the book)
679
- the content tree; writes `<artifact>.zip` and, unless skipped, the book,
680
- both under `build/dist`.
670
+ states it a second time. It also publishes the content index the manifest
671
+ advertises (`flags.metadataUrl`) and, when the stage carries one,
672
+ `schema.json` a repository that names `build/schema.json` in
673
+ `packageBuild.assets` gets it released beside the archive; one that does not
674
+ gets none. When the package publishes content (`publish.site: content`), it
675
+ also builds the content-tree book (see `content-build pdf`) and reports it
676
+ alongside the archive; `--no-pdf` skips that step for a release that has a
677
+ tree but does not want the book this time. A book that fails to build is
678
+ reported, never fatal — the archive above is the release regardless. Reads
679
+ the staged package and (for the book) the content tree; writes
680
+ `<artifact>.zip` and, unless skipped, the book, both under `build/dist`.
681
681
 
682
682
  **OPTIONS**
683
683
 
@@ -701,7 +701,7 @@ $ package-build release
701
701
 
702
702
  **SEE ALSO**
703
703
 
704
- `content-build pdf`, [Configuration](configuration.md).
704
+ `content-build pdf`, `package-build schema`, [Configuration](configuration.md).
705
705
 
706
706
  ### `package-build deploy <stage>`
707
707
 
@@ -752,21 +752,22 @@ Any other key under `docs.itemFields` is refused:
752
752
 
753
753
  **Type:** object · **Optional** · every key defaults to nothing published:
754
754
 
755
- | Key | Type | Default |
756
- | ----------------------- | -------- | --------------------- |
757
- | `site.base` | string | `""` |
758
- | `site.assets` | string | `""` |
759
- | `site.packages` | string[] | `[]` |
760
- | `site.sections` | object | `{}` |
761
- | `site.readmeSections` | object | `{}` |
762
- | `site.landing` | object | `null` |
763
- | `site.trees` | array | `[]` |
764
- | `site.pass` | string | `""` |
765
- | `site.passOptions` | object | `{}` |
766
- | `site.backfillSections` | boolean | `false` |
767
- | `site.list` | object | `{shortcodes: false}` |
768
- | `site.notfound` | object | `null` |
769
- | `site.hugo` | object | `{}` |
755
+ | Key | Type | Default |
756
+ | ----------------------- | -------- | ------------------------------------------- |
757
+ | `site.base` | string | `""` |
758
+ | `site.assets` | string | `""`, but required for `content-build site` |
759
+ | `site.description` | string | `""`, but required for `content-build site` |
760
+ | `site.packages` | string[] | `[]` |
761
+ | `site.sections` | object | `{}` |
762
+ | `site.readmeSections` | object | `{}` |
763
+ | `site.landing` | object | `null` |
764
+ | `site.trees` | array | `[]` |
765
+ | `site.pass` | string | `""` |
766
+ | `site.passOptions` | object | `{}` |
767
+ | `site.backfillSections` | boolean | `false` |
768
+ | `site.list` | object | `{shortcodes: false}` |
769
+ | `site.notfound` | object | `null` |
770
+ | `site.hugo` | object | `{}` |
770
771
 
771
772
  How much of a package reaches the web at all is **not** here — it is
772
773
  [`publish.site`](#publish). `site` is framing: what a section is called,
@@ -785,7 +786,7 @@ published. A `site.out` is refused by name:
785
786
 
786
787
  > ``package-build config: `site` must be a mapping.``
787
788
 
788
- > ``package-build config: `site.<key>` is not a recognized option (expected one of: base, assets, packages, sections, readmeSections, landing, trees, pass, passOptions, backfillSections, list, notfound, hugo).``
789
+ > ``package-build config: `site.<key>` is not a recognized option (expected one of: base, assets, description, packages, sections, readmeSections, landing, trees, pass, passOptions, backfillSections, list, notfound, hugo).``
789
790
 
790
791
  `site.assets` is the host every package's imagery is served from, and it is
791
792
  the one address in this file that is not this repository's own. A note names
@@ -798,11 +799,30 @@ Absolute, and the trailing slash is trimmed:
798
799
 
799
800
  > ``package-build config: `site.assets` must be an absolute `http://` or `https://` address — it is the host every package's imagery is served from, and a relative value resolves against whichever page happens to carry the image.``
800
801
 
802
+ `content-build site` refuses to generate a configuration with no
803
+ `site.assets` at all — there is no defensible default, because the theme
804
+ resolves every relative asset against it:
805
+
806
+ > ``package-build config: `site.assets` is not declared, and a site build needs one — it is the host every package's imagery is served from, and the theme resolves every relative asset against it.``
807
+
801
808
  The generated Hugo configuration carries the same host as
802
809
  `params.cdnBaseURL`, which the theme resolves a relative asset path against.
803
810
  The two are one value read by two readers: the toolchain emits it into a
804
811
  page, and the theme joins it onto anything the toolchain left relative.
805
812
 
813
+ `site.description` is the site's `<meta name="description">` — one plain
814
+ sentence, distinct from the Foundry package browser's pitch
815
+ ([`packageBuild.manifest.descriptionHtml`](#packagebuildmanifest), which
816
+ allows HTML). Required for `content-build site`, the way `packageBuild.manifest.title`
817
+ is:
818
+
819
+ > ``package-build config: `site.description` is not declared, and the site's `<meta name="description">` reads from it.``
820
+
821
+ Markup belongs in `descriptionHtml`, not here — a value containing `<` is
822
+ refused:
823
+
824
+ > ``package-build config: `site.description` contains `<` — this is plain text for the site's `<meta name="description">`; markup belongs in `packageBuild.manifest.descriptionHtml`.``
825
+
806
826
  `site.packages` names which content packages' notes the site walks, beyond
807
827
  this one's own; `site.pass` names a repository's own body-rewrite bundle
808
828
  (the one part of the site contract that is code, exactly as `itemBuilders`
@@ -968,9 +988,9 @@ in it has one source, and that source is where it is edited:
968
988
  | `disableKinds` | whether any note in the tree carries `tags:`, which the site walk discovers: `["taxonomy", "term", "RSS"]` when none does, `["RSS"]` when at least one does |
969
989
  | `taxonomies` | the same fact — written as `{ tag = "tags" }` when at least one note carries `tags:`, absent otherwise |
970
990
  | `outputs` | the same fact — written as `{ taxonomy = ["HTML"], term = ["HTML"] }` when at least one note carries `tags:`, absent otherwise |
971
- | `params.description` | `package.json` `description`; absent when the package declares none |
991
+ | `params.description` | `site.description`, which is required |
972
992
  | `params.author` | `package.json` `author`, its `name`; absent when the package declares none |
973
- | `params.cdnBaseURL` | `site.assets`; absent when unset |
993
+ | `params.cdnBaseURL` | `site.assets`, which is required |
974
994
  | `params.brand` | the organisation's brand links — `logo`, `licenseURL`, `discordURL` — in `engine/site-config.mjs` |
975
995
  | `params.list` | `site.list` |
976
996
  | `params.notfound` | `site.notfound`; absent when undeclared |
@@ -991,6 +1011,17 @@ none fails the site build:
991
1011
 
992
1012
  > ``package-build config: `packageBuild.manifest.title` is not declared, and the site's `title` reads from it.``
993
1013
 
1014
+ `params.description` reads from `site.description` the same way, and fails
1015
+ the same way when it is absent:
1016
+
1017
+ > ``package-build config: `site.description` is not declared, and the site's `<meta name="description">` reads from it.``
1018
+
1019
+ `params.cdnBaseURL` reads from `site.assets`, and the theme resolves every
1020
+ relative asset against it, so a configuration declaring none fails the site
1021
+ build the same way:
1022
+
1023
+ > ``package-build config: `site.assets` is not declared, and a site build needs one — it is the host every package's imagery is served from, and the theme resolves every relative asset against it.``
1024
+
994
1025
  Nothing else is emitted. A site whose notes carry no `tags:` publishes no
995
1026
  taxonomy pages — `[taxonomies]` and `[outputs]` go unwritten, and Hugo's
996
1027
  defaults never apply because `taxonomy` and `term` are disabled kinds. A site
@@ -1436,11 +1467,22 @@ that has a wrong answer rather than an unknown one: a key the build
1436
1467
  silently overwritten and the two would be free to disagree with nothing to
1437
1468
  say so.
1438
1469
 
1470
+ `packageBuild.manifest.descriptionHtml` is the exception worth calling out on
1471
+ its own: it is not forbidden, it **is** how `description` is authored. It is
1472
+ the pitch Foundry's package browser shows — HTML allowed, any length — and it
1473
+ is emitted into the generated manifest as `description`; the key itself never
1474
+ survives into the manifest under its own name. `package.json`'s own
1475
+ `description` is read by neither this nor the site (see
1476
+ [`site.description`](#site)) — a declared one is reported as a warning naming
1477
+ both real keys, so it cannot drift back into use:
1478
+
1479
+ > `package.json: warning: \`description\` is read by nothing; the Foundry pitch is \`packageBuild.manifest.descriptionHtml\` and the site's is \`site.description\`` — a JSON manifest carries no line to point at, so only the file is named.
1480
+
1439
1481
  | Forbidden key | Derived from |
1440
1482
  | ------------------------------------- | ---------------------------------------------------------------- |
1441
1483
  | `packageBuild.manifest.id` | `foundryPackage`, itself derived from `package.json` `name` |
1442
1484
  | `packageBuild.manifest.version` | `package.json` `version` |
1443
- | `packageBuild.manifest.description` | `package.json` `description` |
1485
+ | `packageBuild.manifest.description` | `packageBuild.manifest.descriptionHtml` |
1444
1486
  | `packageBuild.manifest.url` | `package.json` `repository` |
1445
1487
  | `packageBuild.manifest.bugs` | `package.json` `repository` |
1446
1488
  | `packageBuild.manifest.manifest` | `package.json` `repository` and the release tag |
@@ -1451,7 +1493,7 @@ say so.
1451
1493
 
1452
1494
  > ``package-build config: `packageBuild.manifest.version` is derived from package.json `version` and must not be declared — it would be overwritten, and the two would disagree with nothing to say so.``
1453
1495
 
1454
- > ``package-build config: `packageBuild.manifest.description` is derived from package.json `description` and must not be declared — it would be overwritten, and the two would disagree with nothing to say so.``
1496
+ > ``package-build config: `packageBuild.manifest.description` is derived from `packageBuild.manifest.descriptionHtml` and must not be declared — it would be overwritten, and the two would disagree with nothing to say so.``
1455
1497
 
1456
1498
  > ``package-build config: `packageBuild.manifest` must be a mapping.``
1457
1499
 
@@ -570,9 +570,22 @@ export function configFromData(data, configPath) {
570
570
  );
571
571
  }
572
572
  }
573
- const { pkg } = readPackageJson(rootDir);
573
+ const { manifestPath, pkg } = readPackageJson(rootDir);
574
574
  if (pkg.homepage !== undefined) input.homepage = pkg.homepage;
575
575
  if (pkg.author !== undefined) input.author = pkg.author;
576
+ // `description` is npm metadata nothing displays for a private package —
577
+ // read by neither the manifest nor the site, so a declared one is a
578
+ // warning rather than a silent no-op that looks like it did something.
579
+ if (pkg.description !== undefined) {
580
+ emitDiagnostic({
581
+ file: manifestPath,
582
+ severity: "warning",
583
+ message:
584
+ "`description` is read by nothing; the Foundry pitch is " +
585
+ "`packageBuild.manifest.descriptionHtml` and the site's is " +
586
+ "`site.description`",
587
+ });
588
+ }
576
589
 
577
590
  if (input.itemBuilders !== undefined) {
578
591
  const declared = input.itemBuilders;
@@ -466,8 +466,8 @@ export function compareEmittedSystem({
466
466
  * Two places to find it, because a system checks itself against source it owns
467
467
  * while a module checks against a dependency it fetched:
468
468
  *
469
- * - **A system**: its own `schema.json`, generated from its `src/` and
470
- * committed beside it.
469
+ * - **A system**: its own `schema.json`, generated from its `src/` into
470
+ * `build/` by `package-build schema`.
471
471
  * - **A module**: the copy cached by `content-build deps fetch`, from the
472
472
  * archive of the version it pins — which is what makes the comparison happen
473
473
  * at `verified` rather than against whatever the system's `main` holds today.
@@ -505,7 +505,7 @@ export function resolveSchemaArtifact(config, system = undefined) {
505
505
 
506
506
  // The system checking itself, against the schema its own build published.
507
507
  if (config.packageKind === "systems" && config.foundryPackage === systemId) {
508
- const own = path.join(config.rootDir, SCHEMA_ARTIFACT_FILE);
508
+ const own = path.join(config.rootDir, "build", SCHEMA_ARTIFACT_FILE);
509
509
  return fs.existsSync(own) ? read(own) : null;
510
510
  }
511
511
 
@@ -683,9 +683,9 @@ export function checkAuthoredSystemData(
683
683
  * subtype declares.
684
684
  *
685
685
  * The build-time face of {@link compareEmittedSystem}: it resolves the schema
686
- * the way every other check here does — the system's own committed artifact, or
687
- * the cached one from the release a module pins — and attaches the message a
688
- * reader sees.
686
+ * the way every other check here does — the system's own published artifact,
687
+ * or the cached one from the release a module pins — and attaches the message
688
+ * a reader sees.
689
689
  *
690
690
  * **Silent where there is nothing to check against**, exactly as its two
691
691
  * siblings are: a module pinning a system version released before the artifact
@@ -44,7 +44,7 @@ import fs from "node:fs";
44
44
  import path from "node:path";
45
45
  import { stringify as stringifyToml } from "smol-toml";
46
46
 
47
- import { checkHomepage } from "../config.mjs";
47
+ import { checkHomepage, fail } from "../config.mjs";
48
48
  import { slugify } from "./content-slug.mjs";
49
49
 
50
50
  /** The Hugo source directory, relative to the repository root. */
@@ -381,7 +381,6 @@ function deepMerge(base, overrides) {
381
381
  *
382
382
  * @param {object} options - The sources.
383
383
  * @param {object} options.config - The resolved build configuration.
384
- * @param {string} [options.description] - `package.json`'s `description`.
385
384
  * @param {readonly NavigationEntry[]} options.navigation - The navigation.
386
385
  * @param {string} options.themesDir - From {@link resolveThemesDir}.
387
386
  * @param {boolean} [options.hasTags] - Whether any note the site build walked
@@ -389,9 +388,10 @@ function deepMerge(base, overrides) {
389
388
  * `hasTags`. Defaults to `false` — no tagged note, no taxonomy pages.
390
389
  * @returns {Record<string, any>} The configuration Hugo reads.
391
390
  * @throws {TypeError} When `homepage` fails `checkHomepage`, or the
392
- * configuration declares no `packageBuild.manifest.title`.
391
+ * configuration declares no `packageBuild.manifest.title`, no
392
+ * `site.description`, or no `site.assets`.
393
393
  */
394
- export function hugoConfig({ config, description, navigation, themesDir, hasTags = false }) {
394
+ export function hugoConfig({ config, navigation, themesDir, hasTags = false }) {
395
395
  checkHomepage(config.homepage, config.contentPackage);
396
396
 
397
397
  const title = config.packageBuild?.manifest?.title;
@@ -401,10 +401,23 @@ export function hugoConfig({ config, description, navigation, themesDir, hasTags
401
401
  "and the site's `title` reads from it.",
402
402
  );
403
403
  }
404
+ if (!config.site.description) {
405
+ fail(
406
+ "site.description",
407
+ 'is not declared, and the site\'s `<meta name="description">` reads from it',
408
+ );
409
+ }
410
+ if (!config.site.assets) {
411
+ fail(
412
+ "site.assets",
413
+ "is not declared, and a site build needs one — it is the host every " +
414
+ "package's imagery is served from, and the theme resolves every " +
415
+ "relative asset against it",
416
+ );
417
+ }
404
418
 
405
419
  /** @type {Record<string, unknown>} */
406
- const params = {};
407
- if (typeof description === "string" && description.trim()) params.description = description;
420
+ const params = { description: config.site.description };
408
421
  if (config.author?.name) params.author = config.author.name;
409
422
  if (config.site.assets) params.cdnBaseURL = config.site.assets;
410
423
  params.brand = { ...BRAND };
@@ -449,24 +462,12 @@ export function hugoToml(generated) {
449
462
  );
450
463
  }
451
464
 
452
- /**
453
- * `package.json`'s `description`, or `undefined` when it declares none.
454
- *
455
- * @param {string} rootDir - The repository root.
456
- * @returns {string|undefined} The description.
457
- */
458
- function packageDescription(rootDir) {
459
- const pkg = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
460
- return typeof pkg.description === "string" ? pkg.description : undefined;
461
- }
462
-
463
465
  /**
464
466
  * The Hugo configuration, every source read from the repository.
465
467
  *
466
- * Reads `package.json`, the cached navigation and the installed theme's
467
- * location, and composes them with {@link hugoConfig}. Nothing is written, so
468
- * a caller can run this before touching the output tree and fail with it
469
- * intact.
468
+ * Reads the cached navigation and the installed theme's location, and
469
+ * composes them with {@link hugoConfig}. Nothing is written, so a caller can
470
+ * run this before touching the output tree and fail with it intact.
470
471
  *
471
472
  * @param {object} config - The resolved build configuration.
472
473
  * @param {object} [options] - Options.
@@ -480,7 +481,6 @@ function packageDescription(rootDir) {
480
481
  export function generateHugoConfig(config, { hasTags = false } = {}) {
481
482
  return hugoConfig({
482
483
  config,
483
- description: packageDescription(config.rootDir),
484
484
  navigation: readCachedNavigation(config),
485
485
  themesDir: resolveThemesDir(config.rootDir),
486
486
  hasTags,
package/manifest.mjs CHANGED
@@ -28,10 +28,12 @@
28
28
  *
29
29
  * - **Declared** — the `packageBuild.manifest` block, emitted unchanged, so a
30
30
  * key Foundry adds in a later version needs no release of this package.
31
- * - **Derived** — the identity, the description, the version, the release
32
- * addresses, the compatibility ranges and the pack list. Declaring one of
33
- * these is an error rather than an override: the authored copy would be
34
- * silently overwritten.
31
+ * `descriptionHtml` is the one exception, folded into the derived
32
+ * `description` below rather than surviving under its own name.
33
+ * - **Derived** the identity, the description (from `descriptionHtml`), the
34
+ * version, the release addresses, the compatibility ranges and the pack
35
+ * list. Declaring `description` directly is an error rather than an
36
+ * override: the authored copy would be silently overwritten.
35
37
  * - **Computed** — namespaced `flags` a repository works out for itself.
36
38
  *
37
39
  * **Nothing here invents an address.** The repository URL is read from
@@ -459,11 +461,13 @@ function withoutBuildKeys(entry) {
459
461
  * Three kinds of key end up in the result:
460
462
  *
461
463
  * - **Declared** — everything in `packageBuild.manifest`, emitted unchanged, so
462
- * a key Foundry adds later needs no release of this package.
464
+ * a key Foundry adds later needs no release of this package. The one
465
+ * exception is `descriptionHtml`, folded into the description below rather
466
+ * than surviving under its own name.
463
467
  * - **Derived** — the identity, the description, the release addresses, the
464
468
  * version, the Foundry and system compatibility ranges, and the pack list.
465
- * These are refused if also declared: an authored copy would be overwritten
466
- * and the two would disagree with nothing to say so.
469
+ * These are refused if also declared (`description` directly; `descriptionHtml`
470
+ * is how it is authored) and the two would disagree with nothing to say so.
467
471
  * - **Computed** — namespaced `flags` a repository works out for itself, merged
468
472
  * over any it declared.
469
473
  *
@@ -479,7 +483,9 @@ function withoutBuildKeys(entry) {
479
483
  * @returns {object} The manifest, ready to serialise.
480
484
  */
481
485
  export function buildManifest({ config, packageJson, artifact, flags }) {
482
- const declared = config.packageBuild?.manifest ?? {};
486
+ // `descriptionHtml` is the authored source of `description` — pulled out
487
+ // so it never survives the spread below under its own name.
488
+ const { descriptionHtml, ...declared } = config.packageBuild?.manifest ?? {};
483
489
  const repoUrl = normalizeRepoUrl(packageJson.repository);
484
490
 
485
491
  const derived = {
@@ -495,8 +501,8 @@ export function buildManifest({ config, packageJson, artifact, flags }) {
495
501
  };
496
502
  // Own-property presence, not just value, decides whether a key survives
497
503
  // into `ordered` below — an explicit `undefined` would still occupy a slot
498
- // in it. Set only when `package.json` actually declares one.
499
- if (packageJson.description !== undefined) derived.description = packageJson.description;
504
+ // in it. Set only when the repository actually declares one.
505
+ if (descriptionHtml !== undefined) derived.description = descriptionHtml;
500
506
  if (config.compatibility) derived.compatibility = config.compatibility;
501
507
 
502
508
  // `requiresSystem` is the gate half of the declare/require split. It
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "22.1.1",
3
+ "version": "22.2.0",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
package/release.mjs CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  /**
15
15
  * The release archive — the two files a Foundry package's GitHub Release
16
- * carries.
16
+ * carries, plus the optional siblings a repository opts into.
17
17
  *
18
18
  * Foundry installs a package by fetching the `download` URL its manifest
19
19
  * advertises, so a release publishes `<artifact>.zip`, the whole staged tree,
@@ -21,7 +21,10 @@
21
21
  * re-fetches to notice a new version. A package that ships content publishes a
22
22
  * third: the content index other packages resolve its addresses through,
23
23
  * named by the `flags.metadataUrl` the manifest advertises. Every name is fixed
24
- * by what the manifest says, not chosen here — see `manifest.mjs`.
24
+ * by what the manifest says, not chosen here — see `manifest.mjs`. A package
25
+ * that publishes a DataModel schema (`packageBuild.schema`, staged through
26
+ * `packageBuild.assets`) gets a fourth: `schema.json`, published whenever the
27
+ * stage carries one and silently skipped otherwise.
25
28
  *
26
29
  * Kept apart from `stage.mjs` because this is the only part of assembling a
27
30
  * package that needs a dependency. A repository that never cuts a release from
@@ -40,6 +43,8 @@ import path from "node:path";
40
43
  // release job came to fail before a single byte was written.
41
44
  import { ZipArchive } from "archiver";
42
45
 
46
+ import { SCHEMA_ARTIFACT_FILE } from "./engine/foreign-catalog.mjs";
47
+
43
48
  /**
44
49
  * Zip the staged tree and place the manifest beside the archive.
45
50
  *
@@ -60,14 +65,14 @@ import { ZipArchive } from "archiver";
60
65
  * @param {boolean} [opts.pdf] - Whether to build the book that ships beside the
61
66
  * archive. `true` by default; `false` skips the build and reports the skip.
62
67
  * @returns {Promise<{zip: string, manifest: string, metadata?: string,
63
- * pdf?: string, pdfFindings: object[], pdfSkipped: string|null,
68
+ * schema?: string, pdf?: string, pdfFindings: object[], pdfSkipped: string|null,
64
69
  * bytes: number, version: string}>} The paths written, what the book build
65
70
  * found, the archive's size, and the version the manifest declares.
66
- * `metadata` is absent when the manifest advertises no content index, and
67
- * `pdf` is absent when no book was written. `pdfSkipped` is `null` when a
68
- * book was built, and otherwise the reason none was — itself `null` when the
69
- * book builder could not be loaded, which is reported through
70
- * `pdfFindings`.
71
+ * `metadata` is absent when the manifest advertises no content index,
72
+ * `schema` is absent when the stage carries no `schema.json`, and `pdf` is
73
+ * absent when no book was written. `pdfSkipped` is `null` when a book was
74
+ * built, and otherwise the reason none was itself `null` when the book
75
+ * builder could not be loaded, which is reported through `pdfFindings`.
71
76
  * @throws {Error} When the stage has no manifest — there is nothing to release,
72
77
  * and an archive without one installs as nothing.
73
78
  */
@@ -119,6 +124,7 @@ export async function packRelease({
119
124
  await fsp.copyFile(stagedManifest, path.join(out, manifestName));
120
125
 
121
126
  const metadata = await publishMetadataIndex({ manifest, stage, out, metadataDir });
127
+ const schema = await publishSchemaAsset({ stage, out });
122
128
 
123
129
  // Last, and never fatal: the archive and the manifest are the release, and
124
130
  // a book that failed to set is a reported problem rather than a reason to
@@ -132,6 +138,7 @@ export async function packRelease({
132
138
  zip: zipPath,
133
139
  manifest: path.join(out, manifestName),
134
140
  ...(metadata ? { metadata } : {}),
141
+ ...(schema ? { schema } : {}),
135
142
  ...(book.pdf ? { pdf: book.pdf } : {}),
136
143
  pdfFindings: book.findings,
137
144
  pdfSkipped: book.pdf ? null : book.reason,
@@ -226,3 +233,29 @@ async function publishMetadataIndex({ manifest, stage, out, metadataDir }) {
226
233
  await fsp.copyFile(found, dest);
227
234
  return dest;
228
235
  }
236
+
237
+ /**
238
+ * Place the published DataModel schema beside the archive, when the stage
239
+ * carries one.
240
+ *
241
+ * **The stage is the one place this looks.** `package-build schema` writes
242
+ * `build/schema.json`, and a repository that wants it released names it in
243
+ * `packageBuild.assets` (`from: build/schema.json`) the way every other
244
+ * staged file is declared — so a schema at the stage root is a repository
245
+ * that opted in, and its absence is a repository that has not, which is
246
+ * exactly as releasable as one that never adopted the artifact at all.
247
+ *
248
+ * @param {object} opts
249
+ * @param {string} opts.stage - The staged tree.
250
+ * @param {string} opts.out - Where release assets are written.
251
+ * @returns {Promise<string|undefined>} The published path, or nothing when
252
+ * the stage carries no `schema.json`.
253
+ */
254
+ async function publishSchemaAsset({ stage, out }) {
255
+ const src = path.join(stage, SCHEMA_ARTIFACT_FILE);
256
+ if (!fs.existsSync(src)) return undefined;
257
+
258
+ const dest = path.join(out, SCHEMA_ARTIFACT_FILE);
259
+ await fsp.copyFile(src, dest);
260
+ return dest;
261
+ }
@@ -1,3 +1,20 @@
1
+ /**
2
+ * Reject a configured value, naming the key it was written under.
3
+ *
4
+ * The dotted path rides on the error as `field` as well as appearing in the
5
+ * message, so {@link loadPackageBuildConfig} — the half that knows which file
6
+ * was read — can resolve it to a line and column. This half stays pure.
7
+ *
8
+ * Exported so a sibling module composing a configuration value this module
9
+ * does not itself validate — {@link module:engine/site-config}'s
10
+ * `hugoConfig`, checking `site.assets` — reports through the one helper
11
+ * rather than a second copy.
12
+ *
13
+ * @param {string} where - Dotted path of the offending key.
14
+ * @param {string} problem - What is wrong with it.
15
+ * @returns {never}
16
+ */
17
+ export function fail(where: string, problem: string): never;
1
18
  /**
2
19
  * Where a declared `assetTransform` is loaded from.
3
20
  *
@@ -157,8 +157,8 @@ export function compareEmittedSystem({ system, artifact, documentType, subtype,
157
157
  * Two places to find it, because a system checks itself against source it owns
158
158
  * while a module checks against a dependency it fetched:
159
159
  *
160
- * - **A system**: its own `schema.json`, generated from its `src/` and
161
- * committed beside it.
160
+ * - **A system**: its own `schema.json`, generated from its `src/` into
161
+ * `build/` by `package-build schema`.
162
162
  * - **A module**: the copy cached by `content-build deps fetch`, from the
163
163
  * archive of the version it pins — which is what makes the comparison happen
164
164
  * at `verified` rather than against whatever the system's `main` holds today.
@@ -282,9 +282,9 @@ export function checkAuthoredSystemData(fm: object, { block, documentType, subTy
282
282
  * subtype declares.
283
283
  *
284
284
  * The build-time face of {@link compareEmittedSystem}: it resolves the schema
285
- * the way every other check here does — the system's own committed artifact, or
286
- * the cached one from the release a module pins — and attaches the message a
287
- * reader sees.
285
+ * the way every other check here does — the system's own published artifact,
286
+ * or the cached one from the release a module pins — and attaches the message
287
+ * a reader sees.
288
288
  *
289
289
  * **Silent where there is nothing to check against**, exactly as its two
290
290
  * siblings are: a module pinning a system version released before the artifact
@@ -89,7 +89,6 @@ export function resolveThemesDir(rootDir: string): string;
89
89
  *
90
90
  * @param {object} options - The sources.
91
91
  * @param {object} options.config - The resolved build configuration.
92
- * @param {string} [options.description] - `package.json`'s `description`.
93
92
  * @param {readonly NavigationEntry[]} options.navigation - The navigation.
94
93
  * @param {string} options.themesDir - From {@link resolveThemesDir}.
95
94
  * @param {boolean} [options.hasTags] - Whether any note the site build walked
@@ -97,11 +96,11 @@ export function resolveThemesDir(rootDir: string): string;
97
96
  * `hasTags`. Defaults to `false` — no tagged note, no taxonomy pages.
98
97
  * @returns {Record<string, any>} The configuration Hugo reads.
99
98
  * @throws {TypeError} When `homepage` fails `checkHomepage`, or the
100
- * configuration declares no `packageBuild.manifest.title`.
99
+ * configuration declares no `packageBuild.manifest.title`, no
100
+ * `site.description`, or no `site.assets`.
101
101
  */
102
- export function hugoConfig({ config, description, navigation, themesDir, hasTags }: {
102
+ export function hugoConfig({ config, navigation, themesDir, hasTags }: {
103
103
  config: object;
104
- description?: string | undefined;
105
104
  navigation: readonly NavigationEntry[];
106
105
  themesDir: string;
107
106
  hasTags?: boolean | undefined;
@@ -116,10 +115,9 @@ export function hugoToml(generated: Record<string, unknown>): string;
116
115
  /**
117
116
  * The Hugo configuration, every source read from the repository.
118
117
  *
119
- * Reads `package.json`, the cached navigation and the installed theme's
120
- * location, and composes them with {@link hugoConfig}. Nothing is written, so
121
- * a caller can run this before touching the output tree and fail with it
122
- * intact.
118
+ * Reads the cached navigation and the installed theme's location, and
119
+ * composes them with {@link hugoConfig}. Nothing is written, so a caller can
120
+ * run this before touching the output tree and fail with it intact.
123
121
  *
124
122
  * @param {object} config - The resolved build configuration.
125
123
  * @param {object} [options] - Options.
@@ -187,11 +187,13 @@ export function publishedRelationships(relationships: Record<string, unknown>):
187
187
  * Three kinds of key end up in the result:
188
188
  *
189
189
  * - **Declared** — everything in `packageBuild.manifest`, emitted unchanged, so
190
- * a key Foundry adds later needs no release of this package.
190
+ * a key Foundry adds later needs no release of this package. The one
191
+ * exception is `descriptionHtml`, folded into the description below rather
192
+ * than surviving under its own name.
191
193
  * - **Derived** — the identity, the description, the release addresses, the
192
194
  * version, the Foundry and system compatibility ranges, and the pack list.
193
- * These are refused if also declared: an authored copy would be overwritten
194
- * and the two would disagree with nothing to say so.
195
+ * These are refused if also declared (`description` directly; `descriptionHtml`
196
+ * is how it is authored) and the two would disagree with nothing to say so.
195
197
  * - **Computed** — namespaced `flags` a repository works out for itself, merged
196
198
  * over any it declared.
197
199
  *
@@ -18,14 +18,14 @@
18
18
  * @param {boolean} [opts.pdf] - Whether to build the book that ships beside the
19
19
  * archive. `true` by default; `false` skips the build and reports the skip.
20
20
  * @returns {Promise<{zip: string, manifest: string, metadata?: string,
21
- * pdf?: string, pdfFindings: object[], pdfSkipped: string|null,
21
+ * schema?: string, pdf?: string, pdfFindings: object[], pdfSkipped: string|null,
22
22
  * bytes: number, version: string}>} The paths written, what the book build
23
23
  * found, the archive's size, and the version the manifest declares.
24
- * `metadata` is absent when the manifest advertises no content index, and
25
- * `pdf` is absent when no book was written. `pdfSkipped` is `null` when a
26
- * book was built, and otherwise the reason none was — itself `null` when the
27
- * book builder could not be loaded, which is reported through
28
- * `pdfFindings`.
24
+ * `metadata` is absent when the manifest advertises no content index,
25
+ * `schema` is absent when the stage carries no `schema.json`, and `pdf` is
26
+ * absent when no book was written. `pdfSkipped` is `null` when a book was
27
+ * built, and otherwise the reason none was itself `null` when the book
28
+ * builder could not be loaded, which is reported through `pdfFindings`.
29
29
  * @throws {Error} When the stage has no manifest — there is nothing to release,
30
30
  * and an archive without one installs as nothing.
31
31
  */
@@ -39,6 +39,7 @@ export function packRelease({ stageDir, outDir, artifact, metadataDir, pdf, }?:
39
39
  zip: string;
40
40
  manifest: string;
41
41
  metadata?: string;
42
+ schema?: string;
42
43
  pdf?: string;
43
44
  pdfFindings: object[];
44
45
  pdfSkipped: string | null;