@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
@@ -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,
@@ -12,50 +12,39 @@
12
12
  */
13
13
 
14
14
  /**
15
- * The URL segment of a content note — derived from its name.
16
- *
17
- * Content notes carry no authored `slug`: it was a hand-maintained second
18
- * spelling of something already determined, free to drift from the page it
19
- * named. The URL is derived instead.
20
- *
21
- * It is derived from the **name**, deliberately not from the `shortcode`, even
22
- * though `(type, shortcode)` is unique by rule and would be a tempting key. A
23
- * shortcode is *identity*: it is referenced from saved world data actions,
24
- * cohorts, expressions, archetypes, pack lookups so binding the public URL to
25
- * it would make a cosmetic URL change into a data migration. A URL is
26
- * presentation, and it should read like one (`/creature/nusvorroth/`, not
27
- * `/creature/nsvrroth/`). Renames are what a URL must survive, and they do:
28
- * every change appends to the legacy-URL map, which emits a redirect.
29
- *
30
- * **One normalisation, for every slug this build makes.** {@link slugify} is it.
31
- * Two things are layered on top of it for **document identity** only, in
32
- * {@link contentSlug}:
33
- *
34
- * - it must produce something, and throws when a name yields no slug;
35
- * - it **abbreviates**see {@link ABBREVIATIONS}.
36
- *
37
- * Abbreviation stops at the document's own address on purpose. A heading anchor
38
- * is not a name the build invents: an author writes the matching key by hand —
39
- * a map note pins `locations.stair-foot` at a heading called *Stair Foot* — so a
40
- * slug that silently became `stair-ft` would break a reference nobody could
41
- * have predicted. The same goes for a pack filename, which is only ever read
42
- * back by the unpacker. Shortening either buys nothing and costs the author's
43
- * ability to guess the key.
44
- *
45
- * This header used to claim the opposite — that anchor slugs, filename slugs and
46
- * this one were deliberately separate operations. Three of them had drifted into
47
- * dropping non-ASCII letters instead of transliterating them, so `Kûrbúl Helm`
48
- * addressed a page at `kurbul-helm` while its pack file was `k-rb-l-helm` and a
49
- * link to a heading of the same name pointed at `#k-rb-l-helm`. Twenty-two of
50
- * this repository's notes were affected. That was not a design; it was three
51
- * copies of a regex, and the differences between them were all mistakes.
15
+ * One normalisation, for every slug this build makes.
16
+ *
17
+ * {@link slugify} reduces a piece of prose a heading, a document name — to a
18
+ * URL-safe token. It is **not** how a page is addressed: a note's URL is its
19
+ * address, `type-shortcode`, derived in `engine/content-address.mjs` and
20
+ * touching no display string at all (#181).
21
+ *
22
+ * That used to be the other way round. This module carried a `contentSlug` that
23
+ * derived a page's URL from `name.full`, abbreviating it through a table of 200
24
+ * words so the result stayed short, and a `findSlugCollisions` to catch the two
25
+ * notes that would then claim one URL. Its own header justified the readability
26
+ * cost with a rename-survival story *"every change appends to the legacy-URL
27
+ * map, which emits a redirect"* and no such map was ever written, in this
28
+ * package or in any consumer. So a rename silently 404'd every existing link,
29
+ * a display string was load-bearing, and a uniqueness check was needed to keep
30
+ * it correct. An address has none of those properties, so all three went.
31
+ *
32
+ * What is left is the normalisation the rest of the build still needs, in the
33
+ * two places it was always right for:
34
+ *
35
+ * - **heading anchors**, where an author writes the matching key by hand a map
36
+ * note pins `locations.stair-foot` at a heading called *Stair Foot*;
37
+ * - **pack filenames**, read back only by the unpacker.
38
+ *
39
+ * Neither is abbreviated, and neither ever was: abbreviation existed solely to
40
+ * shorten a name-derived URL, so it left with it.
52
41
  *
53
42
  * What the rule does, and why:
54
43
  *
55
44
  * - **Transliterate, don't discard.** `unidecode` carries every non-ASCII letter
56
45
  * to its ASCII sense — `æ` → `ae`, `þ` → `th`, `œ` → `oe`, `ß` → `ss`,
57
46
  * `ö` → `o`, `¾` → `3/4`. A rule that merely strips them turns a name into
58
- * punctuation.
47
+ * punctuation, which is how `Kûrbúl Helm` once became `k-rb-l-helm`.
59
48
  * - **An apostrophe elides.** `’` and `'` mark a pronunciation break — a glottal
60
49
  * stop — inside one word, so `Kenbet’Pat` is `kenbetpat`, not `kenbet-pat`.
61
50
  * - **Everything else non-alphanumeric becomes a hyphen**, collapsed and
@@ -70,31 +59,26 @@
70
59
 
71
60
  import unidecode from "unidecode";
72
61
 
73
- import { abbreviateTokens } from "./abbreviations.mjs";
74
-
75
62
  /**
76
- * The URL segment for one content note.
63
+ * The URL-safe token a piece of prose reduces to.
77
64
  *
78
- * The name is **transliterated** before it is reduced, so an accented character
79
- * is carried across rather than dropped dropping is what turned `Nüsvōrroth`
80
- * into `n-sv-rroth` and forced a hand-written slug. Ligatures expand the way a
81
- * reader would spell them out: `þ`→`th`, `æ`→`ae`, `œ`→`oe`, `ß`→`ss`, `ij`→`ij`,
82
- * `fi`→`fi`, and eth (`ð`) follows the Icelandic convention of a bare `d`.
65
+ * The text is **transliterated** before it is reduced, so an accented character
66
+ * is carried across rather than dropped. Ligatures expand the way a reader would
67
+ * spell them out: `þ`→`th`, `æ`→`ae`, `œ`→`oe`, `ß`→`ss`, `ij`→`ij`, `fi`→`fi`,
68
+ * and eth (`ð`) follows the Icelandic convention of a bare `d`.
83
69
  *
84
70
  * Two reductions are ours rather than the transliterator's:
85
71
  *
86
72
  * - **apostrophes are removed**, not treated as separators (`Armorer's Kit` →
87
- * `armorers-kit`), matching the URLs these pages already publish at;
73
+ * `armorers-kit`);
88
74
  * - **a fraction keeps its digits together** — a vulgar fraction expands to
89
75
  * `3/4`, and the solidus would otherwise split it into `3-4`, so a slash
90
76
  * *between digits* is closed up (`Kûrbúl ¾-Helm` → `kurbul-34-helm`).
91
77
  *
92
- * @param {string | undefined} name - The note's display name (`name.full`),
93
- * which a malformed note may not have at all.
94
- * @returns {string} The URL segment (never empty).
95
- * @throws {Error} When there is no name, or the name carries no URL-safe
96
- * characters — either way the note cannot be addressed, which is a content
97
- * error rather than something to paper over with a fallback.
78
+ * @param {string | undefined} text - The prose to reduce.
79
+ * @returns {string} The token, or `""` when the text carries nothing URL-safe.
80
+ * Empty is an ordinary answer here: nothing is addressed by a slug any more,
81
+ * so an anchor that reduces to nothing is the caller's to judge.
98
82
  */
