@heroiclands/package-build 6.0.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +798 -0
  2. package/CONTENT.md +228 -4
  3. package/bin/content-build.mjs +196 -10
  4. package/bin/package-build.mjs +8 -1
  5. package/config.mjs +25 -3
  6. package/content-config.mjs +283 -29
  7. package/engine/address-diff.mjs +290 -0
  8. package/engine/base-compiler.mjs +25 -0
  9. package/engine/content-links.mjs +132 -27
  10. package/engine/content-lint.mjs +23 -2
  11. package/engine/diagnostics.mjs +61 -1
  12. package/engine/frontmatter-lint.mjs +22 -0
  13. package/engine/generate.mjs +10 -5
  14. package/engine/helpers.mjs +38 -0
  15. package/engine/homepage.mjs +206 -2
  16. package/engine/journals.mjs +8 -1
  17. package/engine/macros.mjs +2 -0
  18. package/engine/pack-config.mjs +143 -13
  19. package/engine/prose-lint.mjs +10 -2
  20. package/engine/scenes.mjs +2 -2
  21. package/engine/site-build.mjs +74 -19
  22. package/engine/web-wikilinks.mjs +13 -4
  23. package/engine/wikilink-syntax.mjs +25 -0
  24. package/engine/wikilinks.mjs +6 -3
  25. package/manifest.mjs +37 -2
  26. package/package.json +5 -3
  27. package/sohl/actors.mjs +23 -10
  28. package/sohl/items.mjs +1 -1
  29. package/types/content-config.d.mts +14 -0
  30. package/types/engine/address-diff.d.mts +108 -0
  31. package/types/engine/base-compiler.d.mts +18 -1
  32. package/types/engine/content-links.d.mts +11 -3
  33. package/types/engine/content-lint.d.mts +4 -1
  34. package/types/engine/diagnostics.d.mts +33 -1
  35. package/types/engine/generate.d.mts +3 -2
  36. package/types/engine/helpers.d.mts +29 -3
  37. package/types/engine/homepage.d.mts +117 -0
  38. package/types/engine/journals.d.mts +7 -1
  39. package/types/engine/pack-config.d.mts +22 -0
  40. package/types/engine/prose-lint.d.mts +10 -2
  41. package/types/engine/site-build.d.mts +28 -3
  42. package/types/engine/wikilink-syntax.d.mts +24 -0
  43. package/types/sohl/actors.d.mts +3 -3
package/CONTENT.md CHANGED
@@ -155,7 +155,9 @@ the file sits in, fills the optional halves with their defaults
155
155
  manifest switches off and `publish.site` at its `homepage` floor),
156
156
  derives `assetRoot`, `packDirectories`, `itemTypes` and `docEntryTypes`, and
157
157
  freezes the result. A malformed configuration throws a `TypeError` naming the
