@heroiclands/package-build 22.9.1 → 22.10.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 (100) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/CONTENT.md +33 -0
  3. package/bin/content-build.mjs +3 -1
  4. package/docs/api.md +10 -6
  5. package/docs/content-format.md +384 -200
  6. package/docs/project-setup.md +23 -0
  7. package/engine/actor-compiler.mjs +20 -7
  8. package/engine/address-fields.mjs +404 -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 +59 -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 +20 -4
  24. package/engine/folder-notes.mjs +44 -15
  25. package/engine/foundry-entries.mjs +3 -0
  26. package/engine/frontmatter-lint.mjs +144 -43
  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 +310 -0
  41. package/engine/note-vocabulary.mjs +145 -26
  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/githooks/attribution.mjs +82 -0
  58. package/githooks/commit-message.mjs +26 -0
  59. package/githooks/commit-msg +3 -35
  60. package/hm3/actors.mjs +8 -24
  61. package/hm3/item-fields.mjs +15 -5
  62. package/package.json +1 -1
  63. package/sohl/infobox.mjs +80 -17
  64. package/sohl/item-fields.mjs +114 -41
  65. package/types/engine/address-fields.d.mts +90 -0
  66. package/types/engine/address-values.d.mts +34 -0
  67. package/types/engine/address.d.mts +452 -0
  68. package/types/engine/art-fields.d.mts +59 -53
  69. package/types/engine/art-slots.d.mts +40 -0
  70. package/types/engine/base-compiler.d.mts +24 -2
  71. package/types/engine/bundle-notes.d.mts +2 -2
  72. package/types/engine/content-address.d.mts +3 -139
  73. package/types/engine/content-embeds.d.mts +1 -1
  74. package/types/engine/content-format-check.d.mts +25 -0
  75. package/types/engine/content-index.d.mts +2 -2
  76. package/types/engine/content-links.d.mts +6 -5
  77. package/types/engine/field-spec.d.mts +44 -9
  78. package/types/engine/folder-notes.d.mts +11 -10
  79. package/types/engine/frontmatter-lint.d.mts +7 -97
  80. package/types/engine/frontmatter.d.mts +16 -1
  81. package/types/engine/helpers.d.mts +2 -2
  82. package/types/engine/infobox.d.mts +6 -0
  83. package/types/engine/item-docs.d.mts +1 -35
  84. package/types/engine/map-notes.d.mts +2 -2
  85. package/types/engine/note-addresses.d.mts +41 -0
  86. package/types/engine/note-vocabulary.d.mts +20 -3
  87. package/types/engine/place-relations.d.mts +12 -1
  88. package/types/engine/populations.d.mts +17 -2
  89. package/types/engine/reference-targets.d.mts +9 -0
  90. package/types/engine/shortcode-references.d.mts +14 -0
  91. package/types/engine/site-build.d.mts +3 -3
  92. package/types/engine/site-index.d.mts +9 -30
  93. package/types/engine/sql-tables.d.mts +2 -2
  94. package/types/engine/system-block.d.mts +17 -0
  95. package/types/engine/system-vocabulary.d.mts +16 -0
  96. package/types/engine/wikilink-syntax.d.mts +7 -0
  97. package/types/engine/wikilinks.d.mts +6 -109
  98. package/types/hm3/item-fields.d.mts +5 -0
  99. package/types/sohl/infobox.d.mts +4 -5
  100. package/types/sohl/item-fields.d.mts +8 -0
@@ -51,6 +51,8 @@
51
51
  * @module
52
52
  */
53
53
 
54
+ import { positionOfYamlPath } from "./diagnostics.mjs";
55
+ import { decodeNoteAddresses, noteAddressContext, encodeAddresses } from "./note-addresses.mjs";
54
56
  import fs from "node:fs";
55
57
  import path from "node:path";
56
58
  import { createRequire } from "node:module";
