@heroiclands/package-build 9.0.0 → 10.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 (78) hide show
  1. package/CHANGELOG.md +694 -0
  2. package/CONTENT.md +273 -13
  3. package/bin/content-build.mjs +437 -7
  4. package/content-config.mjs +259 -28
  5. package/docs/content-format.md +1418 -0
  6. package/engine/address-charset.mjs +62 -0
  7. package/engine/alias-index.mjs +153 -0
  8. package/engine/base-compiler.mjs +194 -4
  9. package/engine/content-address.mjs +4 -4
  10. package/engine/content-format-check.mjs +570 -0
  11. package/engine/content-format.mjs +253 -0
  12. package/engine/content-links.mjs +132 -56
  13. package/engine/content-lint.mjs +8 -1
  14. package/engine/diagnostics.mjs +33 -0
  15. package/engine/document-subtypes.mjs +440 -0
  16. package/engine/field-spec.mjs +49 -43
  17. package/engine/frontmatter-lint.mjs +343 -27
  18. package/engine/generate.mjs +32 -4
  19. package/engine/helpers.mjs +41 -29
  20. package/engine/ids.mjs +19 -1
  21. package/engine/index.mjs +15 -0
  22. package/engine/item-registry.mjs +72 -5
  23. package/engine/kb-manifest.mjs +36 -7
  24. package/engine/map-notes.mjs +34 -18
  25. package/engine/note-claims.mjs +383 -0
  26. package/engine/note-vocabulary.mjs +678 -0
  27. package/engine/pack-config.mjs +39 -22
  28. package/engine/pack-router.mjs +17 -6
  29. package/engine/prose-lint.mjs +55 -3
  30. package/engine/retired-fields.mjs +117 -3
  31. package/engine/scenes.mjs +19 -1
  32. package/engine/schema-check.mjs +347 -3
  33. package/engine/site-build.mjs +1 -1
  34. package/engine/site-index.mjs +38 -21
  35. package/engine/system-block.mjs +513 -0
  36. package/engine/web-wikilinks.mjs +112 -80
  37. package/engine/wikilink-syntax.mjs +30 -0
  38. package/engine/wikilinks.mjs +67 -51
  39. package/package.json +6 -2
  40. package/sohl/actors.mjs +249 -36
  41. package/sohl/document-subtypes.mjs +82 -0
  42. package/sohl/index.mjs +3 -0
  43. package/sohl/items.mjs +110 -14
  44. package/sohl/note-schemas.mjs +11 -7
  45. package/types/content-config.d.mts +48 -4
  46. package/types/engine/address-charset.d.mts +45 -0
  47. package/types/engine/alias-index.d.mts +122 -0
  48. package/types/engine/base-compiler.d.mts +132 -4
  49. package/types/engine/content-address.d.mts +2 -2
  50. package/types/engine/content-format-check.d.mts +163 -0
  51. package/types/engine/content-format.d.mts +101 -0
  52. package/types/engine/content-links.d.mts +16 -1
  53. package/types/engine/content-lint.d.mts +6 -0
  54. package/types/engine/diagnostics.d.mts +29 -0
  55. package/types/engine/document-subtypes.d.mts +233 -0
  56. package/types/engine/field-spec.d.mts +76 -23
  57. package/types/engine/frontmatter-lint.d.mts +47 -2
  58. package/types/engine/generate.d.mts +14 -1
  59. package/types/engine/helpers.d.mts +21 -13
  60. package/types/engine/ids.d.mts +10 -0
  61. package/types/engine/index.d.mts +5 -0
  62. package/types/engine/item-registry.d.mts +21 -2
  63. package/types/engine/kb-manifest.d.mts +35 -8
  64. package/types/engine/map-notes.d.mts +21 -11
  65. package/types/engine/note-claims.d.mts +113 -0
  66. package/types/engine/note-vocabulary.d.mts +251 -0
  67. package/types/engine/pack-config.d.mts +4 -3
  68. package/types/engine/pack-router.d.mts +4 -4
  69. package/types/engine/prose-lint.d.mts +6 -2
  70. package/types/engine/retired-fields.d.mts +73 -2
  71. package/types/engine/schema-check.d.mts +182 -0
  72. package/types/engine/system-block.d.mts +281 -0
  73. package/types/engine/web-wikilinks.d.mts +23 -12
  74. package/types/engine/wikilink-syntax.d.mts +29 -0
  75. package/types/sohl/actors.d.mts +62 -6
  76. package/types/sohl/document-subtypes.d.mts +14 -0
  77. package/types/sohl/index.d.mts +1 -0
  78. package/types/sohl/items.d.mts +21 -0