158
- offending field, so it fails at load rather than as an empty pack much later.
158
+ offending field and the line and column it was written on, in the
159
+ [located form](#diagnostics) — so it fails at load rather than as an empty pack
160
+ much later.
159
161
 
160
162
  **Four values are derived rather than authored**, because each is something a
161
163
  file can be asked for rather than told:
@@ -474,6 +476,7 @@ npx content-build markdown [paths..] [--fix]
474
476
  npx content-build manifest [root] [--out <dir>]
475
477
  npx content-build site [--out <dir>]
476
478
  npx content-build reachability <dir> [file] [--index <shortcode>]
479
+ npx content-build addresses diff --from <zip|dir> [--strict]
477
480
  ```
478
481
 
479
482
  | Command | What it does |
@@ -487,6 +490,7 @@ npx content-build reachability <dir> [file] [--index <shortcode>]
487
490
  | `manifest` | Emit this package's cross-package link manifest. See [Publishing a link manifest](#publishing-a-link-manifest). |
488
491
  | `site` | Publish the content tree as a website. See [Publishing a website](#publishing-a-website). |
489
492
  | `reachability` | Walk outward from an index note and report what no path reaches, for a tree meant to be navigable from one entry point. |
493
+ | `addresses` | Report every published item address this build has stopped publishing. See [Diffing published addresses](#diffing-published-addresses). |
490
494
 
491
495
  Every path, pack name and root it needs comes from the consuming repository's
492
496
  `package-build.config.yaml`, so the usual invocation takes no arguments beyond
@@ -505,7 +509,7 @@ npx content-build lint # the configured `paths.content`
505
509
  npx content-build lint some/tree # or a tree named outright
506
510
  ```
507
511
 
508
- Checks the two rules every note's **identity** is authored against, and reports
512
+ Checks the three rules every note's **identity** is authored against, and reports
509
513
  each finding in the located form below:
510
514
 
511
515
  - **Shape** — a `shortcode` is strictly ASCII-alphanumeric. It is the identity
@@ -514,6 +518,8 @@ each finding in the located form below:
514
518
  - **Uniqueness** — `(type, shortcode)` names one note. A document is addressed
515
519
  across _every_ pack of its document type, so routing two same-address notes to
516
520
  different packs with `pack:` does not separate them.
521
+ - **The package's own address** — exactly one note claims `/<package>/`. See
522
+ [Exactly one homepage](#exactly-one-homepage) below.
517
523
 
518
524
  It compiles nothing, opens no LevelDB and needs no Foundry manifest, so it runs
519
525
  in about a second and can gate a commit. An empty or untyped tree **fails**
@@ -527,6 +533,48 @@ addressed by the package rather than by a slug — so a package in
527
533
  permanently unkeyed. That tree passes; a tree holding no notes at all still
528
534
  fails.
529
535
 
536
+ ### Exactly one homepage
537
+
538
+ A content tree declares **exactly one** `type: homepage` note (#52). Zero is an
539
+ error and two is an error, at the same severity, because they are one defect: a
540
+ package whose front page is not the page a person chose.
541
+
542
+ - _Zero_ and the package serves nothing at `/<package>/` — the failure the
543
+ authored homepage exists to prevent, and a silent one: the site build reports
544
+ `wrote 0 homepage(s)` and exits 0.
545
+ - _Two_ and it serves a page nobody chose. Every homepage is written to the same
546
+ `_index.md`, so the second overwrites the first and the front page is decided
547
+ by the order the walk reached the files in — by _filename_, on a type whose
548
+ whole point is that it is routed by frontmatter. There is no "first wins"
549
+ convention to fall back on, so nothing can pick the right one.
550
+
551
+ Neither has a safe default, so neither is a warning: a build that proceeded past
552
+ either would publish the wrong front page while reporting success, which is
553
+ exactly what a warning tolerates.
554
+
555
+ **Where it fires: `lint` _and_ `site`.** No single command reaches every
556
+ package — `HarnMaster-3-FoundryVTT` runs `content-build site` and no
557
+ `content-build lint`; `sohl-thalorna` runs `content-build lint` and its own site
558
+ builder — so a rule in one of them is a rule two of the six packages do not
559
+ have. Both call the same function, so there is one rule and two call sites
560
+ rather than two rules. In the site build it runs **before the output tree is
561
+ cleared**, so a failing gate cannot destroy a good site to report a bad tree.
562
+
563
+ **It does not vary by `publish.site`.** That setting chooses whether the
564
+ _content_ surfaces are published; the homepage is the floor beneath both modes.
565
+ The lint call site reads no `site:` block at all, and so could not vary by mode
566
+ even if the rule wanted to.
567
+
568
+ Zero has no file to name, so the locator is the **content root** — a real path,
569
+ and the directory the note has to be added to. No line or column is invented for
570
+ it. Two is reported once per offending note, located at its own `type:` value and
571
+ naming the other, because each note is a place an author has to open and edit:
572
+
573
+ ```text
574
+ assets/content: error: holds no `type: homepage` note, so package "sohl" publishes nothing at its own address /sohl/ — a package's front page is one authored note in this tree, routed by `type:` rather than by filename
575
+ assets/content/homepage.md:3:7: error: duplicate `type: homepage` note, also declared by assets/content/Landing.md; a package has one front page, at /sohl/, and every homepage is written to the same `_index.md` — so the one the walk reaches last silently overwrites the rest
576
+ ```
577
+
530
578
  ### Frontmatter, against the schema its type declares
531
579
 
532
580
  The same command also checks that each note's `sohl:` block is what its **type**
@@ -570,6 +618,55 @@ does not exist. Removing it was verified output-neutral first: across 1,735
570
618
  stripped notes, `package compile` produced byte-identical `build/packs-json` and
571
619
  the site build byte-identical `site/content`.
572
620
 
621
+ ### The homepage carries no address of its own
622
+
623
+ A note's URL derives from `name.full` and its identity from
624
+ `(type, shortcode)`. The homepage is the one page for which neither holds: it
625
+ publishes at `/<package>/`, fixed by the package id. So `content-build lint`
626
+ **refuses** `name`, `shortcode` and `id` on a `type: homepage` note (#53) rather
627
+ than ignoring them — an author fluent in the conventions writes them here
628
+ expecting exactly what they do everywhere else, and gets none of it.
629
+
630
+ They were never inert, which is why ignoring them was the wrong answer. A
631
+ `shortcode` puts the note in the address index and in the `dataview` link
632
+ universe, so `[[homepage-<shortcode>]]` resolves _green_ — to `homepage/<slug>/`,
633
+ an address derived from `name.full` that the site build never writes, because a
634
+ homepage goes to `_index.md` at the package root. A build reporting a live link
635
+ to a 404 is worse than one saying nothing. It also inflates this command's own
636
+ address tally, so the lint and the link manifest disagree about what the package
637
+ publishes: SoHL's tree reports `1607 address(es) across 1607 note(s)` with a
638
+ `shortcode` on its landing and `1606 address(es) across 1607 note(s)` without
639
+ one. That is one defect, not two — the tally is only ever printed on a clean run,
640
+ so refusing the field is what makes the count honest.
641
+
642
+ Each finding is located at the offending key and says what the field would have
643
+ decided, not merely that it does not belong:
644
+
645
+ ```text
646
+ assets/content/homepage.md:26:1: error: `shortcode` decides nothing on a `type: homepage` note: this page's address is the package's own, `/<package>/`, fixed by the package id. It is not ignored either — it puts the note in the address index, so `[[homepage-<shortcode>]]` resolves to a page the site build never writes. Delete it
647
+ assets/content/homepage.md:28:1: error: `name` decides nothing on a `type: homepage` note: a page's slug derives from `name.full`, and a homepage's destination is fixed — it is written to `_index.md` at the package's own address, `/<package>/`. Write `title:` for what the page is called, and delete `name`
648
+ ```
649
+
650
+ **A named class, not an allow-list.** The documented envelope is `type` plus an
651
+ optional `title`, with `landing`, `description` and `banner` legitimate beside
652
+ them — but an unknown top-level key is **not** refused, and that boundary is the
653
+ decision rather than an omission. A homepage's frontmatter is emitted into the
654
+ published page, so an unrecognised key is a Hugo or theme parameter this build
655
+ has never heard of and has no standing to reject; a closed list would make every
656
+ new theme parameter wait on a package-build release. What is refused is the
657
+ specific class that makes a false claim about _where this page is_. `aliases` is
658
+ not in that class either: it is already dropped from every emitted page, so
659
+ authoring one here is the same no-op it is anywhere else.
660
+
661
+ **Where it fires: `content-build lint` only.** Unlike a rule about the shape of
662
+ the _tree_, which the site build has its own reason to gate on, this is a
663
+ _frontmatter-schema_ rule and `content-build site` runs none of them — wiring in one type's field
664
+ rule would have the site build refuse `shortcode` on a homepage while accepting
665
+ `weight: heavy` on a weapon. The gap that leaves is `HarnMaster-3-FoundryVTT`,
666
+ which runs no `content-build lint` at all and so receives no frontmatter finding
667
+ of any kind; that is a missing script in that repository, not a rule to duplicate
668
+ one at a time.
669
+
573
670
  ### The homepage's own links
574
671
 
575
672
  The homepage is the page a reader arrives at, and until #54 it was the one page
@@ -789,6 +886,10 @@ title: HârnMaster Kethira Basic # optional; defaults to packageBuild.manifest.t
789
886
  What the module is, which system it needs, how to install it.
790
887
  ```
791
888
 
889
+ A package declares **exactly one** of these, and both `content-build lint` and
890
+ `content-build site` require it — see
891
+ [Exactly one homepage](#exactly-one-homepage).
892
+
792
893
  That is the whole envelope. A homepage **compiles into no compendium
793
894
  document**, appears in no pack and in no link manifest, and is addressed by the
794
895
  _package_ rather than by its own name — so `name.full`, `shortcode` and `id`
@@ -853,7 +954,10 @@ site:
853
954
  trees:
854
955
  - { from: kb/dev-docs, section: dev-docs }
855
956
  sections:
856
- being: { title: Beings, banner: banners/creature.webp }
957
+ being:
958
+ title: Beings
959
+ banner: banners/creature.webp
960
+ description: Folk, animals and the things that walk the world.
857
961
  readmeSections:
858
962
  dev-docs: { title: Developer Documentation, banner: banners/dev-docs.webp }
859
963
  ```
@@ -863,7 +967,7 @@ site:
863
967
  | `out` | The Hugo content root. **Required** in both modes, and wiped on every run — see below. |
864
968
  | `base` | Where the package is served. Defaults to `/<contentPackage>/`. |
865
969
  | `packages` | Which content packages this site renders. Defaults to its own. |
866
- | `sections` | Landing title and hero per section, so a landing matches the card that links to it. |
970
+ | `sections` | What each section says about itself on its landing see below. |
867
971
  | `readmeSections` | The same, for a section whose landing comes from a `README`. |
868
972
  | `landing` | Frontmatter for the mount's own `_index.md`. Passed through — the vocabulary is the theme's. |
869
973
  | `backfillSections` | Write a bare `_index.md` for any other section directly under the mount. |
@@ -871,6 +975,42 @@ site:
871
975
  | `pass` | A named bundle of this repository's own body rewrites. |
872
976
  | `passOptions` | That bundle's options. |
873
977
 
978
+ ### What a section may declare
979
+
980
+ A generated section landing is the **only** place a section can describe itself.
981
+ A content package authors no `_index.md` for `weapongear` or `affliction`, so
982
+ the file the theme reads is the one this build writes from `sections` — and
983
+ whatever that entry may carry is the whole of what the section can say.
984
+
985
+ | Key | Required | What it does |
986
+ | ------------- | -------- | ------------------------------------------------------------------------- |
987
+ | `title` | yes | The landing's heading, so it matches the card that links to it. |
988
+ | `banner` | no | The hero image, resolved as a CDN asset like any other `banner:`. |
989
+ | `description` | no | The hero standfirst under the heading, and the blurb a landing card uses. |
990
+
991
+ `readmeSections` takes the same three, for a section whose landing comes from a
992
+ `README` rather than from nothing. What the section declares wins over what the
993
+ `README` happens to carry — the landing has to match the card that links to it.
994
+
995
+ **The vocabulary is closed, and a key outside it is refused by name:**
996
+
997
+ ```text
998
+ package-build config: `site.sections.affliction.descrption` is not a
999
+ recognized option (expected one of: title, banner, description).
1000
+ ```
1001
+
1002
+ That refusal is the point. `landing` is passed through unvalidated because it is
1003
+ written once, for the mount, in one landing template's own vocabulary. A section
1004
+ entry is written fourteen to twenty times per build against a contract every
1005
+ package and every section shares, so an unbounded one would let a mistyped
1006
+ `descrption:` publish into front matter, be read by nobody, and say nothing to
1007
+ anyone. Refusing it costs one line here when the vocabulary genuinely grows, and
1008
+ buys a build that cannot quietly emit a key no theme reads.
1009
+
1010
+ The **writers** name no keys: a section's `_index.md` is whatever the entry
1011
+ resolved to, `title` first. So extending the vocabulary is a change to the
1012
+ schema alone, and the two can no longer drift apart.
1013
+
874
1014
  ### Why `out` is required
875
1015
 
876
1016
  The output tree is a build artifact and is **deleted on every run**, so that a
@@ -931,6 +1071,72 @@ None of them exits the process from inside the library; the command decides. Tha
931
1071
  is what makes them testable, which the consumer scripts' inline `process.exit`
932
1072
  calls were not.
933
1073
 
1074
+ ## Diffing published addresses
1075
+
1076
+ ```bash
1077
+ gh release download v0.8.2 -p system.zip -D build/baseline
1078
+ npx content-build addresses diff --from build/baseline/system.zip
1079
+ npx content-build addresses diff --from build/baseline/system.zip --strict
1080
+ ```
1081
+
1082
+ A package's `(type, shortcode)` addresses are a **published interface**. Every
1083
+ satellite declaring `itemCatalog: true` assembles its beings out of them —
1084
+ `attribute:str`, `skill:awar`, `weapongear:Tabri` — resolving each against the
1085
+ Item packs of the release its `compatibility.verified` pins. Renaming a
1086
+ shortcode is therefore a breaking change to something other repositories
1087
+ consume, and it used to cost nothing and produce no signal: the check that got
1088
+ made was a repository-local grep, which cannot see the other repositories and
1089
+ reports the reassuring answer.
1090
+
1091
+ `sohl` renamed one weapon's shortcode from `Tabri` to `Taburi` two days after
1092
+ the `v0.8.2` tag, on the stated ground that "nothing referenced the old value".
1093
+ True of that repository. Both satellites pin `v0.8.2` and address
1094
+ `weapongear:Tabri` on their copy of the same character — five lookups that
1095
+ resolve today and fail the moment either pin moves, reporting a missing item.
1096
+
1097
+ **A rename is told from a removal by the document id, and that is an identity
1098
+ match rather than an inference.** A note authors its `_id` in frontmatter; it is
1099
+ not derived from the shortcode, so it survives a rename. An address that
1100
+ disappeared while its document is still published elsewhere _is_ a rename:
1101
+
1102
+ ```text
1103
+ assets/content/Weapons/Melee/Taburi.md:12:1: warning: since sohl@0.8.2, weapongear:Tabri is no longer published; the same document (s5D6QJbw7ZbETxdN) is now published as weapongear:Taburi. Every package that resolves weapongear:Tabri breaks when it moves past sohl@0.8.2
1104
+ ```
1105
+
1106
+ Where the id is published under no address at all, that is all it says —
1107
+ **withdrawn**, with no successor named. A split, a deletion and a merge are
1108
+ indistinguishable at that point, and a "did you mean" guessed from string
1109
+ similarity would be worse than silence, because a wrong one sends the reader to
1110
+ the wrong fix.
1111
+
1112
+ | Finding | What it means | Severity |
1113
+ | ----------- | -------------------------------------------------------- | --------------------------------------- |
1114
+ | `renamed` | Address gone; the same document publishes under another. | `warning` — legitimate, but not silent |
1115
+ | `withdrawn` | Address gone; its document publishes under none. | `warning` — retiring content is allowed |
1116
+
1117
+ Neither fails a build. Retiring content is legitimate, and so is renaming — the
1118
+ shortcode charset rule forces some. What a rename must not do is happen without
1119
+ anyone noticing. `--strict` reports both as errors and exits non-zero, for a
1120
+ release workflow that wants a gate.
1121
+
1122
+ **A finding is placed against the note, not the pack it was read from.** The
1123
+ address space is read from compiled output because that is what actually ships;
1124
+ the content tree is read only to find the note carrying the id, so the reader
1125
+ lands on the `shortcode:` line they just edited. A withdrawal has no such note,
1126
+ so it degrades to the baseline document — and where neither is readable the
1127
+ position is dropped rather than guessed.
1128
+
1129
+ **The baseline is named, never derived, and never downloaded.** `--from` takes
1130
+ the artifact for the same reason `deps fetch --from` does: a command that
1131
+ reaches the network on its own is not reproducible and fails strangely offline.
1132
+ A baseline that yields **no** addressable item is refused rather than read as
1133
+ "nothing changed" — it would report a clean result for every possible input,
1134
+ which is the one failure a check like this can never catch.
1135
+
1136
+ Item packs only, because that is the address space consumers resolve against: a
1137
+ being's embedded items are the only cross-package resolution by
1138
+ `(type, shortcode)`.
1139
+
934
1140
  ## Diagnostics
935
1141
 
936
1142
  Every warning or error a build reports **about a content note** is emitted in the
@@ -956,6 +1162,24 @@ Two rules keep it that way, both in `engine/diagnostics.mjs`:
956
1162
  meaningless, `file: …` when only the note is known. Nothing defaults to
957
1163
  `1:1`, which would send a reader to the frontmatter every time.
958
1164
 
1165
+ **A configuration error is located the same way.** Every check in
1166
+ `content-config.mjs` and `config.mjs` reports through one `fail()`, naming the
1167
+ offending key's dotted path — a good description and a bad locator, in a file
1168
+ that runs to hundreds of lines with sibling entries flow-mapped onto one. The
1169
+ path now rides on the error, and the loader that read the file resolves it
1170
+ against the YAML, so all of them come out located:
1171
+
1172
+ ```text
1173
+ package-build.config.yaml:382:64: error: package-build config: `site.sections.being.descrption` is not a recognized option (expected one of: title, banner).
1174
+ ```
1175
+
1176
+ The same two rules apply. A key the file never declares — a required one that is
1177
+ simply missing — has no node of its own, so the position names the **mapping it
1178
+ belongs in** and no further out; a missing _top-level_ key has nothing above it
1179
+ but the document, and an `.mjs` configuration has no YAML to resolve a path
1180
+ against at all. Both report `package-build.config.yaml: error: …`, the file
1181
+ without a line, rather than a line that would be wrong.
1182
+
959
1183
  Establishing a position at all takes three corrections, applied only where they
960
1184
  hold — see `positionInBody`. A body offset is not a file line until the
961
1185
  frontmatter's lines are added (`bodyLine`); the trim that strips the body can
@@ -45,6 +45,7 @@
45
45
  * npx content-build manifest [root] [--out <dir>]
46
46
  * npx content-build site [--out <dir>]
47
47
  * npx content-build reachability <dir> [file] [--index <shortcode>]
48
+ * npx content-build addresses diff --from <zip|dir> [--strict]
48
49
  *
49
50
  * In a consuming repository, wrapped as npm scripts — SoHL spells them:
50
51
  * npm run build:compiledb // → … package compile (all packs)
@@ -92,6 +93,14 @@ import {
92
93
  walkReachability,
93
94
  } from "../engine/content-links.mjs";
94
95
  import { emitDiagnostic, positionOfLiteral } from "../engine/diagnostics.mjs";
96
+ import {
97
+ readItemAddresses,
98
+ diffItemAddresses,
99
+ noteFilesById,
100
+ locateAddressFinding,
101
+ addressFindingMessage,
102
+ } from "../engine/address-diff.mjs";
103
+ import { itemPackJsonDirs } from "../engine/generate.mjs";
95
104
  import {
96
105
  formatUnaddressableFinding,
97
106
  unaddressableForeignPackages,
@@ -147,6 +156,25 @@ prefix.apply(log, {
147
156
  },
148
157
  });
149
158
 
159
+ /**
160
+ * Report a command's failure.
161
+ *
162
+ * A configuration error carries its own `file:line:column: error: ` locator
163
+ * (#95), and `loglevel`'s `[timestamp] [ERROR]:` prefix occupies exactly the
164
+ * position a parser reads the path from — so a located failure is printed
165
+ * unprefixed, as `emitDiagnostic` prints every other finding. Everything else
166
+ * is ordinary prose and keeps the log line it always had.
167
+ *
168
+ * @param {unknown} err - What was thrown.
169
+ * @returns {void}
170
+ */
171
+ function reportFailure(err) {
172
+ const message = err instanceof Error ? err.message : String(err);
173
+ if (/** @type {{located?: boolean}} */ (err)?.located)
174
+ console.error(message);
175
+ else log.error(message);
176
+ }
177
+
150
178
  const argv = yargs(hideBin(process.argv))
151
179
  .command(packageCommand())
152
180
  .command(depsCommand())
@@ -158,6 +186,7 @@ const argv = yargs(hideBin(process.argv))
158
186
  .command(manifestCommand())
159
187
  .command(siteCommand())
160
188
  .command(reachabilityCommand())
189
+ .command(addressesCommand())
161
190
  .version(ownVersion())
162
191
  .help()
163
192
  .alias("help", "h")
@@ -293,7 +322,7 @@ function docsCommand() {
293
322
  process.stdout.write(page);
294
323
  }
295
324
  } catch (err) {
296
- log.error(err.message);
325
+ reportFailure(err);
297
326
  process.exitCode = 1;
298
327
  }
299
328
  },
@@ -340,7 +369,11 @@ function lintCommand() {
340
369
  const root = argv.root ?? config.paths.content;
341
370
  const manifestDir = argv.manifests ?? config.paths.manifests;
342
371
 
343
- const addresses = lintContentTree(root);
372
+ // The package is passed for the homepage rule (#52), which
373
+ // names the address a tree with no front page fails to serve.
374
+ const addresses = lintContentTree(root, {
375
+ contentPackage: config.contentPackage,
376
+ });
344
377
  // One index, built once, for the reference check. It is the
345
378
  // same resolver the wikilink audit uses, so a frontmatter
346
379
  // reference and a body link answer the same way.
@@ -371,7 +404,7 @@ function lintCommand() {
371
404
  );
372
405
  }
373
406
  } catch (err) {
374
- log.error(err.message);
407
+ reportFailure(err);
375
408
  process.exitCode = 1;
376
409
  }
377
410
  },
