@heroiclands/package-build 22.9.2 → 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 (96) hide show
  1. package/CHANGELOG.md +50 -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/engine/actor-compiler.mjs +20 -7
  7. package/engine/address-fields.mjs +404 -0
  8. package/engine/address-values.mjs +80 -0
  9. package/engine/address.mjs +769 -0
  10. package/engine/art-fields.mjs +112 -97
  11. package/engine/art-slots.mjs +81 -0
  12. package/engine/base-compiler.mjs +59 -7
  13. package/engine/bundle-notes.mjs +5 -2
  14. package/engine/bundles.mjs +17 -10
  15. package/engine/content-address.mjs +23 -186
  16. package/engine/content-embeds.mjs +18 -4
  17. package/engine/content-format-check.mjs +50 -22
  18. package/engine/content-index.mjs +85 -43
  19. package/engine/content-links.mjs +105 -104
  20. package/engine/content-lint.mjs +11 -1
  21. package/engine/content-tables.mjs +9 -1
  22. package/engine/field-spec.mjs +20 -4
  23. package/engine/folder-notes.mjs +44 -15
  24. package/engine/foundry-entries.mjs +3 -0
  25. package/engine/frontmatter-lint.mjs +144 -43
  26. package/engine/frontmatter.mjs +55 -10
  27. package/engine/generate.mjs +7 -1
  28. package/engine/helpers.mjs +37 -9
  29. package/engine/holdings.mjs +41 -12
  30. package/engine/index-records.mjs +2 -0
  31. package/engine/infobox-render.mjs +2 -1
  32. package/engine/infobox.mjs +26 -6
  33. package/engine/item-compiler.mjs +5 -0
  34. package/engine/item-docs.mjs +7 -44
  35. package/engine/map-dot.mjs +2 -0
  36. package/engine/map-notes.mjs +7 -2
  37. package/engine/map-places.mjs +42 -10
  38. package/engine/metadata-index.mjs +60 -5
  39. package/engine/note-addresses.mjs +310 -0
  40. package/engine/note-vocabulary.mjs +145 -26
  41. package/engine/pdf-build.mjs +1 -1
  42. package/engine/pdf-toc.mjs +17 -2
  43. package/engine/place-relations.mjs +160 -50
  44. package/engine/populations.mjs +17 -9
  45. package/engine/reference-targets.mjs +54 -0
  46. package/engine/scenes.mjs +5 -2
  47. package/engine/shortcode-references.mjs +68 -0
  48. package/engine/site-build.mjs +39 -15
  49. package/engine/site-index.mjs +57 -75
  50. package/engine/sql-tables.mjs +43 -9
  51. package/engine/system-block.mjs +21 -0
  52. package/engine/system-vocabulary.mjs +46 -0
  53. package/engine/web-wikilinks.mjs +11 -9
  54. package/engine/wikilink-syntax.mjs +21 -0
  55. package/engine/wikilinks.mjs +92 -306
  56. package/hm3/actors.mjs +8 -24
  57. package/hm3/item-fields.mjs +15 -5
  58. package/package.json +1 -1
  59. package/sohl/infobox.mjs +80 -17
  60. package/sohl/item-fields.mjs +114 -41
  61. package/types/engine/address-fields.d.mts +90 -0
  62. package/types/engine/address-values.d.mts +34 -0
  63. package/types/engine/address.d.mts +452 -0
  64. package/types/engine/art-fields.d.mts +59 -53
  65. package/types/engine/art-slots.d.mts +40 -0
  66. package/types/engine/base-compiler.d.mts +24 -2
  67. package/types/engine/bundle-notes.d.mts +2 -2
  68. package/types/engine/content-address.d.mts +3 -139
  69. package/types/engine/content-embeds.d.mts +1 -1
  70. package/types/engine/content-format-check.d.mts +25 -0
  71. package/types/engine/content-index.d.mts +2 -2
  72. package/types/engine/content-links.d.mts +6 -5
  73. package/types/engine/field-spec.d.mts +44 -9
  74. package/types/engine/folder-notes.d.mts +11 -10
  75. package/types/engine/frontmatter-lint.d.mts +7 -97
  76. package/types/engine/frontmatter.d.mts +16 -1
  77. package/types/engine/helpers.d.mts +2 -2
  78. package/types/engine/infobox.d.mts +6 -0
  79. package/types/engine/item-docs.d.mts +1 -35
  80. package/types/engine/map-notes.d.mts +2 -2
  81. package/types/engine/note-addresses.d.mts +41 -0
  82. package/types/engine/note-vocabulary.d.mts +20 -3
  83. package/types/engine/place-relations.d.mts +12 -1
  84. package/types/engine/populations.d.mts +17 -2
  85. package/types/engine/reference-targets.d.mts +9 -0
  86. package/types/engine/shortcode-references.d.mts +14 -0
  87. package/types/engine/site-build.d.mts +3 -3
  88. package/types/engine/site-index.d.mts +9 -30
  89. package/types/engine/sql-tables.d.mts +2 -2
  90. package/types/engine/system-block.d.mts +17 -0
  91. package/types/engine/system-vocabulary.d.mts +16 -0
  92. package/types/engine/wikilink-syntax.d.mts +7 -0
  93. package/types/engine/wikilinks.d.mts +6 -109
  94. package/types/hm3/item-fields.d.mts +5 -0
  95. package/types/sohl/infobox.d.mts +4 -5
  96. package/types/sohl/item-fields.d.mts +8 -0
