@heroiclands/package-build 10.0.1 → 11.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 (44) hide show
  1. package/CHANGELOG.md +279 -0
  2. package/CONTENT.md +218 -71
  3. package/MIGRATING.md +64 -0
  4. package/bin/content-build.mjs +59 -75
  5. package/docs/content-format.md +90 -67
  6. package/engine/base-compiler.mjs +7 -1
  7. package/engine/content-address.mjs +71 -18
  8. package/engine/content-format-check.mjs +1 -1
  9. package/engine/content-links.mjs +93 -112
  10. package/engine/content-lint.mjs +14 -10
  11. package/engine/content-slug.mjs +39 -105
  12. package/engine/diagnostics.mjs +16 -2
  13. package/engine/frontmatter-lint.mjs +26 -13
  14. package/engine/helpers.mjs +31 -68
  15. package/engine/homepage.mjs +131 -86
  16. package/engine/index.mjs +2 -5
  17. package/engine/manifest-emit.mjs +23 -4
  18. package/engine/note-vocabulary.mjs +58 -1
  19. package/engine/retired-fields.mjs +117 -6
  20. package/engine/site-build.mjs +182 -59
  21. package/engine/site-index.mjs +57 -102
  22. package/engine/web-wikilinks.mjs +183 -127
  23. package/engine/wikilink-syntax.mjs +174 -34
  24. package/engine/wikilinks.mjs +159 -117
  25. package/package.json +1 -1
  26. package/types/engine/base-compiler.d.mts +1 -1
  27. package/types/engine/content-address.d.mts +46 -14
  28. package/types/engine/content-links.d.mts +13 -17
  29. package/types/engine/content-slug.d.mts +11 -48
  30. package/types/engine/diagnostics.d.mts +14 -1
  31. package/types/engine/helpers.d.mts +4 -3
  32. package/types/engine/homepage.d.mts +96 -60
  33. package/types/engine/index.d.mts +0 -1
  34. package/types/engine/note-vocabulary.d.mts +43 -0
  35. package/types/engine/retired-fields.d.mts +78 -1
  36. package/types/engine/site-build.d.mts +70 -17
  37. package/types/engine/site-index.d.mts +19 -21
  38. package/types/engine/web-wikilinks.d.mts +29 -28
  39. package/types/engine/wikilink-syntax.d.mts +126 -40
  40. package/types/engine/wikilinks.d.mts +29 -24
  41. package/engine/abbreviations.mjs +0 -0
  42. package/engine/alias-index.mjs +0 -153
  43. package/types/engine/abbreviations.d.mts +0 -44
  44. package/types/engine/alias-index.d.mts +0 -122
@@ -18,15 +18,14 @@
18
18
  *
19
19
  * `[[type-shortcode|Text]]` a document of that type
20
20
  * `[[type-shortcode|]]` the same, showing the target's current name
21
- * `[[Text]]` an alias unique within the source's own type
22
21
  * `[[type-shortcode#slug|T]]` a section (see below)
23
22
  * `[[#slug|Text]]` a section of the source note itself
24
23
  * `[[doctype-shortcode|T]]` an item's *documentation* (see below)
25
24
  *
