@heroiclands/package-build 10.0.1 → 11.1.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 (47) hide show
  1. package/CHANGELOG.md +375 -0
  2. package/CONTENT.md +234 -71
  3. package/MIGRATING.md +64 -0
  4. package/bin/content-build.mjs +79 -75
  5. package/content-config.mjs +28 -0
  6. package/docs/content-format.md +90 -67
  7. package/engine/base-compiler.mjs +7 -1
  8. package/engine/content-address.mjs +71 -18
  9. package/engine/content-format-check.mjs +1 -1
  10. package/engine/content-links.mjs +93 -112
  11. package/engine/content-lint.mjs +14 -10
  12. package/engine/content-slug.mjs +39 -105
  13. package/engine/diagnostics.mjs +16 -2
  14. package/engine/frontmatter-lint.mjs +161 -18
  15. package/engine/helpers.mjs +31 -68
  16. package/engine/homepage.mjs +131 -86
  17. package/engine/index.mjs +2 -5
  18. package/engine/manifest-emit.mjs +23 -4
  19. package/engine/note-vocabulary.mjs +58 -1
  20. package/engine/retired-fields.mjs +117 -6
  21. package/engine/site-build.mjs +182 -59
  22. package/engine/site-index.mjs +57 -102
  23. package/engine/web-wikilinks.mjs +183 -127
  24. package/engine/wikilink-syntax.mjs +174 -34
  25. package/engine/wikilinks.mjs +159 -117
  26. package/package.json +1 -1
  27. package/types/content-config.d.mts +24 -0
  28. package/types/engine/base-compiler.d.mts +1 -1
  29. package/types/engine/content-address.d.mts +46 -14
  30. package/types/engine/content-links.d.mts +13 -17
  31. package/types/engine/content-slug.d.mts +11 -48
  32. package/types/engine/diagnostics.d.mts +14 -1
  33. package/types/engine/frontmatter-lint.d.mts +27 -2
  34. package/types/engine/helpers.d.mts +4 -3
  35. package/types/engine/homepage.d.mts +96 -60
  36. package/types/engine/index.d.mts +0 -1
  37. package/types/engine/note-vocabulary.d.mts +43 -0
  38. package/types/engine/retired-fields.d.mts +78 -1
  39. package/types/engine/site-build.d.mts +70 -17
  40. package/types/engine/site-index.d.mts +19 -21
  41. package/types/engine/web-wikilinks.d.mts +29 -28
  42. package/types/engine/wikilink-syntax.d.mts +126 -40
  43. package/types/engine/wikilinks.d.mts +29 -24
  44. package/engine/abbreviations.mjs +0 -0
  45. package/engine/alias-index.mjs +0 -153
  46. package/types/engine/abbreviations.d.mts +0 -44
  47. package/types/engine/alias-index.d.mts +0 -122
@@ -18,9 +18,17 @@
18
18
  * knowledgebase build renders the page, and the link manifest records the
19
19
  * address other packages link to. Stating it twice is how a manifest comes to
20
20
  * assert a URL that resolves at build time and 404s for the reader.
21
+ *
22
+ * **A page's URL is its address** — `<package>/<type>-<shortcode>/` (#181). It
23
+ * used to be derived from `name.full`, which made a display string load-bearing
24
+ * in three separate ways: a rename moved the URL and nothing redirected, two
25
+ * notes in one section could derive the same URL so a uniqueness check had to
26
+ * run, and a long name had to be abbreviated through a word table to keep the
27
+ * result short. `(type, shortcode)` is unique within a package by rule
28
+ * (`engine/content-lint.mjs`), so the address is **unique by construction** —
29
+ * there is no check to run, and no rename to survive.
21
30
  */
22
31
 
23
- import { contentSlug } from "./content-slug.mjs";
24
32
  // The scheme vocabulary is part of the configuration contract — a
25
33
  // repository names its scheme in `package-build.config.yaml` — so it is
26
34
  // declared beside the rest of that vocabulary rather than here, and this
@@ -49,22 +57,54 @@ export function sectionOf(fm) {
49
57
  }
50
58
 