@@ -136,13 +138,28 @@ function siteCorpusFiles(contentBase, ctx) {
136
138
  * @param {string} file - Absolute path.
137
139
  * @returns {{fm: object, body: string}|null}
138
140
  */
139
- function readNote(file) {
141
+ function readNote(file, ctx) {
142
+ let parsed;
140
143
  try {
141
- const { data, content } = matter(fs.readFileSync(file, "utf8"));
142
- return { fm: data, body: content };
144
+ parsed = matter(fs.readFileSync(file, "utf8"));
143
145
  } catch {
144
146
  return null;
145
147
  }
148
+ const context = {
149
+ ...noteAddressContext(ctx.config ?? {}),
150
+ package: ctx.contentPackage ?? ctx.config?.contentPackage,
151
+ };
152
+ try {
153
+ return { fm: decodeNoteAddresses(parsed.data, context), body: parsed.content };
154
+ } catch (error) {
155
+ const position = positionOfYamlPath(parsed.matter, error.keyPath ?? [], {
156
+ key: error.addressKey,
157
+ });
158
+ if (position.line) position.line++;
159
+ error.file = file;
160
+ error.position = position;
161
+ throw error;
162
+ }
146
163
  }
147
164
 
148
165
  /**
@@ -182,7 +199,7 @@ export function collectContentPages(contentBase, ctx) {
182
199
  );
183
200
 
184
201
  for (const file of siteCorpusFiles(contentBase, ctx)) {
185
- const note = readNote(file);
202
+ const note = readNote(file, ctx);
186
203
  if (!note) continue;
187
204
  applyComputedBeingAge(note.fm, present);
188
205
  const { fm, body } = note;
@@ -288,7 +305,7 @@ export function collectHomepages(contentBase, ctx) {
288
305
  const pages = [];
289
306
  const addressFindings = [];
290
307
  for (const file of siteCorpusFiles(contentBase, ctx)) {
291
- const note = readNote(file);
308
+ const note = readNote(file, ctx);
292
309
  if (!note || !isHomepage(note.fm)) continue;
293
310
  try {
294
311
  addressSlug(note.fm);
@@ -347,7 +364,7 @@ export function writeHomepages(outRoot, pages, config, { related } = {}) {
347
364
  });
348
365
  const dest = path.join(outRoot, HOMEPAGE_DESTINATION);
349
366
  fs.mkdirSync(path.dirname(dest), { recursive: true });
350
- fs.writeFileSync(dest, matter.stringify(page.body, data));
367
+ fs.writeFileSync(dest, matter.stringify(page.body, encodeAddresses(data)));
351
368
  }
352
369
  return pages.length;
353
370
  }
@@ -377,7 +394,7 @@ export function writeHomepages(outRoot, pages, config, { related } = {}) {
377
394
  * @param {object} options.config - The resolved build configuration.
378
395
  * @returns {object} The gate results and, when they pass, the built index.
379
396
  */
380
- export function siteGates(pages, findings, { config }) {
397
+ export function siteGates(pages, findings, { config, records }) {
381
398
  const out = {
382
399
  // Always empty here: the homepage count is decided in `buildSite`
383
400
  // before the content walk, and a failing count returns without ever
@@ -410,7 +427,10 @@ export function siteGates(pages, findings, { config }) {
410
427
  if (out.unaddressable.length) return out;
411
428
 
412
429
  out.index = buildSiteIndex(pages, {
430
+ package: config.contentPackage,
413
431
  foreignIndex: foreign.index,
432
+ foreignReferences: foreign.references,
433
+ records,
414
434
  noIndexPackages: noContentIndexPackages(config),
415
435
  });
416
436
  return out;
@@ -526,7 +546,7 @@ export function tableUniverse(pages) {
526
546
  * @param {object} options
527
547
  * @param {(data: object, page: object) => void} [options.decorate] - Called
528
548
  * with each page's frontmatter, for whatever a consumer's own pass adds.
529
- * @param {(value: unknown, type: string) => string|null} [options.artSrc] -
549
+ * @param {(value: unknown, type: string, accepts?: Iterable<string>) => string|null} [options.artSrc] -
530
550
  * Translates an art address into the pathname the website resolver takes, or
531
551
  * `null` where nothing answers it.
532
552
  * @param {(src: string) => string} [options.webSrc] - Translates an authored
@@ -593,17 +613,17 @@ function pageTitle(page) {
593
613
  *
594
614
  * @param {object} data - The frontmatter being emitted, rewritten in place.
595
615
  * @param {(src: string) => string} webSrc - The website's pathname resolver.
596
- * @param {(value: unknown, type: string) => string|null} artSrc - The address
597
- * resolver, yielding the pathname `webSrc` takes.
616
+ * @param {(value: unknown, type: string, accepts?: Iterable<string>) => string|null} artSrc -
617
+ * The address resolver, yielding the pathname `webSrc` takes.
598
618
  * @returns {void}
599
619
  */
600
620
  function resolveArtFields(data, webSrc, artSrc) {
601
- for (const { key, type } of ART_SLOTS) {
621
+ for (const { key, type, accepts } of ART_SLOTS) {
602
622
  const holder = isPlainObject(data.data) ? data.data : null;
603
623
  if (!holder) continue;
604
624
  const value = holder[key];
605
625
  if (typeof value !== "string" || value === "") continue;
606
- const pathname = artSrc(value, type);
626
+ const pathname = artSrc(value, type, accepts);
607
627
  if (pathname === null) delete holder[key];
608
628
  else holder[key] = webSrc(pathname);
609
629
  }
@@ -798,7 +818,8 @@ export function renderPages(pages, options) {
798
818
  });
799
819
 
800
820
  const webSrc = webAddresses(page.file);
801
- const artSrc = (value, type) => artPathname(artIndex, value, type).pathname;
821
+ const artSrc = (value, type, accepts) =>
822
+ artPathname(artIndex, value, type, accepts).pathname;
802
823
  const resolve = (text) => {
803
824
  let t = text;
804
825
  if (pass.beforeLinks) t = pass.beforeLinks(t, page);
@@ -856,7 +877,7 @@ export function renderPages(pages, options) {
856
877
  data.map = map.name;
857
878
  withMap += 1;
858
879
  }
859
- fs.writeFileSync(dest, matter.stringify(body, data));
880
+ fs.writeFileSync(dest, matter.stringify(body, encodeAddresses(data)));
860
881
  byKind[page.kind] = (byKind[page.kind] ?? 0) + 1;
861
882
  }
862
883
 
@@ -1078,7 +1099,10 @@ export function buildSite({ config, sqlTables, locate } = {}) {
1078
1099
  url: base,
1079
1100
  }));
1080
1101
 
1081
- const gates = siteGates([...pages, ...homepageEntries], content, { config: resolved });
1102
+ const gates = siteGates([...pages, ...homepageEntries], content, {
1103
+ config: resolved,
1104
+ records: ctx.records,
1105
+ });
1082
1106
  if (gatesFailed(gates)) {
1083
1107
  return {
1084
1108
  gates,
@@ -49,9 +49,20 @@
49
49
  * @module
50
50
  */
51
51
 
52
+ import { buildReferenceTargets } from "./reference-targets.mjs";
53
+
54
+ import { resolveShortcodeReference } from "./shortcode-references.mjs";
55
+
52
56
  import path from "node:path";
53
57
 
54
58
  import { canonicalKey, readCanonicalKey } from "./content-address.mjs";
59
+ import {
60
+ readQualifier,
61
+ parseAddress,
62
+ renderAddress,
63
+ isAddressTuple,
64
+ completeAddress,
65
+ } from "./address.mjs";
55
66
  import { NO_SYSTEM } from "./systems.mjs";
56
67
  import { systemOf } from "./document-subtypes.mjs";
57
68
  import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./note-claims.mjs";
@@ -156,7 +167,13 @@ function mergeForeign(index, foreignIndex) {
156
167
  */
157
168
  export function buildSiteIndex(
158
169
  entries,
159
- { foreignIndex = new Map(), noIndexPackages = new Set() } = {},
170
+ {
171
+ foreignIndex = new Map(),
172
+ noIndexPackages = new Set(),
173
+ package: builderPackage,
174
+ records = [],
175
+ foreignReferences = new Map(),
176
+ } = {},
160
177
  ) {
161
178
  const index = new Map();
162
179
  const contentTypes = new Set();
@@ -165,7 +182,7 @@ export function buildSiteIndex(
165
182
  // vendored manifest speaks for. Without it `readQualifier` cannot see the
166
183
  // leading package segment of a canonical address, and `kethira-place-x`
167
184
  // reads as the unknown type `kethira`.
168
- const ownPackage = contentPackage();
185
+ const ownPackage = builderPackage ?? contentPackage();
169
186
  const packages = new Set(ownPackage ? [ownPackage] : []);
170
187
 
171
188
  // A page is addressed by `(type, shortcode)` and nothing else. Its name,
@@ -240,7 +257,13 @@ export function buildSiteIndex(
240
257
  // ordinary in-page anchor. One authored link, correct in both
241
258
  // builds — restricted to the types that actually have an item doc,
242
259
  // so a qualifier the packs would reject is reported broken here too.
243
- if (hasDocEntry(type)) {
260
+ const proseAddress = completeAddress({
261
+ package: e.pkg ?? ownPackage,
262
+ system: NO_SYSTEM,
263
+ type,
264
+ shortcode,
265
+ });
266
+ if (proseAddress.type !== type || hasDocEntry(type)) {
244
267
  contentTypes.add(`doc${type}`);
245
268
  index.set(`doc${type}/${shortcode}`.toLowerCase(), value);
246
269
  // The canonical documentation address too, so the page answers
@@ -259,6 +282,8 @@ export function buildSiteIndex(
259
282
  }
260
283
 
261
284
  return {
285
+ contentPackage: ownPackage,
286
+ referenceTargets: buildReferenceTargets(records, new Map([...foreignReferences, ...index])),
262
287
  index,
263
288
  ambiguous,
264
289
  contentTypes,
@@ -319,7 +344,7 @@ export function wikiContext(
319
344
  // none. Taken from the resolved configuration, the same source
320
345
  // the index's own addresses are built from, so a bare link cannot
321
346
  // resolve against a package the index never keyed.
322
- contentPackage: contentPackage(),
347
+ contentPackage: built.contentPackage,
323
348
  type,
324
349
  errors,
325
350
  src,
@@ -329,79 +354,36 @@ export function wikiContext(
329
354
  }
330
355
 
331
356
  /**
332
- * Resolve one infobox reference against a site index.
333
- *
334
- * A note writes a reference three ways and all three reach here: a bare
335
- * **shortcode** (`slntlncmpny`), a short **address** (`affiliation-slntlncmpny`)
336
- * and a **canonical** one (`sohl-sohl-skill-melee`). The first is the ordinary
337
- * case and the ambiguous one — a shortcode is unique within a type and not
338
- * across a tree — so a caller that knows what it expects passes `hint.type`
339
- * and the lookup is narrowed to it.
340
- *
341
- * **Without a hint the types are tried in sorted order**, so two trees holding
342
- * the same note resolve it the same way. A shortcode two types both claim
343
- * answers with the first alphabetically, which is a stable wrong answer rather
344
- * than an unstable one; a caller that cares supplies the hint.
345
- *
346
- * The `address` on the answer is the slug form — `type-shortcode` — because
347
- * that is what the book's own link map is keyed by and what a wikilink is
348
- * written as.
349
- *
350
- * @param {SiteIndex} siteIndex - The index.
351
- * @param {unknown} ref - The reference, as authored.
352
- * @param {object} [hint] - `{type}`, where the caller knows it.
353
- * @returns {{name?: string, url?: string, address?: string, subType?: string}|undefined}
354
- * The page, or `undefined` where nothing answers.
357
+ * Resolve one Address against the site's canonical identity index.
358
+ * A bare Shortcode requires a declared default type. The returned Address is
359
+ * the complete tuple; URL and display name are presentation values.
360
+ * @param {SiteIndex} siteIndex - The site index.
361
+ * @param {unknown} ref - Tuple or authored reference.
362
+ * @param {object} [hint] - A declared default type.
363
+ * @returns {object|undefined} The matching page and tuple.
355
364
  */
356
365
  export function resolveInfoboxRef(siteIndex, ref, hint) {
357
- if (typeof ref !== "string" || !ref) return undefined;
358
- const wanted = ref.toLowerCase();
359
- const keys = [];
360
- if (hint?.type) keys.push(`${String(hint.type).toLowerCase()}/${wanted}`);
361
- // An authored address already names its own type, so it is tried whole
362
- // before the type sweep — `affiliation-slntlncmpny` must not be read as a
363
- // shortcode of some other type that happens to spell it.
364
- if (wanted.includes("-")) {
365
- keys.push(wanted);
366
- const segments = wanted.split("-");
367
- if (segments.length >= 2) {
368
- keys.push(`${segments[segments.length - 2]}/${segments[segments.length - 1]}`);
369
- }
370
- }
371
- if (!hint?.type) {
372
- for (const type of [...(siteIndex?.contentTypes ?? [])].sort()) {
373
- keys.push(`${type}/${wanted}`);
374
- }
375
- }
376
- for (const key of keys) {
377
- const found = siteIndex?.index?.get(key);
378
- if (!found) continue;
379
- const slug = key.includes("/") ? key.replace("/", "-") : addressSlugOfKey(key);
380
- // No `uuid`, although a foreign entry carries one: this resolver
381
- // answers for the **published** surfaces, which address a page by URL
382
- // and an entry in the book by its slug. A compendium reference is what
383
- // `resolveReference` answers with, from the compile's own index.
384
- return {
385
- ...(found.name ? { name: found.name } : {}),
386
- ...(found.url ? { url: found.url } : {}),
387
- ...(found.subType ? { subType: found.subType } : {}),
388
- address: slug,
389
- };
366
+ if (hint?.kind === "shortcode") {
367
+ const found = resolveShortcodeReference(
368
+ [siteIndex.referenceTargets ?? siteIndex.index],
369
+ ref,
370
+ hint,
371
+ );
372
+ return found ?
373
+ { name: found.name, url: found.url, subType: found.subType, address: found.address }
374
+ : undefined;
390
375
  }
376
+ const context = {
377
+ package: siteIndex.contentPackage ?? contentPackage(),
378
+ system: "none",
379
+ types: siteIndex.contentTypes,
380
+ packages: siteIndex.packages,
381
+ };
382
+ const tuple = parseAddress(ref, { ...context, type: hint?.type });
383
+ if (tuple.reason) return undefined;
384
+ const found =
385
+ siteIndex.referenceTargets?.get(renderAddress(tuple)) ??
386
+ siteIndex.index?.get(renderAddress(tuple));
387
+ if (found) return { name: found.name, url: found.url, subType: found.subType, address: tuple };
391
388
  return undefined;
392
389
  }
393
-
394
- /**
395
- * The `type-shortcode` slug of a canonical key.
396
- *
397
- * A canonical key is `package-system-type-shortcode`, and its slug is the last
398
- * two segments — the same rule `contentAddress` states, applied to a key
399
- * rather than to frontmatter.
400
- *
401
- * @param {string} key - The canonical key.
402
- * @returns {string} The slug.
403
- */
404
- function addressSlugOfKey(key) {
405
- const segments = String(key).split("-");
406
- return segments.length >= 2 ? segments.slice(-2).join("-") : key;
407
- }
@@ -33,6 +33,9 @@
33
33
  * @module
34
34
  */
35
35
 
36
+ import { parseAddress, renderAddress, isAddressTuple } from "./address.mjs";
37
+ import { NOTE_VOCABULARY } from "./note-vocabulary.mjs";
38
+ import { encodeAddresses } from "./address-values.mjs";
36
39
  import fs from "node:fs";
37
40
  import os from "node:os";
38
41
  import path from "node:path";
@@ -41,7 +44,7 @@ import { FENCE_LINE, parseHeaderArgs } from "./code-fences.mjs";
41
44
  import { MARKET_CLASSES } from "./market-class.mjs";
42
45
  import { parseMarkdownFile } from "./helpers.mjs";
43
46
  // The record accessors only — see `engine/index-records.mjs`.
44
- import { isNoteRecord, noteFile } from "./index-records.mjs";
47
+ import { isNoteRecord, noteFile, sortKeysDeep } from "./index-records.mjs";
45
48
 
46
49
  /** Rendered in a cell whose value is absent. */
47
50
  const EMPTY_CELL = "—";
@@ -157,12 +160,15 @@ export function findSqlBlocks(markdown) {
157
160
  * @returns {Promise<{query: (sql: string) => Promise<object[]>,
158
161
  * close: () => Promise<void>}>} The open database.
159
162
  */
160
- export async function openNotesDatabase(records, { dir, dependencies = [] } = {}) {
163
+ export async function openNotesDatabase(records, { dir, dependencies = [], addressContext } = {}) {
161
164
  const { DuckDBInstance } = await import("@duckdb/node-api");
162
165
  const base = dir ?? fs.mkdtempSync(path.join(os.tmpdir(), "content-sql-"));
163
166
  fs.mkdirSync(base, { recursive: true });
164
167
  const jsonl = path.join(base, "notes.jsonl");
165
- fs.writeFileSync(jsonl, records.map((record) => JSON.stringify(record)).join("\n"));
168
+ fs.writeFileSync(
169
+ jsonl,
170
+ records.map((record) => JSON.stringify(sortKeysDeep(encodeAddresses(record)))).join("\n"),
171
+ );
166
172
 
167
173
  const instance = await DuckDBInstance.create(":memory:");
168
174
  const connection = await instance.connect();
@@ -244,6 +250,11 @@ export async function openNotesDatabase(records, { dir, dependencies = [] } = {}
244
250
  }
245
251
 
246
252
  return {
253
+ addressContext: addressContext ?? {
254
+ package: records.find((record) => record.package)?.package,
255
+ types: new Set(Object.keys(NOTE_VOCABULARY)),
256
+ system: "none",
257
+ },
247
258
  stubsExcluded,
248
259
  async query(sql) {
249
260
  const reader = await connection.runAndReadAll(sql);
@@ -368,6 +379,15 @@ function readJsonAuto(file) {
368
379
  */
369
380
  export async function runSqlQuery(db, sql) {
370
381
  const { rows, columnNames } = await db.query(sql);
382
+ if (db.addressContext?.package) {
383
+ for (const row of rows) {
384
+ const value = row[RENDER_ALIASES.ref];
385
+ if (!value) continue;
386
+ const tuple = parseAddress(value, db.addressContext, { declared: true });
387
+ if (tuple.reason) throw new Error(`SQL _ref is not an Address: ${value}`);
388
+ row[RENDER_ALIASES.ref] = tuple;
389
+ }
390
+ }
371
391
  return { columns: columnNames.filter((key) => !key.startsWith("_")), rows };
372
392
  }
373
393
 
@@ -454,7 +474,10 @@ function cellText(value, column) {
454
474
  * @param {number} [opts.sectionLevel=2] - Heading level for `_section`.
455
475
  * @returns {string} The markdown.
456
476
  */
457
- export function renderSqlTable(result, { linkable = () => true, sectionLevel = 2 } = {}) {
477
+ export function renderSqlTable(
478
+ result,
479
+ { linkable = () => true, sectionLevel = 2, addressContext } = {},
480
+ ) {
458
481
  const { columns, rows } = result;
459
482
  if (!columns.length) throw new Error("query selects no rendered column");
460
483
 
@@ -479,11 +502,18 @@ export function renderSqlTable(result, { linkable = () => true, sectionLevel = 2
479
502
  const cells = group.rows.map((row) =>
480
503
  columns.map((column, index) => {
481
504
  const text = cellText(row[column], column);
482
- const ref = row[RENDER_ALIASES.ref];
483
- if (index !== 0 || !ref || !linkable(String(ref))) return text;
505
+ const written = row[RENDER_ALIASES.ref];
506
+ if (index !== 0 || !written) return text;
507
+ const ref =
508
+ isAddressTuple(written) ? written : (
509
+ parseAddress(written, addressContext ?? {}, { declared: true })
510
+ );
511
+ if (ref.reason)
512
+ throw new Error(`SQL _ref needs a complete Address context: ${written}`);
513
+ if (!linkable(ref)) return text;
484
514
  // A wikilink's own separator is a literal `|`, written `\|`
485
515
  // inside a table cell, so the label must not carry one.
486
- return `[[${ref}\\|${text.replace(/\\?\|/g, "/")}]]`;
516
+ return `[[${renderAddress(ref)}\\|${text.replace(/\\?\|/g, "/")}]]`;
487
517
  }),
488
518
  );
489
519
  const align = columns.map((_column, index) => {
@@ -542,6 +572,7 @@ export async function prepareSqlTables(db, sources, { linkable } = {}) {
542
572
  forNote.push({
543
573
  markdown: renderSqlTable(result, {
544
574
  linkable,
575
+ addressContext: db.addressContext,
545
576
  sectionLevel: block.sectionLevel,
546
577
  }),
547
578
  rows: result.rows.length,
@@ -621,7 +652,8 @@ export async function prepareTreeSqlTables(contentBase, { config, skipDirectorie
621
652
  const addresses = new Set(
622
653
  indexRecords
623
654
  .filter((record) => !record.documents)
624
- .map((record) => record.address?.slug)
655
+ .map((record) => record.documentation ?? record.address?.canonical)
656
+ .map((target) => target && encodeAddresses(target))
625
657
  .filter(Boolean),
626
658
  );
627
659
  // Each declared dependency's published index, attached as its own schema so
@@ -639,7 +671,9 @@ export async function prepareTreeSqlTables(contentBase, { config, skipDirectorie
639
671
  }
640
672
  const db = await openNotesDatabase(indexRecords, { dependencies });
641
673
  try {
642
- return await prepareSqlTables(db, sources, { linkable: (ref) => addresses.has(ref) });
674
+ return await prepareSqlTables(db, sources, {
675
+ linkable: (ref) => addresses.has(renderAddress(ref)),
676
+ });
643
677
  } finally {
644
678
  await db.close();
645
679
  }
@@ -419,6 +419,27 @@ export function retiredTopLevelKey(field) {
419
419
  return rest === "" ? undefined : rest;
420
420
  }
421
421
 
422
+ /**
423
+ * A shared source as the key an author writes, without the container holding it.
424
+ *
425
+ * `data.weight` is authored as `weight:` one level inside `data:`, so every
426
+ * question about the *note* rather than about the resolution order asks it under
427
+ * that spelling: which presentation overlay a row takes, which key a finding
428
+ * names, and whether two statements of one fact are the same fact. The
429
+ * specification's mapping tables write the whole path and a `data:` vocabulary
430
+ * writes the key, and this is what makes the two comparable.
431
+ *
432
+ * It takes a **name** rather than a declaration because both sides of that
433
+ * comparison reach it: one holds a field, the other a row of a table.
434
+ *
435
+ * @param {string|undefined} name - The shared source, as either side spells it.
436
+ * @returns {string} The key, without the container.
437
+ */
438
+ export function authoredKey(name) {
439
+ const path = String(name ?? "");
440
+ return path.startsWith(DATA_PREFIX) ? path.slice(DATA_PREFIX.length) : path;
441
+ }
442
+
422
443
  /**
423
444
  * Where a declared field's value came from.
424
445
  *
@@ -0,0 +1,46 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * SPDX-License-Identifier: GPL-3.0-or-later
6
+ */
7
+
8
+ /**
9
+ * Systems named by package declarations and pack targets.
10
+ * @param {object} [config] - Resolved build configuration.
11
+ * @returns {string[]} Declared system identifiers.
12
+ */
13
+ export function declaredSystems(config) {
14
+ const out = [];
15
+ for (const system of Object.keys(config?.systems ?? {})) {
16
+ if (!out.includes(system)) out.push(system);
17
+ }
18
+ for (const pack of config?.packs ?? []) {
19
+ const system = pack?.system;
20
+ if (typeof system === "string" && system && !out.includes(system)) out.push(system);
21
+ }
22
+ if (out.length) return out;
23
+ const packageWide = config?.stats?.systemId;
24
+ return typeof packageWide === "string" && packageWide ? [packageWide] : [];
25
+ }
26
+
27
+ /**
28
+ * System field vocabularies supplied by the configuration and caller.
29
+ * @param {object} [config] - Resolved build configuration.
30
+ * @param {object} [options] - Caller schema context.
31
+ * @param {string} [options.schemaSystem] - Owner of the caller's schemas.
32
+ * @returns {object} System block declarations.
33
+ */
34
+ export function systemBlocksFor(config, { schemaSystem } = {}) {
35
+ const byName = config?.itemFieldsBySystem ?? {};
36
+ /** @type {Record<string, object>} */
37
+ const blocks = {};
38
+ for (const system of declaredSystems(config)) {
39
+ /** @type {object} */
40
+ const spec = {};
41
+ if (system === schemaSystem) spec.fieldVocabulary = true;
42
+ if (byName[system]) spec.fields = byName[system];
43
+ if (Object.keys(spec).length) blocks[system] = Object.freeze(spec);
44
+ }
45
+ return Object.freeze(blocks);
46
+ }
@@ -41,6 +41,7 @@
41
41
  // cannot drift apart on it: they disagreed once over the unlabelled hyphen
42
42
  // form, which the packs showed as a raw shortcode and the knowledgebase as a
43
43
  // name.
44
+ import { readWikilink } from "./wikilink-syntax.mjs";
44
45
  import { readQualifier } from "./wikilinks.mjs";
45
46
  import { replaceOutsideCode } from "./code-fences.mjs";
46
47
  // The canonical `package-system-type-shortcode` key, so a package-qualified
@@ -390,8 +391,14 @@ export function resolveWebWikilinks(body, ctx) {
390
391
  // Code is verbatim: a `[[…]]` inside a code fence, an indented block or an
391
392
  // inline span is source text, not a link.
392
393
  return replaceOutsideCode(embedded.markdown, WIKILINK, (all, rawInner) => {
393
- const parsed = parseWikilink(rawInner);
394
- const { target, anchor, display } = parsed;
394
+ const parsed = readWikilink(rawInner, {
395
+ package: ctx.contentPackage,
396
+ system: "none",
397
+ types: ctx.contentTypes ?? new Set(),
398
+ packages: ctx.packages,
399
+ noIndexPackages: ctx.noIndexPackages,
400
+ });
401
+ const { rawTarget: target, anchor, display } = parsed;
395
402
  // An empty label is not a label: `[[x|]]` addresses the target and
396
403
  // shows its name, so `""` falls through to the same place `null` does.
397
404
  // One reading, from {@link authoredLabel}.
@@ -415,12 +422,7 @@ export function resolveWebWikilinks(body, ctx) {
415
422
  // The canonical separator has to be resolved, not merely
416
423
  // recognised. `null` here means the target is not an address at all,
417
424
  // which is a defect: there is no other namespace to try.
418
- const read = readQualifier(
419
- target,
420
- ctx.contentTypes ?? new Set(),
421
- ctx.packages,
422
- ctx.noIndexPackages,
423
- );
425
+ const read = parsed.problem ?? parsed.target;
424
426
  const rawKey = target.toLowerCase();
425
427
  const hit =
426
428
  lookupRead(ctx.index, read, ctx.contentPackage) ??
@@ -475,7 +477,7 @@ export function resolveWebWikilinks(body, ctx) {
475
477
  // names its package. A qualified target that matched more than one
476
478
  // document is reported by the matcher instead, not by this set.
477
479
  const collideKey =
478
- read && !read.reason && !read.package ?
480
+ read && !read.reason && read.package === ctx.contentPackage ?
479
481
  `${read.type}/${read.shortcode}`.toLowerCase()
480
482
  : rawKey;
481
483
  const reason =
@@ -11,6 +11,8 @@
11
11
  * SPDX-License-Identifier: GPL-3.0-or-later
12
12
  */
13
13
 
14
+ import { parseAddress, isAddressTuple } from "./address.mjs";
15
+
14
16
  /**
15
17
  * What a `[[…]]` **is**, before anything decides where it points.
16
18
  *
@@ -368,3 +370,22 @@ export function linkFindingMessage({ reason, target, packages, anchor, type, stu
368
370
  export function isSamePage({ target, anchor }) {
369
371
  return !target && Boolean(anchor);
370
372
  }
373
+
374
+ /**
375
+ * Read an operational link target in its enclosing Address context.
376
+ * @param {string} inner - Lexical link contents.
377
+ * @param {object} context - Builder defaults and type vocabulary.
378
+ * @returns {object} Typed target and diagnostic source spelling.
379
+ */
380
+ export function readWikilink(inner, context) {
381
+ const parsed = parseWikilink(inner);
382
+ const rawTarget = parsed.target;
383
+ if (!rawTarget) return { ...parsed, rawTarget, target: { kind: "same-page" } };
384
+ const target = parseAddress(rawTarget, context);
385
+ if (target.reason === "no-type") target.reason = "not-an-address";
386
+ if (!target.reason && !isAddressTuple(target))
387
+ throw new Error("Wikilink read requires complete builder Address context");
388
+ return target.reason ?
389
+ { ...parsed, rawTarget, target: { kind: "literal", text: rawTarget }, problem: target }
390
+ : { ...parsed, rawTarget, target };
391
+ }