@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
@@ -32,10 +32,20 @@
32
32
  * documents' `system` keys against `defineSchema()` **by hand**, which is how
33
33
  * the next one would have to be found too.
34
34
  *
35
- * **The emitted half needs no compilation and no parsing.** A builder *is* its
35
+ * **The declared half of the emission is statically known.** A builder *is* its
36
36
  * field list — {@link module:engine/field-spec} makes `buildFromFields` the only
37
- * statement of the mapping — so every `system` path a type can emit is
38
- * `field.to`, known statically. Nothing here compiles a document to find out.
37
+ * statement of the mapping — so every `system` path a *declared field* emits is
38
+ * `field.to`. {@link compareFields} needs no compilation to read that.
39
+ *
40
+ * **The rest of the emission is only observable.** A compiler writes keys of
41
+ * its own alongside the declared fields — `shortcode`, `actionDefs`, `notes`,
42
+ * `docHtml`, and since #126 `archetype` — and those appear in neither set
43
+ * {@link compareFields} compares, so nothing compared them at all (#155). They
44
+ * cannot be listed here without the list going stale the next time a compiler
45
+ * grows a key, so they are read off the `system` object the compiler produced:
46
+ * {@link compareEmittedSystem} takes the assembled block and asks what the
47
+ * schema does not declare. That is the same question, against the same schema,
48
+ * with the emitted set *observed* rather than derived.
39
49
  *
40
50
  * **The declared half is the consumer's, and arrives as data.** `defineSchema()`
41
51
  * lives in the target system's `src/`, so the system publishes its field sets as
@@ -56,6 +66,8 @@ import fs from "node:fs";
56
66
  import path from "node:path";
57
67
 
58
68
  import { cachedSchemaPath, SCHEMA_ARTIFACT_FILE } from "./foreign-catalog.mjs";
69
+ import { loadPackConfig } from "./pack-config.mjs";
70
+ import { systemData, systemDataPaths, undeclaredPaths } from "./system-block.mjs";
59
71
 
60
72
  /**
61
73
  * The artifact version this module reads.
@@ -239,6 +251,186 @@ export function compareFields({
239
251
  return { undeclared, unemitted, skipped };
240
252
  }
241
253
 
254
+ /**
255
+ * One `system` key a compiled document carries that its subtype does not
256
+ * declare.
257
+ *
258
+ * @typedef {object} EmissionFinding
259
+ * @property {string} type - The content type whose note produced the document.
260
+ * @property {string} subtype - The document subtype it compiled into.
261
+ * @property {string} documentType - `Item`, `Actor`, …
262
+ * @property {string} field - The undeclared path, dotted.
263
+ * @property {string} systemVersion - The version checked against.
264
+ * @property {"builder"|"compiler"} origin - What wrote it. See
265
+ * {@link emittedUndeclaredMessage}.
266
+ */
267
+
268
+ /**
269
+ * Whether a value is a mapping this walk descends into. Arrays are leaves: a
270
+ * schema declares an `ArrayField` as one path and says nothing about its
271
+ * indices.
272
+ *
273
+ * @param {unknown} value - The emitted value.
274
+ * @returns {boolean} Whether to walk into it.
275
+ */
276
+ function isMapping(value) {
277
+ return value != null && typeof value === "object" && !Array.isArray(value);
278
+ }
279
+
280
+ /**
281
+ * Every declared path that has at least one declared path beneath it.
282
+ *
283
+ * The artifact does not describe every subtree it declares. A discriminated
284
+ * `TypedSchemaField` — SoHL's `strikeMode` — is published as the single path
285
+ * `strikeMode`, because its sub-schema is chosen by a discriminator at runtime
286
+ * and there is no one field list to publish; the compiled document stores it
287
+ * flat as `{ type, name, … }` all the same. Walking into it against a schema
288
+ * that says nothing about its contents reported all ten of a combat technique's
289
+ * stored keys as undeclared — ten findings, every one wrong, about a document
290
+ * that is correct.
291
+ *
292
+ * So a container is descended into only where the artifact actually enumerates
293
+ * something beneath it. That is the same stance {@link declaredFields} takes on
294
+ * a subtype the artifact does not name: what is not described is not checked,
295
+ * rather than guessed at.
296
+ *
297
+ * @param {ReadonlySet<string>} declared - Every path the subtype declares.
298
+ * @returns {Set<string>} The paths whose subtree the artifact describes.
299
+ */
300
+ function enumeratedContainers(declared) {
301
+ const out = new Set();
302
+ for (const declaredPath of declared) {
303
+ const parts = declaredPath.split(".");
304
+ for (let i = 1; i < parts.length; i++) out.add(parts.slice(0, i).join("."));
305
+ }
306
+ return out;
307
+ }
308
+
309
+ /**
310
+ * The paths in a compiled `system` block that the subtype does not declare.
311
+ *
312
+ * {@link module:engine/system-block.undeclaredPaths} answers this for *authored*
313
+ * data, where every path came from a human and every container is one a human
314
+ * typed. An emitted block is different in one way that matters: it contains
315
+ * whole subtrees the artifact declares as a single path and describes no
316
+ * further — see {@link enumeratedContainers} — so the walk stops there instead
317
+ * of reporting their contents.
318
+ *
319
+ * Reported at the shallowest undeclared path, as its sibling is: everything
320
+ * beneath an undeclared container is undeclared by construction, and listing it
321
+ * all buries the one key that is the problem.
322
+ *
323
+ * @param {Record<string, unknown>} data - The emitted `system` block.
324
+ * @param {ReadonlySet<string>} declared - Every path the subtype declares.
325
+ * @param {ReadonlySet<string>} enumerated - From {@link enumeratedContainers}.
326
+ * @param {string} [prefix] - Internal: the path so far.
327
+ * @returns {string[]} The undeclared paths, shallowest-first.
328
+ */
329
+ function undeclaredEmittedPaths(data, declared, enumerated, prefix = "") {
330
+ const out = [];
331
+ if (!isMapping(data)) return out;
332
+ for (const [key, value] of Object.entries(data)) {
333
+ const emittedPath = prefix ? `${prefix}.${key}` : key;
334
+ if (!declared.has(emittedPath)) {
335
+ out.push(emittedPath);
336
+ continue;
337
+ }
338
+ if (!isMapping(value) || !Object.keys(value).length) continue;
339
+ if (!enumerated.has(emittedPath)) continue;
340
+ out.push(
341
+ ...undeclaredEmittedPaths(
342
+ /** @type {Record<string, unknown>} */ (value),
343
+ declared,
344
+ enumerated,
345
+ emittedPath,
346
+ ),
347
+ );
348
+ }
349
+ return out;
350
+ }
351
+
352
+ /**
353
+ * What a **compiled document** carries in `system`, against what the receiving
354
+ * subtype declares (#155).
355
+ *
356
+ * The third of the three checks, and the only one whose emitted set is
357
+ * *observed*. {@link compareFields} reads the `itemBuilders` declarations and
358
+ * {@link checkAuthoredSystemData} reads a note's `<system>.system`; between them
359
+ * they miss every key a compiler writes on its own initiative, which is not a
360
+ * residue — it is `shortcode`, `actionDefs`, `notes`, `docHtml` and `archetype`.
361
+ *
362
+ * **The keys come from the object the compiler built, after a JSON round trip.**
363
+ * That is exactly what the pack file receives, so a key whose value is
364
+ * `undefined` — dropped by `JSON.stringify`, never written, nothing for Foundry
365
+ * to discard — is correctly not a finding. Reading the assembled block is also
366
+ * the only derivation that cannot go stale: a compiler that grows a key is
367
+ * checked on the next build without anyone remembering to add it to a list.
368
+ *
369
+ * **Authored paths are left alone.** A note's own `<system>.system` is reported
370
+ * by {@link checkAuthoredSystemData}, which can point at the line the author
371
+ * wrote; reporting it again here would be the same defect twice, once without a
372
+ * position.
373
+ *
374
+ * **A subtree the artifact does not describe is not checked**, rather than
375
+ * reported wholesale — see {@link enumeratedContainers} for the
376
+ * `TypedSchemaField` case that makes the distinction load-bearing.
377
+ *
378
+ * @param {object} opts
379
+ * @param {object} opts.system - The `system` block the compiler assembled.
380
+ * @param {SchemaArtifact} opts.artifact - The receiving system's schemas.
381
+ * @param {string} opts.documentType - `Item`, `Actor`, …
382
+ * @param {string} opts.subtype - The document subtype being emitted.
383
+ * @param {string} opts.type - The content type whose note produced it, for the
384
+ * message.
385
+ * @param {readonly {to?: string}[]} [opts.fields] - The type's field
386
+ * declaration, which decides a finding's {@link EmissionFinding.origin}.
387
+ * @param {ReadonlySet<string>} [opts.authored] - Paths the note authored, left
388
+ * to the note-side check.
389
+ * @returns {EmissionFinding[]} One per undeclared path, shallowest-first.
390
+ */
391
+ export function compareEmittedSystem({
392
+ system,
393
+ artifact,
394
+ documentType,
395
+ subtype,
396
+ type,
397
+ fields,
398
+ authored = new Set(),
399
+ }) {
400
+ if (artifact?.version !== SCHEMA_ARTIFACT_VERSION) {
401
+ throw new Error(
402
+ `package-build: schema artifact version ${artifact?.version ?? "(absent)"}, ` +
403
+ `expected ${SCHEMA_ARTIFACT_VERSION}. A schema read under the wrong ` +
404
+ `shape would report confidently in both directions, so the check ` +
405
+ `stops rather than resolving anyway.`,
406
+ );
407
+ }
408
+
409
+ const declared = declaredFields(artifact, documentType, subtype);
410
+ // Not a finding, for the same reason `compareFields` skips one: a subtype
411
+ // the artifact says nothing about is a routing question (#79), not a field
412
+ // one, and guessing at it would report every key on the document.
413
+ if (!declared) return [];
414
+
415
+ // What the pack file receives. `undefined` is gone after this, which is the
416
+ // point: a key that never reaches the JSON is not an emitted key.
417
+ const emitted = JSON.parse(JSON.stringify(system ?? {}));
418
+ const byField = emittedFields(fields ?? []);
419
+
420
+ return undeclaredEmittedPaths(emitted, declared.all, enumeratedContainers(declared.all))
421
+ .filter((field) => !authored.has(field))
422
+ .map((field) => ({
423
+ type,
424
+ subtype,
425
+ documentType,
426
+ field,
427
+ systemVersion: artifact.systemVersion,
428
+ origin: /** @type {"builder"|"compiler"} */ (
429
+ byField.has(field) ? "builder" : "compiler"
430
+ ),
431
+ }));
432
+ }
433
+
242
434
  /**
243
435
  * The published schema this build should check itself against, or `null`.
244
436
  *
@@ -327,3 +519,155 @@ export function unemittedMessage(finding) {
327
519
  `field's initial value rather than an authored one`
328
520
  );
329
521
  }
522
+
523
+ /**
524
+ * What an author is told about an emitted key the target system does not
525
+ * define — in the terms of whoever can actually fix it.
526
+ *
527
+ * The failure is identical in both cases and the remedies are not, which is
528
+ * why the two are told apart at all:
529
+ *
530
+ * | origin | who writes it | what fixes it |
531
+ * | --- | --- | --- |
532
+ * | `builder` | a `fields:` entry in this repository's `itemBuilders` | change the field's `to`, or get the system to declare it |
533
+ * | `compiler` | this package, on every document of the subtype | **nothing here** — the system must declare it, or this package must be pinned to a build that does not write it |
534
+ *
535
+ * A `compiler` finding is the one worth spelling out, because the obvious first
536
+ * move — go and look for the field in `itemBuilders` — leads nowhere: there is
537
+ * no declaration to correct. It means the build is running ahead of the system
538
+ * it compiles for, and the version named in the message is what says so.
539
+ *
540
+ * @param {EmissionFinding} finding - One entry from
541
+ * {@link compareEmittedSystem}.
542
+ * @returns {string} The message.
543
+ */
544
+ export function emittedUndeclaredMessage(finding) {
545
+ if (finding.origin === "builder") return undeclaredMessage(finding);
546
+ return (
547
+ `the compiler writes \`system.${finding.field}\` into every ` +
548
+ `${finding.documentType} of subtype "${finding.subtype}", and no field ` +
549
+ `declaration names it — ${finding.documentType} subtype ` +
550
+ `"${finding.subtype}" does not define it at ${finding.systemVersion}, and ` +
551
+ `Foundry discards an unknown \`system\` key when the document is ` +
552
+ `constructed, without a warning, so the value is lost at load while the ` +
553
+ `build reports success. No \`itemBuilders\` change fixes this: declare ` +
554
+ `the field in the receiving system, or hold this package at a build that ` +
555
+ `does not write it`
556
+ );
557
+ }
558
+
559
+ /** One resolved schema artifact per resolved configuration. */
560
+ const artifacts = new WeakMap();
561
+
562
+ /**
563
+ * {@link resolveSchemaArtifact}, read once per configuration.
564
+ *
565
+ * The per-note check below runs thousands of times in a build and the artifact
566
+ * never changes inside one, so reading and parsing it per note would be a
567
+ * megabyte of JSON per hundred documents for an answer that is already known.
568
+ *
569
+ * @param {object} config - The resolved build configuration.
570
+ * @returns {{artifact: SchemaArtifact, source: string}|null} The schema.
571
+ */
572
+ function schemaFor(config) {
573
+ if (!artifacts.has(config)) artifacts.set(config, resolveSchemaArtifact(config));
574
+ return artifacts.get(config);
575
+ }
576
+
577
+ /**
578
+ * What a note authors under `<system>.system`, against what the receiving
579
+ * subtype declares (#58).
580
+ *
581
+ * The **note-side** half of the check `compareFields` performs on the
582
+ * declarations. A field list is checked once for the whole build because it is
583
+ * the same for every document; an authored `system` block is a property of one
584
+ * note, so it is checked where that note is compiled and reported against that
585
+ * note's file.
586
+ *
587
+ * It is the same failure either way, and the reason both halves exist: Foundry
588
+ * discards an unknown `system` key at construction and says nothing, so a
589
+ * mistyped path is lost at load while the build reports success.
590
+ *
591
+ * **Silent where there is nothing to check against.** A module pinning a system
592
+ * version released before the artifact existed, or a subtype the artifact does
593
+ * not name, produces no findings — the same stance `compareFields` takes, where
594
+ * an unknown subtype is a routing question rather than a field one. The
595
+ * whole-build check in `content-build lint` is where a missing artifact is said
596
+ * out loud, once, instead of per note.
597
+ *
598
+ * @param {object} fm - The note's frontmatter.
599
+ * @param {object} opts
600
+ * @param {string} opts.block - The system block to read, e.g. `"sohl"`.
601
+ * @param {string} opts.documentType - `Item`, `Actor`, …
602
+ * @param {string} opts.subType - The document subtype the note compiles into.
603
+ * @param {object} [opts.config] - The resolved build configuration.
604
+ * @returns {{path: string, message: string}[]} One finding per undeclared path,
605
+ * shallowest-first.
606
+ */
607
+ export function checkAuthoredSystemData(
608
+ fm,
609
+ { block, documentType, subType, config = loadPackConfig() },
610
+ ) {
611
+ const data = systemData(fm, block);
612
+ if (!Object.keys(data).length) return [];
613
+
614
+ const schema = schemaFor(config);
615
+ if (!schema) return [];
616
+ const declared = declaredFields(schema.artifact, documentType, subType);
617
+ if (!declared) return [];
618
+
619
+ return undeclaredPaths(data, declared.all).map((path) => ({
620
+ path,
621
+ message:
622
+ `\`${block}.system.${path}\` is not a field ${documentType} subtype ` +
623
+ `"${subType}" declares at ${schema.artifact.systemVersion} — Foundry ` +
624
+ `discards an unknown \`system\` key when the document is constructed, ` +
625
+ `without a warning, so the value is lost at load while the build ` +
626
+ `reports success`,
627
+ }));
628
+ }
629
+
630
+ /**
631
+ * The `system` block a compiler just assembled, against what the receiving
632
+ * subtype declares (#155).
633
+ *
634
+ * The build-time face of {@link compareEmittedSystem}: it resolves the schema
635
+ * the way every other check here does — the system's own committed artifact, or
636
+ * the cached one from the release a module pins — and attaches the message a
637
+ * reader sees.
638
+ *
639
+ * **Silent where there is nothing to check against**, exactly as its two
640
+ * siblings are: a module pinning a system version released before the artifact
641
+ * existed, or a subtype the artifact does not name, produces no findings.
642
+ * `content-build lint` is where a missing artifact is said out loud, once.
643
+ *
644
+ * @param {object} system - The `system` block the compiler produced.
645
+ * @param {object} opts
646
+ * @param {object} opts.fm - The note's frontmatter, for the authored paths this
647
+ * check leaves to {@link checkAuthoredSystemData}.
648
+ * @param {string} opts.block - The system block to read, e.g. `"sohl"`.
649
+ * @param {string} opts.documentType - `Item`, `Actor`, …
650
+ * @param {string} opts.subType - The document subtype the note compiles into.
651
+ * @param {string} opts.type - The note's content type, for the message.
652
+ * @param {readonly {to?: string}[]} [opts.fields] - The type's field
653
+ * declaration, which decides each finding's origin.
654
+ * @param {object} [opts.config] - The resolved build configuration.
655
+ * @returns {(EmissionFinding & {message: string})[]} One per undeclared path.
656
+ */
657
+ export function checkEmittedSystemData(
658
+ system,
659
+ { fm, block, documentType, subType, type, fields, config = loadPackConfig() },
660
+ ) {
661
+ const schema = schemaFor(config);
662
+ if (!schema) return [];
663
+
664
+ return compareEmittedSystem({
665
+ system,
666
+ artifact: schema.artifact,
667
+ documentType,
668
+ subtype: subType,
669
+ type,
670
+ fields,
671
+ authored: new Set(systemDataPaths(systemData(fm, block))),
672
+ }).map((finding) => ({ ...finding, message: emittedUndeclaredMessage(finding) }));
673
+ }
@@ -223,7 +223,7 @@ export function collectTreePages(tree, ctx) {
223
223
  const rel = path.relative(tree.from, file).replace(/\\/g, "/");
224
224
  const base = path.basename(rel);
225
225
  const isReadme = base.toLowerCase() === "readme.md";
226
- const sec = fm.category ?? tree.section;
226
+ const sec = fm.subType ?? tree.section;
227
227
  const h1 = /^#\s+(.+?)\s*$/m.exec(body);
228
228
  const h1Title = h1 ? h1[1].replace(/\{@link\s+[^}]*\}/g, "").trim() : null;
229
229
  const name = fm.name?.full ?? fm.title ?? h1Title ?? path.basename(base, ".md");
@@ -54,6 +54,9 @@ import path from "node:path";
54
54
  import { canonicalKey, readCanonicalKey } from "./kb-manifest.mjs";
55
55
  import { hasDocEntry } from "./item-docs.mjs";
56
56
  import { contentPackage } from "./content-package.mjs";
57
+ // The alias namespace: what a page may be called, and what happens when two
58
+ // pages of one type claim one name (#131).
59
+ import { aliasesOf, indexAliases } from "./alias-index.mjs";
57
60
 
58
61
  /**
59
62
  * One page the site will publish, as the index needs to see it.
@@ -180,11 +183,17 @@ function mergeForeign(index, foreignIndex) {
180
183
  export function buildSiteIndex(entries, { foreignIndex = new Map() } = {}) {
181
184
  const index = new Map();
182
185
  const ambiguous = new Set();
183
- const typeAlias = new Map();
184
- const typeCollide = new Set();
186
+ /** One entry per page, fed to {@link indexAliases} in a pass of its own. */
187
+ const aliasEntries = [];
185
188
  const contentTypes = new Set();
186
189
  const sections = new Set();
187
190
  const refIndex = new Map();
191
+ // Every package an address may name: this build's own, plus every one a
192
+ // vendored manifest speaks for. Without it `readQualifier` cannot see the
193
+ // leading package segment of a canonical address, and `kethira-place-x`
194
+ // reads as the unknown type `kethira` (#131).
195
+ const ownPackage = contentPackage();
196
+ const packages = new Set(ownPackage ? [ownPackage] : []);
188
197
 
189
198
  // `section/slug` is unique by construction; the rest are fallbacks.
190
199
  for (const e of entries) {
@@ -203,6 +212,7 @@ export function buildSiteIndex(entries, { foreignIndex = new Map() } = {}) {
203
212
  // without it the link reads as prose and silently loses its href.
204
213
  for (const value of foreignIndex.values()) {
205
214
  if (value.type) contentTypes.add(value.type);
215
+ if (value.package) packages.add(value.package);
206
216
  }
207
217
 
208
218
  // Merged *before* the local type-scoped pass below, so a local page always
@@ -239,7 +249,8 @@ export function buildSiteIndex(entries, { foreignIndex = new Map() } = {}) {
239
249
  // The page's package is the configured one — the site collection
240
250
  // resolves it and records it as `pkg`. Never read out of
241
251
  // frontmatter: `package:` is retired (#56).
242
- index.set(canonicalKey(e.pkg ?? contentPackage(), type, shortcode), value);
252
+ index.set(canonicalKey(e.pkg ?? ownPackage, type, shortcode), value);
253
+ if (e.pkg) packages.add(e.pkg);
243
254
  // In Foundry an item and its documentation are two documents, so
244
255
  // `skill/wpnc` and `docskill/wpnc` are two UUIDs (#1362). Here the
245
256
  // item note renders as one page which *is* its documentation, so
@@ -253,33 +264,38 @@ export function buildSiteIndex(entries, { foreignIndex = new Map() } = {}) {
253
264
  }
254
265
  }
255
266
 
256
- const aliases = [
257
- ...(Array.isArray(e.fm.aliases) ? e.fm.aliases : []),
258
- ...(Array.isArray(e.fm.name?.aliases) ? e.fm.name.aliases : []),
259
- e.name,
260
- path.basename(e.base, ".md").replace(/_/g, " "),
261
- ].filter((a) => typeof a === "string" && a);
262
-
263
- for (const alias of aliases) {
264
- const key = `${type}|${alias}`.toLowerCase();
265
- if (typeCollide.has(key)) continue;
266
- const cur = typeAlias.get(key);
267
- if (cur && cur.url !== value.url) {
268
- typeAlias.delete(key);
269
- typeCollide.add(key);
270
- } else if (!cur) {
271
- typeAlias.set(key, value);
272
- }
273
- }
267
+ // The alias sources are the shared ones — `aliases`, `name.aliases`
268
+ // and `name.full`, and deliberately **not** the filename (#131) — plus
269
+ // this page's display name, which is what `name.full` becomes on the
270
+ // site and is carried here already resolved.
271
+ aliasEntries.push({
272
+ type,
273
+ aliases: [...aliasesOf(e.fm), e.name],
274
+ value,
275
+ });
274
276
  }
275
277
 
278
+ // Built in one pass at the end, by the shared rule, so a collision is a
279
+ // reportable fact rather than a silently deleted key. Two pages of one
280
+ // type sharing an alias resolve to neither, and `aliasCollisions` names
281
+ // every claimant — the citing page is innocent (#13, #131).
282
+ const {
283
+ byKey: typeAlias,
284
+ collisions: aliasCollisions,
285
+ claims: aliasClaims,
286
+ } = indexAliases(aliasEntries, { same: (a, b) => a.url === b.url });
287
+ const typeCollide = new Set(aliasCollisions.map((c) => c.key));
288
+
276
289
  return {
277
290
  index,
278
291
  ambiguous,
279
292
  typeAlias,
280
293
  typeCollide,
294
+ aliasCollisions,
295
+ aliasClaims,
281
296
  contentTypes,
282
297
  sections,
298
+ packages,
283
299
  refIndex,
284
300
  conflicts,
285
301
  };
@@ -320,6 +336,7 @@ export function wikiContext(
320
336
  typeAlias: built.typeAlias,
321
337
  typeCollide: built.typeCollide,
322
338
  contentTypes: built.contentTypes,
339
+ packages: built.packages,
323
340
  type,
324
341
  errors,
325
342
  src,