@heroiclands/package-build 22.9.2 → 22.11.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 (97) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/CONTENT.md +54 -0
  3. package/bin/content-build.mjs +3 -1
  4. package/docs/api.md +11 -6
  5. package/docs/commands.md +1 -1
  6. package/docs/content-format.md +411 -201
  7. package/engine/actor-compiler.mjs +20 -7
  8. package/engine/address-fields.mjs +426 -0
  9. package/engine/address-values.mjs +80 -0
  10. package/engine/address.mjs +769 -0
  11. package/engine/art-fields.mjs +112 -97
  12. package/engine/art-slots.mjs +81 -0
  13. package/engine/base-compiler.mjs +61 -7
  14. package/engine/bundle-notes.mjs +5 -2
  15. package/engine/bundles.mjs +17 -10
  16. package/engine/content-address.mjs +23 -186
  17. package/engine/content-embeds.mjs +18 -4
  18. package/engine/content-format-check.mjs +50 -22
  19. package/engine/content-index.mjs +85 -43
  20. package/engine/content-links.mjs +105 -104
  21. package/engine/content-lint.mjs +11 -1
  22. package/engine/content-tables.mjs +9 -1
  23. package/engine/field-spec.mjs +21 -4
  24. package/engine/folder-notes.mjs +44 -15
  25. package/engine/foundry-entries.mjs +3 -0
  26. package/engine/frontmatter-lint.mjs +150 -47
  27. package/engine/frontmatter.mjs +55 -10
  28. package/engine/generate.mjs +7 -1
  29. package/engine/helpers.mjs +37 -9
  30. package/engine/holdings.mjs +41 -12
  31. package/engine/index-records.mjs +2 -0
  32. package/engine/infobox-render.mjs +2 -1
  33. package/engine/infobox.mjs +26 -6
  34. package/engine/item-compiler.mjs +5 -0
  35. package/engine/item-docs.mjs +7 -44
  36. package/engine/map-dot.mjs +2 -0
  37. package/engine/map-notes.mjs +7 -2
  38. package/engine/map-places.mjs +42 -10
  39. package/engine/metadata-index.mjs +60 -5
  40. package/engine/note-addresses.mjs +325 -0
  41. package/engine/note-vocabulary.mjs +140 -28
  42. package/engine/pdf-build.mjs +1 -1
  43. package/engine/pdf-toc.mjs +17 -2
  44. package/engine/place-relations.mjs +160 -50
  45. package/engine/populations.mjs +17 -9
  46. package/engine/reference-targets.mjs +54 -0
  47. package/engine/scenes.mjs +5 -2
  48. package/engine/shortcode-references.mjs +68 -0
  49. package/engine/site-build.mjs +39 -15
  50. package/engine/site-index.mjs +57 -75
  51. package/engine/sql-tables.mjs +43 -9
  52. package/engine/system-block.mjs +21 -0
  53. package/engine/system-vocabulary.mjs +46 -0
  54. package/engine/web-wikilinks.mjs +11 -9
  55. package/engine/wikilink-syntax.mjs +21 -0
  56. package/engine/wikilinks.mjs +92 -306
  57. package/hm3/actors.mjs +8 -24
  58. package/hm3/item-fields.mjs +15 -5
  59. package/package.json +1 -1
  60. package/sohl/infobox.mjs +111 -17
  61. package/sohl/item-fields.mjs +131 -41
  62. package/types/engine/address-fields.d.mts +95 -0
  63. package/types/engine/address-values.d.mts +34 -0
  64. package/types/engine/address.d.mts +452 -0
  65. package/types/engine/art-fields.d.mts +59 -53
  66. package/types/engine/art-slots.d.mts +40 -0
  67. package/types/engine/base-compiler.d.mts +24 -2
  68. package/types/engine/bundle-notes.d.mts +2 -2
  69. package/types/engine/content-address.d.mts +3 -139
  70. package/types/engine/content-embeds.d.mts +1 -1
  71. package/types/engine/content-format-check.d.mts +25 -0
  72. package/types/engine/content-index.d.mts +2 -2
  73. package/types/engine/content-links.d.mts +6 -5
  74. package/types/engine/field-spec.d.mts +46 -9
  75. package/types/engine/folder-notes.d.mts +11 -10
  76. package/types/engine/frontmatter-lint.d.mts +7 -97
  77. package/types/engine/frontmatter.d.mts +16 -1
  78. package/types/engine/helpers.d.mts +2 -2
  79. package/types/engine/infobox.d.mts +6 -0
  80. package/types/engine/item-docs.d.mts +1 -35
  81. package/types/engine/map-notes.d.mts +2 -2
  82. package/types/engine/note-addresses.d.mts +41 -0
  83. package/types/engine/note-vocabulary.d.mts +20 -3
  84. package/types/engine/place-relations.d.mts +12 -1
  85. package/types/engine/populations.d.mts +17 -2
  86. package/types/engine/reference-targets.d.mts +9 -0
  87. package/types/engine/shortcode-references.d.mts +14 -0
  88. package/types/engine/site-build.d.mts +3 -3
  89. package/types/engine/site-index.d.mts +9 -30
  90. package/types/engine/sql-tables.d.mts +2 -2
  91. package/types/engine/system-block.d.mts +17 -0
  92. package/types/engine/system-vocabulary.d.mts +16 -0
  93. package/types/engine/wikilink-syntax.d.mts +7 -0
  94. package/types/engine/wikilinks.d.mts +6 -109
  95. package/types/hm3/item-fields.d.mts +5 -0
  96. package/types/sohl/infobox.d.mts +6 -5
  97. package/types/sohl/item-fields.d.mts +8 -0
