@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
@@ -0,0 +1,290 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * Diffing a package's published item addresses against a released one (#66).
16
+ *
17
+ * A package's `(type, shortcode)` addresses are a **published interface**.
18
+ * Every satellite that declares `itemCatalog: true` assembles its beings out of
19
+ * them — `attribute:str`, `skill:awar`, `weapongear:Tabri` — resolving each one
20
+ * against the Item packs of the release its `compatibility.verified` pins. So
21
+ * renaming a shortcode is a breaking change to something other repositories
22
+ * consume, and until this module there was nothing that noticed: the check that
23
+ * got made was a repository-local grep, which cannot see the other
24
+ * repositories and reports the reassuring answer.
25
+ *
26
+ * `sohl` renamed one weapon's shortcode from `Tabri` to `Taburi` two days after
27
+ * the `v0.8.2` tag, on the stated ground that "nothing referenced the old
28
+ * value, so the rename is self-contained". True of that repository. Both
29
+ * satellites pin `v0.8.2` and address `weapongear:Tabri` on their copy of the
30
+ * same character — five lookups that resolve today and fail the moment either
31
+ * pin moves, with an error reading like a missing item.
32
+ *
33
+ * **The comparison is release-to-release, in the repository doing the
34
+ * renaming.** The alternative — checking a consumer's addresses against its
35
+ * pinned release — already exists and already fails the build (`no predefined
36
+ * item for "weapongear:Taburi"`); what it lacks is an explanation, and it
37
+ * cannot honestly produce one, because at the point of the miss all it holds is
38
+ * the address string. It has no document id and no name to match a candidate
39
+ * against, so any successor it named would be a guess at a similar-looking
40
+ * string. Here both sides are whole documents, so the question is decidable.
41
+ *
42
+ * **A rename is told from a removal by the document id, and that is an identity
43
+ * match rather than an inference.** A note authors its `_id` in frontmatter; it
44
+ * is not derived from the shortcode, and the `Tabri` → `Taburi` commit changed
45
+ * the shortcode alone. So an address that disappeared while its document is
46
+ * still published elsewhere *is* a rename — not "probably" one. When the id is
47
+ * published under no address at all, that is all this can say: **withdrawn**,
48
+ * with no successor named. A split, a deletion and a merge are indistinguish-
49
+ * able from one another at that point, and inventing a "did you mean" from
50
+ * string similarity would be worse than saying nothing, because a wrong one
51
+ * sends the reader to the wrong fix.
52
+ *
53
+ * **Severity is decided per case.** A withdrawal is legitimate — content is
54
+ * allowed to be retired — so it is reported and does not fail a build. A rename
55
+ * is equally legitimate as a decision (#1397's charset rule forces some), which
56
+ * is why it does not fail one either; what it must not do is happen in silence.
57
+ * A caller that wants a gate passes `error` and treats any finding as one.
58
+ *
59
+ * Item packs only, because that is the address space consumers resolve
60
+ * against: {@link foreignItemCatalogDirs} extracts nothing else, and a being's
61
+ * embedded items are the only cross-package resolution by `(type, shortcode)`.
62
+ *
63
+ * @module
64
+ */
65
+
66
+ import fs from "node:fs";
67
+ import path from "node:path";
68
+
69
+ import { formatDiagnostic, positionInFrontmatter } from "./diagnostics.mjs";
70
+ import { positionOfLiteral } from "./diagnostics.mjs";
71
+ import { walkMarkdownTree } from "./helpers.mjs";
72
+
73
+ /**
74
+ * The address space a set of compiled Item pack directories publishes.
75
+ *
76
+ * The directories are read as one space for the same reason the actors pass
77
+ * reads them as one: a being names an item by `(type, shortcode)` and never by
78
+ * the pack it happens to ship in. Both sides of a diff are built by this one
79
+ * function, so a released catalogue extracted by `deps fetch` and a freshly
80
+ * compiled pack are indexed identically and a difference between them is a real
81
+ * one rather than an artefact of two readers.
82
+ *
83
+ * A missing directory throws rather than reading as an empty space: an empty
84
+ * baseline would report every address in the package as withdrawn, and an empty
85
+ * current side would report every address as gone — the loudest possible
86
+ * output from the quietest possible mistake.
87
+ *
88
+ * @param {readonly string[]} dirs - Directories of item JSON.
89
+ * @returns {Map<string, {id: string, name: string, type: string, shortcode: string, file: string}>}
90
+ * Every item, keyed `type:shortcode`.
91
+ */
92
+ export function readItemAddresses(dirs) {
93
+ const space = new Map();
94
+ for (const dir of dirs) {
95
+ if (!fs.existsSync(dir)) {
96
+ throw new Error(
97
+ `Item source directory ${dir} does not exist — an address ` +
98
+ `diff reads compiled Item pack output, so those packs ` +
99
+ `must be compiled (or the catalogue fetched) first`,
100
+ );
101
+ }
102
+ for (const name of fs.readdirSync(dir)) {
103
+ if (!name.endsWith(".json")) continue;
104
+ if (name.startsWith("folder_")) continue;
105
+ const file = path.join(dir, name);
106
+ let doc;
107
+ try {
108
+ doc = JSON.parse(fs.readFileSync(file, "utf8"));
109
+ } catch {
110
+ // Unparseable output is the compile's problem to report, not
111
+ // this pass's; skipping it here loses one address rather than
112
+ // failing a diff that has nothing to do with it.
113
+ continue;
114
+ }
115
+ const shortcode = doc?.system?.shortcode;
116
+ if (!doc?.type || !shortcode || !doc?._id) continue;
117
+ space.set(`${doc.type}:${shortcode}`, {
118
+ id: doc._id,
119
+ name: doc.name ?? "",
120
+ type: doc.type,
121
+ shortcode,
122
+ file,
123
+ });
124
+ }
125
+ }
126
+ return space;
127
+ }
128
+
129
+ /**
130
+ * Every address the baseline published that this build does not.
131
+ *
132
+ * An address that merely *arrived* is not a finding: adding one breaks nobody.
133
+ * The arrivals are read only to answer the one question that matters about a
134
+ * departure — is the document still here under another name?
135
+ *
136
+ * @param {Map<string, object>} baseline - The released address space.
137
+ * @param {Map<string, object>} current - This build's address space.
138
+ * @param {object} opts
139
+ * @param {string} opts.baseline - What the baseline is, for the message —
140
+ * conventionally `<package>@<version>`.
141
+ * @returns {Array<object>} One finding per departed address, in address order
142
+ * so two runs read the same. `kind` is `"renamed"` (with `to`) or
143
+ * `"withdrawn"`.
144
+ */
145
+ export function diffItemAddresses(baseline, current, { baseline: label }) {
146
+ // A baseline that yields no address at all cannot produce a finding, so it
147
+ // reports a clean result for every possible input — the one failure a check
148
+ // like this can never catch, and the same one `foreign-manifests.mjs` exists
149
+ // to stop. It is a real state, not a hypothetical: `sohl-kethira-basic@0.5.3`
150
+ // shipped 307 items carrying no `system.shortcode` between them.
151
+ if (!baseline.size) {
152
+ throw new Error(
153
+ `${label} publishes no addressable item — no document in its Item ` +
154
+ `packs carries a \`system.shortcode\`. A diff against it can ` +
155
+ `only report that nothing changed, whatever this build does, ` +
156
+ `so it is refused rather than passed`,
157
+ );
158
+ }
159
+ // Where each still-published document lives now. Built once: a rename is
160
+ // decided by identity, so this is the whole evidence base.
161
+ const currentById = new Map();
162
+ for (const [address, entry] of current) {
163
+ if (!currentById.has(entry.id)) currentById.set(entry.id, address);
164
+ }
165
+
166
+ const findings = [];
167
+ for (const [address, entry] of baseline) {
168
+ if (current.has(address)) continue;
169
+ const to = currentById.get(entry.id);
170
+ findings.push({
171
+ kind: to ? "renamed" : "withdrawn",
172
+ address,
173
+ ...(to ? { to } : {}),
174
+ id: entry.id,
175
+ name: entry.name,
176
+ shortcode: entry.shortcode,
177
+ baselineFile: entry.file,
178
+ baseline: label,
179
+ });
180
+ }
181
+ findings.sort((a, b) => (a.address < b.address ? -1 : 1));
182
+ return findings;
183
+ }
184
+
185
+ /**
186
+ * Every content note in a tree, indexed by the document id it authors.
187
+ *
188
+ * The address space is read from compiled output because that is what actually
189
+ * ships; the tree is read only to place a finding somewhere a reader can open
190
+ * and fix it. Each source answers the question it is good at, and the id is the
191
+ * exact key that joins them.
192
+ *
193
+ * @param {string} contentBase - Root of the content tree.
194
+ * @param {object} [opts]
195
+ * @param {readonly string[]} [opts.skipDirectories] - Passed to the walk.
196
+ * @returns {Map<string, string>} Document id → the note's absolute path.
197
+ */
198
+ export function noteFilesById(contentBase, { skipDirectories } = {}) {
199
+ const byId = new Map();
200
+ const walkOpts = skipDirectories ? { skipDirectories } : undefined;
201
+ for (const { frontmatter: fm, absPath } of walkMarkdownTree(
202
+ contentBase,
203
+ walkOpts,
204
+ )) {
205
+ if (fm?.id && !byId.has(fm.id)) byId.set(fm.id, absPath);
206
+ }
207
+ return byId;
208
+ }
209
+
210
+ /**
211
+ * Where to send the reader for one finding.
212
+ *
213
+ * A rename is fixed in the note that made it, so a finding whose id is still in
214
+ * this tree is reported at that note's `shortcode:` line — the line the author
215
+ * just edited. A withdrawal has no such note by definition, so it degrades to
216
+ * the baseline document, which is the only artefact left that records the
217
+ * address existing. When neither is readable the position is **dropped**, never
218
+ * defaulted to `1:1`.
219
+ *
220
+ * @param {object} finding - One finding from {@link diffItemAddresses}.
221
+ * @param {Map<string, string>} noteFiles - From {@link noteFilesById}.
222
+ * @returns {{file?: string, line?: number, column?: number}} Spreadable
223
+ * position fields for {@link formatDiagnostic}.
224
+ */
225
+ export function locateAddressFinding(finding, noteFiles) {
226
+ const note = noteFiles?.get(finding.id);
227
+ if (note) {
228
+ try {
229
+ const raw = fs.readFileSync(note, "utf8");
230
+ return { file: note, ...positionInFrontmatter(raw, "shortcode") };
231
+ } catch {
232
+ return { file: note };
233
+ }
234
+ }
235
+ if (!finding.baselineFile) return {};
236
+ try {
237
+ const raw = fs.readFileSync(finding.baselineFile, "utf8");
238
+ return {
239
+ file: finding.baselineFile,
240
+ ...positionOfLiteral(raw, `"${finding.shortcode}"`),
241
+ };
242
+ } catch {
243
+ return { file: finding.baselineFile };
244
+ }
245
+ }
246
+
247
+ /**
248
+ * What one finding says, without a locator or a severity.
249
+ *
250
+ * The rename message names the identity it matched on, because that is what
251
+ * separates this from a spelling suggestion: the reader can check the id in
252
+ * both artefacts. The withdrawal message names no successor, because none is
253
+ * known — and says so, rather than leaving the reader to wonder whether one was
254
+ * looked for.
255
+ *
256
+ * @param {object} finding - One finding from {@link diffItemAddresses}.
257
+ * @returns {string} The message.
258
+ */
259
+ export function addressFindingMessage(finding) {
260
+ if (finding.kind === "renamed") {
261
+ return (
262
+ `since ${finding.baseline}, ${finding.address} is no longer ` +
263
+ `published; the same document (${finding.id}) is now published ` +
264
+ `as ${finding.to}. Every package that resolves ${finding.address} ` +
265
+ `breaks when it moves past ${finding.baseline}`
266
+ );
267
+ }
268
+ return (
269
+ `since ${finding.baseline}, ${finding.address} is no longer ` +
270
+ `published, and its document (${finding.id}) is published under no ` +
271
+ `other address`
272
+ );
273
+ }
274
+
275
+ /**
276
+ * One finding, in the standard `file:line:column: severity: message` form.
277
+ *
278
+ * @param {object} finding - One finding from {@link diffItemAddresses}.
279
+ * @param {{file?: string, line?: number, column?: number}} at - From
280
+ * {@link locateAddressFinding}.
281
+ * @param {"warning"|"error"} [severity] - `error` when the caller is gating.
282
+ * @returns {string} The formatted diagnostic, path first on the line.
283
+ */
284
+ export function formatAddressFinding(finding, at, severity = "warning") {
285
+ return formatDiagnostic({
286
+ ...at,
287
+ severity,
288
+ message: addressFindingMessage(finding),
289
+ });
290
+ }
@@ -73,6 +73,7 @@ import {
73
73
  convertNoteWikilinks,
74
74
  collectContentDocs,
75
75
  expandNoteTables,
76
+ statsForPack,
76
77
  } from "./helpers.mjs";
77
78
  import { emitDiagnostic } from "./diagnostics.mjs";
78
79
  import { assertNoDeclaredPackage } from "./note-package.mjs";
@@ -237,6 +238,7 @@ export class BasePackCompiler {
237
238
  dest,
238
239
  folderResolver = () => null,
239
240
  packName,
241
+ packSystem = null,
240
242
  docType,
241
243
  router,
242
244
  routingReporter = false,
@@ -262,11 +264,34 @@ export class BasePackCompiler {
262
264
  writable: false,
263
265
  });
264
266
  this.packName = packName;
267
+ this.packSystem = packSystem;
265
268
  this.docType = docType;
266
269
  this.router = router;
267
270
  this.routingReporter = routingReporter;
268
271
  }