@@ -45,13 +45,12 @@ import { DEFAULT_ADDRESS_SCHEME } from "../content-config.mjs";
45
45
  // The system vocabulary is the `<system>` segment's own registry, and
46
46
  // `engine/systems.mjs` imports nothing but `engine/address-charset.mjs`, so
47
47
  // the direction is toward the leaf and cannot close a cycle.
48
- import { NO_SYSTEM, assertSystemSegment, isSystemSegment } from "./systems.mjs";
49
- // The asset vocabulary, for the one thing the address grammar asks of it:
50
- // whether a type's `<system>` segment is fixed. `engine/asset-types.mjs` is a
51
- // leaf, so the direction cannot close a cycle.
52
- import { isAssetType } from "./asset-types.mjs";
53
- import { systemOf } from "./document-subtypes.mjs";
54
- import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./subtype-registry.mjs";
48
+ import { NO_SYSTEM, isSystemSegment } from "./systems.mjs";
49
+
50
+ // The address grammar, which sits below this module: a page URL is derived from
51
+ // an address, so the rule for reading and writing one is stated there and
52
+ // applied here.
53
+ import { canonicalKey } from "./address.mjs";
55
54
 
56
55
  // `ids.mjs` is a leaf with no local imports — the module note there says why —
57
56
  // so an address may hash itself without any risk of closing a cycle.
@@ -66,6 +65,21 @@ export { DEFAULT_ADDRESS_SCHEME };
66
65
  */
67
66
  export { NO_SYSTEM };
68
67
 
68
+ /**
69
+ * The address grammar, under the names its callers hold it by.
70
+ *
71
+ * One parser, one renderer and one reader, all of them in `engine/address.mjs`:
72
+ * {@link canonicalKey} renders the four segments, `expandAddress` applies a
73
+ * position's defaults to a written value and renders the result, and
74
+ * `readCanonicalKey` reads a rendered address back.
75
+ */
76
+ export {
77
+ canonicalKey,
78
+ expandAddress,
79
+ readCanonicalKey,
80
+ CANONICAL_KEY_SEGMENTS,
81
+ } from "./address.mjs";
82
+
69
83
  /** The knowledgebase's mount within this package's site. */
70
84
  export const KB_PREFIX = "kb/";
71
85
 
@@ -73,7 +87,7 @@ export const KB_PREFIX = "kb/";
73
87
  * The single path segment a note is addressed by: `type-shortcode`.
74
88
  *
75
89
  * Lowercased and hyphen-joined by the same rule as the note's canonical key
76
- * ({@link canonicalKey}, below, lowercases too), so it is that
90
+ * ({@link canonicalKey} lowercases too), so it is that
77
91
  * key's **last two segments** — which is what makes a manifest entry's `path`
78
92
  * derivable from the key it is filed under rather than transported beside it.
79
93
  *
@@ -152,63 +166,6 @@ export function packageAddress(fm) {
152
166
  return `${addressSlug(fm)}/`;
153
167
  }
154
168
 