@@ -24,10 +24,9 @@
24
24
  * dead-ends for the reader.
25
25
  * 2. **A dead address.** Every link is an address, and one resolving to no note
26
26
  * is a typo. So is a target that does not parse as an address at all. A
27
- * written target is a *partial* address — the segments it omits are
28
- * wildcards, and the package it omits is this one — so a target resolving to
29
- * *several* notes is an ambiguity rather than a first match, and is reported
30
- * naming every candidate.
27
+ * written target expands to exactly one canonical address, from the
28
+ * position's own defaults — the package it omits is always this one — so a
29
+ * lookup finds one entry or none. There is no candidate set to disambiguate.
31
30
  * 3. **An unlabelled link.** `[[x]]` addresses nothing: the alias namespace it
32
31
  * once named is retired, and a shortcode is an address rather than
33
32
  * prose, so the link has neither a resolvable target nor text to show. The
@@ -35,7 +34,7 @@
35
34
  * 4. **A wikilink authored in frontmatter.** Both builds walk a note's *body*
36
35
  * and copy frontmatter through verbatim, so a link written in a
37
36
  * `description` is never resolved and publishes as literal `[[…]]` text.
38
- * Frontmatter is data: a `WikiLink` field is parsed by the address grammar
37
+ * Frontmatter is data: an `Address` field is parsed by the address grammar
39
38
  * and a bracketed link there is a finding naming the note and the field.
40
39
  *
41
40
  * **This resolves links the way the builds do**, calling the same
@@ -54,6 +53,8 @@
54
53
  * @module
55
54
  */
56
55
 
56
+ import { readWikilink } from "./wikilink-syntax.mjs";
57
+ import { encodeAddresses } from "./address-values.mjs";
57
58
  import fs from "node:fs";
58
59
  import path from "node:path";
59
60
 