51
59
  /**
52
- * A note's address below the knowledgebase mount, e.g. `affliction/aconite/`.
60
+ * The single path segment a note is addressed by: `type-shortcode`.
61
+ *
62
+ * Lowercased, so it is exactly the tail of the note's canonical key
63
+ * (`canonicalKey` in `engine/kb-manifest.mjs` lowercases too) — which is what
64
+ * makes a manifest entry's `path` derivable from the key it is filed under
65
+ * rather than transported beside it.
66
+ *
67
+ * The hyphen is a separator and never occurs inside a segment: a shortcode is
68
+ * `^[A-Za-z0-9]+$` (`ADDRESS_SEGMENT_PATTERN`, enforced by `content-lint.mjs`)
69
+ * and a type is a bare word. That is the same charset guarantee positional key
70
+ * parsing rests on, so the address and the key cannot disagree about where one
71
+ * ends and the other begins.
72
+ *
73
+ * @param {object} fm - Parsed frontmatter.
74
+ * @returns {string} The address segment, e.g. `weapongear-dagger`.
75
+ * @throws {Error} When the note declares no type or no shortcode — either way
76
+ * it has no address, which is reported rather than papered over: inventing
77
+ * one would publish a page nothing can link to and record a manifest entry
78
+ * pointing at it.
79
+ */
80
+ export function addressSlug(fm) {
81
+ const type = typeof fm?.type === "string" ? fm.type.trim() : "";
82
+ if (!type) {
83
+ throw new Error("note declares no type, so it has no address");
84
+ }
85
+ const shortcode = typeof fm?.shortcode === "string" ? fm.shortcode.trim() : "";
86
+ if (!shortcode) {
87
+ throw new Error(
88
+ `note declares no shortcode, so it has no address — a page is ` +
89
+ `addressed as "${type}-<shortcode>"`,
90
+ );
91
+ }
92
+ return `${type}-${shortcode}`.toLowerCase();
93
+ }
94
+
95
+ /**
96
+ * A note's address below the knowledgebase mount, e.g. `affliction-aconite/`.
53
97
  *
54
98
  * A `README.md` **is** its section's landing page rather than a page within it,
55
- * so it addresses the section itself and has no slug of its own.
99
+ * so it addresses the section itself and has no address of its own.
56
100
  *
57
101
  * @param {object} fm - Parsed frontmatter.
58
- * @param {string} name - The note's display name; the slug derives from it
59
- * (#1278), never from the shortcode, which is identity rather than
60
- * presentation.
61
102
  * @param {boolean} isReadme - Whether the file is a `README.md`.
62
- * @returns {string} The section-relative address, with a trailing slash.
63
- * @throws {Error} When the name yields no usable slug.
103
+ * @returns {string} The mount-relative address, with a trailing slash.
104
+ * @throws {Error} When the note has no address.
64
105
  */