155
- /**
156
- * The **canonical** address of a note: fully qualified, one spelling per
157
- * document, and globally unique.
158
- *
159
- * The written form of a link is a **partial** address: it may omit leading
160
- * segments, and each one it omits is filled in by rule rather than left
161
- * unconstrained. An omitted package (`[[skill-lang]]`) defaults to the citing
162
- * note's own, so an unqualified link resolves locally and only locally, and a
163
- * link into another package must name it. An omitted **system** is a
164
- * *wildcard*, not a default — most links target items, which belong to a
165
- * system — and the resolver requires exactly one match: none is a dead link,
166
- * more than one is an ambiguity reported with every candidate named.
167
- * Everything internal — index keys, cache keys, every lookup — uses this fully
168
- * qualified form instead, so no consumer has to know what a short form
169
- * defaulted to or matched.
170
- *
171
- * Global uniqueness is what lets a dependency's index merge straight into a
172
- * local one: the keys cannot collide by accident, so a key already present on
173
- * merge is a real conflict rather than an artefact of two packages sharing a
174
- * namespace. `(type, shortcode)` alone is unique only *within* a package, and
175
- * two independently authored packages reaching for the same short string is a
176
- * matter of time.
177
- *
178
- * **The system segment.** A package may ship content for more than one
179
- * system, and one note then compiles into a document per system — an actor in
180
- * `actors-sohl` *and* an actor in `actors-hm3`. Without a system segment both
181
- * land on one key, so the address cannot name either of them. `harn-ensemble`
182
- * carries 2,497 such notes.
183
- *
184
- * The value is a system id, or the literal **`none`** for a document no game
185
- * system defines: a journal, a macro, a scene, and an item's documentation
186
- * journal — which is `none` however many systems the item itself declares,
187
- * because it is one journal.
188
- *
189
- * `none` rather than `any`: every segment of an address is an exact literal,
190
- * and `any` reads as a wildcard — "matches under any system" — which is not
191
- * what it does. A resolver written to that misreading would fail silently,
192
- * since a lookup miss already returns nothing rather than erroring. And not
193
- * `null` or `~`, both of which are YAML nulls that parse to an absent value and
194
- * drop the segment entirely.
195
- *
196
- * @param {string} pkg - The owning **content** package (`sohl`, `thalorna`) —
197
- * not the Foundry package, which varies per compilation target.
198
- * @param {string} system - The system whose document this addresses, or `none`.
199
- * @param {string} type - The note's `type`.
200
- * @param {string} shortcode - The note's `shortcode`.
201
- * @returns {string} `package-system-type-shortcode`, lowercased.
202
- */
203
- export function canonicalKey(pkg, system, type, shortcode) {
204
- // Checked where an address is *written*, not where one is read: a fetched
205
- // index naming a system this build has never heard of is data to report,
206
- // while emitting one is a defect in this build. The registry is closed, so
207
- // an unknown value here can only be a typo or a system nobody declared.
208
- assertSystemSegment(system, `the address of ${type}-${shortcode}`);
209
- return `${pkg}-${system}-${type}-${shortcode}`.toLowerCase();
210
- }
211
-
212
169
  /**
213
170
  * Which system a frontmatter key path is written under.
214
171
  *
@@ -218,7 +175,7 @@ export function canonicalKey(pkg, system, type, shortcode) {
218
175
  * top-level frontmatter, the shared `data:` container, and body prose, which has
219
176
  * no key path at all and passes `undefined`.
220
177
  *
221
- * It is the block rather than the field, so a `WikiLink` field needs no opinion
178
+ * It is the block rather than the field, so an `Address` field needs no opinion
222
179
  * about systems and no per-field table has to be kept in step with the schema.
223
180
  *
224
181
  * The first segment must **be** a declared system, not merely look like one:
@@ -235,126 +192,6 @@ export function blockSystem(keyPath) {
235
192
  return isSystemSegment(first) && first !== NO_SYSTEM ? first : NO_SYSTEM;
236
193
  }
237
194
 
238
- /**
239
- * Expand a written address to the one canonical address it names.
240
- *
241
- * **An omitted segment defaults from where the link is written** — it is
242
- * not a wildcard, and resolution is not a search. Package omitted means the
243
- * citing note's own; system omitted means {@link blockSystem} of the key path it
244
- * was written under. So every short form has exactly one expansion, computed
245
- * before anything is looked up, and there is no candidate set to disambiguate.
246
- *
247
- * **Under `none`, a system-bearing type addresses its documentation journal.**
248
- * A note's `none` address *is* its `doc<type>` entry — the Item is the one with
249
- * a system — so a prose `[[affiliation-sirvadar|…]]` names the page, which is
250
- * almost always what prose means. A link that means the Item states the system
251
- * and gets it. This is the defaulting rule applied, not an exception carved out
252
- * of it.
253
- *
254
- * **Only a type whose own document carries a system is redirected.** A `macro`
255
- * and the map types have documentation journals too, but their own documents
256
- * are core ones and already live at `none` — so `<pkg>-none-macro-x` names the
257
- * Macro and `<pkg>-none-docmacro-x` its journal, two live addresses that the
258
- * redirect would collapse into one. The test is the note type's own system,
259
- * not merely whether it has a doc entry.
260
- *
261
- * A `doc<type>` written explicitly is `none` **wherever** it appears, even
262
- * inside a system block: no game system defines a JournalEntry, so there is no
263
- * other system for one to belong to.
264
- *
265
- * @param {{type: string, shortcode: string, package?: string, system?: string,
266
- * itemDoc?: boolean}} read - A qualifier, as `readQualifier` returns one.
267
- * @param {{package: string, system?: string}} where - The citing context: the
268
- * tree's own content package, and the system of the block the link sits in.
269
- * @returns {string} The canonical `package-system-type-shortcode`.
270
- */
271
- export function expandAddress(read, where) {
272
- const pkg = read.package ?? where.package;
273
- // A documentation journal is a core document, so it is `none` however it was
274
- // reached, and an **asset** is `none` for a stronger reason: a file belongs
275
- // to no game system, so the segment is a property of the type rather than of
276
- // where the reference was written. Without that, a `sohl:` block naming
277
- // `icon-anvil` would expand to `sohl-sohl-icon-anvil` and resolve to
278
- // nothing, which is exactly where an embedded item's art is written.
279
- // Otherwise the block's system, which body prose reports as `none`.
280
- const system =
281
- read.itemDoc || isAssetType(read.type) ?
282
- NO_SYSTEM
283
- : (read.system ?? where.system ?? NO_SYSTEM);
284
- const redirected = system === NO_SYSTEM && isSystemBearing(read.type);
285
- const type = read.itemDoc || redirected ? `doc${read.type}` : read.type;
286
- return canonicalKey(pkg, system, type, read.shortcode);
287
- }
288
-
289
- /**
290
- * Whether a note type's **own** document carries a game system.
291
- *
292
- * True for the types some shipped map compiles into an Item or an Actor; false
293
- * for the core-document types — `doc`, `lore`, `place`, `scenario`, `macro` and
294
- * the map types — whose documents Foundry itself defines and which therefore
295
- * already live at `none`.
296
- *
297
- * It is what {@link expandAddress} tests rather than {@link hasDocEntry}: a
298
- * `macro` has a documentation journal *and* a `none` address of its own, so
299
- * redirecting on "has a doc entry" would collapse two live addresses into one
300
- * and a `[[macro-autoattack|]]` would stop naming the Macro.
301
- *
302
- * @param {string} type - The note type.
303
- * @returns {boolean} True when the type compiles into a system document.
304
- */
305
- function isSystemBearing(type) {
306
- return systemOf(type, KNOWN_DOCUMENT_SUBTYPE_MAPS) !== NO_SYSTEM;
307
- }
308
-
309
- /**
310
- * How many segments a canonical key has, and therefore how many the reader
311
- * below counts.
312
- *
313
- * Named rather than written as a literal because it is the *grammar*, not an
314
- * implementation detail of one function: it is the number a change to the
315
- * address form would move, and the thing a reader of that change has to find.
316
- *
317
- * @type {number}
318
- */
319
- export const CANONICAL_KEY_SEGMENTS = 4;
320
-
321
- /**
322
- * Reads a canonical key back into its parts.
323
- *
324
- * Parsing is plain positional counting: split on the separator, require
325
- * {@link CANONICAL_KEY_SEGMENTS} of them, and assign each position its field.
326
- * **The charset rule is what makes that sound** — every segment matches
327
- * `ADDRESS_SEGMENT_PATTERN` (`engine/address-charset.mjs`), so the hyphen is
328
- * purely a separator and the count alone determines every field. That is
329
- * enforced at each of the three sources rather than assumed of
330
- * the data: shortcodes by `content-lint.mjs`, `contentPackage` by
331
- * `defineConfig`, and types are bare words. Were any of them free to
332
- * carry a hyphen, no amount of counting would recover the fields and the reader
333
- * would need a vocabulary to match against instead.
334
- *
335
- * **Nothing to read and nothing readable are different answers.** A key that
336
- * cannot be canonical — `harn-adventures-sohl-skill-melee`, five segments,
337
- * because the package name carries the separator — yields `null`, while an
338
- * absent or blank input yields `undefined`. Both are falsy, so
339
- * every call site (all of which test the result for truthiness) is unaffected;
340
- * the distinction is there so a caller reporting "this key is unreadable" can
341
- * tell that it has a key to report about.
342
- *
343
- * @param {unknown} key - A canonical key, or nothing.
344
- * @returns {{package: string, system: string, type: string, shortcode: string}
345
- * |null|undefined}
346
- * The parts; `null` when there is a string that is not in canonical form;
347
- * `undefined` when there is no key at all.
348
- */
349
- export function readCanonicalKey(key) {
350
- if (key == null || key === "") return undefined;
351
- const parts = String(key).split("-");
352
- if (parts.length !== CANONICAL_KEY_SEGMENTS) return null;
353
- const [pkg, system, type, shortcode] = parts;
354
- if (!pkg || !system || !type || !shortcode) return null;
355
- return { package: pkg, system, type, shortcode };
356
- }
357
-
358
195
  /**
359
196
  * Where this build serves each package, keyed by package name.
360
197
  *
@@ -59,6 +59,7 @@
59
59
  * @module
60
60
  */