@@ -452,7 +485,7 @@ function formatCommand() {
452
485
  log.info(`Formatting is clean (${checked} file(s)).`);
453
486
  }
454
487
  } catch (err) {
455
- log.error(err.message);
488
+ reportFailure(err);
456
489
  process.exitCode = 1;
457
490
  }
458
491
  },
@@ -501,7 +534,7 @@ function markdownCommand() {
501
534
  log.info("Markdown is clean.");
502
535
  }
503
536
  } catch (err) {
504
- log.error(err.message);
537
+ reportFailure(err);
505
538
  process.exitCode = 1;
506
539
  }
507
540
  },
@@ -649,7 +682,7 @@ function linksCommand() {
649
682
  );
650
683
  }
651
684
  } catch (err) {
652
- log.error(err.message);
685
+ reportFailure(err);
653
686
  process.exitCode = 1;
654
687
  }
655
688
  },
@@ -725,7 +758,7 @@ function manifestCommand() {
725
758
  });
726
759
  }
727
760
  } catch (err) {
728
- log.error(err.message);
761
+ reportFailure(err);
729
762
  process.exitCode = 1;
730
763
  }
731
764
  },
@@ -767,6 +800,11 @@ function siteCommand() {
767
800
  });
768
801
  const { gates } = result;
769
802
 