26
- * **The pipe decides which of the two namespaces a target belongs to** (#131),
27
- * and neither falls back to the other — see {@link resolvesAsAddress}, which
28
- * states the rule for both builds. A piped target is parsed by the address
29
- * grammar; an unpiped one is looked up in the alias index.
25
+ * **Every link is an address, and every address carries a label** (#180). A
26
+ * link written without one addresses nothing and is reported — see
27
+ * {@link unlabelledLinkMessage}, which states the rule for both builds. The
28
+ * bare `[[Alias]]` form and the index it was looked up in are retired.
30
29
  *
31
30
  * The qualifier is the note's **type**, which with its shortcode is the system's
32
31
  * logical identity: `(type, shortcode)` is unique by rule (see the Shortcode
@@ -34,13 +33,11 @@
34
33
  * unique per type, not per directory, so a directory qualifier would add nothing
35
34
  * to the address while breaking every inbound link the moment a note is refiled.
36
35
  *
37
- * The bare form is a **name**, not an abbreviated address: it resolves against
38
- * the aliases of the source's **own type**, so a `doc` reaches any other `doc`
39
- * by name wherever it is filed. Nothing narrower is consulted a note's
40
- * directory and its `category` play no part in resolution. Where two notes of a
41
- * type legitimately share a name (a rules page and a user-guide page both called
42
- * "Gear"), the bare form is ambiguous and resolves to neither; the author writes
43
- * the `[[type-shortcode|Text]]` address instead.
36
+ * Nothing narrower than `(type, shortcode)` is consulted a note's directory
37
+ * and its `category` play no part in resolution and nothing wider: a note's
38
+ * *name* is not an address, so two notes of a type may share a display name
39
+ * ("Gear" as a rules page and as a user-guide page) with nothing to disambiguate
40
+ * (#179, #180).
44
41
  *
45
42
  * At compile time each becomes a Foundry UUID enricher, routed to the pack that
46
43
  * the target's type compiles into (see {@link packForType}):
@@ -93,10 +90,12 @@ import { hasDocEntry, itemDocEntryId } from "./item-docs.mjs";
93
90
  import { replaceOutsideCode } from "./code-fences.mjs";
94
91
  // The syntax lives in `./wikilink-syntax.mjs`, so the web resolver and this
95
92
  // one cannot disagree about what counts as a link.
96
- import { authoredLabel, WIKILINK, parseWikilink, resolvesAsAddress } from "./wikilink-syntax.mjs";
97
- // The alias half of the two namespaces: what may be claimed, and how a claim
98
- // is keyed. Shared with the site build and the link checker (#131).
99
- import { aliasKey } from "./alias-index.mjs";
93
+ import {
94
+ authoredLabel,
95
+ WIKILINK,
96
+ parseWikilink,
97
+ unlabelledLinkMessage,
98
+ } from "./wikilink-syntax.mjs";
100
99
 
101
100
  export { ITEM_PACK, PACK_BY_TYPE, packForType };
102
101
 
@@ -145,7 +144,7 @@ export function resolveItemDocType(qualifier, types) {
145
144
 
146
145
  /**
147
146
  * Read a link target as a **qualified** `type-shortcode` reference, or report
148
- * that it is a bare alias instead.
147
+ * that it does not parse as one.
149
148
  *
150
149
  * Two separators are accepted, and they are **not** interchangeable in how
151
150
  * confidently they mark a target as qualified:
@@ -154,19 +153,20 @@ export function resolveItemDocType(qualifier, types) {
154
153
  * a wikilink as a *path* and resolves it against the vault's folders, so a
155
154
  * slash-qualified link is a broken link in the editor where the content is now
156
155
  * authored. A hyphen qualifies **only when what precedes it is a known type**:
157
- * note names contain hyphens too (`Grukar-ahk`), and those must keep resolving
158
- * as aliases. The split is at the **first** hyphen, so a shortcode may itself
159
- * contain one (`trauma-self-pro` `trauma` + `self-pro`).
156
+ * note names contain hyphens too (`Grukar-ahk`), and a target that is one is
157
+ * reported as not an address rather than split at an arbitrary place. The
158
+ * split is at the **first** hyphen, so a shortcode may itself contain one
159
+ * (`trauma-self-pro` → `trauma` + `self-pro`).
160
160
  * - **`type/shortcode`** — the legacy form, still resolved so that a link
161
161
  * written before the vault migrated does not silently die. A slash is
162
162
  * *unconditionally* a qualifier: nothing else uses one, so an unknown type
163
- * before it is an error rather than an invitation to try the alias index. The
164
- * split is at the **last** slash, as it always was.
163
+ * before it is reported rather than guessed at. The split is at the **last**
164
+ * slash, as it always was.
165
165
  *
166
166
  * A leading **package** segment is optional and outermost: `sohl-skill-lang` is
167
167
  * `skill-lang` in the `sohl` package. It is read only when `packages` is given
168
168
  * and names the segment, and only when the remainder is itself a valid address,
169
- * so a note called "Grukar-ahk" stays an alias (#1499).
169
+ * so a note called "Grukar-ahk" is not mistaken for one (#1499).
170
170
  *
171
171
  * @param {string} target - The link target, anchor already removed.
172
172
  * @param {Set<string>} types - Every type the content tree contains.
@@ -175,7 +175,7 @@ export function resolveItemDocType(qualifier, types) {
175
175
  * @returns {{type: string, shortcode: string, itemDoc: boolean,
176
176
  * package?: string, reason?: undefined} | {reason: "unknown-type"} | null}
177
177
  * The resolved qualifier; a `reason` when the target is definitely qualified
178
- * but names no known type; or `null` when it is a bare alias.
178
+ * but names no known type; or `null` when it is not an address at all.
179
179
  */
180
180
  export function readQualifier(target, types, packages) {
181
181
  // A leading **package** segment is the optional outermost qualifier:
@@ -183,7 +183,7 @@ export function readQualifier(target, types, packages) {
183
183
  // here so everything below reads the same `type`/`shortcode` it always did,
184
184
  // and it is recognised only when what precedes the hyphen is a package this
185
185
  // build knows *and* the remainder is itself a valid address — so a note
186
- // named "Sohl-something" is still an alias (#1499).
186
+ // named "Sohl-something" is not mistaken for one (#1499).
187
187
  if (packages?.size) {
188
188
  const hyphen = target.indexOf("-");
189
189
  if (hyphen > 0) {
@@ -204,7 +204,8 @@ export function readQualifier(target, types, packages) {
204
204
 
205
205
  const hyphen = target.indexOf("-");
206
206
  if (hyphen > 0) {
207
- // A hyphen qualifies only on a known type; otherwise it is part of a name.
207
+ // A hyphen qualifies only on a known type; otherwise it is part of a
208
+ // name, and a name is not an address.
208
209
  return readTypeAndCode(target.slice(0, hyphen), target.slice(hyphen + 1), types);
209
210
  }
210
211
  return null;
@@ -257,21 +258,21 @@ export function anchorPageId(noteId, anchorSlug) {
257
258
  * Builds the link-resolution tables for a content tree.
258
259
  *
259
260
  * @param {Array<{type: string, id: string, shortcode?: string|null,
260
- * aliases?: string[], name?: string, pack?: string, docPack?: string}>} docs -
261
+ * name?: string, pack?: string, docPack?: string,
262
+ * draft?: boolean}>} docs -
261
263
  * One entry per content note. `pack` / `docPack` name the packs the note's
262
264
  * document and its documentation entry landed in; omitted, the conventional
263
- * one-pack-per-type names stand in.
265
+ * one-pack-per-type names stand in. `draft` says the note carries the `draft`
266
+ * tag, which marks links *into* it and changes nothing else (#183).
264
267
  * @param {string} packageId - The Foundry package shipping the packs; the first
265
268
  * segment of every emitted UUID.
266
269
  * @param {Map<string, object>} [foreign] - Canonically keyed entries from
267
270
  * vendored manifests of packages this build links into but does not publish.
268
271
  * @param {string} [contentPackage] - This build's *content* package, which an
269
272
  * authored address may name explicitly. Defaults to `packageId`.
270
- * @returns {{byShortcode: Map<string, object>, byAlias: Map<string, object|null>,
271
- * types: Set<string>}} `byAlias` holds `null` where a type-scoped alias is
272
- * claimed by more than one document, which makes the bare `[[Text]]` form
273
- * unusable for it. `types` is every type the tree actually contains, so a
274
- * qualifier naming no real type can be told apart from a missing target.
273
+ * @returns {{byShortcode: Map<string, object>, types: Set<string>}} `types` is
274
+ * every type the tree actually contains, so a qualifier naming no real type
275
+ * can be told apart from a missing target.
275
276
  */
276
277
  export function buildWikilinkIndex(docs, packageId, foreign, contentPackage) {
277
278
  if (!packageId) {
@@ -283,11 +284,6 @@ export function buildWikilinkIndex(docs, packageId, foreign, contentPackage) {
283
284
  }
284
285
 
285
286
  const byShortcode = new Map();
286
- const byAlias = new Map();
287
- // key -> every note claiming it. `byAlias` records only the *verdict*
288
- // (a note, or `null` for poisoned); this records the claimants, which is
289
- // what an ambiguity report has to name.
290
- const aliasClaims = new Map();
291
287
  const types = new Set();
292
288
 
293
289
  // Each note's address is computed once, here, and every reference to it is
@@ -312,19 +308,6 @@ export function buildWikilinkIndex(docs, packageId, foreign, contentPackage) {
312
308
  });
313
309
 
314
310
  if (d.shortcode) byShortcode.set(`${norm(d.type)}/${norm(d.shortcode)}`, d);
315
- for (const a of d.aliases ?? []) {
316
- const key = aliasKey(d.type, a);
317
- // Second claimant poisons the alias: it can no longer be resolved.
318
- byAlias.set(key, byAlias.has(key) && byAlias.get(key) !== d ? null : d);
319
- // Every claimant is kept alongside, because poisoning the alias
320
- // discards exactly the information needed to report the problem.
321
- // The note that *cites* an ambiguous alias is innocent — whoever
322
- // added the second claimant broke it — so a message that can only
323
- // name the citing note points at the wrong file (#13).
324
- const claims = aliasClaims.get(key);
325
- if (!claims) aliasClaims.set(key, [d]);
326
- else if (!claims.includes(d)) claims.push(d);
327
- }
328
311
  }
329
312
  // Entries published by *other* packages, keyed canonically. Merged as one
330
313
  // map rather than consulted separately: the keys are globally unique, so a
@@ -369,8 +352,6 @@ export function buildWikilinkIndex(docs, packageId, foreign, contentPackage) {
369
352
 
370
353
  return {
371
354
  byShortcode,
372
- byAlias,
373
- aliasClaims,
374
355
  types,
375
356
  uuidByDoc,
376
357
  packageId,
@@ -382,22 +363,43 @@ export function buildWikilinkIndex(docs, packageId, foreign, contentPackage) {
382
363
  /**
383
364
  * The foreign manifest entry an address names, or `null`.
384
365
  *
385
- * A package-qualified address is one lookup. A bare one names no package, so it
366
+ * A package-qualified address is one lookup. An unqualified one names no
367
+ * package, so it
386
368
  * resolves against whichever foreign package publishes it — and only when
387
369
  * exactly one does. Claimed by two, it is genuinely ambiguous and the author
388
370
  * writes the qualified form; guessing would make the build depend on which
389
371
  * manifest happened to load first.
390
372
  *
391
373
  * @param {object} index - From {@link buildWikilinkIndex}.
392
- * @param {object|null} read - The parsed qualifier, or `null` for a bare alias.
374
+ * @param {object|null} read - The parsed qualifier, or `null` when the target
375
+ * did not parse as an address.
393
376
  * @returns {object|null} The manifest entry.
394
377
  */
395
378
  function findForeign(index, read) {
396
- if (!read || read.reason || !index.foreign?.size) return null;
379
+ const hits = foreignHits(index, read);
380
+ return hits.length === 1 ? hits[0] : null;
381
+ }
382
+
383
+ /**
384
+ * Every foreign manifest entry an address names.
385
+ *
386
+ * The count is what separates *nothing publishes this* from *two packages do*,
387
+ * and those are different findings with different fixes (#184), so the caller
388
+ * gets the list rather than a single answer that has already collapsed the
389
+ * distinction.
390
+ *
391
+ * @param {object} index - From {@link buildWikilinkIndex}.
392
+ * @param {object|null} read - The parsed qualifier, or `null` when the target
393
+ * did not parse as an address.
394
+ * @returns {object[]} The manifest entries.
395
+ */
396
+ function foreignHits(index, read) {
397
+ if (!read || read.reason || !index.foreign?.size) return [];
397
398
  const wanted = norm(read.itemDoc ? `doc${read.type}` : read.type);
398
399
  const shortcode = norm(read.shortcode);
399
400
  if (read.package) {
400
- return index.foreign.get(`${read.package}-${wanted}-${shortcode}`.toLowerCase()) ?? null;
401
+ const one = index.foreign.get(`${read.package}-${wanted}-${shortcode}`.toLowerCase());
402
+ return one ? [one] : [];
401
403
  }
402
404
  const hits = [];
403
405
  for (const [key, v] of index.foreign) {
@@ -405,7 +407,7 @@ function findForeign(index, read) {
405
407
  if (parts.length !== 3) continue;
406
408
  if (parts[1] === wanted && parts[2] === shortcode) hits.push(v);
407
409
  }
408
- return hits.length === 1 ? hits[0] : null;
410
+ return hits;
409
411
  }
410
412
 
411
413
  /**
@@ -433,6 +435,36 @@ function unresolvedLink(text, target) {
433
435
  );
434
436
  }
435
437
 
438
+ /**
439
+ * How a link to a **draft** note renders (#183).
440
+ *
441
+ * A note tagged `draft` exists so a link into it is not dead, and nothing more.
442
+ * Unmarked, a reader follows a promising link into an empty page and an author
443
+ * cannot see which of their links still owe content.
444
+ *
445
+ * **The wrapper carries the cue and nothing else.** The link itself is
446
+ * untouched — Foundry enriches inside HTML, so the `@UUID` still becomes a live
447
+ * content link, and the note is in the packs, the manifest and the index
448
+ * exactly as any other. Nothing here resembles the retired `draft:` field,
449
+ * which moved a note from published to unresolvable without saying so.
450
+ *
451
+ * The appearance lives in `scss/components/_draft-link.scss`, beside the
452
+ * unresolved-link partial, not here.
453
+ *
454
+ * **Byte-identical with the site build's copy** in `web-wikilinks.mjs`, down to
455
+ * the class name and the `title` wording — one authored link renders on two
456
+ * surfaces, and the two builds have drifted before over exactly this kind of
457
+ * detail (#1409). The argument is already-built markup and is deliberately not
458
+ * escaped; the *authored* text inside it was escaped, or made into a link, by
459
+ * whichever resolver called this.
460
+ *
461
+ * @param {string} inner - The resolved link, as that build emits it.
462
+ * @returns {string} An inline HTML span wrapping it.
463
+ */
464
+ function draftLink(inner) {
465
+ return `<span class="sohl-draft-link" title="Draft — not yet written">${inner}</span>`;
466
+ }
467
+
436
468
  /** Matches a whole wikilink, capturing its inner text. */
437
469
 
438
470
  /**
@@ -452,19 +484,23 @@ function unresolvedLink(text, target) {
452
484
  *
453
485
  * @param {string} markdown - The note body (frontmatter already stripped).
454
486
  * @param {object} ctx
455
- * @param {string} ctx.type - The source note's `type`, which scopes a bare `[[Text]]`.
487
+ * @param {string} ctx.type - The source note's `type`, which addresses a
488
+ * `[[#slug]]` self-link.
456
489
  * @param {string} ctx.id - The source note's document id.
457
490
  * @param {string} [ctx.pack] - The pack the source note's own document landed
458
491
  * in, which addresses a `[[#slug]]` self-link — the one target with no index
459
492
  * entry.
460
493
  * @param {string} [ctx.docPack] - The pack the source note's documentation
461
494
  * entry landed in.
462
- * @param {{byShortcode: Map, byAlias: Map, types: Set}} ctx.index - From
495
+ * @param {{byShortcode: Map, types: Set}} ctx.index - From
463
496
  * {@link buildWikilinkIndex}.
464
497
  * @returns {{markdown: string, unresolved: Array<{link: string, target: string,
465
- * offset: number, reason: "unknown"|"ambiguous"|"unknown-type"}>}} `offset`
466
- * is the link's 0-based position in `markdown`, which is what lets a caller
467
- * report the line and column it sits on (#17).
498
+ * offset: number, reason: string, packages?: string[], anchor?: string}>}}
499
+ * Each `reason` is one of {@link LINK_FINDING_REASONS}, the vocabulary all
500
+ * three resolvers share (#184) `ambiguous` carries the claiming `packages`
501
+ * and `unknown-anchor` the section it named. `offset` is the link's 0-based
502
+ * position in `markdown`, which is what lets a caller report the line and
503
+ * column it sits on (#17).
468
504
  */
469
505
  export function convertWikilinks(markdown, { type, id, pack, docPack, index }) {
470
506
  const unresolved = [];
@@ -474,37 +510,47 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index }) {
474
510
  // one note tellable apart, and a position reportable at all (#17).
475
511
  const out = replaceOutsideCode(markdown, WIKILINK, (all, rawInner, offset) => {
476
512
  const parsed = parseWikilink(rawInner);
477
- const { labelled } = parsed;
478
- let target = parsed.target;
479
- // An unlabelled link shows its interior verbatim, anchor included;
480
- // a labelled one shows its label. An *empty* label is not a label
481
- // — `[[x|]]` means "show the target's name" — and that reading
482
- // comes from {@link authoredLabel} so the web resolver cannot draw
483
- // the line somewhere else (#113).
484
- let text = labelled ? (authoredLabel(parsed) ?? "") : parsed.inner;
513
+ const target = parsed.target;
485
514
  const slug = parsed.anchor || null;
486
515
 
487
- // Resolve the document: same-page (empty target), an address, or an
488
- // alias. **The pipe chooses which**, with no fallback either way
489
- // (#131) see {@link resolvesAsAddress}.
516
+ // **Every link carries a label** (#180). Without one there is nothing
517
+ // to resolve against: the alias namespace a bare `[[Text]]` was looked
518
+ // up in is retired, and a shortcode is an address rather than prose, so
519
+ // the link has neither a target this build can find nor text to show.
520
+ // Reported before anything else, including the same-page form, because
521
+ // it is a statement about how the link is *written* — `[[#slug]]` needs
522
+ // the pipe exactly as `[[skill-clmb]]` does.
523
+ if (!parsed.labelled) {
524
+ unresolved.push({
525
+ link: all,
526
+ target: target || (slug ? `#${slug}` : ""),
527
+ offset,
528
+ reason: "unlabelled",
529
+ addressed: false,
530
+ });
531
+ return unresolvedLink(parsed.inner, parsed.inner);
532
+ }
533
+
534
+ // An *empty* label is not a label — `[[x|]]` means "show the target's
535
+ // name" — and that reading comes from {@link authoredLabel} so the web
536
+ // resolver cannot draw the line somewhere else (#113).
537
+ let text = authoredLabel(parsed) ?? "";
538
+
539
+ // Resolve the document: the source note itself for an empty target, or
540
+ // the address the target parses as.
490
541
  let doc;
491
542
  // Set when the qualifier was the virtual `doc<type>` form, so the UUID
492
543
  // is built against the item doc entry rather than the item itself.
493
544
  let itemDoc = false;
494
- // Set when the target was read as an address, which is what decides
495
- // whether a foreign manifest is consulted for it below.
496
- let addressed = false;
497
545
  // Kept for the foreign fallback below, which needs the parsed address.
498
546
  let qualifiedRead = null;
499
547
  if (target === "" && slug) {
500
548
  doc = { type, id, pack, docPack };
501
- } else if (resolvesAsAddress(parsed)) {
549
+ } else {
502
550
  const qualified = readQualifier(target, index.types, index.packages);
503
551
  qualifiedRead = qualified;
504
- // The author wrote a pipe, so they meant an address. A target that
505
- // does not parse as one is therefore a defect and not, as it was
506
- // under the old resolve-by-shape rule, an invitation to try the
507
- // alias index — which is what let a note *name* resolve here.
552
+ // A target that does not parse as an address is a defect: there is
553
+ // no second namespace left to fall through to (#180).
508
554
  if (!qualified || qualified.reason) {
509
555
  unresolved.push({
510
556
  link: all,
@@ -515,36 +561,30 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index }) {
515
561
  });
516
562
  return unresolvedLink(text || target, target);
517
563
  }
518
- addressed = true;
519
564
  itemDoc = qualified.itemDoc;
520
565
  doc = index.byShortcode.get(`${qualified.type}/${qualified.shortcode}`);
521
- } else {
522
- const key = aliasKey(type, target);
523
- const hit = index.byAlias.get(key);
524
- if (hit === null) {
566
+ }
567
+ if (!doc) {
568
+ // Nothing local answers. A foreign package may publish this
569
+ // address, in which case the manifest hands back a complete UUID —
570
+ // including, for a section link, the anchor's own — so nothing is
571
+ // derived here.
572
+ const hits = foreignHits(index, qualifiedRead);
573
+ if (hits.length > 1) {
574
+ // Two packages publish the short address, so it names neither.
575
+ // Its own class: the fix is the package-qualified form, not a
576
+ // corrected shortcode (#184).
525
577
  unresolved.push({
526
578
  link: all,
527
579
  target,
528
580
  offset,
529
581
  reason: "ambiguous",
530
- // Who claimed it, so the report can name the collision
531
- // rather than the note that merely cites it (#13).
532
- candidates: (index.aliasClaims?.get(key) ?? []).map((d) => ({
533
- type: d.type,
534
- shortcode: d.shortcode,
535
- name: d.name,
536
- })),
582
+ packages: hits.map((h) => h.package).filter(Boolean),
583
+ addressed: true,
537
584
  });
538
585
  return unresolvedLink(text || target, target);
539
586
  }
540
- doc = hit;
541
- }
542
- if (!doc) {
543
- // Nothing local answers. A foreign package may publish this
544
- // address, in which case the manifest hands back a complete UUID —
545
- // including, for a section link, the anchor's own — so nothing is
546
- // derived here.
547
- const hit = findForeign(index, qualifiedRead);
587
+ const hit = hits[0] ?? null;
548
588
  if (hit) {
549
589
  const uuid = slug ? hit.anchors?.[slug] : hit.uuid;
550
590
  if (uuid) {
@@ -555,6 +595,7 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index }) {
555
595
  target,
556
596
  offset,
557
597
  reason: "unknown-anchor",
598
+ anchor: slug,
558
599
  addressed: true,
559
600
  });
560
601
  return unresolvedLink(text || target, target);
@@ -563,24 +604,20 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index }) {
563
604
  link: all,
564
605
  target,
565
606
  offset,
566
- reason: "unknown",
567
- // An *address* that resolves nowhere is a typo: every package
568
- // it could name is either built here or vendored, so there is
569
- // no third possibility left. A bare alias is not — it may
570
- // simply be prose, or a worldbuilding placeholder.
571
- addressed,
607
+ reason: "unresolved",
608
+ // An address that resolves nowhere is a typo: every package it
609
+ // could name is either built here or vendored, so there is no
610
+ // third possibility left.
611
+ addressed: true,
572
612
  });
573
613
  return unresolvedLink(text || target, target);
574
614
  }
575
615
 
576
- // An address with no label — `[[skill-clmb|]]` — has no prose to show,
577
- // a shortcode being an address rather than display text, so the
578
- // document's **current** name stands in and a rename shows at every
579
- // citation with no link edited (#1409, #131). A bare `[[Text]]` is
580
- // already the prose the author wrote, and substituting the canonical
581
- // name there would rewrite the sentence ("worsens the [[Shock State]]"
582
- // must not render as "Shock"). The knowledgebase build reads the same
583
- // authored link the same way.
616
+ // An address with an *empty* label — `[[skill-clmb|]]` — has no prose
617
+ // to show, a shortcode being an address rather than display text, so
618
+ // the document's **current** name stands in and a rename shows at every
619
+ // citation with no link edited (#1409). The knowledgebase build reads
620
+ // the same authored link the same way.
584
621
  if (!text) text = doc.name ?? target;
585
622
 
586
623
  // Both addresses were computed when the target was indexed. An item
@@ -605,7 +642,12 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index }) {
605
642
  // item's pages are addressed through its `doc<type>` counterpart.
606
643
  const uuid =
607
644
  slug && isJournal ? pageUuid(entryUuid, anchorPageId(entryId, slug)) : entryUuid;
608
- return `@UUID[${uuid}]{${text}}`;
645
+ const link = `@UUID[${uuid}]{${text}}`;
646
+ // A link into a note that exists but is not written renders marked
647
+ // (#183). Presentation only — the UUID above is unchanged, and a
648
+ // `[[#slug]]` self-link is not marked because the reader is already in
649
+ // the note it would be telling them about.
650
+ return doc.draft ? draftLink(link) : link;
609
651
  });
610
652
 
611
653
  return { markdown: out, unresolved };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "10.0.1",
3
+ "version": "11.0.0",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -269,7 +269,7 @@ export class BasePackCompiler {
269
269
  prepare(): Promise<void>;
270
270
  linkIndex: {
271
271
  byShortcode: Map<any, any>;
272
- byAlias: Map<any, any>;
272
+ types: Set<any>;
273
273
  } | undefined;
274
274
  contentDocs: {
275
275
  fm: object;
@@ -10,32 +10,64 @@
10
10
  */
11
11
  export function sectionOf(fm: object): string | undefined;
12
12
  /**
13
- * A note's address below the knowledgebase mount, e.g. `affliction/aconite/`.
13
+ * The single path segment a note is addressed by: `type-shortcode`.
14
+ *
15
+ * Lowercased, so it is exactly the tail of the note's canonical key
16
+ * (`canonicalKey` in `engine/kb-manifest.mjs` lowercases too) — which is what
17
+ * makes a manifest entry's `path` derivable from the key it is filed under
18
+ * rather than transported beside it.
19
+ *
20
+ * The hyphen is a separator and never occurs inside a segment: a shortcode is
21
+ * `^[A-Za-z0-9]+$` (`ADDRESS_SEGMENT_PATTERN`, enforced by `content-lint.mjs`)
22
+ * and a type is a bare word. That is the same charset guarantee positional key
23
+ * parsing rests on, so the address and the key cannot disagree about where one
24
+ * ends and the other begins.
25
+ *
26
+ * @param {object} fm - Parsed frontmatter.
27
+ * @returns {string} The address segment, e.g. `weapongear-dagger`.
28
+ * @throws {Error} When the note declares no type or no shortcode — either way
29
+ * it has no address, which is reported rather than papered over: inventing
30
+ * one would publish a page nothing can link to and record a manifest entry
31
+ * pointing at it.
32
+ */
33
+ export function addressSlug(fm: object): string;
34
+ /**
35
+ * A note's address below the knowledgebase mount, e.g. `affliction-aconite/`.
14
36
  *
15
37
  * A `README.md` **is** its section's landing page rather than a page within it,
16
- * so it addresses the section itself and has no slug of its own.
38
+ * so it addresses the section itself and has no address of its own.
17
39
  *
18
40
  * @param {object} fm - Parsed frontmatter.
19
- * @param {string} name - The note's display name; the slug derives from it
20
- * (#1278), never from the shortcode, which is identity rather than
21
- * presentation.
22
41
  * @param {boolean} isReadme - Whether the file is a `README.md`.
23
- * @returns {string} The section-relative address, with a trailing slash.
24
- * @throws {Error} When the name yields no usable slug.
42
+ * @returns {string} The mount-relative address, with a trailing slash.
43
+ * @throws {Error} When the note has no address.
25
44
  */
26
- export function contentAddress(fm: object, name: string, isReadme: boolean): string;
45
+ export function contentAddress(fm: object, isReadme: boolean): string;
27
46
  /**
28
- * A note's address relative to its **package**, e.g. `kb/affliction/aconite/`.
47
+ * A note's address relative to its **package**, e.g. `affliction-aconite/`.
29
48
  *
30
49
  * This is the form the link manifest records and the site build emits pages at,
31
50
  * and it is one function because those two must agree — a manifest asserting an
32
51
  * address the site does not publish resolves at build time and 404s for the
33
52
  * reader, which is the failure this module exists to prevent.
34
53
  *
54
+ * **The prefix does not apply to a page's own address.** `prefix` says where the
55
+ * content tree *mounts inside the package* — where its section directories and
56
+ * their landing pages live — and a landing page is addressed by that mount
57
+ * (`kb/rules/`). An ordinary page is addressed by `(type, shortcode)`, which is
58
+ * a package-wide identity and takes no mount: `sohl` publishes
59
+ * `/sohl/affliction-aconite/` while its section landings stay at
60
+ * `/sohl/kb/affliction/`. The `type-` half is what keeps that flat namespace
61
+ * clear of the package's fixed mounts — `/<package>/` for the landing,
62
+ * `/<package>/api/` for generated API docs, neither of which contains a hyphen
63
+ * or names a type.
64
+ *
65
+ * **The section still decides where the *file* is written**, which is why a
66
+ * note without one still has no address: Hugo derives a section from a page's
67
+ * directory rather than from its URL, so a page with nowhere to be filed is a
68
+ * page with no section landing, no `.CurrentSection` and no per-section layout.
69
+ *
35
70
  * @param {object} fm - Parsed frontmatter.
36
- * @param {string} name - The note's display name; a page slug derives from it
37
- * (#1278), never from the shortcode, which is identity rather than
38
- * presentation.
39
71
  * @param {object} [options] - Options.
40
72
  * @param {boolean} [options.isReadme] - Whether the file is a `README.md`.
41
73
  * @param {{prefix?: string, landing?: string}} [options.scheme] - The
@@ -43,11 +75,11 @@ export function contentAddress(fm: object, name: string, isReadme: boolean): str
43
75
  * @returns {string} The package-relative address, with a trailing slash and no
44
76
  * leading one.
45
77
  * @throws {Error} When the note has no address — no section, a landing page
46
- * naming no section, or a name yielding no usable slug. Each is a note that
78
+ * naming no section, or no shortcode to be addressed by. Each is a note that
47
79
  * is not published, and inventing an address for one would put a dead entry
48
80
  * in the manifest.
49
81
  */
50
- export function packageAddress(fm: object, name: string, { isReadme, scheme }?: {
82
+ export function packageAddress(fm: object, { isReadme, scheme }?: {
51
83
  isReadme?: boolean | undefined;
52
84
  scheme?: {
53
85
  prefix?: string;
@@ -86,30 +86,26 @@ export function auditHomepageLinks(index: ReturnType<typeof buildLinkIndex>): Ar
86
86
  /**
87
87
  * Every link in a tree that lands nowhere.
88
88
  *
89
- * **The two failure modes are separate findings, because they are separate
90
- * problems** (#131). A piped target the author declared to be an address, and
91
- * which resolves nowhere, is a typo: every package it could name is either
92
- * built here or vendored, so there is no third possibility. An unpiped target
93
- * naming no note of the source's type may be exactly that typo or a
94
- * worldbuilding placeholder, which is a long-standing convention in the
95
- * setting trees. So the first is an error and the second a warning, and the
96
- * caller can tell them apart without parsing a message.
89
+ * **How the link is *written* is a separate finding from where it points**, and
90
+ * the two are kept apart because the corrections differ. An unlabelled link
91
+ * (#180) has to become `[[type-shortcode|Text]]`; a labelled one whose target
92
+ * resolves nowhere has a shortcode to fix. Reporting a bare `[[Name]]` as a
93
+ * dead address would send an author hunting for a note that was never named.
97
94
  *
98
95
  * @param {ReturnType<typeof buildLinkIndex>} index - The built index.
99
96
  * @returns {{deadAnchors: object[], deadAddresses: object[],
100
- * deadAliases: object[], aliasCollisions: object[],
101
- * frontmatterLinks: object[], homepageLinks: object[],
102
- * usedManifest: Set<string>}} The findings, and which addresses a foreign
103
- * manifest answered. Each `deadAddresses` entry carries a `reason`:
104
- * `"not-an-address"` when the target does not parse as one at all,
105
- * `"unknown-type"` when it is qualified but names no known type, and
106
- * `"unresolved"` when it parses and nothing answers it.
97
+ * unlabelledLinks: object[], frontmatterLinks: object[],
98
+ * homepageLinks: object[], usedManifest: Set<string>}} The findings, and
99
+ * which addresses a foreign manifest answered. Each `deadAddresses` entry
100
+ * carries a `reason` from {@link LINK_FINDING_REASONS}
101
+ * `"not-an-address"`, `"unknown-type"`, `"ambiguous"` (with the claiming
102
+ * `packages`), or `"unresolved"` and every one of them is an **error**:
103
+ * the three resolvers agree on severity for every class (#184).
107
104
  */
108
105
  export function auditLinks(index: ReturnType<typeof buildLinkIndex>): {
109
106
  deadAnchors: object[];
110
107
  deadAddresses: object[];
111
- deadAliases: object[];
112
- aliasCollisions: object[];
108
+ unlabelledLinks: object[];
113
109
  frontmatterLinks: object[];
114
110
  homepageLinks: object[];
115
111
  usedManifest: Set<string>;