99
83
  export function slugify(text) {
100
84
  const raw = typeof text === "string" ? text.trim() : "";
@@ -102,10 +86,10 @@ export function slugify(text) {
102
86
  const tokens = unidecode(raw)
103
87
  .toLowerCase()
104
88
  // An apostrophe marks a pronunciation break, not a word boundary:
105
- // `Kenbet\u2019Pat` is one name said with a catch in it, so it elides
89
+ // `Kenbet’Pat` is one name said with a catch in it, so it elides
106
90
  // rather than becoming a hyphen.
107
91
  .replace(/['\u2019]/g, "")
108
- // A vulgar fraction transliterates to its digits (`\u00be` \u2192 `3/4`); the
92
+ // A vulgar fraction transliterates to its digits (`¾` `3/4`); the
109
93
  // solidus between them is not a word boundary either.
110
94
  .replace(/(\d)\/(\d)/g, "$1$2")
111
95
  .split(/[^a-z0-9]+/)
@@ -113,53 +97,3 @@ export function slugify(text) {
113
97
 
114
98
  return tokens.join("-");
115
99
  }
116
-
117
- /**
118
- * The URL segment a content note publishes at.
119
- *
120
- * {@link slugify} with the rule that a document *must* be addressable: a note
121
- * that yields no slug is a content error, not something to paper over with a
122
- * fallback, because the alternative is a page nobody can reach.
123
- *
124
- * @param {string | undefined} name - The note's display name (`name.full`),
125
- * which a malformed note may not have at all.
126
- * @returns {string} The URL segment (never empty).
127
- * @throws {Error} When there is no name, or the name carries no URL-safe
128
- * characters.
129
- */
130
- export function contentSlug(name) {
131
- const raw = typeof name === "string" ? name.trim() : "";
132
- if (!raw) {
133
- throw new Error("content note has no name, so it has no URL");
134
- }
135
- const normalised = slugify(raw);
136
- const slug = abbreviateTokens(normalised.split("-").filter(Boolean)).join("-");
137
- if (!slug) {
138
- throw new Error(`name "${raw}" has no URL-safe characters, so it cannot address a page`);
139
- }
140
- return slug;
141
- }
142
-
143
- /**
144
- * Find pages that would publish to the same URL.
145
- *
146
- * Nothing constrains two notes in one section from sharing a name, and a
147
- * collision silently overwrites one page with the other. This turns it into a
148
- * build failure that names every claimant, so the fix is a more specific title.
149
- * (The content tree has no collisions today.)
150
- *
151
- * @param {Array<{sec: string, slug: string, src: string}>} pages
152
- * @returns {Array<{url: string, sources: string[]}>} One entry per collision, in
153
- * first-claim order; empty when every URL is unique.
154
- */
155
- export function findSlugCollisions(pages) {
156
- const byUrl = new Map();
157
- for (const { sec, slug, src } of pages) {
158
- const url = `/${sec}/${slug}/`;
159
- if (!byUrl.has(url)) byUrl.set(url, []);
160
- byUrl.get(url).push(src);
161
- }
162
- return [...byUrl.entries()]
163
- .filter(([, sources]) => sources.length > 1)
164
- .map(([url, sources]) => ({ url, sources }));
165
- }
@@ -181,16 +181,27 @@ export function positionInBody(body, offset, { bodyLine = 1, bodyColumn = 1, lin
181
181
  * a line of prose — sending the reader to a position that is not the problem,
182
182
  * which is the one thing the located form exists to prevent.
183
183
  *
184
+ * The key match tolerates leading whitespace by default, so a nested key of the
185
+ * same name answers when no top-level one is present — which is usually what a
186
+ * reader wants, the key being unique in nearly every note. Pass `topLevel` where
187
+ * it is not: `aliases` is both a retired top-level field and a **permitted**
188
+ * `name.aliases` (#180), and a finding about the first must never open on the
189
+ * second, which would tell an author to delete a field they are allowed to
190
+ * write.
191
+ *
184
192
  * @param {string} raw - The file's full contents, frontmatter included.
185
193
  * @param {string} key - The top-level frontmatter key.
186
194
  * @param {string} [value] - When given, prefer the occurrence whose line also
187
195
  * carries this text. A list-valued key (`aliases`) is reported at the entry
188
196
  * that is wrong, not at the key that introduces it.
197
+ * @param {object} [options] - Options.
198
+ * @param {boolean} [options.topLevel=false] - Require the key at column 1, so
199
+ * an identically named nested key cannot answer for it.
189
200
  * @returns {{line?: number, column?: number}} Spreadable position fields, empty
190
201
  * when the key cannot be located — dropped rather than guessed, as
191
202
  * {@link formatDiagnostic} requires.
192
203
  */
193
- export function positionInFrontmatter(raw, key, value = undefined) {
204
+ export function positionInFrontmatter(raw, key, value = undefined, { topLevel = false } = {}) {
194
205
  if (typeof raw !== "string" || !key) return {};
195
206
  const fence = raw.match(/^---\n([\s\S]*?)\n---/);
196
207
  if (!fence) return {};
@@ -205,7 +216,10 @@ export function positionInFrontmatter(raw, key, value = undefined) {
205
216
  keyLine = i;
206
217
  break;
207
218
  }
208
- if (keyLine === -1 && new RegExp(`^\\s*${escape(key)}\\s*:`).test(lines[i])) {
219
+ if (
220
+ keyLine === -1 &&
221
+ new RegExp(`^${topLevel ? "" : "\\s*"}${escape(key)}\\s*:`).test(lines[i])
222
+ ) {
209
223
  keyLine = i;
210
224
  if (wanted == null) break;
211
225
  }