@@ -208,7 +209,8 @@ export function buildLinkIndex(
208
209
  // alongside the notes because `byKey` holds notes, and a caller that
209
210
  // reaches for `.fm` or `.body` on one must not be handed a file.
210
211
  if (isAssetRecord(record)) {
211
- if (record.address?.canonical) byAssetKey.set(record.address.canonical, record);
212
+ if (record.address?.canonical)
213
+ byAssetKey.set(encodeAddresses(record.address.canonical), record);
212
214
  continue;
213
215
  }
214
216
  // A documentation journal has a record of its own but no file and no
@@ -247,7 +249,7 @@ export function buildLinkIndex(
247
249
  // Taken from the record, which is where the address rule is applied
248
250
  // once for the whole build.
249
251
  const canonical =
250
- record.address?.canonical ??
252
+ encodeAddresses(record.address?.canonical) ??
251
253
  canonicalKey(pkg, systemOf(type, KNOWN_DOCUMENT_SUBTYPE_MAPS), type, fm.shortcode);
252
254
  // A note with no address publishes no page, so its identity is
253
255
  // filed where a link cannot reach it and a reference still can.
@@ -285,7 +287,12 @@ export function buildLinkIndex(
285
287
  : { index: new Map(), packages: new Set(), stale: [] };
286
288
  for (const v of foreign.index.values()) if (v.type) types.add(v.type);
287
289
 
288
- const packages = new Set([...(byKey.size ? [pkg] : []), ...foreign.packages]);
290
+ // A stub counts. It publishes no page, so nothing may *link* to one, but its
291
+ // address is a name the tree holds and a reference reaches it — a border's
292
+ // far side is the case that matters. Leaving this package out of the set
293
+ // whenever every note it holds is a stub would make a fully qualified local
294
+ // address unreadable in precisely the tree whose relations need it.
295
+ const packages = new Set([...(byKey.size || byStub.size ? [pkg] : []), ...foreign.packages]);
289
296
  // Packages declared `contentIndex: false` — a Foundry dependency only, with
290
297
  // no fetched index. A link naming one is refused with a diagnostic that
291
298
  // names the key, rather than reading as an undeclared package or a typo.
@@ -414,8 +421,15 @@ export function buildLinkIndex(
414
421
  // Code is verbatim, so a `[[…]]` inside a fence, an indented block or an
415
422
  // inline span is not a link — the compilers make none of it either.
416
423
  for (const [all, rawInner] of matchAllOutsideCode(body, new RegExp(WIKILINK.source, "g"))) {
417
- const parsed = parseWikilink(rawInner);
418
- const { target, anchor } = parsed;
424
+ const parsed = readWikilink(rawInner, {
425
+ package: pkg,
426
+ system: "none",
427
+ types,
428
+ packages,
429
+ noIndexPackages,
430
+ });
431
+ const target = parsed.target.kind ? parsed.rawTarget : parsed.target;
432
+ const { anchor, rawTarget } = parsed;
419
433
  const occurrence = (seen.get(all) ?? 0) + 1;
420
434
  seen.set(all, occurrence);
421
435
  // `text` is the link exactly as authored, which is what locates it
@@ -423,6 +437,7 @@ export function buildLinkIndex(
423
437
  // so the search simply fails and a finding names the file.
424
438
  out.push({
425
439
  target,
440
+ rawTarget,
426
441
  anchor,
427
442
  text: all,
428
443
  occurrence,
@@ -432,45 +447,6 @@ export function buildLinkIndex(
432
447
  return out;
433
448
  }
434
449
 
435
- /**
436
- * Every indexed entry an address names, matching only the segments it
437
- * supplies.
438
- *
439
- * This is the whole resolution rule in one place: a written
440
- * address is a *partial* one, unsupplied segments are wildcards, and the
441
- * caller requires exactly one hit. Nothing here decides an ambiguity — zero
442
- * and many are different findings with different fixes, so the count is
443
- * returned rather than collapsed.
444
- *
445
- * The **system** is wildcarded unless stated. Defaulting it to `none` would
446
- * exclude every link to an item, which is most of them.
447
- *
448
- * @param {Array<[string, any]>} pairs - Indexed `[canonicalKey, value]`.
449
- * @param {object} q - The parsed qualifier.
450
- * @returns {Array<[string, any]>} The matching pairs.
451
- */
452
- function matchAddress(pairs, q) {
453
- const type = String(q.type).toLowerCase();
454
- const shortcode = String(q.shortcode).toLowerCase();
455
- return pairs.filter(([k]) => {
456
- const parts = readCanonicalKey(k);
457
- if (!parts) return false;
458
- if (q.package && parts.package !== String(q.package).toLowerCase()) return false;
459
- if (q.system && parts.system !== String(q.system).toLowerCase()) return false;
460
- return parts.type === type && parts.shortcode === shortcode;
461
- });
462
- }
463
-
464
- /**
465
- * The local notes an address names, by the same rule.
466
- *
467
- * @param {object} q - The parsed qualifier.
468
- * @returns {object[]} The notes.
469
- */
470
- function matchLocal(q) {
471
- return matchAddress([...byKey], q).map(([, v]) => v);
472
- }
473
-
474
450
  /**
475
451
  * The note an **address** names, or `undefined`.
476
452
  *
@@ -551,7 +527,7 @@ export function buildLinkIndex(
551
527
  // saying so is the whole point: a link that resolved into `sohl` only
552
528
  // because no local note claimed the address was resolving by accident,
553
529
  // and would have retargeted silently the day one did.
554
- if (!q.package) return [];
530
+ if (!q.package || q.package === pkg) return [];
555
531
  const hit = foreign.index.get(
556
532
  expandAddress(q, { package: q.package, system: blockSystem(keyPath) }),
557
533
  );
@@ -573,38 +549,71 @@ export function buildLinkIndex(
573
549
  }
574
550
 
575
551
  /**
576
- * The note or foreign entry a **frontmatter reference** names, or null.
552
+ * The note, asset, stub or foreign entry an **address** names, or
553
+ * `undefined`.
577
554
  *
578
- * A `ref:` field holds a shortcode, not an address. The system persists it
579
- * verbatim and resolves it at runtime against the items embedded on one
580
- * actor, where packages do not exist: an actor assembled from several
581
- * packages carries their items side by side. So a reference resolves when
582
- * *any* reachable package declares the `(type, shortcode)` pair — package
583
- * and system wildcarded, local notes first, then the fetched indexes.
555
+ * The same rule as {@link resolveAddress} — read with {@link readQualifier},
556
+ * expanded with {@link expandAddress} against this index's own package and
557
+ * the citing block's system — consulted against every source this index
558
+ * holds rather than only the notes a page link may reach: a stub the tree
559
+ * holds and a fetched foreign package both answer here, where neither
560
+ * answers a page link. A caller with its own position-specific default
561
+ * type — a place's `to`, whose default is `place` rather than anything
562
+ * this index states — parses and expands the value itself and hands this
563
+ * the canonical string it already computed; every segment that string
564
+ * states overrides the default that would otherwise apply, so it round-trips
565
+ * to itself and this is a plain keyed lookup either way.
584
566
  *
585
- * Distinct from {@link resolveAddress}, which defaults an omitted package
586
- * to this one. That is the rule for a link, whose target is a document to
587
- * point at; a reference names an item to stand beside.
567
+ * @param {string} target - The address, or a short form read against this
568
+ * index's own defaults.
569
+ * @param {string} [keyPath] - The dotted frontmatter key path the value
570
+ * sits under; body prose has none. Ignored once `target` is already
571
+ * canonical.
572
+ * @returns {object|undefined} The entry.
573
+ */
574
+ function addressHit(target, keyPath) {
575
+ const qualified = readQualifier(target, types, packages, noIndexPackages);
576
+ if (!qualified || qualified.reason) return undefined;
577
+ const canonical = expandAddress(qualified, { package: pkg, system: blockSystem(keyPath) });
578
+ // Stubs and foreign entries answer here and nowhere else in this
579
+ // index: a reference names an item to stand beside rather than a
580
+ // document to point at, so a border with a stub — or a place in a
581
+ // dependency — on the far side is a valid statement about the world.
582
+ return (
583
+ byKey.get(canonical) ??
584
+ byAssetKey.get(canonical) ??
585
+ byStub.get(canonical) ??
586
+ foreign.index.get(canonical)
587
+ );
588
+ }
589
+
590
+ /**
591
+ * The note, asset, stub or foreign entry a **Shortcode** names, or `null`.
588
592
  *
589
- * The files this package ships answer here too, and by the same rule: an
590
- * asset's address is a `(type, shortcode)` pair like any other, so a field
591
- * naming `icon-anvil` resolves to the record that carries the file's path.
593
+ * A `code:` field's value is a Shortcode, not an address — one segment,
594
+ * persisted verbatim and resolved at runtime against the items embedded on
595
+ * one actor, where packages do not exist. So `type` and `shortcode` are
596
+ * searched for directly rather than assembled into a written form and read
597
+ * back through the address grammar: a Shortcode never reaches that grammar,
598
+ * by {@link module:engine/address}'s own contract. It resolves in any
599
+ * reachable package, because the value comes from every package the actor
600
+ * draws on — package and system are never part of the search, not defaulted
601
+ * out of it.
592
602
  *
593
- * @param {string} target - The reference as `type-shortcode`.
594
- * @returns {object|null} The note, asset record or foreign entry declaring it.
603
+ * @param {string} type - The field's declared `code:`.
604
+ * @param {string} shortcode - The value as authored, already checked
605
+ * against the shortcode charset.
606
+ * @returns {object|null} The first entry naming the pair, from local notes,
607
+ * local assets, local stubs, or a fetched foreign index, in that order.
595
608
  */
596
- function referenceHit(target) {
597
- const q = readQualifier(target, types, packages, noIndexPackages);
598
- if (!q || q.reason) return null;
599
- // Stubs last, and deliberately included: a reference names an item to
600
- // stand beside rather than a document to point at, so a border with a
601
- // stub on the far side is a valid statement about the world. Resolving
602
- // these against the addressed rows alone would report every one of
603
- // them dead.
604
- const local = matchAddress([...byKey, ...byAssetKey, ...byStub], q);
605
- if (local.length) return local[0][1];
606
- const abroad = matchAddress([...foreign.index], q);
607
- return abroad.length ? abroad[0][1] : null;
609
+ function shortcodeHit(type, shortcode) {
610
+ const wantType = String(type).toLowerCase();
611
+ const wantCode = String(shortcode).toLowerCase();
612
+ for (const [key, value] of [...byKey, ...byAssetKey, ...byStub, ...foreign.index]) {
613
+ const parts = readCanonicalKey(key);
614
+ if (parts && parts.type === wantType && parts.shortcode === wantCode) return value;
615
+ }
616
+ return null;
608
617
  }
609
618
 
610
619
  return {
@@ -646,7 +655,8 @@ export function buildLinkIndex(
646
655
  stubAt,
647
656
  manifestHit,
648
657
  foreignHits,
649
- referenceHit,
658
+ addressHit,
659
+ shortcodeHit,
650
660
  /** Whether a target reads as a qualified address at all. */
651
661
  isAddress: (target) => Boolean(readQualifier(target, types, packages, noIndexPackages)),
652
662
  };
@@ -936,11 +946,12 @@ export function auditHomepageLinks(index) {
936
946
  * unlabelledLinks: object[], frontmatterLinks: object[],
937
947
  * homepageLinks: object[], usedManifest: Set<string>}} The findings, and
938
948
  * which addresses a foreign manifest answered. Each `deadAddresses` entry
939
- * carries a `reason` from {@link LINK_FINDING_REASONS} —
940
- * `"not-an-address"`, `"unknown-type"`, `"ambiguous"` (with the claiming
941
- * `packages`), `"no-content-index"`, `"stub"` (with the stub's path) or
942
- * `"unresolved"` — and every one of them is an **error**: the three
943
- * resolvers agree on severity for every class.
949
+ * carries a `reason` from {@link LINK_FINDING_REASONS} — `"not-an-address"`,
950
+ * `"unknown-type"`, `"no-content-index"`, `"stub"` (with the stub's path) or
951
+ * `"unresolved"`, never `"ambiguous"`: every target here expands to one
952
+ * canonical address before anything is looked up, so there is no candidate
953
+ * set for two packages to claim — and every reason is an **error**: the
954
+ * three resolvers agree on severity for every class.
944
955
  */
945
956
  export function auditLinks(index) {
946
957
  const { notes, anchors, linksOf, embedsOf, resolve, manifestHit, isAddress } = index;
@@ -955,7 +966,7 @@ export function auditLinks(index) {
955
966
 
956
967
  const deadAnchors = [];
957
968
  for (const note of notes) {
958
- for (const { target, anchor, text, occurrence, labelled } of linksOf(note)) {
969
+ for (const { target, rawTarget, anchor, text, occurrence, labelled } of linksOf(note)) {
959
970
  if (!anchor || !labelled) continue;
960
971
  const dest = target ? resolve(target) : note;
961
972
  // An unresolvable target is reported by the pass below; its anchor
@@ -966,7 +977,7 @@ export function auditLinks(index) {
966
977
  if (!(anchors.get(dest) ?? new Set()).has(slugify(anchor))) {
967
978
  deadAnchors.push({
968
979
  note,
969
- link: `${target}#${anchor}`,
980
+ link: `${rawTarget}#${anchor}`,
970
981
  dest,
971
982
  text,
972
983
  occurrence,
@@ -979,13 +990,13 @@ export function auditLinks(index) {
979
990
  const unlabelledLinks = [];
980
991
  const usedManifest = new Set();
981
992
  for (const note of notes) {
982
- for (const { target, anchor, text, occurrence, labelled } of linksOf(note)) {
993
+ for (const { target, rawTarget, anchor, text, occurrence, labelled } of linksOf(note)) {
983
994
  // The label is required whatever the link part is, an anchor
984
995
  // included — so this is tested before the same-page form.
985
996
  if (!labelled) {
986
997
  unlabelledLinks.push({
987
998
  note,
988
- target: target || (anchor ? `#${anchor}` : ""),
999
+ target: rawTarget || (anchor ? `#${anchor}` : ""),
989
1000
  text,
990
1001
  occurrence,
991
1002
  // Carried like every other finding's, so a reporter reads
@@ -995,7 +1006,7 @@ export function auditLinks(index) {
995
1006
  continue;
996
1007
  }
997
1008
  if (!target) continue; // a same-page `[[#anchor|Text]]`
998
- const at = { note, target, text, occurrence };
1009
+ const at = { note, target: rawTarget, text, occurrence };
999
1010
 
1000
1011
  if (!isAddress(target)) {
1001
1012
  deadAddresses.push({ ...at, reason: "not-an-address" });
@@ -1012,22 +1023,12 @@ export function auditLinks(index) {
1012
1023
  continue;
1013
1024
  }
1014
1025
  // A manifest answers with the target package's own build output
1015
- // rather than a reviewed guess.
1016
- const hits = index.foreignHits(target);
1017
- if (hits.length === 1) {
1018
- usedManifest.add(target.toLowerCase());
1019
- continue;
1020
- }
1021
- if (hits.length > 1) {
1022
- // Two packages publish the short address, so it names neither.
1023
- // Reported as its own class: "no document has that identity" is
1024
- // false here — two do — and the fix is the qualified form
1025
- // rather than a corrected shortcode.
1026
- deadAddresses.push({
1027
- ...at,
1028
- reason: "ambiguous",
1029
- packages: hits.map((h) => h.package).filter(Boolean),
1030
- });
1026
+ // rather than a reviewed guess. `manifestHit` is a keyed lookup —
1027
+ // a fully qualified target names one package, so there is nothing
1028
+ // left to disambiguate, and no ambiguity finding this resolver can
1029
+ // report.
1030
+ if (manifestHit(target)) {
1031
+ usedManifest.add(encodeAddresses(target).toLowerCase());
1031
1032
  continue;
1032
1033
  }
1033
1034
  const read = readQualifier(target, index.types, index.packages, index.noIndexPackages);
@@ -160,6 +160,16 @@ function collectNotes(contentBase, { skipDirectories, config, records, problems
160
160
  file: path.relative(process.cwd(), absPath),
161
161
  });
162
162
  }
163
+ for (let i = (problems?.length ?? 0) - 1; i >= 0; i--) {
164
+ const problem = problems[i];
165
+ if (!problem.identity) continue;
166
+ notes.push({
167
+ fm: problem.identity,
168
+ absPath: problem.file,
169
+ file: path.relative(process.cwd(), problem.file),
170
+ });
171
+ problems.splice(i, 1);
172
+ }
163
173
  // The records already come in content-path order, so this only re-states
164
174
  // the guarantee findings depend on: they read top to bottom.
165
175
  notes.sort((a, b) => (a.absPath < b.absPath ? -1 : 1));
@@ -291,7 +301,7 @@ function checkRenamedFrom({ fm, file }, raw) {
291
301
  */
292
302
  export function lintContentTree(
293
303
  contentBase,
294
- { skipDirectories, contentPackage, config, records, problems } = {},
304
+ { skipDirectories, contentPackage, config, records, problems = [] } = {},
295
305
  ) {
296
306
  const findings = [];
297
307
  const notes = collectNotes(contentBase, { skipDirectories, config, records, problems });
@@ -11,6 +11,9 @@
11
11
  * SPDX-License-Identifier: GPL-3.0-or-later
12
12
  */
13
13
 
14
+ import { completeAddress, renderAddress, isAddressTuple } from "./address.mjs";
15
+ import { contentPackage } from "./content-package.mjs";
16
+
14
17
  /**
15
18
  * Generated content tables — Obsidian **Dataview** `TABLE` queries.
16
19
  *
@@ -702,6 +705,7 @@ export function resolveField(doc, path) {
702
705
 
703
706
  /** Render a value as the plain text a comparison or a cell works with. */
704
707
  function asText(value) {
708
+ if (isAddressTuple(value)) return renderAddress(value);
705
709
  if (value == null) return "";
706
710
  if (isLink(value)) return value.display;
707
711
  if (Array.isArray(value)) return value.map(asText).join(", ");
@@ -1035,7 +1039,11 @@ export function renderContentTable(spec, rows, linkable, self) {
1035
1039
  // table cell; the resolvers unescape it before splitting, so the
1036
1040
  // display text itself must not carry one.
1037
1041
  const label = text.replace(/\\?\|/g, "/");
1038
- return `[[${doc.fm.type}/${doc.fm.shortcode}\\|${label}]]`;
1042
+ const target = completeAddress(
1043
+ { type: doc.fm.type, shortcode: doc.fm.shortcode },
1044
+ { package: doc.fm.package ?? contentPackage(), system: "none" },
1045
+ );
1046
+ return `[[${renderAddress(target)}\\|${label}]]`;
1039
1047
  }),
1040
1048
  );
1041
1049
 
@@ -138,10 +138,27 @@ export { legacyKeyOf, retiredTopLevelKey, setPath };
138
138
  * several do — `weight` is coerced leniently but is still a number, and
139
139
  * `weight: heavy` is an authoring mistake worth reporting where it was made.
140
140
  * Absent means the lint makes no claim about the value.
141
- * @property {string} [ref] - The content type a value addresses by shortcode,
142
- * for the linter's dead-reference check. Only for references to a **note**:
143
- * `bodyLocationCode` names a part inside a being's own body structure, not a
144
- * note, so it declares none.
141
+ * @property {string} [code] - The content type a value names by **Shortcode**,
142
+ * for the linter's dead-reference check. The authored value is one segment —
143
+ * held to {@link module:engine/address-charset.ADDRESS_SEGMENT_PATTERN} —
144
+ * persisted verbatim into the emitted field and resolved at runtime among
145
+ * the items embedded on one actor, where packages do not exist; the linter
146
+ * still forms the `type-shortcode` pair to check it against the index. Only
147
+ * for references to a **note**: `bodyLocationCode` names a part inside a
148
+ * being's own body structure, not a note, so it declares none. Distinct
149
+ * from a `DataFieldSpec`'s `ref` ({@link module:engine/note-vocabulary}),
150
+ * which names an **Address** instead.
151
+ * @property {import("./address-fields.mjs").AddressPosition} [address] - The
152
+ * authored value is an Address. The emitted value is its Shortcode, or the
153
+ * exact published Item UUID when `emit: "uuid"` is declared. The
154
+ * statement carries the default type an omitted `<type>` segment takes, the
155
+ * set of types the position accepts — two separate things, which coincide at
156
+ * most positions — and where the Addresses sit: the value itself, every
157
+ * element of a list, or every key of a map.
158
+ * {@link module:engine/address-fields.reduceAddressFields} performs the
159
+ * reduction once the block is assembled, so a value authored at the
160
+ * destination is reduced along with one this field read. Distinct from `code`,
161
+ * which is a Shortcode at both ends and resolves nothing.
145
162
  * @property {any|((fm: object) => any)} [value] - For a field with no `name`:
146
163
  * the constant, or a function deriving it from the frontmatter.
147
164
  * @property {boolean} [omitWhenAbsent] - **The key is left out entirely when
@@ -53,7 +53,9 @@ import path from "node:path";
53
53
 
54
54
  import log from "loglevel";
55
55
 
56
- import { NO_SYSTEM, canonicalKey } from "./content-address.mjs";
56
+ import { isAddressTuple } from "./address.mjs";
57
+ import { NO_SYSTEM, canonicalKey, readCanonicalKey } from "./content-address.mjs";
58
+ import { parseAddress, renderAddress } from "./address.mjs";
57
59
  import { ADDRESS_SEGMENT_PATTERN, isAddressSegment } from "./address-charset.mjs";
58
60
  import { makeId } from "./ids.mjs";
59
61
  import { locateFrontmatterKey } from "./retired-fields.mjs";
@@ -65,6 +67,14 @@ import { locateFrontmatterKey } from "./retired-fields.mjs";
65
67
  */
66
68
  export const FOLDER_TYPE = "folder";
67
69
 
70
+ /**
71
+ * The one type a folder reference may name — {@link parseAddress}'s
72
+ * vocabulary for a position that accepts exactly one.
73
+ *
74
+ * @type {ReadonlySet<string>}
75
+ */
76
+ const FOLDER_TYPES = Object.freeze(new Set([FOLDER_TYPE]));
77
+
68
78
  /**
69
79
  * The id namespace a derived folder id is hashed under.
70
80
  *
@@ -135,6 +145,7 @@ function folderFields(fm) {
135
145
  const read = (key) => data[key] ?? fm?.[key];
136
146
  const text = (value) => {
137
147
  if (value == null) return null;
148
+ if (isAddressTuple(value)) return value;
138
149
  const trimmed = String(value).trim();
139
150
  return trimmed === "" ? null : trimmed;
140
151
  };
@@ -142,7 +153,12 @@ function folderFields(fm) {
142
153
  const authored = read("parent");
143
154
  /** @type {Record<string, string|null>} */
144
155
  const parent = {};
145
- if (authored != null && typeof authored === "object" && !Array.isArray(authored)) {
156
+ if (
157
+ authored != null &&
158
+ typeof authored === "object" &&
159
+ !Array.isArray(authored) &&
160
+ !isAddressTuple(authored)
161
+ ) {
146
162
  // An explicit `~` under a pack key means "at the root *there*", which
147
163
  // is a different statement from saying nothing — so the key is kept
148
164
  // with a null value rather than dropped.
@@ -157,8 +173,8 @@ function folderFields(fm) {
157
173
  /**
158
174
  * An authored `parent` with any wikilink brackets and label stripped.
159
175
  *
160
- * The specification types `parent` as a `WikiLink`, and a frontmatter link is
161
- * written as a bare address — but `[[address]]` is what an author reaches for,
176
+ * The specification types `parent` as an `Address`, written bare — but
177
+ * `[[address]]` is what an author reaches for,
162
178
  * and Obsidian wrote that form for years. Accepting both costs one regex and
163
179
  * removes a failure whose message would have to explain the difference.
164
180
  *
@@ -166,6 +182,7 @@ function folderFields(fm) {
166
182
  * @returns {string|null} The bare address.
167
183
  */
168
184
  export function bareAddress(value) {
185
+ if (isAddressTuple(value)) return value;
169
186
  if (value == null) return null;
170
187
  const text = String(value).trim();
171
188
  if (!text) return null;
@@ -280,14 +297,15 @@ export function collectFolderNotes(notes, pkg) {
280
297
  }
281
298
 
282
299
  /**
283
- * Index folder notes by every form an author may address one by, and check the
284
- * invariants that make the index sound.
300
+ * Index folder notes by their canonical address, and check the invariants
301
+ * that make the index sound.
285
302
  *
286
- * Three keys per folder, and no more: the canonical address, the
287
- * `folder-<shortcode>` short form, and the bare shortcode. They are the
288
- * suffixes of the canonical address the grammar admits — a `packFolder`
289
- * or `parent` field supplies the type itself, so a bare shortcode is a complete
290
- * address there.
303
+ * A folder reference is resolved by {@link parseAddress}, taking `folder` as
304
+ * the type an omitted segment fills in — the same suffix ladder every other
305
+ * position climbs, so `cooking`, `folder-cooking` and the full address all
306
+ * expand to the one key this index holds. A `packFolder` or `parent` field
307
+ * supplies no type of its own; the position's default is what makes a bare
308
+ * shortcode a complete address there.
291
309
  *
292
310
  * @param {FolderNote[]} folders - From {@link collectFolderNotes}.
293
311
  * @returns {{byKey: Map<string, FolderNote>, folders: FolderNote[],
@@ -303,6 +321,12 @@ export function buildFolderNoteIndex(folders) {
303
321
  const byShortcode = new Map();
304
322
  /** @type {Map<string, FolderNote>} */
305
323
  const byId = new Map();
324
+ // Every folder in one content tree shares one package — a `Folder` is
325
+ // filed inside the module this build compiles, and nothing addresses one
326
+ // in another package. Read from the first folder's own canonical address
327
+ // rather than threaded through as a parameter, so a caller holding
328
+ // `FolderNote[]` never has to carry the package alongside it.
329
+ const pkg = folders.length ? readCanonicalKey(folders[0].address)?.package : undefined;
306
330
 
307
331
  for (const folder of folders) {
308
332
  const key = folder.shortcode.toLowerCase();
@@ -339,8 +363,6 @@ export function buildFolderNoteIndex(folders) {
339
363
  byId.set(folder.id, folder);
340
364
 
341
365
  byKey.set(folder.address.toLowerCase(), folder);
342
- byKey.set(`${FOLDER_TYPE}-${folder.shortcode}`.toLowerCase(), folder);
343
- byKey.set(key, folder);
344
366
  }
345
367
 
346
368
  /**
@@ -355,11 +377,18 @@ export function buildFolderNoteIndex(folders) {
355
377
  if (!address) {
356
378
  throw new Error("a folder reference is blank");
357
379
  }
358
- const hit = byKey.get(address.toLowerCase());
380
+ const tuple = parseAddress(address, {
381
+ package: pkg,
382
+ system: NO_SYSTEM,
383
+ type: FOLDER_TYPE,
384
+ types: FOLDER_TYPES,
385
+ packages: pkg ? new Set([pkg]) : undefined,
386
+ });
387
+ const hit = tuple.reason ? undefined : byKey.get(renderAddress(tuple).toLowerCase());
359
388
  if (!hit) {
360
389
  const known = [...byShortcode.values()].map((f) => f.shortcode).sort();
361
390
  throw new Error(
362
- `no folder note is addressed "${address}" — this package ` +
391
+ `no folder note is addressed "${isAddressTuple(address) ? renderAddress(address) : address}" — this package ` +
363
392
  `declares ${known.length} folder(s)` +
364
393
  (known.length ? `: ${known.join(", ")}` : ""),
365
394
  );
@@ -58,6 +58,7 @@
58
58
  import fs from "node:fs";
59
59
  import path from "node:path";
60
60
 
61
+ import { noteAddressContext } from "./note-addresses.mjs";
61
62
  import { canonicalKey, packageAddress } from "./content-address.mjs";
62
63
  import { NO_SYSTEM, systemOf } from "./document-subtypes.mjs";
63
64
  import {
@@ -320,6 +321,7 @@ export function collectFoundryEntries(contentBase, ctx) {
320
321
  let notes = 0;
321
322
  for (const { frontmatter: fm, body, absPath } of walkMarkdownTree(contentBase, {
322
323
  skipDirectories: ctx.skipDirectories,
324
+ addressContext: ctx.addressContext ?? { package: ctx.contentPackage },
323
325
  })) {
324
326
  if (!fm) continue;
325
327
  // Its authored pin, or the id derived from its canonical address.
@@ -376,6 +378,7 @@ export function collectFoundryEntries(contentBase, ctx) {
376
378
  export function foundryIdentities(config = loadPackConfig()) {
377
379
  return {
378
380
  contentPackage: config.contentPackage,
381
+ addressContext: noteAddressContext(config),
379
382
  foundryPackageId: config.foundryPackage,
380
383
  packRouter: routerFor(config),
381
384
  // Carried in the context rather than read from the global config at the