269
272
 
273
+ /**
274
+ * The `_stats` block every entry this pass emits is stamped with (#48).
275
+ *
276
+ * Per pack rather than per package, because a module may ship the same
277
+ * content for two systems — `harn-ensemble` has an `actors-hm3` pack and an
278
+ * `actors-sohl` pack — and those documents were built against different
279
+ * system versions. A single global block stamped both identically.
280
+ *
281
+ * Memoised on the instance: one pass, one pack, one system, so the block is
282
+ * constant for the life of the compiler. The previous module-level memo
283
+ * could not be, because it was shared across passes for different packs.
284
+ *
285
+ * @returns {object} The block, built once per compiler.
286
+ */
287
+ get stats() {
288
+ this.#stats ??= statsForPack(this.packSystem);
289
+ return this.#stats;
290
+ }
291
+
292
+ /** @type {object|undefined} */
293
+ #stats;
294
+
270
295
  /**
271
296
  * Whether this pass's pack is the one a claimed note belongs in.
272
297
  *
@@ -58,6 +58,7 @@ import {
58
58
  canonicalKey,
59
59
  loadForeignManifests,
60
60
  manifestsComplete,
61
+ PACKAGE_BASE,
61
62
  readCanonicalKey,
62
63
  } from "./kb-manifest.mjs";
63
64
  import { frontmatterWikilinks, slugify } from "./web-wikilinks.mjs";
@@ -336,6 +337,74 @@ export function buildLinkIndex(
336
337
  */
