@heroiclands/package-build 10.0.0 → 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 +306 -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 +27 -7
  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
@@ -19,13 +19,13 @@
19
19
  *
20
20
  * `[[type-shortcode|Text]]` → `[Text](/section/slug/)`
21
21
  * `[[type-shortcode|]]` → the same, showing the target's own name
22
- * `[[Text]]` → the same, via a type-scoped alias
23
22
  * `[[type-shortcode#slug|Text]]` → `[Text](/section/slug/#slug)`
24
23
  * `[[#slug|Text]]` → `[Text](#slug)`
25
24
  *
26
- * **The pipe decides which namespace a target belongs to** (#131), with no
27
- * fallback either way see {@link resolvesAsAddress}, which states the rule
28
- * for this build and the pack build together.
25
+ * **Every link is an address and carries a label** (#180). One written without
26
+ * a label addresses nothing and is reported see
27
+ * {@link unlabelledLinkMessage}, which states the rule for this build and the
28
+ * pack build together.
29
29
  *
30
30
  * The KB *section* is not always the type: prose pages (`type: doc`) route by
31
31
  * their `category`, so `doc/quickstart` lands on `/user-guide/sohl-quickstart/`.
@@ -46,12 +46,9 @@ import { replaceOutsideCode } from "./code-fences.mjs";
46
46
  // The canonical `package-type-shortcode` key, so a package-qualified address
47
47
  // is looked up the way a vendored manifest publishes it.
48
48
  import { canonicalKey } from "./kb-manifest.mjs";
49
- // The alias half of the two namespaces the key rule, shared with the pack
50
- // build and the link checker (#131).
51
- import { aliasKey } from "./alias-index.mjs";
52
- // Which namespace a target belongs to. The pipe decides, and this is the one
53
- // place that says so.
54
- import { resolvesAsAddress } from "./wikilink-syntax.mjs";
49
+ // The one rule about a link's shape both builds share: it carries a label, and
50
+ // {@link unlabelledLinkMessage} is the one place that says so (#180).
51
+ import { unlabelledLinkMessage } from "./wikilink-syntax.mjs";
55
52
  // One slug rule for the whole build — see `./content-slug.mjs`. This module
56
53
  // carried a copy that dropped non-ASCII letters rather than transliterating
57
54
  // them, so a link to a heading named `Kûrbúl Helm` pointed at `#k-rb-l-helm`.
@@ -70,21 +67,24 @@ import { authoredLabel, WIKILINK, isSamePage, parseWikilink } from "./wikilink-s
70
67
  *
71
68
  * The KB index is keyed by the canonical `type/shortcode`, so a target written
72
69
  * in the hyphen separator — which is what the content tree authors (#1398) —
73
- * has to be rewritten to it before lookup. Uses the pack build's own
74
- * {@link readQualifier}, so recognising an address and resolving one can never
75
- * disagree: the two separators and the optional leading package segment are
76
- * stated once, there.
70
+ * has to be rewritten to it before lookup. The target is read by the pack
71
+ * build's own {@link readQualifier}, so recognising an address and resolving
72
+ * one can never disagree: the two separators and the optional leading package
73
+ * segment are stated once, there.
74
+ *
75
+ * It takes the **parsed** qualifier rather than the raw target because the
76
+ * caller needs the parse for a second purpose: `unknown-type` and
77
+ * `not-an-address` are different findings with different fixes, and only the
78
+ * `reason` tells them apart (#184). Reading the target twice would let the two
79
+ * readings drift.
77
80
  *
78
81
  * The build indexes an item note under both `skill/climb` and `docskill/climb`,
79
82
  * and `contentTypes` carries both qualifiers, so either form finds the page.
80
83
  *
81
- * @param {string} target - The link target, anchor already removed.
82
- * @param {Set<string>} [contentTypes] - Every content type the KB build saw.
83
- * @param {Set<string>} [packages] - Every package an address may name.
84
+ * @param {object|null} read - From {@link readQualifier}.
84
85
  * @returns {string | null} The index key, or `null` when not an address.
85
86
  */
86
- function qualifiedKey(target, contentTypes, packages) {
87
- const read = readQualifier(target, contentTypes ?? new Set(), packages);
87
+ function keyOfRead(read) {
88
88
  if (!read || read.reason) return null;
89
89
  // A package-qualified address keeps its package: the canonical key is what
90
90
  // a vendored manifest publishes, and dropping the segment would resolve
@@ -132,6 +132,39 @@ function unresolvedLink(text, target) {
132
132
  );
133
133
  }
134
134
 
135
+ /**
136
+ * How a link to a **draft** note renders (#183).
137
+ *
138
+ * A note tagged `draft` exists so a link into it is not dead, and nothing more.
139
+ * Unmarked, a reader follows a promising link into an empty page and an author
140
+ * cannot see which of their links still owe content.
141
+ *
142
+ * **The wrapper carries the cue and nothing else.** The link itself is
143
+ * untouched — Goldmark parses inline markdown inside an inline HTML span, so
144
+ * the markdown link still becomes an anchor, and the note is on the site, in
145
+ * the packs and in the manifest exactly as any other. Nothing here resembles
146
+ * the retired `draft:` field, which moved a note from published to unresolvable
147
+ * without saying so.
148
+ *
149
+ * The appearance lives in the Hugo theme for the website and in
150
+ * `scss/components/_draft-link.scss` for Foundry, not here.
151
+ *
152
+ * **Byte-identical with the pack build's copy** in `wikilinks.mjs`, down to the
153
+ * class name and the `title` wording — one authored link renders on two
154
+ * surfaces, and the two builds have drifted before over exactly this kind of
155
+ * detail (#1409). Duplicated rather than imported for the same reason
156
+ * {@link unresolvedLink} is; hoisting both is HeroicLands/content-build#13.
157
+ *
158
+ * The argument is already-built markup and is deliberately not escaped; the
159
+ * *authored* text inside it was escaped, or made into a link, by the caller.
160
+ *
161
+ * @param {string} inner - The resolved link, as this build emits it.
162
+ * @returns {string} An inline HTML span wrapping it.
163
+ */
164
+ function draftLink(inner) {
165
+ return `<span class="sohl-draft-link" title="Draft — not yet written">${inner}</span>`;
166
+ }
167
+
135
168
  /**
136
169
  * A wikilink, as it is written, anywhere in a value that is not markdown.
137
170
  *
@@ -207,33 +240,34 @@ function isPlainMap(value) {
207
240
  /**
208
241
  * Rewrites the wikilinks in a markdown body as KB-local markdown links.
209
242
  *
210
- * A target is looked up case-insensitively in **one** of two namespaces, and
211
- * the pipe chooses which (#131):
212
- *
213
- * - **Unpiped** an alias scoped to the source's own **type**
214
- * (`ctx.typeAlias`, keyed `type|alias`). A note's directory and `category`
215
- * play no part.
216
- * - **Piped** — an address, parsed by {@link readQualifier} and looked up in
217
- * the KB-wide `ctx.index` (the canonical `package-type-shortcode`,
218
- * `type/shortcode`, and the site's own `section/slug`), then in the vendored
219
- * `ctx.foreign` manifests.
220
- *
221
- * Neither falls back to the other, so the name/basename/slug fallbacks that
222
- * share `ctx.index` no longer answer for an address: only a slash-qualified
223
- * target reaches the raw key, which is what keeps `section/slug` addressable.
224
- *
225
- * An unresolved target fails the build when it is a genuine intra-KB problem —
226
- * an ambiguous alias, a qualified `prefix/key` whose prefix is a real KB
227
- * section or content directory, or a **piped** target that is not an address
228
- * at all. Anything else is treated as an external reference until every
229
- * package's manifest is present, after which any address resolving nowhere
230
- * fails too. Failures are collected in `ctx.errors`.
231
- *
232
- * Whether or not it fails the build, a target that resolves nowhere renders
233
- * through {@link unresolvedLink} rather than as bare prose (#1665): the author's
234
- * text is kept, marked so a reader can see a link was intended. Not failing the
235
- * build is a statement that the link *may* be legitimate prose it was never a
236
- * reason to make a dead link indistinguishable from the sentence around it.
243
+ * **Every target is an address**, parsed by {@link readQualifier} and looked up
244
+ * case-insensitively in the KB-wide `ctx.index` (the canonical
245
+ * `package-type-shortcode`, `type/shortcode`, and the site's own
246
+ * `section/slug`), then in the vendored `ctx.foreign` manifests. A link written
247
+ * without a label addresses nothing at all and is reported as such (#180) —
248
+ * there is no second namespace left for it to name.
249
+ *
250
+ * Only a slash-qualified target reaches the raw key, which is what keeps
251
+ * `section/slug` addressable without a page's own slug answering for it.
252
+ *
253
+ * **Every target that resolves nowhere fails the build** (#184), and is
254
+ * classified into the vocabulary all three resolvers share `unlabelled`,
255
+ * `not-an-address`, `unknown-type`, `ambiguous`, `unresolved`. Failures are
256
+ * collected in `ctx.errors`, each carrying the authored `link` and its
257
+ * `occurrence` so a caller can report the line and column it sits on.
258
+ *
259
+ * There used to be one exception: a hyphen-form address was let through while
260
+ * any linkable package had no vendored manifest, since a real cross-package
261
+ * reference and a typo look identical from here. The pack compilers and the
262
+ * link checker never made that allowance, so its only surviving effect was to
263
+ * give one authored link two verdicts. The advice moved into the message
264
+ * instead.
265
+ *
266
+ * A target that resolves nowhere still renders through {@link unresolvedLink}
267
+ * rather than as bare prose (#1665): the author's text is kept, marked so a
268
+ * reader can see a link was intended. The marking and the failure are separate
269
+ * jobs and always were the mark is for whoever reads the page a *previous*
270
+ * build emitted, the failure is for the author of this one.
237
271
  *
238
272
  * A target that **resolved** to an entry with no page is not this case and is
239
273
  * not marked: a pack-only package (#1516) publishes Foundry addresses and no
@@ -241,19 +275,48 @@ function isPlainMap(value) {
241
275
  * link to.
242
276
  *
243
277
  * @param {string} body - The markdown body.
244
- * @param {object} ctx - `{ index, typeAlias, collide, typeCollide, sections,
245
- * contentTypes, packages, foreign, manifestsComplete, type, errors, src }`.
278
+ * @param {object} ctx - `{ index, collide, sections, contentTypes, packages,
279
+ * foreign, type, errors, src, file }`.
246
280
  * `packages` is every package an address may name, without which the leading
247
281
  * package segment of a canonical address reads as an unknown type; `foreign`
248
- * is the cross-package manifest index (#1446); `manifestsComplete` says
249
- * whether every linkable package is accounted for. Together they decide
250
- * whether an unresolved address is a typo or a package merely absent.
282
+ * is the cross-package manifest index (#1446). `src` is the page's display
283
+ * path and `file` the source file a diagnostic should name — absent, `src`
284
+ * stands in.
251
285
  * @returns {string} The body with wikilinks rewritten.
252
286
  */
253
287
  export function resolveWebWikilinks(body, ctx) {
288
+ // How many times each authored link has been seen, so two identical links
289
+ // on one page are located at their own positions in the source file — the
290
+ // same counting the checker does, and what turns a finding into a
291
+ // `file:line:column:` diagnostic rather than a note-wide one (#17, #184).
292
+ const seen = new Map();
293
+ /**
294
+ * Records a finding, and returns the marked-up link it renders as.
295
+ *
296
+ * @param {string} all - The authored link, brackets and all.
297
+ * @param {object} finding - `{ target, reason }`, plus any extras the class
298
+ * carries.
299
+ * @param {string} text - What the link renders as.
300
+ * @returns {string} The marked span.
301
+ */
302
+ const report = (all, finding, text) => {
303
+ const occurrence = (seen.get(all) ?? 0) + 1;
304
+ seen.set(all, occurrence);
305
+ ctx.errors.push({
306
+ // Absolute where the caller supplied one, so a diagnostic locates
307
+ // the file an editor opens; the display path otherwise.
308
+ file: ctx.file ?? ctx.src,
309
+ src: ctx.src,
310
+ link: all,
311
+ occurrence,
312
+ ...finding,
313
+ });
314
+ return unresolvedLink(text, finding.target);
315
+ };
316
+
254
317
  // Code is verbatim: a `[[…]]` inside a code fence, an indented block or an
255
318
  // inline span is source text, not a link (#1505).
256
- return replaceOutsideCode(body, WIKILINK, (_m, rawInner) => {
319
+ return replaceOutsideCode(body, WIKILINK, (all, rawInner) => {
257
320
  const parsed = parseWikilink(rawInner);
258
321
  const { target, anchor, display } = parsed;
259
322
  // An empty label is not a label: `[[x|]]` addresses the target and
@@ -261,46 +324,43 @@ export function resolveWebWikilinks(body, ctx) {
261
324
  // (#113). One reading, from {@link authoredLabel}.
262
325
  const label = authoredLabel({ display });
263
326
 
327
+ // **Every link carries a label** (#180). Without one there is nothing
328
+ // to resolve against — the alias namespace a bare `[[Text]]` named is
329
+ // retired — and nothing to show either, a shortcode being an address
330
+ // rather than prose. Reported before the same-page form, because the
331
+ // rule is about how the link is *written*: `[[#slug]]` needs the pipe
332
+ // exactly as `[[skill-clmb]]` does.
333
+ if (!parsed.labelled) {
334
+ return report(all, { target: parsed.inner, reason: "unlabelled" }, parsed.inner);
335
+ }
336
+
264
337
  // `[[#section-slug|Text]]` — a section of this same page.
265
338
  if (isSamePage({ target, anchor })) {
266
339
  return `[${label ?? anchor}](#${slugify(anchor)})`;
267
340
  }
268
341
 
269
- // **The pipe chooses the namespace, with no fallback either way**
270
- // (#131). The two used to be tried in turn, so a note *name* that
271
- // looked like an address resolved as one and a genuine address that
272
- // resolved nowhere silently became a name lookup.
273
- const addressed = resolvesAsAddress(parsed);
274
- const typeKey = ctx.type ? aliasKey(ctx.type, target) : null;
275
342
  // The canonical separator (#1398) has to be resolved, not merely
276
- // recognised. `null` here means the piped target is not an address at
277
- // all, which is now a defect rather than a reason to try the aliases.
278
- const hyphenKey = addressed ? qualifiedKey(target, ctx.contentTypes, ctx.packages) : null;
343
+ // recognised. `null` here means the target is not an address at all,
344
+ // which is a defect: there is no other namespace to try.
345
+ const read = readQualifier(target, ctx.contentTypes ?? new Set(), ctx.packages);
346
+ const hyphenKey = keyOfRead(read);
279
347
  const rawKey = target.toLowerCase();
280
348
  const hit =
281
- addressed ?
282
- ((hyphenKey ? ctx.index.get(hyphenKey) : undefined) ??
283
- // `section/slug` is the site's own address for a page, and it
284
- // is in the same map. Admitted only when the target carries a
285
- // slash, which is what keeps the *alias* fallbacks sharing
286
- // that map a page's name, basename and slug out of the
287
- // address namespace.
288
- (rawKey.includes("/") ? ctx.index.get(rawKey) : undefined) ??
289
- // A manifest entry carries the same `{ url, name }` shape as a
290
- // local one (#1446), so a cross-package hit needs no special
291
- // case below. Local wins: a live build is authoritative and a
292
- // vendored manifest can only be staler.
293
- (hyphenKey ? ctx.foreign?.get(hyphenKey) : undefined))
294
- : typeKey ? ctx.typeAlias.get(typeKey)
295
- : undefined;
349
+ (hyphenKey ? ctx.index.get(hyphenKey) : undefined) ??
350
+ // `section/slug` is the site's own address for a page, and it is in
351
+ // the same map. Admitted only when the target carries a slash, so
352
+ // a page's bare slug cannot answer for an address.
353
+ (rawKey.includes("/") ? ctx.index.get(rawKey) : undefined) ??
354
+ // A manifest entry carries the same `{ url, name }` shape as a
355
+ // local one (#1446), so a cross-package hit needs no special case
356
+ // below. Local wins: a live build is authoritative and a vendored
357
+ // manifest can only be staler.
358
+ (hyphenKey ? ctx.foreign?.get(hyphenKey) : undefined);
296
359
  if (hit) {
297
- // An address with no label has no prose to show (a shortcode is
298
- // not display text), so the document's **current** name stands in
299
- // and a rename shows at every citation. A bare `[[Text]]` is
300
- // already the prose the author wrote — substituting the canonical
301
- // name there would rewrite the sentence ("worsens the [[Shock
302
- // State]]" must not render as "Shock").
303
- const text = label ?? (addressed ? hit.name : target);
360
+ // An address with an *empty* label has no prose to show (a
361
+ // shortcode is not display text), so the document's **current**
362
+ // name stands in and a rename shows at every citation.
363
+ const text = label ?? hit.name;
304
364
  // A pack-only package publishes Foundry addresses and no pages
305
365
  // (#1516), so its entries carry no `path` and resolve to no URL.
306
366
  // The address is real — this is not a typo and must not fail the
@@ -308,55 +368,51 @@ export function resolveWebWikilinks(body, ctx) {
308
368
  // reader gets the text and no href. Emitting the href anyway is
309
369
  // what the manifest exists to prevent: `[Name](undefined)` renders
310
370
  // as a link and goes nowhere.
311
- if (!hit.url) return text;
312
- return `[${text}](${anchor ? `${hit.url}#${slugify(anchor)}` : hit.url})`;
371
+ const link =
372
+ hit.url ? `[${text}](${anchor ? `${hit.url}#${slugify(anchor)}` : hit.url})` : text;
373
+ // A link into a note that exists but is not written renders marked
374
+ // (#183). Presentation only — the href above is unchanged, and a
375
+ // `[[#anchor]]` self-link is not marked because the reader is
376
+ // already on the page it would be telling them about.
377
+ return hit.draft ? draftLink(link) : link;
313
378
  }
314
379
 
315
380
  const slash = target.indexOf("/");
316
381
  const prefix = slash === -1 ? null : target.slice(0, slash).toLowerCase();
317
- // A slash-qualified target whose prefix is a real section or content
318
- // type is definitely local, so it is a typo whatever the manifest
319
- // situation.
320
- const badQualified =
321
- prefix !== null && (ctx.sections.has(prefix) || ctx.contentTypes.has(prefix));
322
- // The hyphen form is the canonical address (#1398) and is what the
323
- // authored content writes. It could not be guarded while some packages
324
- // were invisible here: `Rules/Bestiary.md` addresses `being-grkrahk`,
325
- // a real note in the `thalorna` package, and nothing in the syntax
326
- // separated that legitimate cross-package reference from a typo — so
327
- // treating the form as definitely-local would have failed the build on
328
- // correct content.
382
+ // A slash-qualified target whose prefix is a real KB **section** is an
383
+ // address in the site's own `section/slug` space, which the lookup
384
+ // above already consulted. It parses as no `type/shortcode`, but it did
385
+ // address something and nothing answered — so it is unresolved, not
386
+ // unaddressable. (A prefix that is a content *type* never reaches here:
387
+ // it yields a `hyphenKey`.)
388
+ const siteAddress = prefix !== null && ctx.sections.has(prefix);
389
+
390
+ // **An address resolving nowhere is a failure, unconditionally** (#184).
329
391
  //
330
- // The link manifest settles it (#1446). Once every linkable package is
331
- // accounted for — built here or vendored as a manifest — an address
332
- // resolving in none of them is a typo and nothing else. Until then
333
- // `manifestsComplete` is false and the form stays unguarded, so the
334
- // check returns exactly when it becomes decidable rather than on a date
335
- // someone has to remember.
336
- const badAddress = ctx.manifestsComplete === true && hyphenKey !== null;
392
+ // It was gated on `manifestsComplete` while any linkable package was
393
+ // invisible here, `Rules/Bestiary.md` addressing `being-grkrahk` in the
394
+ // `thalorna` package was indistinguishable from a typo, so the form
395
+ // stayed unguarded rather than fail correct content. The gate has
396
+ // outlived that: every linkable package publishes a manifest now, and
397
+ // more to the point the *other two* resolvers never had it. The pack
398
+ // compilers fail the note and the link checker reports an error, so the
399
+ // gate's only remaining effect was to give one authored link two
400
+ // verdicts depending on which build read it. The advice a missing
401
+ // manifest calls for is in the message instead — {@link
402
+ // unresolvedAddressMessage} names both corrections — which informs the
403
+ // author without excusing the link.
404
+ const reason =
405
+ ctx.collide?.has(hyphenKey ?? rawKey) ? "ambiguous"
406
+ : hyphenKey !== null || siteAddress ? "unresolved"
407
+ : read?.reason === "unknown-type" ? "unknown-type"
408
+ // Every link is an address, and this is not one. Distinct from
409
+ // a dead address, because the fix is different: a name has to
410
+ // become an address, not be corrected (#180).
411
+ : "not-an-address";
337
412
 
338
- if (addressed && hyphenKey === null && !badQualified) {
339
- // The author wrote a pipe, so they meant an address and this is
340
- // not one. Distinct from a dead address, because the fix is
341
- // different: a name has to become an address, not be corrected
342
- // (#131).
343
- ctx.errors.push({ file: ctx.src, target, reason: "not-an-address" });
344
- } else if (
345
- addressed ?
346
- rawKey.includes("/") && ctx.collide.has(rawKey)
347
- : Boolean(typeKey && ctx.typeCollide.has(typeKey))
348
- ) {
349
- ctx.errors.push({ file: ctx.src, target, reason: "ambiguous" });
350
- } else if (badQualified || badAddress) {
351
- ctx.errors.push({
352
- file: ctx.src,
353
- target,
354
- reason: "broken type/shortcode",
355
- });
356
- }
357
- // An unresolved *alias* stays soft: it may be ordinary prose, or a
358
- // worldbuilding placeholder for a note not yet written. It still
359
- // renders marked, so a reader can see a link was intended.
360
- return unresolvedLink(label ?? target, target);
413
+ // Whether or not it failed the build, the link renders marked: the
414
+ // author's text is kept so the sentence still reads, and a reader can
415
+ // see that something was meant to be a link.
416
+ return report(all, { target, reason }, label ?? target);
361
417
  });
362
418
  }