65
- export function contentAddress(fm, name, isReadme) {
66
- const sec = sectionOf(fm);
67
- return isReadme ? `${sec}/` : `${sec}/${contentSlug(name)}/`;
106
+ export function contentAddress(fm, isReadme) {
107
+ return isReadme ? `${sectionOf(fm)}/` : `${addressSlug(fm)}/`;
68
108
  }
69
109
 
70
110
  /**
@@ -92,17 +132,30 @@ function landingOf(fm, isReadme, landing) {
92
132
  }
93
133
 
94
134
  /**
95
- * A note's address relative to its **package**, e.g. `kb/affliction/aconite/`.
135
+ * A note's address relative to its **package**, e.g. `affliction-aconite/`.
96
136
  *
97
137
  * This is the form the link manifest records and the site build emits pages at,
98
138
  * and it is one function because those two must agree — a manifest asserting an
99
139
  * address the site does not publish resolves at build time and 404s for the
100
140
  * reader, which is the failure this module exists to prevent.
101
141
  *
142
+ * **The prefix does not apply to a page's own address.** `prefix` says where the
143
+ * content tree *mounts inside the package* — where its section directories and
144
+ * their landing pages live — and a landing page is addressed by that mount
145
+ * (`kb/rules/`). An ordinary page is addressed by `(type, shortcode)`, which is
146
+ * a package-wide identity and takes no mount: `sohl` publishes
147
+ * `/sohl/affliction-aconite/` while its section landings stay at
148
+ * `/sohl/kb/affliction/`. The `type-` half is what keeps that flat namespace
149
+ * clear of the package's fixed mounts — `/<package>/` for the landing,
150
+ * `/<package>/api/` for generated API docs, neither of which contains a hyphen
151
+ * or names a type.
152
+ *
153
+ * **The section still decides where the *file* is written**, which is why a
154
+ * note without one still has no address: Hugo derives a section from a page's
155
+ * directory rather than from its URL, so a page with nowhere to be filed is a
156
+ * page with no section landing, no `.CurrentSection` and no per-section layout.
157
+ *
102
158
  * @param {object} fm - Parsed frontmatter.
103
- * @param {string} name - The note's display name; a page slug derives from it
104
- * (#1278), never from the shortcode, which is identity rather than
105
- * presentation.
106
159
  * @param {object} [options] - Options.
107
160
  * @param {boolean} [options.isReadme] - Whether the file is a `README.md`.
108
161
  * @param {{prefix?: string, landing?: string}} [options.scheme] - The
@@ -110,11 +163,11 @@ function landingOf(fm, isReadme, landing) {
110
163
  * @returns {string} The package-relative address, with a trailing slash and no
111
164
  * leading one.
112
165
  * @throws {Error} When the note has no address — no section, a landing page
113
- * naming no section, or a name yielding no usable slug. Each is a note that
166
+ * naming no section, or no shortcode to be addressed by. Each is a note that
114
167
  * is not published, and inventing an address for one would put a dead entry
115
168
  * in the manifest.
116
169
  */
117
- export function packageAddress(fm, name, { isReadme = false, scheme } = {}) {
170
+ export function packageAddress(fm, { isReadme = false, scheme } = {}) {
118
171
  const { prefix, landing } = { ...DEFAULT_ADDRESS_SCHEME, ...scheme };
119
172
  if (!LANDING_RULES.includes(landing)) {
120
173
  throw new Error(
@@ -133,5 +186,5 @@ export function packageAddress(fm, name, { isReadme = false, scheme } = {}) {
133
186
  if (typeof sec !== "string" || !sec) {
134
187
  throw new Error(`type "${fm.type}" has no section`);
135
188
  }
136
- return `${prefix}${sec}/${contentSlug(name)}/`;
189
+ return `${addressSlug(fm)}/`;
137
190
  }
@@ -269,7 +269,7 @@ function nearest(key, candidates) {
269
269
  * @type {ReadonlySet<string>}
270
270
  */
271
271
  export const NOTE_LEVEL_KEYS = Object.freeze(
272
- new Set(["id", "type", "subType", "shortcode", "description", "tags", "name", "aliases"]),
272
+ new Set(["id", "type", "subType", "shortcode", "description", "tags", "name"]),
273
273
  );
274
274
 
275
275
  /** Whether a value is a plain object a block could be written as. */
@@ -22,20 +22,17 @@
22
22
  * anchor slug; nothing checks that a heading declaring that slug exists. A
23
23
  * link to an anchor nobody declares compiles cleanly, emits an enricher, and
24
24
  * dead-ends for the reader.
25
- * 2. **A dead address.** A *piped* target — `[[x|…]]` — is an address, and one
26
- * resolving to no note is a typo. So is one that does not parse as an
27
- * address at all: the pipe says the author meant one.
28
- * 3. **A dead alias.** An *unpiped* target `[[x]]` names a note of the
29
- * source's own type. One that finds nothing may be a worldbuilding
30
- * placeholder, so it is reported as a warning rather than a failure; it is a
31
- * different problem from a dead address and reads differently.
25
+ * 2. **A dead address.** Every link is an address, and one resolving to no note
26
+ * is a typo. So is a target that does not parse as an address at all.
27
+ * 3. **An unlabelled link.** `[[x]]` addresses nothing: the alias namespace it
28
+ * used to name is retired (#180), and a shortcode is an address rather than
29
+ * prose, so the link has neither a resolvable target nor text to show. The
30
+ * correction is always `[[type-shortcode|Text]]`.
32
31
  * 4. **A wikilink authored in frontmatter.** Both builds walk a note's *body*
33
32
  * and copy frontmatter through verbatim, so a link written in a
34
33
  * `description` is never resolved and publishes as literal `[[…]]` text.
35
34
  * Frontmatter is data: a `WikiLink` field is parsed by the address grammar
36
35
  * and a bracketed link there is a finding naming the note and the field.
37
- * 5. **An alias two notes of one type both claim.** It used to be deleted
38
- * silently, so the pair resolved to nothing and nobody was told (#131).
39
36
  *
40
37
  * **This resolves links the way the builds do**, calling the same
41
38
  * {@link readQualifier} and the same {@link parseWikilink} rather than a second
@@ -72,9 +69,8 @@ import {
72
69
  import { frontmatterWikilinks, slugify } from "./web-wikilinks.mjs";
73
70
  import { homepageAddresses, isHomepage } from "./homepage.mjs";
74
71
  import { RETIRED_TYPES } from "./ids.mjs";
75
- import { parseWikilink, resolvesAsAddress, WIKILINK } from "./wikilink-syntax.mjs";
72
+ import { parseWikilink, WIKILINK } from "./wikilink-syntax.mjs";
76
73
  import { readQualifier } from "./wikilinks.mjs";
77
- import { aliasesOf, aliasKey, indexAliases } from "./alias-index.mjs";
78
74
 
79
75
  /**
80
76
  * Every `{#anchor}` a note declares on a heading.
@@ -154,21 +150,6 @@ export function buildLinkIndex(contentBase, { manifestDir, skipDirectories } = {
154
150
  }
155
151
  }
156
152
 
157
- // The alias half of the two namespaces, built by the shared rule so the
158
- // checker, the pack build and the site build cannot disagree about what a
159
- // bare `[[…]]` can name (#131).
160
- const {
161
- byKey: byAlias,
162
- claims: aliasClaims,
163
- collisions: aliasCollisions,
164
- } = indexAliases(
165
- notes.map((note) => ({
166
- type: note.type,
167
- aliases: aliasesOf(note.fm),
168
- value: note,
169
- })),
170
- );
171
-
172
153
  const types = new Set(notes.map((n) => n.type));
173
154
 
174
155
  // A foreign package may use a type this tree has never seen, so its types
@@ -201,8 +182,8 @@ export function buildLinkIndex(contentBase, { manifestDir, skipDirectories } = {
201
182
  * @param {object} note - A note from this index.
202
183
  * @returns {Array<{target: string, anchor: string, text: string,
203
184
  * occurrence: number, labelled: boolean}>} `target` is `""` for a
204
- * same-page `[[#anchor]]`; `labelled` says which namespace the target
205
- * belongs to (#131).
185
+ * same-page `[[#anchor]]`; `labelled` says whether the link carries the
186
+ * `|` every link must have (#180).
206
187
  */
207
188
  function linksOf(note) {
208
189
  let body = note.body;
@@ -234,28 +215,12 @@ export function buildLinkIndex(contentBase, { manifestDir, skipDirectories } = {
234
215
  anchor,
235
216
  text: all,
236
217
  occurrence,
237
- labelled: resolvesAsAddress(parsed),
218
+ labelled: parsed.labelled,
238
219
  });
239
220
  }
240
221
  return out;
241
222
  }
242
223
 
243
- /**
244
- * The note an **alias** names, or `undefined`.
245
- *
246
- * Scoped to the *source* note's own type, so one word may be an alias in
247
- * several types without colliding. An alias two same-type notes claim is
248
- * absent from the index entirely — see {@link indexAliases} — so this can
249
- * never resolve to whichever was walked first.
250
- *
251
- * @param {object} note - The note the link is written in.
252
- * @param {string} target - The link target, anchor already removed.
253
- * @returns {object|undefined} The note it names.
254
- */
255
- function resolveAlias(note, target) {
256
- return byAlias.get(aliasKey(note.type, target));
257
- }
258
-
259
224
  /**
260
225
  * The note an **address** names, or `undefined`.
261
226
  *
@@ -278,45 +243,47 @@ export function buildLinkIndex(contentBase, { manifestDir, skipDirectories } = {
278
243
  }
279
244
 
280
245
  /**
281
- * Resolve a link target the way both builds do, or `undefined`.
246
+ * Every foreign manifest entry an address names, in package order.
282
247
  *
283
- * **The pipe chooses the namespace, and there is no fallback either way**
284
- * (#131) see {@link resolvesAsAddress} for why. The caller therefore has
285
- * to say which form was authored; it is a required argument rather than a
286
- * defaulted one, because either default would silently resolve half the
287
- * corpus through the wrong namespace.
248
+ * A **package-qualified** address names at most one, by construction. An
249
+ * unqualified one names no package, so it resolves against any foreign one
250
+ * that publishes it and only when exactly one does. Two claimants make it
251
+ * ambiguous, which is a different finding from resolving nowhere and has a
252
+ * different fix, so the count is returned rather than collapsed here
253
+ * (#184).
288
254
  *
289
- * @param {object} note - The note the link is written in.
290
255
  * @param {string} target - The link target.
291
- * @param {boolean} labelled - Whether the link carried a `|`.
292
- * @returns {object|undefined} The note it names.
256
+ * @returns {object[]} The foreign entries, each carrying its `package`.
293
257
  */
294
- function resolve(note, target, labelled) {
295
- return labelled ? resolveAddress(target) : resolveAlias(note, target);
258
+ function foreignHits(target) {
259
+ const q = readQualifier(target, types, packages);
260
+ if (!q || q.reason) return [];
261
+ if (q.package) {
262
+ const one = foreign.index.get(canonicalKey(q.package, q.type, q.shortcode));
263
+ return one ? [one] : [];
264
+ }
265
+ const type = String(q.type).toLowerCase();
266
+ const shortcode = String(q.shortcode).toLowerCase();
267
+ return [...foreign.index]
268
+ .filter(([k]) => {
269
+ const parts = readCanonicalKey(k);
270
+ return parts?.type === type && parts.shortcode === shortcode;
271
+ })
272
+ .map(([, v]) => v);
296
273
  }
297
274
 
298
275
  /**
299
276
  * The manifest entry a qualified address names in another package, or null.
300
277
  *
278
+ * The single-hit reading of {@link foreignHits}: an address two packages
279
+ * publish names neither.
280
+ *
301
281
  * @param {string} target - The link target.
302
282
  * @returns {object|null} The foreign entry.
303
283
  */
304
284
  function manifestHit(target) {
305
- const q = readQualifier(target, types, packages);
306
- if (!q || q.reason) return null;
307
- if (q.package) {
308
- return foreign.index.get(canonicalKey(q.package, q.type, q.shortcode)) ?? null;
309
- }
310
- // A bare address names no package, so it resolves against any foreign
311
- // one that publishes it. Claimed by two, it is ambiguous and the author
312
- // must write the qualified form.
313
- const type = String(q.type).toLowerCase();
314
- const shortcode = String(q.shortcode).toLowerCase();
315
- const hits = [...foreign.index].filter(([k]) => {
316
- const parts = readCanonicalKey(k);
317
- return parts?.type === type && parts.shortcode === shortcode;
318
- });
319
- return hits.length === 1 ? hits[0][1] : null;
285
+ const hits = foreignHits(target);
286
+ return hits.length === 1 ? hits[0] : null;
320
287
  }
321
288
 
322
289
  return {
@@ -333,15 +300,16 @@ export function buildLinkIndex(contentBase, { manifestDir, skipDirectories } = {
333
300
  contentPackage: pkg,
334
301
  foreign,
335
302
  manifests: manifestsComplete(localPackages, foreign.packages),
336
- /** Every note claiming each type-scoped alias, colliding ones included. */
337
- aliasClaims,
338
- /** One entry per alias two or more same-type notes claim (#131). */
339
- aliasCollisions,
340
303
  linksOf,
341
- resolve,
342
- resolveAlias,
304
+ /**
305
+ * Resolve a link target the way both builds do, or `undefined`. Every
306
+ * link is an address, so this is {@link resolveAddress} under the name
307
+ * the walkers use (#180).
308
+ */
309
+ resolve: resolveAddress,
343
310
  resolveAddress,
344
311
  manifestHit,
312
+ foreignHits,
345
313
  /** Whether a target reads as a qualified address at all. */
346
314
  isAddress: (target) => Boolean(readQualifier(target, types, packages)),
347
315
  };
@@ -655,24 +623,21 @@ export function auditHomepageLinks(index) {
655
623
  /**
656
624
  * Every link in a tree that lands nowhere.
657
625
  *
658
- * **The two failure modes are separate findings, because they are separate
659
- * problems** (#131). A piped target the author declared to be an address, and
660
- * which resolves nowhere, is a typo: every package it could name is either
661
- * built here or vendored, so there is no third possibility. An unpiped target
662
- * naming no note of the source's type may be exactly that typo or a
663
- * worldbuilding placeholder, which is a long-standing convention in the
664
- * setting trees. So the first is an error and the second a warning, and the
665
- * caller can tell them apart without parsing a message.
626
+ * **How the link is *written* is a separate finding from where it points**, and
627
+ * the two are kept apart because the corrections differ. An unlabelled link
628
+ * (#180) has to become `[[type-shortcode|Text]]`; a labelled one whose target
629
+ * resolves nowhere has a shortcode to fix. Reporting a bare `[[Name]]` as a
630
+ * dead address would send an author hunting for a note that was never named.
666
631
  *
667
632
  * @param {ReturnType<typeof buildLinkIndex>} index - The built index.
668
633
  * @returns {{deadAnchors: object[], deadAddresses: object[],
669
- * deadAliases: object[], aliasCollisions: object[],
670
- * frontmatterLinks: object[], homepageLinks: object[],
671
- * usedManifest: Set<string>}} The findings, and which addresses a foreign
672
- * manifest answered. Each `deadAddresses` entry carries a `reason`:
673
- * `"not-an-address"` when the target does not parse as one at all,
674
- * `"unknown-type"` when it is qualified but names no known type, and
675
- * `"unresolved"` when it parses and nothing answers it.
634
+ * unlabelledLinks: object[], frontmatterLinks: object[],
635
+ * homepageLinks: object[], usedManifest: Set<string>}} The findings, and
636
+ * which addresses a foreign manifest answered. Each `deadAddresses` entry
637
+ * carries a `reason` from {@link LINK_FINDING_REASONS}
638
+ * `"not-an-address"`, `"unknown-type"`, `"ambiguous"` (with the claiming
639
+ * `packages`), or `"unresolved"` and every one of them is an **error**:
640
+ * the three resolvers agree on severity for every class (#184).
676
641
  */
677
642
  export function auditLinks(index) {
678
643
  const { notes, anchors, linksOf, resolve, manifestHit, isAddress } = index;
@@ -680,10 +645,10 @@ export function auditLinks(index) {
680
645
  const deadAnchors = [];
681
646
  for (const note of notes) {
682
647
  for (const { target, anchor, text, occurrence, labelled } of linksOf(note)) {
683
- if (!anchor) continue;
684
- const dest = target ? resolve(note, target, labelled) : note;
685
- // An unresolvable target is reported by the address or alias pass
686
- // below; its anchor has nothing to be checked against.
648
+ if (!anchor || !labelled) continue;
649
+ const dest = target ? resolve(target) : note;
650
+ // An unresolvable target is reported by the pass below; its anchor
651
+ // has nothing to be checked against.
687
652
  if (!dest) continue;
688
653
  if (!anchors.get(dest).has(slugify(anchor))) {
689
654
  deadAnchors.push({
@@ -698,22 +663,26 @@ export function auditLinks(index) {
698
663
  }
699
664
 
700
665
  const deadAddresses = [];
701
- const deadAliases = [];
666
+ const unlabelledLinks = [];
702
667
  const usedManifest = new Set();
703
668
  for (const note of notes) {
704
- for (const { target, text, occurrence, labelled } of linksOf(note)) {
705
- if (!target) continue; // a same-page `[[#anchor]]`
706
- const at = { note, target, text, occurrence };
707
-
669
+ for (const { target, anchor, text, occurrence, labelled } of linksOf(note)) {
670
+ // The label is required whatever the link part is, an anchor
671
+ // included so this is tested before the same-page form (#180).
708
672
  if (!labelled) {
709
- if (index.resolveAlias(note, target)) continue;
710
- // Kept alongside, so a report can say *why* nothing answered:
711
- // an alias claimed twice is absent from the index, and blaming
712
- // this note for it would blame the wrong file.
713
- const claimants = index.aliasClaims.get(aliasKey(note.type, target)) ?? [];
714
- deadAliases.push({ ...at, ambiguous: claimants.length > 1, claimants });
673
+ unlabelledLinks.push({
674
+ note,
675
+ target: target || (anchor ? `#${anchor}` : ""),
676
+ text,
677
+ occurrence,
678
+ // Carried like every other finding's, so a reporter reads
679
+ // one field rather than knowing which list it drew from.
680
+ reason: "unlabelled",
681
+ });
715
682
  continue;
716
683
  }
684
+ if (!target) continue; // a same-page `[[#anchor|Text]]`
685
+ const at = { note, target, text, occurrence };
717
686
 
718
687
  if (!isAddress(target)) {
719
688
  deadAddresses.push({ ...at, reason: "not-an-address" });
@@ -722,10 +691,23 @@ export function auditLinks(index) {
722
691
  if (index.resolveAddress(target)) continue;
723
692
  // A manifest answers with the target package's own build output
724
693
  // rather than a reviewed guess.
725
- if (manifestHit(target)) {
694
+ const hits = index.foreignHits(target);
695
+ if (hits.length === 1) {
726
696
  usedManifest.add(target.toLowerCase());
727
697
  continue;
728
698
  }
699
+ if (hits.length > 1) {
700
+ // Two packages publish the short address, so it names neither.
701
+ // Reported as its own class: "no document has that identity" is
702
+ // false here — two do — and the fix is the qualified form
703
+ // rather than a corrected shortcode (#184).
704
+ deadAddresses.push({
705
+ ...at,
706
+ reason: "ambiguous",
707
+ packages: hits.map((h) => h.package).filter(Boolean),
708
+ });
709
+ continue;
710
+ }
729
711
  const read = readQualifier(target, index.types, index.packages);
730
712
  deadAddresses.push({
731
713
  ...at,
@@ -737,8 +719,7 @@ export function auditLinks(index) {
737
719
  return {
738
720
  deadAnchors,
739
721
  deadAddresses,
740
- deadAliases,
741
- aliasCollisions: index.aliasCollisions,
722
+ unlabelledLinks,
742
723
  frontmatterLinks: index.frontmatterLinks,
743
724
  homepageLinks: auditHomepageLinks(index),
744
725
  usedManifest,
@@ -793,8 +774,8 @@ export function walkReachability(index, { root, scope, stopAt = () => false }) {
793
774
  const note = queue.shift();
794
775
  if (stopAt(note)) continue;
795
776
  for (const { target, labelled } of index.linksOf(note)) {
796
- if (!target) continue;
797
- const dest = index.resolve(note, target, labelled);
777
+ if (!target || !labelled) continue;
778
+ const dest = index.resolve(target);
798
779
  if (!dest || !scope(dest) || reached.has(dest)) continue;
799
780
  reached.add(dest);
800
781
  queue.push(dest);
@@ -41,9 +41,9 @@
41
41
  * its own `type-shortcode` address in the top-level `aliases:` list. That
42
42
  * served exactly one reader — **Obsidian**, so `[[type-shortcode]]` resolved in
43
43
  * the editor — and nothing else ever read it: both resolvers parse the hyphen
44
- * qualifier themselves, and the alias list feeds only the bare-alias fallback
45
- * index. The project no longer authors in Obsidian, so the rule required a line
46
- * of frontmatter per note for a reader that does not exist. Removing it was
44
+ * qualifier themselves. The project no longer authors in Obsidian, so the rule
45
+ * required a line of frontmatter per note for a reader that does not exist. The
46
+ * field itself is retired now (#180), refused from `retired-fields.mjs`. Removing it was
47
47
  * verified output-neutral beforehand: across 1,735 stripped notes,
48
48
  * `package compile` produced byte-identical `build/packs-json` and the site
49
49
  * build byte-identical `site/content`.
@@ -178,13 +178,17 @@ export function lintContentTree(contentBase, { skipDirectories, contentPackage }
178
178
  // green on the one state it most needs to catch.
179
179
  //
180
180
  // The state that catches is an **empty walk**, not an empty key set (#77).
181
- // A note may be keyless by design: a homepage carries no `shortcode`
182
- // because it is addressed by the package rather than by a slug, so a
183
- // package in `publish.site: homepage` mode has a content tree that is
184
- // populated, correct, and permanently unkeyed. Reporting that as a missing
185
- // checkout trains its author to stop reading the output — the one thing
186
- // this guard needs them to do. A tree holding notes is therefore a tree;
187
- // only a tree holding none is the absent one.
181
+ // Notes may be keyless: a folder document carries no `shortcode`, and a
182
+ // tree of them is populated, correct, and unkeyed. Reporting that as a
183
+ // missing checkout trains its author to stop reading the output — the one
184
+ // thing this guard needs them to do. A tree holding notes is therefore a
185
+ // tree; only a tree holding none is the absent one.
186
+ //
187
+ // The homepage used to be the headline example, because it was addressed
188
+ // by the package rather than by a slug — so a `publish.site: homepage`
189
+ // package had a tree with exactly one note and no key at all. It carries an
190
+ // address like every other note now (#182); the guard is unchanged, because
191
+ // what it reads was never the key count.
188
192
  if (notes.length === 0) {
189
193
  findings.push({
190
194
  file: path.relative(process.cwd(), contentBase) || contentBase,