337
338
  const SITE_HOST = /^(?:[a-z0-9-]+\.)*heroiclands\.org$/i;
338
339
 
340
+ /**
341
+ * Every package landing this build can name, as `package` → base (#87).
342
+ *
343
+ * **A landing needs no manifest, and that is what makes it work.** The link
344
+ * manifest indexes content notes, and a homepage is deliberately not one — it
345
+ * compiles to no document and is entered in no manifest. The reading that
346
+ * follows from this, and that left a hardcoded URL as the only authored form,
347
+ * is that a landing therefore cannot be addressed. It does not follow: a
348
+ * landing's address is not a *note's* address but the **package's**, and
349
+ * {@link PACKAGE_BASE} already records where each package is served. That is a
350
+ * frozen constant vendored into every repository, so consulting it walks no
351
+ * tree, reads no manifest and builds no index — which is precisely why the
352
+ * mechanism survives `homepage` mode, where the licensing fence means none of
353
+ * those exist.
354
+ *
355
+ * The roster is consulted **for landings only**. Widening the package set the
356
+ * other rules read would make them offer manifest-based advice about packages
357
+ * no manifest is vendored for.
358
+ *
359
+ * @param {string} ownPackage - The package this build publishes.
360
+ * @param {Iterable<string>} manifestPackages - Packages a vendored manifest
361
+ * names, which are addressable whether or not the roster lists them.
362
+ * @returns {Map<string, string>} Package to base, each base slash-terminated.
363
+ */
364
+ function landingBases(ownPackage, manifestPackages) {
365
+ const bases = new Map();
366
+ // Convention first, roster second, so a package the roster relocates is
367
+ // recorded at the relocated base rather than the default one.
368
+ for (const pkg of [ownPackage, ...manifestPackages]) {
369
+ if (pkg) bases.set(pkg, `/${pkg}/`);
370
+ }
371
+ for (const [pkg, base] of Object.entries(PACKAGE_BASE)) {
372
+ if (typeof base === "string" && base.endsWith("/")) {
373
+ bases.set(pkg, base);
374
+ }
375
+ }
376
+ return bases;
377
+ }
378
+
379
+ /**
380
+ * The package whose landing an address names, or `null`.
381
+ *
382
+ * Matches the whole path, not a prefix: `/sohl/` is the landing, `/sohl/kb/`
383
+ * is a page inside the package and belongs to the manifest rules instead.
384
+ *
385
+ * @param {string} url - The authored address.
386
+ * @param {Map<string, string>} bases - From {@link landingBases}.
387
+ * @returns {{pkg: string, base: string}|null} The package and its base.
388
+ */
389
+ function landingTarget(url, bases) {
390
+ const value = String(url ?? "").trim();
391
+ if (!value || !/^[a-z][a-z0-9+.-]*:/i.test(value)) return null;
392
+ let parsed;
393
+ try {
394
+ parsed = new URL(value);
395
+ } catch {
396
+ return null;
397
+ }
398
+ if (!/^https?:$/.test(parsed.protocol)) return null;
399
+ if (!SITE_HOST.test(parsed.hostname)) return null;
400
+ const pathname =
401
+ parsed.pathname.endsWith("/") ? parsed.pathname : `${parsed.pathname}/`;
402
+ for (const [pkg, base] of bases) {
403
+ if (pathname === base) return { pkg, base };
404
+ }
405
+ return null;
406
+ }
407
+
339
408
  /**
340
409
  * How an authored address resolves, or `null` for one nothing here can judge.
341
410
  *
@@ -400,9 +469,17 @@ function readAddress(url, packages) {
400
469
  * and what replaced it, so this is a fact rather than a guess — and it is
401
470
  * exactly the SoHL defect.
402
471
  * - A **hardcoded absolute URL** into this package's own prefix, or into one a
403
- * vendored manifest names. Both have a better form to write, which is why they
404
- * are reported; a bare `/<package>/` is left alone, because a package
405
- * homepage is in no manifest and there is nothing better to write.
472
+ * vendored manifest names. Every one of them has a better form to write, which
473
+ * is why every one is reported including a bare `/<package>/`, which names
474
+ * another package's landing (#87).
475
+ *
476
+ * That last case was exempt until the better form was identified, on the
477
+ * reasoning that a landing is in no link manifest so nothing could resolve it.
478
+ * True, and beside the point: it does not need resolving. A landing's address
479
+ * *is* its package prefix, so `/<package>/` is the absolute URL with the host
480
+ * struck off — host-free, emitted verbatim, and needing no index, which is
481
+ * what lets it hold in homepage-only mode where the tree is never walked. The
482
+ * form was already accepted here; nothing had ever named it as the one to use.
406
483
  * - A **root-relative `url:`**, which the theme's `relURL` prefixes a second
407
484
  * time. `href:` means "already resolved, use verbatim", so the same leading
408
485
  * slash is correct there and is not reported.
@@ -424,6 +501,7 @@ function readAddress(url, packages) {
424
501
  export function auditHomepageLinks(index) {
425
502
  const findings = [];
426
503
  const packages = new Set([index.contentPackage, ...index.packages]);
504
+ const bases = landingBases(index.contentPackage, index.packages);
427
505
 
428
506
  for (const note of index.notes) {
429
507
  if (!isHomepage(note.fm)) continue;
@@ -467,28 +545,43 @@ export function auditHomepageLinks(index) {
467
545
  if (!address) continue;
468
546
  const { shape, segments, prefix } = address;
469
547
 
470
- if (shape === "absolute" && prefix) {
471
- // A bare `/<package>/` is a package's homepage, which is in no
472
- // link manifest and has no relative form from another package.
473
- // A finding with no fix is noise.
548
+ // Landings first, and by the roster rather than by the manifest
549
+ // package set: a landing is addressable in a repository that
550
+ // vendors no manifest at all, which is the case the fence creates
551
+ // and the case this rule exists for (#87).
552
+ const landing = landingTarget(url, bases);
553
+ if (landing) {
554
+ report(
555
+ field,
556
+ url,
557
+ url,
558
+ occurrence,
559
+ `hardcoded absolute URL to ` +
560
+ (landing.pkg === index.contentPackage ?
561
+ `this package's own landing`
562
+ : `package "${landing.pkg}"'s landing`) +
563
+ ` — write "${landing.base}", which names no host, is ` +
564
+ `emitted verbatim, and resolves through the package ` +
565
+ `roster rather than through an index, so it holds ` +
566
+ `where no content tree is walked`,
567
+ );
568
+ } else if (shape === "absolute" && prefix) {
474
569
  const rest = segments.slice(1).join("/");
475
- if (rest) {
476
- report(
477
- field,
478
- url,
479
- url,
480
- occurrence,
481
- prefix === index.contentPackage ?
482
- `hardcoded absolute URL into this package's own ` +
483
- `address write the package-relative ` +
484
- `"${rest}/", which the landing resolves ` +
485
- `against the site so the page follows the mount`
486
- : `hardcoded absolute URL into package "${prefix}" ` +
487
- `— resolve it through that package's link ` +
488
- `manifest, whose entries carry the address, so a ` +
489
- `relocation does not leave this page behind`,
490
- );
491
- }
570
+ report(
571
+ field,
572
+ url,
573
+ url,
574
+ occurrence,
575
+ prefix === index.contentPackage ?
576
+ `hardcoded absolute URL into this package's own ` +
577
+ `address write the package-relative ` +
578
+ `"${rest}/", which the landing resolves ` +
579
+ `against the site so the page follows the mount`
580
+ : `hardcoded absolute URL into package "${prefix}" ` +
581
+ `— resolve it through that package's link ` +
582
+ `manifest, whose entries carry the address, so a ` +
583
+ `relocation does not leave this page behind`,
584
+ );
492
585
  } else if (shape === "rooted" && kind === "url") {
493
586
  const rest =
494
587
  prefix ? segments.slice(1).join("/") : segments.join("/");
@@ -497,9 +590,21 @@ export function auditHomepageLinks(index) {
497
590
  url,
498
591
  url,
499
592
  occurrence,
500
- `url "${url}" is root-relative, but a landing's url: is ` +
501
- `resolved against the site write "${rest}/", or ` +
502
- `href: for an address that is already resolved`,
593
+ // A `url:` is package-relative by construction, so it
594
+ // cannot address anything outside this package at all
595
+ // there is no relative spelling of another package's root.
596
+ // `href:` is the field for an address already resolved.
597
+ !rest ?
598
+ `url "${url}" addresses ` +
599
+ (prefix ?
600
+ `package "${prefix}"'s landing`
601
+ : `the site root`) +
602
+ `, but a landing's url: is package-relative and ` +
603
+ `cannot leave this package — write ` +
604
+ `href: "${url}", which is used verbatim`
605
+ : `url "${url}" is root-relative, but a landing's url: ` +
606
+ `is resolved against the site — write "${rest}/", ` +
607
+ `or href: for an address that is already resolved`,
503
608
  );
504
609
  }
505
610
 
@@ -22,13 +22,18 @@
22
22
  * disagree without anything detecting it, which the canonical-separator
23
23
  * handling already did once on each side.
24
24
  *
25
- * Two rules, both about a note's identity:
25
+ * Three rules, all about a note's identity:
26
26
  *
27
27
  * 1. **Shape** — a `shortcode` is strictly ASCII-alphanumeric. It is the
28
28
  * identity key referenced from saved world data, and it is half of the
29
29
  * `type-shortcode` address, whose parse depends on the separating hyphen
30
30
  * being the only hyphen in the string.
31
31
  * 2. **Uniqueness** — `(type, shortcode)` names one note.
32
+ * 3. **The package's own address** — exactly one note claims `/<package>/`,
33
+ * which is {@link checkHomepageCount} (#52). It belongs here for the same
34
+ * reason the other two do: it is a statement about which note holds which
35
+ * address, it needs no `site:` configuration to decide, and a package with
36
+ * no front page is misconfigured whether or not anyone runs a site build.
32
37
  *
33
38
  * **Nothing here writes.** A check reports and an author fixes.
34
39
  *
@@ -56,6 +61,7 @@ import path from "node:path";
56
61
 
57
62
  import { positionInFrontmatter } from "./diagnostics.mjs";
58
63
  import { walkMarkdownTree } from "./helpers.mjs";
64
+ import { checkHomepageCount, isHomepage } from "./homepage.mjs";
59
65
 
60
66
  /**
61
67
  * The shape every `shortcode` must match: ASCII letters and digits only.
@@ -122,11 +128,16 @@ function collectNotes(contentBase, { skipDirectories } = {}) {
122
128
  * @param {object} [opts]
123
129
  * @param {readonly string[]} [opts.skipDirectories] - Directory names the walk
124
130
  * ignores. Defaults to the configured list.
131
+ * @param {string} [opts.contentPackage] - The package this tree builds, for the
132
+ * homepage rule. Dropped from that finding when unknown rather than guessed.
125
133
  * @returns {{findings: Array<{file: string, line?: number, column?: number,
126
134
  * severity: "error"|"warning", message: string}>, notes: number,
127
135
  * keys: number}} The findings, and what was inspected to produce them.
128
136
  */
129
- export function lintContentTree(contentBase, { skipDirectories } = {}) {
137
+ export function lintContentTree(
138
+ contentBase,
139
+ { skipDirectories, contentPackage } = {},
140
+ ) {
130
141
  const findings = [];
131
142
  const notes = collectNotes(contentBase, { skipDirectories });
132
143
 
@@ -184,6 +195,16 @@ export function lintContentTree(contentBase, { skipDirectories } = {}) {
184
195
  return { findings, notes: 0, keys: 0 };
185
196
  }
186
197
 
198
+ // Deliberately after that return: a tree nobody has established exists has
199
+ // no homepage either, and saying so is noise about the second problem when
200
+ // the first is "check that the content tree is present".
201
+ findings.push(
202
+ ...checkHomepageCount(
203
+ notes.filter((n) => isHomepage(n.fm)),
204
+ { contentBase, contentPackage },
205
+ ),
206
+ );
207
+
187
208
  for (const [key, files] of byKey) {
188
209
  if (files.length < 2) continue;
189
210
  // Reported once per offending note rather than once per key: each note