61
61
 
62
+ import { parseAddress } from "./address.mjs";
62
63
  import { matchAllOutsideCode } from "./code-fences.mjs";
63
64
  import { parseImageDirective, standsAlone } from "./content-images.mjs";
64
65
  import { positionInBody } from "./diagnostics.mjs";
@@ -118,16 +119,20 @@ export const EMBED_PATTERN = /!\[\[([^\]\n]+)\]\](\{[^}\n]*\})?/g;
118
119
  * @param {string} body - The note's markdown, without its frontmatter.
119
120
  * @returns {ParsedEmbed[]} One entry per embed.
120
121
  */
121
- export function embedsIn(body) {
122
+ export function embedsIn(body, context) {
122
123
  const text = String(body ?? "");
123
124
  return matchAllOutsideCode(text, new RegExp(EMBED_PATTERN.source, "g")).map((match) => {
124
125
  const index = /** @type {number} */ (match.index);
125
126
  const parsed = parseWikilink(match[1] ?? "");
126
127
  const length = match[0].length;
128
+ const written = parsed.anchor ? `${parsed.target}#${parsed.anchor}` : parsed.target;
127
129
  return {
128
130
  all: match[0],
129
131
  inner: parsed.inner,
130
- written: parsed.anchor ? `${parsed.target}#${parsed.anchor}` : parsed.target,
132
+ written,
133
+ ...(context ?
134
+ { target: parseAddress(written, { ...context, type: EMBED_DEFAULT_TYPE }) }
135
+ : {}),
131
136
  display: parsed.display,
132
137
  labelled: parsed.labelled,
133
138
  directive: match[2] ?? "",
@@ -157,7 +162,11 @@ export function resolveEmbed(index, embed) {
157
162
  // reported before anything is looked up.
158
163
  if (!embed.labelled) return { reason: "unlabelled", target: embed.inner };
159
164
 
160
- const read = readAssetAddress(index, embed.written, EMBED_DEFAULT_TYPE);
165
+ const read = readAssetAddress(
166
+ index,
167
+ embed.target?.reason ? embed.written : (embed.target ?? embed.written),
168
+ EMBED_DEFAULT_TYPE,
169
+ );
161
170
  if (read.record) {
162
171
  return { pathname: read.pathname };
163
172
  }
@@ -245,7 +254,12 @@ export function resolveEmbeds(body, { index }) {
245
254
  let out = "";
246
255
  let last = 0;
247
256
 
248
- for (const embed of embedsIn(text)) {
257
+ for (const embed of embedsIn(text, {
258
+ package: index?.contentPackage ?? index?.packageId,
259
+ system: "none",
260
+ types: index?.types,
261
+ packages: index?.packages,
262
+ })) {
249
263
  problems.push(...embedProblems(embed));
250
264
  const resolved = resolveEmbed(index, embed);
251
265
  if (!("pathname" in resolved)) {
@@ -103,6 +103,7 @@ import { SCHEMA_ARTIFACT_VERSION } from "./schema-check.mjs";
103
103
  import { authoredFields } from "./field-spec.mjs";
104
104
  import { positionInFrontmatter } from "./diagnostics.mjs";
105
105
  import { RETIRED_FIELD_ALIASES } from "./retired-fields.mjs";
106
+ import { authoredKey } from "./system-block.mjs";
106
107
 
107
108
  /**
108
109
  * Every field path any subtype of a published schema declares.
@@ -493,11 +494,54 @@ export function fieldDriftMessage({ noteType, source, target, name, to }) {
493
494
  * A note-level source — the `subType` several tables name — carries no prefix
494
495
  * and is returned unchanged.
495
496
  *
497
+ * **What that normalization cannot see is whether the declaration reads the
498
+ * source at all.** Holding the two spellings equal is right for the question
499
+ * asked here — do the row and the declaration agree about where the value *goes*
500
+ * — and blind to whether the declaration can reach where it *is*. That is
501
+ * `tests/mapped-data-source-is-read.test.ts`, which compares the same pair on
502
+ * the source rather than the target.
503
+ *
496
504
  * @param {string} path - The source, as either side wrote it.
497
505
  * @returns {string} The source without its `data.` prefix.
498
506
  */
499
- function sharedSource(path) {
500
- return String(path).replace(/^data\./, "");
507
+ const sharedSource = authoredKey;
508
+
509
+ /**
510
+ * The declaration that compiles one mapping row, and what is left over.
511
+ *
512
+ * The longest declared name the row's source sits under: a field declared
513
+ * `impact.die` claims `data.impact.die` ahead of any field declared `impact`,
514
+ * and a field declared `charges` claims `data.charges.max` with `.max` left
515
+ * over. Compared on the normalized spelling, so a field naming its source under
516
+ * `data:` matches at the same length a bare one does.
517
+ *
518
+ * Exported because two readers ask it and must agree: {@link
519
+ * checkDeclaredFields}, which holds the row's target to the declaration's, and
520
+ * the guard that holds the declaration's *source* to the row's — a second copy
521
+ * of the match would let the two disagree about which declaration a row is even
522
+ * about.
523
+ *
524
+ * @param {string} source - The row's shared source, as either side spells it.
525
+ * @param {readonly object[]} fields - The type's authored declarations.
526
+ * @returns {{field: object, rest: string}|undefined} The declaration and the
527
+ * remainder of the source beneath its name, or nothing where no declaration
528
+ * names it.
529
+ */
530
+ export function declarationFor(source, fields) {
531
+ const path = sharedSource(source);
532
+ let field;
533
+ let rest;
534
+ let matched = "";
535
+ for (const candidate of fields ?? []) {
536
+ const name = sharedSource(candidate.name);
537
+ const remainder = under(path, name);
538
+ if (remainder === undefined) continue;
539
+ if (field && name.length <= matched.length) continue;
540
+ field = candidate;
541
+ matched = name;
542
+ rest = remainder;
543
+ }
544
+ return field === undefined ? undefined : { field, rest: /** @type {string} */ (rest) };
501
545
  }
502
546
 
503
547
  /**
@@ -550,32 +594,16 @@ export function checkDeclaredFields({ format, itemFields, system, severity = "er
550
594
 
551
595
  const authored = authoredFields(declared[noteType]);
552
596
  for (const claim of claimsFor(format, noteType, system)) {
553
- const source = sharedSource(claim.source);
554
597
  const target = claim.target.replace(/^system\./, "");
555
- // The longest declared name that the source sits under: a field
556
- // declared `impact.die` claims `data.impact.die` ahead of any
557
- // field declared `impact`. Compared on the normalized spelling, so
558
- // a field that has moved its source under `data:` is the same
559
- // length it was.
560
- let match;
561
- let rest;
562
- let matched = "";
563
- for (const field of authored) {
564
- const name = sharedSource(field.name);
565
- const remainder = under(source, name);
566
- if (remainder === undefined) continue;
567
- if (match && name.length <= matched.length) continue;
568
- match = field;
569
- matched = name;
570
- rest = remainder;
571
- }
598
+ const found = declarationFor(claim.source, authored);
572
599
  // No declaration names it. That is coverage, not a contradiction —
573
600
  // the specification maps fields no builder emits yet, which is the
574
601
  // ordinary mid-migration state and what `schema-check.mjs`
575
602
  // already reports as unemitted.
576
- if (!match) continue;
603
+ if (!found) continue;
604
+ const match = found.field;
577
605
  fields += 1;
578
- if (under(target, match.to) === rest) continue;
606
+ if (under(target, match.to) === found.rest) continue;
579
607
  findings.push({
580
608
  file: format.file,
581
609
  line: claim.line,
@@ -86,6 +86,14 @@
86
86
  * @module
87
87
  */
88
88
 
89
+ import { isAddressSegment } from "./address-charset.mjs";
90
+ import { positionOfYamlPath } from "./diagnostics.mjs";
91
+ import {
92
+ decodeNoteAddresses,
93
+ decodeIndexAddresses,
94
+ noteAddressContext,
95
+ encodeAddresses,
96
+ } from "./note-addresses.mjs";
89
97
  import fs from "node:fs";
90
98
  import path from "node:path";
91
99
 
@@ -442,11 +450,26 @@ export function buildIndexRecord({
442
450
  body,
443
451
  bodyLine,
444
452
  manifest,
453
+ addressContext,
445
454
  }) {
446
455
  assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage);
447
456
 
448
457
  const posix = relPath.split(path.sep).join("/");
449
458
  const folder = posix.includes("/") ? posix.slice(0, posix.lastIndexOf("/")) : "";
459
+ try {
460
+ decodeNoteAddresses(frontmatter, addressContext ?? { package: contentPackage });
461
+ } catch (error) {
462
+ error.file = absPath ?? relPath;
463
+ if (absPath && error.keyPath) {
464
+ const yamlText = fs
465
+ .readFileSync(absPath, "utf8")
466
+ .match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1];
467
+ const position = positionOfYamlPath(yamlText, error.keyPath, { key: error.addressKey });
468
+ if (position.line) position.line++;
469
+ error.position = position;
470
+ }
471
+ throw error;
472
+ }
450
473
  const address = noteAddress(frontmatter, contentPackage);
451
474
  // Derived from the address the note *would* hold, because a stub still
452
475
  // compiles every document derived from `data:` — an unwritten arcane talent
@@ -463,45 +486,48 @@ export function buildIndexRecord({
463
486
  const stub = isStubNote(frontmatter, body);
464
487
 
465
488
  return /** @type {Record<string, any>} */ (
466
- sortKeysDeep({
467
- ...frontmatter,
468
- package: contentPackage,
469
- address: stub ? null : address,
470
- nameAscii: asciiName(frontmatter?.name?.full),
471
- aliasesAscii: asciiAliases(frontmatter?.name?.aliases),
472
- // Each anchor carries the link that reaches it, so a section is
473
- // addressable from the index without anyone re-deriving how an
474
- // anchor is spelled — and its file line, so an editor can jump
475
- // there rather than search for the heading.
476
- anchors:
477
- stub ? null : (
478
- collectAnchors(body, bodyLine).map((a) => ({
479
- ...a,
480
- link: address ? `${address.slug}#${a.slug}` : null,
481
- }))
489
+ decodeIndexAddresses(
490
+ {
491
+ ...frontmatter,
492
+ package: contentPackage,
493
+ address: stub ? null : address,
494
+ nameAscii: asciiName(frontmatter?.name?.full),
495
+ aliasesAscii: asciiAliases(frontmatter?.name?.aliases),
496
+ // Each anchor carries the link that reaches it, so a section is
497
+ // addressable from the index without anyone re-deriving how an
498
+ // anchor is spelled — and its file line, so an editor can jump
499
+ // there rather than search for the heading.
500
+ anchors:
501
+ stub ? null : (
502
+ collectAnchors(body, bodyLine).map((a) => ({
503
+ ...a,
504
+ link: address ? `${address.slug}#${a.slug}` : null,
505
+ }))
506
+ ),
507
+ foundry: foundryBlock(
508
+ entries?.own,
509
+ systemOf(frontmatter?.type, KNOWN_DOCUMENT_SUBTYPE_MAPS),
482
510
  ),
483
- foundry: foundryBlock(
484
- entries?.own,
485
- systemOf(frontmatter?.type, KNOWN_DOCUMENT_SUBTYPE_MAPS),
486
- ),
487
- // Forward link to the note's documentation journal, which is its
488
- // own record. Named rather than nested, because the journal is a
489
- // separate document with its own address — see `buildDocRecord`.
490
- documentation: entries?.doc?.key ?? null,
491
- file: {
492
- // Relative to the content root, and deliberately not absolute.
493
- // An absolute path is a fact about the machine that built the
494
- // index, not about the content: it would differ between two
495
- // checkouts of the same tree, so the file would stop being
496
- // byte-stable, and a published copy would carry someone's home
497
- // directory and be wrong for every reader. Anyone holding the
498
- // index knows the root it was built from, and `root + path` is
499
- // the absolute form whenever it is wanted.
500
- path: posix,
501
- folder,
502
- name: path.basename(posix, ".md"),
511
+ // Forward link to the note's documentation journal, which is its
512
+ // own record. Named rather than nested, because the journal is a
513
+ // separate document with its own address — see `buildDocRecord`.
514
+ documentation: entries?.doc?.key ?? null,
515
+ file: {
516
+ // Relative to the content root, and deliberately not absolute.
517
+ // An absolute path is a fact about the machine that built the
518
+ // index, not about the content: it would differ between two
519
+ // checkouts of the same tree, so the file would stop being
520
+ // byte-stable, and a published copy would carry someone's home
521
+ // directory and be wrong for every reader. Anyone holding the
522
+ // index knows the root it was built from, and `root + path` is
523
+ // the absolute form whenever it is wanted.
524
+ path: posix,
525
+ folder,
526
+ name: path.basename(posix, ".md"),
527
+ },
503
528
  },
504
- })
529
+ addressContext ?? { package: contentPackage },
530
+ )
505
531
  );
506
532
  }
507
533
 
@@ -599,7 +625,7 @@ function buildDocRecord({ frontmatter, address, entry, file, contentPackage, anc
599
625
  */
600
626
  export function collectContentIndex(
601
627
  contentBase,
602
- { contentPackage, skipDirectories, assetsBase, manifest, problems },
628
+ { contentPackage, skipDirectories, assetsBase, manifest, problems, addressContext },
603
629
  ) {
604
630
  const records = [];
605
631
  // Passed through rather than defaulted away: an absent scope is the
@@ -639,8 +665,20 @@ export function collectContentIndex(
639
665
  body,
640
666
  bodyLine,
641
667
  manifest,
668
+ addressContext,
642
669
  });
643
670
  } catch (err) {
671
+ if (fm.shortcode && !isAddressSegment(fm.shortcode) && err.keyPath) {
672
+ err.identity = { type: fm.type, shortcode: fm.shortcode };
673
+ err.keyPath = ["shortcode"];
674
+ err.message = `shortcode "${fm.shortcode}" is not strictly alphanumeric — lowercase letters and digits only`;
675
+ const yamlText = fs
676
+ .readFileSync(absPath, "utf8")
677
+ .match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1];
678
+ err.position = positionOfYamlPath(yamlText, err.keyPath);
679
+ if (err.position.line) err.position.line++;
680
+ }
681
+ err.file = absPath;
644
682
  // No `problems` array means the caller wants the old contract: a
645
683
  // note that cannot be recorded fails the derivation outright, which
646
684
  // is right for the *emitter* — an index quietly missing a note
@@ -653,10 +691,11 @@ export function collectContentIndex(
653
691
  ...(err.position ?? {}),
654
692
  severity: "error",
655
693
  message: String(err.message),
694
+ ...(err.identity ? { identity: err.identity } : {}),
656
695
  });
657
696
  continue;
658
697
  }
659
- records.push(record);
698
+ records.push(decodeIndexAddresses(record, addressContext ?? { package: contentPackage }));
660
699
 
661
700
  // An item note is two documents, so it is two records. Derived from
662
701
  // the address the note *would* hold rather than from the one its
@@ -703,13 +742,15 @@ export function collectContentIndex(
703
742
  records.sort(
704
743
  (a, b) =>
705
744
  recordPath(a).localeCompare(recordPath(b), "en") ||
706
- String(a.address?.canonical ?? "").localeCompare(
707
- String(b.address?.canonical ?? ""),
745
+ String(encodeAddresses(a.address?.canonical) ?? "").localeCompare(
746
+ String(encodeAddresses(b.address?.canonical) ?? ""),
708
747
  "en",
709
748
  ) ||
710
749
  String(a.id ?? "").localeCompare(String(b.id ?? ""), "en"),
711
750
  );
712
- return records;
751
+ return records.map((record) =>
752
+ decodeIndexAddresses(record, addressContext ?? { package: contentPackage }),
753
+ );
713
754
  }
714
755
 
715
756
  /**
@@ -722,7 +763,7 @@ export function collectContentIndex(
722
763
  */
723
764
  export function serializeContentIndex(records) {
724
765
  if (records.length === 0) return "";
725
- return `${records.map((r) => JSON.stringify(r)).join("\n")}\n`;
766
+ return `${records.map((r) => JSON.stringify(sortKeysDeep(encodeAddresses(r)))).join("\n")}\n`;
726
767
  }
727
768
 
728
769
  /**
@@ -781,6 +822,7 @@ export function indexRecordsFor({
781
822
  }
782
823
  return collectContentIndex(tree, {
783
824
  contentPackage: resolved.contentPackage,
825
+ addressContext: noteAddressContext(resolved),
784
826
  skipDirectories: skipDirectories ?? resolved.skipDirectories,
785
827
  assetsBase: assetsBase ?? resolved.paths.assets,
786
828
  // Only the identities a UUID is a function of — see emitContentIndex.