@@ -17,10 +17,15 @@
17
17
  * The same authored links the pack compilers turn into Foundry `@UUID` enrichers
18
18
  * (see `./wikilinks.mjs`) become site-local hrefs here:
19
19
  *
20
- * `[[type/shortcode|Text]]` → `[Text](/section/slug/)`
20
+ * `[[type-shortcode|Text]]` → `[Text](/section/slug/)`
21
+ * `[[type-shortcode|]]` → the same, showing the target's own name
21
22
  * `[[Text]]` → the same, via a type-scoped alias
22
- * `[[type/shortcode#slug|Text]]` → `[Text](/section/slug/#slug)`
23
- * `[[#slug|Text]]` → `[Text](#slug)`
23
+ * `[[type-shortcode#slug|Text]]` → `[Text](/section/slug/#slug)`
24
+ * `[[#slug|Text]]` → `[Text](#slug)`
25
+ *
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.
24
29
  *
25
30
  * The KB *section* is not always the type: prose pages (`type: doc`) route by
26
31
  * their `category`, so `doc/quickstart` lands on `/user-guide/sohl-quickstart/`.
@@ -32,12 +37,21 @@
32
37
  * exactly the drift one rule with two implementations produces (#20).
33
38
  */
34
39
 
35
- // Whether a target is an *address* rather than prose is read with the pack
36
- // build's own rule, so the two builds cannot drift apart on it: they disagreed
37
- // once over the unlabelled hyphen form, which the packs showed as a raw
38
- // shortcode and the knowledgebase as a name (#1409).
40
+ // How an address *parses* is the pack build's own rule, so the two builds
41
+ // cannot drift apart on it: they disagreed once over the unlabelled hyphen
42
+ // form, which the packs showed as a raw shortcode and the knowledgebase as a
43
+ // name (#1409).
39
44
  import { readQualifier } from "./wikilinks.mjs";
40
45
  import { replaceOutsideCode } from "./code-fences.mjs";
46
+ // The canonical `package-type-shortcode` key, so a package-qualified address
47
+ // is looked up the way a vendored manifest publishes it.
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";
41
55
  // One slug rule for the whole build — see `./content-slug.mjs`. This module
42
56
  // carried a copy that dropped non-ASCII letters rather than transliterating
43
57
  // them, so a link to a heading named `Kûrbúl Helm` pointed at `#k-rb-l-helm`.
@@ -51,43 +65,33 @@ import { authoredLabel, WIKILINK, isSamePage, parseWikilink } from "./wikilink-s
51
65
  /** KB heading/anchor slug: lowercase, non-alphanumerics to single hyphens. */
52
66
 
53
67
  /**
54
- * Whether a link target addresses a document as `type-shortcode` (or the legacy
55
- * `type/shortcode`) rather than naming it in prose.
56
- *
57
- * Delegates to the pack build's {@link readQualifier} so one rule serves both
58
- * builds. A `reason` is as much an address as a resolved qualifier is — the
59
- * target is qualified either way, it just names no known type — and the caller
60
- * only ever asks this of a target that already resolved.
61
- *
62
- * @param {string} target - The link target, anchor already removed.
63
- * @param {Set<string>} [contentTypes] - Every content type the KB build saw.
64
- * @returns {boolean} `true` when the target is an address.
65
- */
66
- function isAddress(target, contentTypes) {
67
- return readQualifier(target, contentTypes ?? new Set()) !== null;
68
- }
69
-
70
- /**
71
- * The `type/shortcode` index key a qualified target resolves to, or `null`.
68
+ * The index key a **piped** target resolves to, or `null` when it does not
69
+ * parse as an address at all.
72
70
  *
73
71
  * The KB index is keyed by the canonical `type/shortcode`, so a target written
74
- * in the hyphen separator — which is what the vault authors (#1398) — has to be
75
- * rewritten to it before lookup. Uses the same {@link readQualifier} as
76
- * {@link isAddress}, so recognising an address and resolving one can never
77
- * disagree: the first-hyphen split and the known-type condition that keeps
78
- * `[[Grukar-ahk]]` an alias are stated once, in the pack build.
72
+ * 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.
79
77
  *
80
78
  * The build indexes an item note under both `skill/climb` and `docskill/climb`,
81
79
  * and `contentTypes` carries both qualifiers, so either form finds the page.
82
80
  *
83
81
  * @param {string} target - The link target, anchor already removed.
84
82
  * @param {Set<string>} [contentTypes] - Every content type the KB build saw.
85
- * @returns {string | null} The index key, or `null` when not qualified.
83
+ * @param {Set<string>} [packages] - Every package an address may name.
84
+ * @returns {string | null} The index key, or `null` when not an address.
86
85
  */
87
- function qualifiedKey(target, contentTypes) {
88
- const read = readQualifier(target, contentTypes ?? new Set());
86
+ function qualifiedKey(target, contentTypes, packages) {
87
+ const read = readQualifier(target, contentTypes ?? new Set(), packages);
89
88
  if (!read || read.reason) return null;
90
- return `${read.type}/${read.shortcode}`.toLowerCase();
89
+ // A package-qualified address keeps its package: the canonical key is what
90
+ // a vendored manifest publishes, and dropping the segment would resolve
91
+ // another package's address against this one's short key.
92
+ return read.package ?
93
+ canonicalKey(read.package, read.type, read.shortcode)
94
+ : `${read.type}/${read.shortcode}`.toLowerCase();
91
95
  }
92
96
 
93
97
  /**
@@ -203,18 +207,27 @@ function isPlainMap(value) {
203
207
  /**
204
208
  * Rewrites the wikilinks in a markdown body as KB-local markdown links.
205
209
  *
206
- * A target is looked up case-insensitively: first as an alias scoped to the
207
- * source's own **type** (`ctx.typeAlias`, keyed `type|alias`) — a note's
208
- * directory and `category` play no part — then in the KB-wide `ctx.index` (keyed
209
- * by the unambiguous `section/slug` and `type/shortcode`, plus name/filename/slug
210
- * fallbacks).
210
+ * A target is looked up case-insensitively in **one** of two namespaces, and
211
+ * the pipe chooses which (#131):
211
212
  *
212
- * An unresolved target fails the build only when it is a genuine intra-KB
213
- * problem an ambiguous alias, or a qualified `prefix/key` whose prefix is a
214
- * real KB section or content directory. Anything else is treated as an external
215
- * referenceuntil every package's manifest is present, after which any
216
- * `type-shortcode` address resolving nowhere fails too. Failures are collected
217
- * in `ctx.errors`.
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`.
218
231
  *
219
232
  * Whether or not it fails the build, a target that resolves nowhere renders
220
233
  * through {@link unresolvedLink} rather than as bare prose (#1665): the author's
@@ -229,7 +242,9 @@ function isPlainMap(value) {
229
242
  *
230
243
  * @param {string} body - The markdown body.
231
244
  * @param {object} ctx - `{ index, typeAlias, collide, typeCollide, sections,
232
- * contentTypes, foreign, manifestsComplete, type, errors, src }`. `foreign`
245
+ * contentTypes, packages, foreign, manifestsComplete, type, errors, src }`.
246
+ * `packages` is every package an address may name, without which the leading
247
+ * package segment of a canonical address reads as an unknown type; `foreign`
233
248
  * is the cross-package manifest index (#1446); `manifestsComplete` says
234
249
  * whether every linkable package is accounted for. Together they decide
235
250
  * whether an unresolved address is a typo or a package merely absent.
@@ -239,7 +254,8 @@ export function resolveWebWikilinks(body, ctx) {
239
254
  // Code is verbatim: a `[[…]]` inside a code fence, an indented block or an
240
255
  // inline span is source text, not a link (#1505).
241
256
  return replaceOutsideCode(body, WIKILINK, (_m, rawInner) => {
242
- const { target, anchor, display } = parseWikilink(rawInner);
257
+ const parsed = parseWikilink(rawInner);
258
+ const { target, anchor, display } = parsed;
243
259
  // An empty label is not a label: `[[x|]]` addresses the target and
244
260
  // shows its name, so `""` falls through to the same place `null` does
245
261
  // (#113). One reading, from {@link authoredLabel}.
@@ -250,32 +266,41 @@ export function resolveWebWikilinks(body, ctx) {
250
266
  return `[${label ?? anchor}](#${slugify(anchor)})`;
251
267
  }
252
268
 
253
- const key = target.toLowerCase();
254
- const typeKey = ctx.type ? `${ctx.type}|${key}`.toLowerCase() : null;
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;
255
275
  // The canonical separator (#1398) has to be resolved, not merely
256
- // recognised. Without this the form resolved only when source and
257
- // target shared a type, by way of the seeded alias below; every
258
- // *cross-type* link written in it silently lost its href.
259
- const hyphenKey = qualifiedKey(target, ctx.contentTypes);
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;
279
+ const rawKey = target.toLowerCase();
260
280
  const hit =
261
- (typeKey ? ctx.typeAlias.get(typeKey) : undefined) ??
262
- ctx.index.get(key) ??
263
- (hyphenKey ? ctx.index.get(hyphenKey) : undefined) ??
264
- // A manifest entry carries the same `{ url, name }` shape as a
265
- // local one (#1446), so a cross-package hit needs no special case
266
- // below. Local wins: a live build is authoritative and a vendored
267
- // manifest can only be staler.
268
- (hyphenKey ? ctx.foreign?.get(hyphenKey) : undefined);
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;
269
296
  if (hit) {
270
- // With no explicit label, a *qualified* target has no prose to show
271
- // (a shortcode is not display text), so fall back to the document's
272
- // name. A bare `[[Text]]` is already the prose the author wrote —
273
- // substituting the canonical name there would rewrite the sentence
274
- // ("worsens the [[Shock State]]" must not render as "Shock").
275
- // Both separators qualify: `type-shortcode` is the canonical form
276
- // (#1398), and a hyphen inside a note *name* ("Grukar-ahk") is not
277
- // one, which is why the rule is the packs' own (#1409).
278
- const text = label ?? (isAddress(target, ctx.contentTypes) ? hit.name : target);
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);
279
304
  // A pack-only package publishes Foundry addresses and no pages
280
305
  // (#1516), so its entries carry no `path` and resolve to no URL.
281
306
  // The address is real — this is not a typo and must not fail the
@@ -289,15 +314,9 @@ export function resolveWebWikilinks(body, ctx) {
289
314
 
290
315
  const slash = target.indexOf("/");
291
316
  const prefix = slash === -1 ? null : target.slice(0, slash).toLowerCase();
292
- // Deliberately *not* extended to the hyphen form, which is also how a
293
- // note addresses content in a package this build does not publish
294
- // (`Rules/Bestiary.md` → `being-grkrahk`, a real note in the `thalorna`
295
- // package). Nothing in the syntax separates that from a typo,
296
- // so failing here would break the build on correct content.
297
- //
298
- // A dead address is caught instead by `lint:content-links` (#1414),
299
- // which holds the reviewed list of cross-package exceptions — and which,
300
- // unlike this build, runs as part of `npm run lint` on every change.
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.
301
320
  const badQualified =
302
321
  prefix !== null && (ctx.sections.has(prefix) || ctx.contentTypes.has(prefix));
303
322
  // The hyphen form is the canonical address (#1398) and is what the
@@ -316,7 +335,17 @@ export function resolveWebWikilinks(body, ctx) {
316
335
  // someone has to remember.
317
336
  const badAddress = ctx.manifestsComplete === true && hyphenKey !== null;
318
337
 
319
- if ((typeKey && ctx.typeCollide.has(typeKey)) || ctx.collide.has(key)) {
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
+ ) {
320
349
  ctx.errors.push({ file: ctx.src, target, reason: "ambiguous" });
321
350
  } else if (badQualified || badAddress) {
322
351
  ctx.errors.push({
@@ -325,6 +354,9 @@ export function resolveWebWikilinks(body, ctx) {
325
354
  reason: "broken type/shortcode",
326
355
  });
327
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.
328
360
  return unresolvedLink(label ?? target, target);
329
361
  });
330
362
  }
@@ -116,6 +116,36 @@ export function authoredLabel({ display }) {
116
116
  return display ? display : null;
117
117
  }
118
118
 
119
+ /**
120
+ * Which namespace a link resolves in: the **address** space, or the **alias**
121
+ * space.
122
+ *
123
+ * **The pipe decides, and nothing else does** (#131). `[[x|…]]` is an address,
124
+ * parsed by the address grammar; `[[x]]` is an alias, looked up within the
125
+ * source note's own type. Neither falls back to the other.
126
+ *
127
+ * Both resolvers used to decide by *shape* instead — try the address, fall
128
+ * back to the alias — which had three costs. An author could not say which
129
+ * they meant, so a note whose **name** looked like an address (`Grukar-ahk`)
130
+ * was read as one, and a genuine address that resolved nowhere silently became
131
+ * a name lookup and reported nothing. And a positional address grammar could
132
+ * not split a target confidently until it had first ruled out every note name
133
+ * in the corpus.
134
+ *
135
+ * An **empty** label is still a pipe: `[[x|]]` is an address that renders the
136
+ * target's *current* name, so a rename shows at every citation with no link
137
+ * edited. That is why this reads {@link ParsedWikilink.labelled} and not
138
+ * {@link authoredLabel} — the two answer different questions, and only one of
139
+ * them is about namespaces.
140
+ *
141
+ * @param {{labelled: boolean}} parsed - A parsed wikilink, or anything
142
+ * carrying its `labelled`.
143
+ * @returns {boolean} True when the target is an address.
144
+ */
145
+ export function resolvesAsAddress({ labelled }) {
146
+ return Boolean(labelled);
147
+ }
148
+
119
149
  /**
120
150
  * Whether a parsed link addresses a section of the page it is written on.
121
151
  *
@@ -16,11 +16,17 @@
16
16
  *
17
17
  * Content notes link to one another with wikilinks rather than file paths:
18
18
  *
19
- * `[[type/shortcode|Text]]` a document of that type
19
+ * `[[type-shortcode|Text]]` a document of that type
20
+ * `[[type-shortcode|]]` the same, showing the target's current name
20
21
  * `[[Text]]` an alias unique within the source's own type
21
- * `[[type/shortcode#slug|T]]` a section (see below)
22
+ * `[[type-shortcode#slug|T]]` a section (see below)
22
23
  * `[[#slug|Text]]` a section of the source note itself
23
- * `[[doctype/shortcode|T]]` an item's *documentation* (see below)
24
+ * `[[doctype-shortcode|T]]` an item's *documentation* (see below)
25
+ *
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.
24
30
  *
25
31
  * The qualifier is the note's **type**, which with its shortcode is the system's
26
32
  * logical identity: `(type, shortcode)` is unique by rule (see the Shortcode
@@ -28,13 +34,13 @@
28
34
  * unique per type, not per directory, so a directory qualifier would add nothing
29
35
  * to the address while breaking every inbound link the moment a note is refiled.
30
36
  *
31
- * The bare form is the same address with the qualifier left implicit: it resolves
32
- * against the aliases of the source's **own type**, so a `doc` reaches any other
33
- * `doc` by name wherever it is filed. Nothing narrower is consulted — a note's
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
34
40
  * directory and its `category` play no part in resolution. Where two notes of a
35
41
  * type legitimately share a name (a rules page and a user-guide page both called
36
42
  * "Gear"), the bare form is ambiguous and resolves to neither; the author writes
37
- * the full `[[type/shortcode|Text]]` form instead.
43
+ * the `[[type-shortcode|Text]]` address instead.
38
44
  *
39
45
  * At compile time each becomes a Foundry UUID enricher, routed to the pack that
40
46
  * the target's type compiles into (see {@link packForType}):
@@ -87,7 +93,10 @@ import { hasDocEntry, itemDocEntryId } from "./item-docs.mjs";
87
93
  import { replaceOutsideCode } from "./code-fences.mjs";
88
94
  // The syntax lives in `./wikilink-syntax.mjs`, so the web resolver and this
89
95
  // one cannot disagree about what counts as a link.
90
- import { authoredLabel, WIKILINK, parseWikilink } from "./wikilink-syntax.mjs";
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";
91
100
 
92
101
  export { ITEM_PACK, PACK_BY_TYPE, packForType };
93
102
 
@@ -304,7 +313,7 @@ export function buildWikilinkIndex(docs, packageId, foreign, contentPackage) {
304
313
 
305
314
  if (d.shortcode) byShortcode.set(`${norm(d.type)}/${norm(d.shortcode)}`, d);
306
315
  for (const a of d.aliases ?? []) {
307
- const key = `${norm(d.type)}|${norm(a)}`;
316
+ const key = aliasKey(d.type, a);
308
317
  // Second claimant poisons the alias: it can no longer be resolved.
309
318
  byAlias.set(key, byAlias.has(key) && byAlias.get(key) !== d ? null : d);
310
319
  // Every claimant is kept alongside, because poisoning the alias
@@ -475,55 +484,60 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index }) {
475
484
  let text = labelled ? (authoredLabel(parsed) ?? "") : parsed.inner;
476
485
  const slug = parsed.anchor || null;
477
486
 
478
- // Resolve the document: same-page (empty target), type-shortcode, or alias.
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}.
479
490
  let doc;
480
491
  // Set when the qualifier was the virtual `doc<type>` form, so the UUID
481
492
  // is built against the item doc entry rather than the item itself.
482
493
  let itemDoc = false;
483
- // Set when the target was read as `type-shortcode` — an address rather
484
- // than prose, which decides what an unlabelled link shows (#1409).
494
+ // Set when the target was read as an address, which is what decides
495
+ // whether a foreign manifest is consulted for it below.
485
496
  let addressed = false;
486
497
  // Kept for the foreign fallback below, which needs the parsed address.
487
498
  let qualifiedRead = null;
488
499
  if (target === "" && slug) {
489
500
  doc = { type, id, pack, docPack };
490
- } else {
501
+ } else if (resolvesAsAddress(parsed)) {
491
502
  const qualified = readQualifier(target, index.types, index.packages);
492
503
  qualifiedRead = qualified;
493
- if (qualified?.reason) {
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.
508
+ if (!qualified || qualified.reason) {
494
509
  unresolved.push({
495
510
  link: all,
496
511
  target,
497
512
  offset,
498
- reason: qualified.reason,
513
+ reason: qualified?.reason ?? "not-an-address",
514
+ addressed: true,
499
515
  });
500
516
  return unresolvedLink(text || target, target);
501
517
  }
502
- if (qualified) {
503
- addressed = true;
504
- itemDoc = qualified.itemDoc;
505
- doc = index.byShortcode.get(`${qualified.type}/${qualified.shortcode}`);
506
- } else {
507
- const aliasKey = `${norm(type)}|${norm(target)}`;
508
- const hit = index.byAlias.get(aliasKey);
509
- if (hit === null) {
510
- unresolved.push({
511
- link: all,
512
- target,
513
- offset,
514
- reason: "ambiguous",
515
- // Who claimed it, so the report can name the collision
516
- // rather than the note that merely cites it (#13).
517
- candidates: (index.aliasClaims?.get(aliasKey) ?? []).map((d) => ({
518
- type: d.type,
519
- shortcode: d.shortcode,
520
- name: d.name,
521
- })),
522
- });
523
- return unresolvedLink(text || target, target);
524
- }
525
- doc = hit;
518
+ addressed = true;
519
+ itemDoc = qualified.itemDoc;
520
+ 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) {
525
+ unresolved.push({
526
+ link: all,
527
+ target,
528
+ offset,
529
+ 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
+ })),
537
+ });
538
+ return unresolvedLink(text || target, target);
526
539
  }
540
+ doc = hit;
527
541
  }
528
542
  if (!doc) {
529
543
  // Nothing local answers. A foreign package may publish this
@@ -550,22 +564,24 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index }) {
550
564
  target,
551
565
  offset,
552
566
  reason: "unknown",
553
- // A *qualified* address that resolves nowhere is a typo: every
554
- // package it could name is either built here or vendored, so
555
- // there is no third possibility left. A bare alias is not — it
556
- // may simply be prose.
557
- addressed: !!qualifiedRead && !qualifiedRead.reason,
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,
558
572
  });
559
573
  return unresolvedLink(text || target, target);
560
574
  }
561
575
 
562
- // With no explicit label, a *qualified* target has no prose to show — a
563
- // shortcode is an address, not display text so the document's own name
564
- // stands in (#1409). A bare `[[Text]]` is already the prose the author
565
- // wrote, and substituting the canonical name there would rewrite the
566
- // sentence ("worsens the [[Shock State]]" must not render as "Shock").
567
- // The knowledgebase build reads the same authored link the same way.
568
- if (!text || (!labelled && addressed)) text = doc.name ?? target;
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.
584
+ if (!text) text = doc.name ?? target;
569
585
 
570
586
  // Both addresses were computed when the target was indexed. An item
571
587
  // doc lives in the journals pack under its own derived entry id, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "9.0.0",
3
+ "version": "10.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",
@@ -101,6 +101,7 @@
101
101
  "container.mjs",
102
102
  "content-config.mjs",
103
103
  "coverage.mjs",
104
+ "docs",
104
105
  "deploy.mjs",
105
106
  "e2e.mjs",
106
107
  "engine",
@@ -148,9 +149,12 @@
148
149
  "build:types": "tsc -p tsconfig.dts.json",
149
150
  "format": "prettier --write .",
150
151
  "format:check": "prettier --check .",
151
- "lint": "npm run format:check && npm run lint:markdown",
152
+ "lint": "npm run format:check && npm run lint:markdown && npm run lint:content-format",
152
153
  "lint:markdown": "node bin/content-build.mjs markdown",
153
154
  "lint:markdown:fix": "node bin/content-build.mjs markdown --fix",
155
+ "lint:content-format": "npm run lint:content-format:schema && npm run lint:content-format:fields",
156
+ "lint:content-format:schema": "node bin/content-build.mjs content-format schema --schema sohl=tests/fixtures/content-format/schema-sohl.json",
157
+ "lint:content-format:fields": "node bin/content-build.mjs content-format fields --fields sohl",
154
158
  "changeset": "changeset",
155
159
  "changeset:check": "changeset status --since=origin/main",
156
160
  "changeset:version": "changeset version && npm install --package-lock-only",