803
+ // First, because it is decided before the tree is walked and
804
+ // before the output is cleared: a package with no front page,
805
+ // or two competing for it, has nothing to say about its pages
806
+ // yet (#52).
807
+ for (const f of gates.homepages) emitDiagnostic(f);
770
808
  for (const f of gates.frontmatterLinks) {
771
809
  emitDiagnostic({
772
810
  file: f.file,
@@ -849,7 +887,7 @@ function siteCommand() {
849
887
  `landing(s) to ${path.relative(process.cwd(), s.out)}`,
850
888
  );
851
889
  } catch (err) {
852
- log.error(err.message);
890
+ reportFailure(err);
853
891
  process.exitCode = 1;
854
892
  }
855
893
  },
@@ -943,7 +981,7 @@ function reachabilityCommand() {
943
981
  );
944
982
  }
945
983
  } catch (err) {
946
- log.error(err.message);
984
+ reportFailure(err);
947
985
  process.exitCode = 1;
948
986
  }
949
987
  },
@@ -1032,6 +1070,154 @@ function depsCommand() {
1032
1070
  const count = await fetchAllCatalogs(config);
1033
1071
  if (count)
1034
1072
  log.info(`Fetched ${count} dependency catalogue(s).`);
1073
+ } catch (err) {
1074
+ reportFailure(err);
1075
+ process.exitCode = 1;
1076
+ }
1077
+ },
1078
+ };
1079
+ }
1080
+
1081
+ /**
1082
+ * `addresses diff` — report every published `(type, shortcode)` this build no
1083
+ * longer publishes, against a released artifact.
1084
+ *
1085
+ * The address space is a published interface (see `engine/address-diff.mjs`),
1086
+ * and renaming a shortcode used to cost nothing and produce no signal. This is
1087
+ * the signal, emitted in the repository doing the renaming while the change is
1088
+ * still in front of the author.
1089
+ *
1090
+ * **Its own command rather than a step of `package compile`.** It reads a
1091
+ * *second* artifact that the compile knows nothing about and that has to be
1092
+ * obtained separately, and it is a question about a release rather than about a
1093
+ * build — a repository between releases has nothing to compare against.
1094
+ *
1095
+ * **The baseline is named, never derived, and never downloaded.** `--from`
1096
+ * takes the artifact — the `.zip` a release publishes, or a directory built
1097
+ * from one — for the same reason `deps fetch --from` does: a command that
1098
+ * reaches the network on its own is not reproducible and fails strangely
1099
+ * offline. In a release workflow the artifact is one line ahead of it:
1100
+ *
1101
+ * ```sh
1102
+ * gh release download v0.8.2 -p system.zip -D build/baseline
1103
+ * npx content-build addresses diff --from build/baseline/system.zip
1104
+ * ```
1105
+ *
1106
+ * @param {object} config - The resolved build configuration.
1107
+ * @param {{from: string, strict?: boolean}} argv - The parsed arguments.
1108
+ * @returns {Promise<void>}
1109
+ */
1110
+ async function diffAddresses(config, argv) {
1111
+ // The baseline is this package's own earlier self, so it is cached beside
1112
+ // the dependency catalogues rather than among them — a release of `sohl`
1113
+ // is not a dependency of `sohl`, and filing it as one would collide with a
1114
+ // genuine relationship of the same id.
1115
+ const cacheConfig = {
1116
+ ...config,
1117
+ paths: {
1118
+ ...config.paths,
1119
+ foreignCache: path.join(
1120
+ path.dirname(config.paths.foreignCache),
1121
+ "baseline",
1122
+ ),
1123
+ },
1124
+ };
1125
+ const dir = await fetchCatalogFromPath(
1126
+ cacheConfig,
1127
+ { id: config.foundryPackage },
1128
+ argv.from,
1129
+ );
1130
+ // `<id>@<version>`, which is what the diagnostics name the baseline by.
1131
+ const label = path.basename(dir);
1132
+
1133
+ const itemsRoot = path.join(dir, "items");
1134
+ const baselineDirs = fs
1135
+ .readdirSync(itemsRoot)
1136
+ .map((name) => path.join(itemsRoot, name));
1137
+ const currentDirs = itemPackJsonDirs(config);
1138
+ if (!currentDirs.length) {
1139
+ throw new Error(
1140
+ 'this repository declares no pack of type "Item", so it ' +
1141
+ "publishes no item addresses to diff",
1142
+ );
1143
+ }
1144
+
1145
+ const findings = diffItemAddresses(
1146
+ readItemAddresses(baselineDirs),
1147
+ readItemAddresses(currentDirs),
1148
+ { baseline: label },
1149
+ );
1150
+ if (!findings.length) {
1151
+ log.info(`Every address ${label} published is still published.`);
1152
+ return;
1153
+ }
1154
+
1155
+ // A rename is fixed in the note that made it, so findings are placed
1156
+ // against the tree rather than against the compiled output they were read
1157
+ // from.
1158
+ const noteFiles = noteFilesById(config.paths.content);
1159
+ const severity = argv.strict ? "error" : "warning";
1160
+ for (const finding of findings) {
1161
+ emitDiagnostic({
1162
+ ...locateAddressFinding(finding, noteFiles),
1163
+ severity,
1164
+ message: addressFindingMessage(finding),
1165
+ });
1166
+ }
1167
+ const renamed = findings.filter((f) => f.kind === "renamed").length;
1168
+ log.info(
1169
+ `${findings.length} address(es) ${label} published are no longer ` +
1170
+ `published (${renamed} renamed, ${findings.length - renamed} ` +
1171
+ `withdrawn).`,
1172
+ );
1173
+ // Retiring content is legitimate and so is renaming; neither fails a build
1174
+ // unless the caller asked for a gate.
1175
+ if (argv.strict) process.exitCode = 1;
1176
+ }
1177
+
1178
+ function addressesCommand() {
1179
+ return {
1180
+ command: "addresses <action>",
1181
+ describe:
1182
+ "Compare the addresses this build publishes against a release's",
1183
+ builder: (yargs) => {
1184
+ // Required, for the reason every other action is (#57): an
1185
+ // optional one exits 0 having compared nothing.
1186
+ yargs.positional("action", {
1187
+ describe: "The action to perform.",
1188
+ type: "string",
1189
+ choices: ["diff"],
1190
+ });
1191
+ yargs.option("from", {
1192
+ describe:
1193
+ "The released artifact to compare against — a package " +
1194
+ "zip, or the directory it was built from.",
1195
+ type: "string",
1196
+ });
1197
+ yargs.option("strict", {
1198
+ describe:
1199
+ "Report findings as errors and exit non-zero, for a " +
1200
+ "release workflow that gates on them.",
1201
+ type: "boolean",
1202
+ default: false,
1203
+ });
1204
+ // Checked while parsing, so it fails in a repository whose
1205
+ // configuration a handler would never get far enough to resolve.
1206
+ yargs.check((parsed) => {
1207
+ if (parsed.action === "diff" && !parsed.from) {
1208
+ throw new Error(
1209
+ "`addresses diff` needs `--from <zip|dir>`: the " +
1210
+ "release to compare against. There is nothing to " +
1211
+ "derive it from — a repository between releases " +
1212
+ "has no previous artifact on disk.",
1213
+ );
1214
+ }
1215
+ return true;
1216
+ });
1217
+ },
1218
+ handler: async (argv) => {
1219
+ try {
1220
+ await diffAddresses(loadPackConfig(), argv);
1035
1221
  } catch (err) {
1036
1222
  log.error(err.message);
1037
1223
  process.exitCode = 1;
@@ -1095,7 +1281,7 @@ function packageCommand() {
1095
1281
  });
1096
1282
  }
1097
1283
  } catch (err) {
1098
- log.error(err.message);
1284
+ reportFailure(err);
1099
1285
  process.exitCode = 1;
1100
1286
  }
1101
1287
  },
@@ -135,7 +135,14 @@ function ownVersion() {
135
135
  */
136
136
  function die(err) {
137
137
  const message = err instanceof Error ? err.message : String(err);
138
- console.error(`package-build: ${message}`);
138
+ // A located diagnostic already starts with `file:line:column:`, which is
139
+ // exactly the position a parser reads the path from — prefixing it would
140
+ // yield a filename no editor can open (#95).
141
+ console.error(
142
+ /** @type {{located?: boolean}} */ (err)?.located ? message : (
143
+ `package-build: ${message}`
144
+ ),
145
+ );
139
146
  process.exit(1);
140
147
  }
141
148