@heroiclands/package-build 17.1.0 → 18.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/CHANGELOG.md +1773 -0
  2. package/CONTENT.md +288 -72
  3. package/README.md +1 -1
  4. package/bin/content-build.mjs +218 -137
  5. package/bin/package-build.mjs +43 -0
  6. package/content-config.mjs +62 -67
  7. package/docs/content-format.md +768 -58
  8. package/engine/actor-compiler.mjs +586 -0
  9. package/engine/address-charset.mjs +11 -8
  10. package/engine/address-diff.mjs +266 -22
  11. package/engine/anchored-sections.mjs +83 -0
  12. package/engine/anchors.mjs +83 -0
  13. package/engine/base-compiler.mjs +148 -18
  14. package/engine/bundle-notes.mjs +276 -0
  15. package/engine/bundles.mjs +307 -0
  16. package/engine/code-fences.mjs +103 -0
  17. package/engine/compile-corpus.mjs +89 -0
  18. package/engine/content-address.mjs +267 -5
  19. package/engine/content-format-check.mjs +54 -11
  20. package/engine/content-format.mjs +37 -5
  21. package/engine/content-index.mjs +394 -111
  22. package/engine/content-links.mjs +185 -77
  23. package/engine/content-lint.mjs +232 -14
  24. package/engine/content-package.mjs +2 -1
  25. package/engine/content-tables.mjs +99 -3
  26. package/engine/document-subtypes.mjs +49 -6
  27. package/engine/field-reference.mjs +1 -1
  28. package/engine/field-spec.mjs +56 -6
  29. package/engine/folder-notes.mjs +470 -0
  30. package/engine/foreign-catalog.mjs +195 -4
  31. package/engine/{manifest-emit.mjs → foundry-entries.mjs} +78 -81
  32. package/engine/frontmatter-lint.mjs +245 -19
  33. package/engine/frontmatter.mjs +88 -8
  34. package/engine/generate.mjs +304 -13
  35. package/engine/helpers.mjs +293 -64
  36. package/engine/ids.mjs +137 -2
  37. package/engine/index-records.mjs +126 -0
  38. package/engine/index.mjs +23 -5
  39. package/engine/item-compiler.mjs +349 -0
  40. package/engine/item-docs.mjs +7 -1
  41. package/engine/item-registry.mjs +6 -0
  42. package/engine/journals.mjs +93 -27
  43. package/engine/macros.mjs +4 -2
  44. package/engine/metadata-index.mjs +495 -0
  45. package/engine/note-claims.mjs +146 -25
  46. package/engine/note-ids.mjs +112 -0
  47. package/engine/note-renames.mjs +134 -0
  48. package/engine/note-schemas.mjs +25 -0
  49. package/engine/note-vocabulary.mjs +110 -27
  50. package/engine/pack-config.mjs +4 -0
  51. package/engine/pack-router.mjs +23 -0
  52. package/engine/retired-fields.mjs +137 -3
  53. package/engine/scenes.mjs +66 -34
  54. package/engine/schema-check.mjs +58 -17
  55. package/engine/site-build.mjs +71 -12
  56. package/engine/site-index.mjs +20 -7
  57. package/engine/sql-tables.mjs +485 -0
  58. package/engine/subtype-registry.mjs +102 -0
  59. package/engine/system-block.mjs +96 -12
  60. package/engine/systems.mjs +201 -0
  61. package/engine/web-wikilinks.mjs +50 -20
  62. package/engine/wikilink-syntax.mjs +17 -8
  63. package/engine/wikilinks.mjs +124 -51
  64. package/engine/yaml-lint.mjs +282 -0
  65. package/hm3/actors.mjs +352 -0
  66. package/hm3/default-item-art.mjs +75 -0
  67. package/hm3/document-subtypes.mjs +134 -0
  68. package/hm3/index.mjs +56 -0
  69. package/hm3/item-builders.mjs +84 -0
  70. package/hm3/item-fields.mjs +180 -0
  71. package/hm3/items.mjs +96 -0
  72. package/hm3/template-priority.mjs +85 -0
  73. package/labels.mjs +191 -0
  74. package/manifest.mjs +43 -2
  75. package/package.json +19 -4
  76. package/release.mjs +62 -7
  77. package/sohl/actors.mjs +33 -487
  78. package/sohl/being-info.mjs +16 -7
  79. package/sohl/default-item-art.mjs +14 -3
  80. package/sohl/document-subtypes.mjs +16 -10
  81. package/sohl/item-builders.mjs +14 -5
  82. package/sohl/item-fields.mjs +68 -7
  83. package/sohl/items.mjs +44 -258
  84. package/sohl/note-schemas.mjs +2 -2
  85. package/types/content-config.d.mts +21 -42
  86. package/types/engine/actor-compiler.d.mts +204 -0
  87. package/types/engine/address-charset.d.mts +11 -8
  88. package/types/engine/address-diff.d.mts +53 -5
  89. package/types/engine/anchored-sections.d.mts +21 -0
  90. package/types/engine/anchors.d.mts +20 -0
  91. package/types/engine/base-compiler.d.mts +17 -17
  92. package/types/engine/bundle-notes.d.mts +173 -0
  93. package/types/engine/bundles.d.mts +60 -0
  94. package/types/engine/code-fences.d.mts +43 -0
  95. package/types/engine/compile-corpus.d.mts +32 -0
  96. package/types/engine/content-address.d.mts +205 -5
  97. package/types/engine/content-format-check.d.mts +6 -2
  98. package/types/engine/content-format.d.mts +57 -1
  99. package/types/engine/content-index.d.mts +64 -80
  100. package/types/engine/content-links.d.mts +52 -7
  101. package/types/engine/content-lint.d.mts +10 -1
  102. package/types/engine/content-package.d.mts +2 -1
  103. package/types/engine/content-tables.d.mts +21 -39
  104. package/types/engine/document-subtypes.d.mts +37 -3
  105. package/types/engine/field-spec.d.mts +76 -5
  106. package/types/engine/folder-notes.d.mts +159 -0
  107. package/types/engine/foreign-catalog.d.mts +53 -0
  108. package/types/engine/{manifest-emit.d.mts → foundry-entries.d.mts} +16 -38
  109. package/types/engine/frontmatter-lint.d.mts +10 -2
  110. package/types/engine/frontmatter.d.mts +64 -0
  111. package/types/engine/generate.d.mts +38 -0
  112. package/types/engine/helpers.d.mts +94 -30
  113. package/types/engine/ids.d.mts +96 -0
  114. package/types/engine/index-records.d.mts +68 -0
  115. package/types/engine/index.d.mts +9 -3
  116. package/types/engine/item-compiler.d.mts +131 -0
  117. package/types/engine/journals.d.mts +47 -9
  118. package/types/engine/metadata-index.d.mts +226 -0
  119. package/types/engine/note-claims.d.mts +57 -11
  120. package/types/engine/note-ids.d.mts +38 -0
  121. package/types/engine/note-renames.d.mts +102 -0
  122. package/types/engine/note-vocabulary.d.mts +44 -8
  123. package/types/engine/retired-fields.d.mts +75 -0
  124. package/types/engine/scenes.d.mts +3 -2
  125. package/types/engine/schema-check.d.mts +25 -4
  126. package/types/engine/site-build.d.mts +4 -4
  127. package/types/engine/site-index.d.mts +1 -1
  128. package/types/engine/sql-tables.d.mts +185 -0
  129. package/types/engine/subtype-registry.d.mts +49 -0
  130. package/types/engine/system-block.d.mts +40 -1
  131. package/types/engine/systems.d.mts +106 -0
  132. package/types/engine/web-wikilinks.d.mts +4 -2
  133. package/types/engine/wikilink-syntax.d.mts +10 -3
  134. package/types/engine/wikilinks.d.mts +41 -13
  135. package/types/engine/yaml-lint.d.mts +107 -0
  136. package/types/hm3/actors.d.mts +48 -0
  137. package/types/hm3/default-item-art.d.mts +42 -0
  138. package/types/hm3/document-subtypes.d.mts +24 -0
  139. package/types/hm3/index.d.mts +7 -0
  140. package/types/hm3/item-builders.d.mts +11 -0
  141. package/types/hm3/item-fields.d.mts +12 -0
  142. package/types/hm3/items.d.mts +23 -0
  143. package/types/hm3/template-priority.d.mts +21 -0
  144. package/types/manifest.d.mts +22 -1
  145. package/types/release.d.mts +9 -4
  146. package/types/sohl/actors.d.mts +7 -74
  147. package/types/sohl/being-info.d.mts +8 -27
  148. package/types/sohl/default-item-art.d.mts +5 -3
  149. package/types/sohl/items.d.mts +17 -32
  150. package/engine/foreign-manifests.mjs +0 -126
  151. package/engine/kb-manifest.mjs +0 -490
  152. package/types/engine/foreign-manifests.d.mts +0 -43
  153. package/types/engine/kb-manifest.d.mts +0 -241
@@ -19,7 +19,12 @@
19
19
  * address other packages link to. Stating it twice is how a manifest comes to
20
20
  * assert a URL that resolves at build time and 404s for the reader.
21
21
  *
22
- * **A page's URL is its address** — `<package>/<type>-<shortcode>/` (#181). It
22
+ * **A page's URL is its address** — `<package>/<type>-<shortcode>/` (#181), and
23
+ * it carries **no `<system>` segment** even though the canonical address does
24
+ * (#59). That is not an omission: a note publishes one page however many
25
+ * systems' documents it compiles into, so there is nothing for the segment to
26
+ * distinguish, and adding it would split one page's URL in two. The canonical
27
+ * address names a *document*; this names a *page*. It
23
28
  * used to be derived from `name.full`, which made a display string load-bearing
24
29
  * in three separate ways: a rename moved the URL and nothing redirected, two
25
30
  * notes in one section could derive the same URL so a uniqueness check had to
@@ -37,18 +42,42 @@
37
42
  // so the direction cannot close a cycle (see `engine/pack-config.mjs`).
38
43
  import { DEFAULT_ADDRESS_SCHEME } from "../content-config.mjs";
39
44
 
45
+ // The system vocabulary is the `<system>` segment's own registry, and
46
+ // `engine/systems.mjs` imports nothing but `engine/address-charset.mjs`, so
47
+ // the direction is toward the leaf and cannot close a cycle.
48
+ import { NO_SYSTEM, assertSystemSegment } from "./systems.mjs";
49
+
50
+ // `ids.mjs` is a leaf with no local imports — the module note there says why —
51
+ // so an address may hash itself without any risk of closing a cycle.
52
+ import { makeId } from "./ids.mjs";
53
+
40
54
  export { DEFAULT_ADDRESS_SCHEME };
41
55
 
56
+ /**
57
+ * Re-exported so the address grammar and the system vocabulary are one fact:
58
+ * {@link canonicalKey} writes this segment, and `engine/systems.mjs` decides
59
+ * what may appear in it.
60
+ */
61
+ export { NO_SYSTEM };
62
+
42
63
  /** The knowledgebase's mount within this package's site (#1470). */
43
64
  export const KB_PREFIX = "kb/";
44
65
 
45
66
  /**
46
67
  * The single path segment a note is addressed by: `type-shortcode`.
47
68
  *
48
- * Lowercased, so it is exactly the tail of the note's canonical key
49
- * (`canonicalKey` in `engine/kb-manifest.mjs` lowercases too) which is what
50
- * makes a manifest entry's `path` derivable from the key it is filed under
51
- * rather than transported beside it.
69
+ * Lowercased and hyphen-joined by the same rule as the note's canonical key
70
+ * ({@link canonicalKey}, below, lowercases too), so it is that
71
+ * key's **last two segments** — which is what makes a manifest entry's `path`
72
+ * derivable from the key it is filed under rather than transported beside it.
73
+ *
74
+ * It was once the key's whole tail, and #59 ended that: the key gained a
75
+ * `<system>` segment, so its tail is now `system-type-shortcode` and a slug is
76
+ * the tail with that segment dropped. The behaviour here is unchanged, and
77
+ * deliberately — a page has no system to name (see the module note above), so
78
+ * the two forms diverge rather than one having fallen behind the other. A
79
+ * consumer deriving a `path` from a key drops the *package and the system*, not
80
+ * the package alone.
52
81
  *
53
82
  * The hyphen is a separator and never occurs inside a segment: a shortcode is
54
83
  * `^[A-Za-z0-9]+$` (`ADDRESS_SEGMENT_PATTERN`, enforced by `content-lint.mjs`)
@@ -116,3 +145,236 @@ export function addressSlug(fm) {
116
145
  export function packageAddress(fm) {
117
146
  return `${addressSlug(fm)}/`;
118
147
  }
148
+
149
+ /**
150
+ * The **canonical** address of a note: fully qualified, one spelling per
151
+ * document, and globally unique.
152
+ *
153
+ * The written form of a link is a **partial** address: it may omit leading
154
+ * segments, and each one it omits is filled in by rule rather than left
155
+ * unconstrained. An omitted package (`[[skill-lang]]`) defaults to the citing
156
+ * note's own, so an unqualified link resolves locally and only locally, and a
157
+ * link into another package must name it. An omitted **system** is a
158
+ * *wildcard*, not a default — most links target items, which belong to a
159
+ * system — and the resolver requires exactly one match: none is a dead link,
160
+ * more than one is an ambiguity reported with every candidate named.
161
+ * Everything internal — index keys, cache keys, every lookup — uses this fully
162
+ * qualified form instead, so no consumer has to know what a short form
163
+ * defaulted to or matched.
164
+ *
165
+ * Global uniqueness is what lets a dependency's index merge straight into a
166
+ * local one: the keys cannot collide by accident, so a key already present on
167
+ * merge is a real conflict rather than an artefact of two packages sharing a
168
+ * namespace. `(type, shortcode)` alone is unique only *within* a package, and
169
+ * two independently authored packages reaching for the same short string is a
170
+ * matter of time (#1499).
171
+ *
172
+ * **The system segment (#59).** A package may ship content for more than one
173
+ * system, and one note then compiles into a document per system — an actor in
174
+ * `actors-sohl` *and* an actor in `actors-hm3`. Without a system segment both
175
+ * land on one key, so the address cannot name either of them. `harn-ensemble`
176
+ * carries 2,497 such notes.
177
+ *
178
+ * The value is a system id, or the literal **`none`** for a document no game
179
+ * system defines: a journal, a macro, a scene, and an item's documentation
180
+ * journal — which is `none` however many systems the item itself declares,
181
+ * because it is one journal.
182
+ *
183
+ * `none` rather than `any`: every segment of an address is an exact literal,
184
+ * and `any` reads as a wildcard — "matches under any system" — which is not
185
+ * what it does. A resolver written to that misreading would fail silently,
186
+ * since a lookup miss already returns nothing rather than erroring. And not
187
+ * `null` or `~`, both of which are YAML nulls that parse to an absent value and
188
+ * drop the segment entirely.
189
+ *
190
+ * @param {string} pkg - The owning **content** package (`sohl`, `thalorna`) —
191
+ * not the Foundry package, which varies per compilation target.
192
+ * @param {string} system - The system whose document this addresses, or `none`.
193
+ * @param {string} type - The note's `type`.
194
+ * @param {string} shortcode - The note's `shortcode`.
195
+ * @returns {string} `package-system-type-shortcode`, lowercased.
196
+ */
197
+ export function canonicalKey(pkg, system, type, shortcode) {
198
+ // Checked where an address is *written*, not where one is read: a fetched
199
+ // index naming a system this build has never heard of is data to report,
200
+ // while emitting one is a defect in this build. The registry is closed, so
201
+ // an unknown value here can only be a typo or a system nobody declared.
202
+ assertSystemSegment(system, `the address of ${type}-${shortcode}`);
203
+ return `${pkg}-${system}-${type}-${shortcode}`.toLowerCase();
204
+ }
205
+
206
+ /**
207
+ * How many segments a canonical key has, and therefore how many the reader
208
+ * below counts.
209
+ *
210
+ * Named rather than written as a literal because it is the *grammar*, not an
211
+ * implementation detail of one function: it is the number a change to the
212
+ * address form would move, and the thing a reader of that change has to find.
213
+ *
214
+ * @type {number}
215
+ */
216
+ export const CANONICAL_KEY_SEGMENTS = 4;
217
+
218
+ /**
219
+ * Reads a canonical key back into its parts.
220
+ *
221
+ * Parsing is plain positional counting: split on the separator, require
222
+ * {@link CANONICAL_KEY_SEGMENTS} of them, and assign each position its field.
223
+ * **The charset rule is what makes that sound** — every segment is
224
+ * `^[A-Za-z0-9]+$` (`ADDRESS_SEGMENT_PATTERN` in `engine/address-charset.mjs`),
225
+ * so the hyphen is purely a separator and the count alone determines every
226
+ * field. That is enforced at each of the three sources rather than assumed of
227
+ * the data: shortcodes by `content-lint.mjs` (#1397), `contentPackage` by
228
+ * `defineConfig` (#59), and types are bare words. Were any of them free to
229
+ * carry a hyphen, no amount of counting would recover the fields and the reader
230
+ * would need a vocabulary to match against instead.
231
+ *
232
+ * **Nothing to read and nothing readable are different answers.** A key that
233
+ * cannot be canonical — `harn-adventures-sohl-skill-melee`, five segments,
234
+ * because the package name carries the separator — yields `null`, while an
235
+ * absent or blank input yields `undefined`. Both are falsy, so
236
+ * every call site (all of which test the result for truthiness) is unaffected;
237
+ * the distinction is there so a caller reporting "this key is unreadable" can
238
+ * tell that it has a key to report about.
239
+ *
240
+ * @param {unknown} key - A canonical key, or nothing.
241
+ * @returns {{package: string, system: string, type: string, shortcode: string}
242
+ * |null|undefined}
243
+ * The parts; `null` when there is a string that is not in canonical form;
244
+ * `undefined` when there is no key at all.
245
+ */
246
+ export function readCanonicalKey(key) {
247
+ if (key == null || key === "") return undefined;
248
+ const parts = String(key).split("-");
249
+ if (parts.length !== CANONICAL_KEY_SEGMENTS) return null;
250
+ const [pkg, system, type, shortcode] = parts;
251
+ if (!pkg || !system || !type || !shortcode) return null;
252
+ return { package: pkg, system, type, shortcode };
253
+ }
254
+
255
+ /**
256
+ * Where this build serves each package, keyed by package name.
257
+ *
258
+ * One line per package, and the only edit a relocation requires: point a
259
+ * package at another path (`"/setting/thalorna/"`) or another origin
260
+ * (`"https://thalorna.example.org/"`) and every inbound link into it follows.
261
+ * A base is a prefix, so it must end in `/`.
262
+ *
263
+ * Only *foreign* packages are consulted — a package this build publishes is
264
+ * authoritative in its own entries and never resolves through a fetched
265
+ * index — but every linkable package is listed, because which are foreign
266
+ * depends on the consuming repository.
267
+ */
268
+ export const PACKAGE_BASE = Object.freeze({
269
+ sohl: "/sohl/",
270
+ thalorna: "/thalorna/",
271
+ });
272
+
273
+ /**
274
+ * Asserts a base is usable as a prefix and returns it.
275
+ *
276
+ * Exported for the URL helpers' own callers: a build that composes a base
277
+ * before resolving against it should reject a malformed one at the point it
278
+ * is chosen, not at the point a link is emitted.
279
+ *
280
+ * @param {string} base - The package base.
281
+ * @param {string} what - What is being resolved, for the error message.
282
+ * @returns {string} The base.
283
+ */
284
+ export function checkBase(base, what) {
285
+ if (typeof base !== "string" || !base.endsWith("/")) {
286
+ throw new Error(`${what}: package base ${JSON.stringify(base)} must end in a slash`);
287
+ }
288
+ return base;
289
+ }
290
+
291
+ /**
292
+ * The package-relative address a site-absolute URL records as.
293
+ *
294
+ * Strips the emitting package's own base, so what is recorded says *where in
295
+ * the package* a page is and nothing about where the package itself is
296
+ * mounted. A URL outside the base is an error rather than a best effort: it
297
+ * would record an address that silently resolves to the wrong place once a
298
+ * consumer prefixes its own base.
299
+ *
300
+ * @param {string} url - The site-absolute URL the emitting build publishes at.
301
+ * @param {string} base - That build's base for the package, e.g. `"/thalorna/"`.
302
+ * @returns {string} The address relative to `base`, with no leading slash.
303
+ */
304
+ export function packageRelative(url, base) {
305
+ checkBase(base, "packageRelative");
306
+ if (typeof url !== "string" || !url.startsWith(base)) {
307
+ throw new Error(
308
+ `packageRelative: ${JSON.stringify(url)} does not sit under base ` +
309
+ `${JSON.stringify(base)}`,
310
+ );
311
+ }
312
+ return url.slice(base.length);
313
+ }
314
+
315
+ /**
316
+ * The URL a package-relative address resolves to in this build.
317
+ *
318
+ * Plain concatenation, which is what makes an absolute-origin base work: a base
319
+ * of `"https://thalorna.example.org/"` yields an absolute link, and one of
320
+ * `"/thalorna/"` a root-relative one, with no other rule to keep in step.
321
+ *
322
+ * @param {string} rel - The package-relative address from a fetched index.
323
+ * @param {string} base - This build's base for that package.
324
+ * @returns {string} The resolved URL.
325
+ */
326
+ export function resolvePackageUrl(rel, base) {
327
+ checkBase(base, "resolvePackageUrl");
328
+ if (typeof rel !== "string" || !rel || rel.startsWith("/")) {
329
+ throw new Error(
330
+ `resolvePackageUrl: ${JSON.stringify(rel)} is not a package-` + `relative address`,
331
+ );
332
+ }
333
+ return `${base}${rel}`;
334
+ }
335
+
336
+ /**
337
+ * The namespace {@link documentId} hashes a canonical address under.
338
+ *
339
+ * Named rather than written as a literal at the one call site, because it is
340
+ * part of the published derivation: a consumer holding a content-index entry
341
+ * recomputes the document's id — and therefore its compendium UUID — as
342
+ * `makeId(DOCUMENT_ID_NAMESPACE, entry.canonical)`, so the string is a fact
343
+ * about the format rather than an implementation detail. Changing it moves
344
+ * every id this toolchain has ever emitted.
345
+ *
346
+ * @type {string}
347
+ */
348
+ export const DOCUMENT_ID_NAMESPACE = "document";
349
+
350
+ /**
351
+ * The Foundry `_id` of the document a note compiles into, derived from its
352
+ * canonical address (#270).
353
+ *
354
+ * A note used to author this — an opaque 16-character string, one per note,
355
+ * that said nothing its address did not and that no check guaranteed. The
356
+ * address is the identity that *is* guaranteed: `content-lint` refuses a
357
+ * duplicate `(type, shortcode)` across every pack of a document type, which is
358
+ * exactly the scope a primary document's id must be unique within. So the
359
+ * derived id inherits a guard that already exists, where the authored one had
360
+ * none.
361
+ *
362
+ * **The coupling this creates, stated plainly.** The address carries the
363
+ * shortcode, so renaming a shortcode moves the document's id — where an
364
+ * authored id survived one. That is a real trade rather than a free win, and
365
+ * two things make it acceptable: a rename already breaks every wikilink to the
366
+ * note, so it is a breaking change either way; and a note that must keep its
367
+ * identity across a rename pins an `id`, which is what the pin is for. One
368
+ * thing genuinely degrades — `engine/address-diff.mjs` tells a rename from a
369
+ * withdrawal by matching document ids, and can no longer do so for a note that
370
+ * authors none. Its module note records that.
371
+ *
372
+ * @param {string} pkg - The owning **content** package.
373
+ * @param {string} system - The system whose document this is, or `none`.
374
+ * @param {string} type - The note's `type`.
375
+ * @param {string} shortcode - The note's `shortcode`.
376
+ * @returns {string} A 16-character Foundry id.
377
+ */
378
+ export function documentId(pkg, system, type, shortcode) {
379
+ return makeId(DOCUMENT_ID_NAMESPACE, canonicalKey(pkg, system, type, shortcode));
380
+ }
@@ -99,6 +99,7 @@
99
99
  * @module
100
100
  */
101
101
 
102
+ import { currentType } from "./ids.mjs";
102
103
  import { SCHEMA_ARTIFACT_VERSION } from "./schema-check.mjs";
103
104
  import { authoredFields } from "./field-spec.mjs";
104
105
  import { positionInFrontmatter } from "./diagnostics.mjs";
@@ -135,12 +136,24 @@ export function declaredPaths(artifact) {
135
136
  * {@link undeclaredMessage} draws, and the difference between "the
136
137
  * specification is wrong" and "the schema has not caught up".
137
138
  *
138
- * @param {object} finding - `{system, systemVersion, noteType, source, target}`.
139
+ * A shared row names no type, so it is placed rather than typed: "in the shared
140
+ * mappings" instead of "on a `weapongear`". Reading `on a \`the shared
141
+ * mappings\`` would be the alternative, and a diagnostic is prose (#275).
142
+ *
143
+ * @param {object} finding - `{system, systemVersion, noteType, source, target, shared}`.
139
144
  * @returns {string} The message.
140
145
  */
141
- export function undeclaredTargetMessage({ system, systemVersion, noteType, source, target }) {
146
+ export function undeclaredTargetMessage({
147
+ system,
148
+ systemVersion,
149
+ noteType,
150
+ source,
151
+ target,
152
+ shared,
153
+ }) {
154
+ const where = shared ? `in ${noteType}` : `on a \`${noteType}\``;
142
155
  return (
143
- `the format maps \`${source}\` on a \`${noteType}\` to \`${target}\` in ` +
156
+ `the format maps \`${source}\` ${where} to \`${target}\` in ` +
144
157
  `${system}, which ${system}@${systemVersion} does not declare on any ` +
145
158
  `document subtype — the specification and the system disagree, and one ` +
146
159
  `of the two is wrong`
@@ -327,7 +340,11 @@ export function measureNote(note, format, { severity = "warning" } = {}) {
327
340
  message,
328
341
  });
329
342
 
330
- const spec = format.types.get(type);
343
+ // The specification's sections are headed by the current spelling of a note
344
+ // type, so a note still on a renamed one is measured against the section it
345
+ // will move to rather than reported as a type the format does not declare
346
+ // (#78). The rename itself is the frontmatter lint's finding.
347
+ const spec = format.types.get(currentType(type));
331
348
  if (!spec) {
332
349
  add(
333
350
  "unknown-type",
@@ -454,6 +471,27 @@ export function fieldDriftMessage({ noteType, source, target, name, to }) {
454
471
  );
455
472
  }
456
473
 
474
+ /**
475
+ * A shared source, as the two sides spell it.
476
+ *
477
+ * The specification writes every type-specific row `data.<key>` — the container
478
+ * `data:` (#128) put those facts in — while a declaration writes either: the
479
+ * bare key it has always named, or the same dotted path now that a field can
480
+ * declare its shared source and its legacy in-block key separately (#305).
481
+ * Both are the same source, so both are normalized before they are compared;
482
+ * matching one spelling against the other would report every moved field as
483
+ * unmapped, which is the opposite of what this check is for.
484
+ *
485
+ * A note-level source — the `subType` several tables name — carries no prefix
486
+ * and is returned unchanged.
487
+ *
488
+ * @param {string} path - The source, as either side wrote it.
489
+ * @returns {string} The source without its `data.` prefix.
490
+ */
491
+ function sharedSource(path) {
492
+ return String(path).replace(/^data\./, "");
493
+ }
494
+
457
495
  /**
458
496
  * The specification's mapping rows for one type and one system, as field paths.
459
497
  *
@@ -504,20 +542,23 @@ export function checkDeclaredFields({ format, itemFields, system, severity = "er
504
542
 
505
543
  const authored = authoredFields(declared[noteType]);
506
544
  for (const claim of claimsFor(format, noteType, system)) {
507
- // A shared source is written `data.<path>`; a note-level one — the
508
- // `subType` several tables name — carries no prefix.
509
- const source = claim.source.replace(/^data\./, "");
545
+ const source = sharedSource(claim.source);
510
546
  const target = claim.target.replace(/^system\./, "");
511
547
  // The longest declared name that the source sits under: a field
512
548
  // declared `impact.die` claims `data.impact.die` ahead of any
513
- // field declared `impact`.
549
+ // field declared `impact`. Compared on the normalized spelling, so
550
+ // a field that has moved its source under `data:` is the same
551
+ // length it was.
514
552
  let match;
515
553
  let rest;
554
+ let matched = "";
516
555
  for (const field of authored) {
517
- const remainder = under(source, field.name);
556
+ const name = sharedSource(field.name);
557
+ const remainder = under(source, name);
518
558
  if (remainder === undefined) continue;
519
- if (match && field.name.length <= match.name.length) continue;
559
+ if (match && name.length <= matched.length) continue;
520
560
  match = field;
561
+ matched = name;
521
562
  rest = remainder;
522
563
  }
523
564
  // No declaration names it. That is coverage, not a contradiction —
@@ -547,7 +588,9 @@ export function checkDeclaredFields({ format, itemFields, system, severity = "er
547
588
  // equal: the document names the *shared* source a field is written as,
548
589
  // and a declaration names every key the system's own block accepts, so
549
590
  // the sets legitimately differ until #127 has moved the corpus.
550
- const registryKeys = new Set(authored.map((field) => field.name.split(".")[0]));
591
+ const registryKeys = new Set(
592
+ authored.map((field) => sharedSource(field.name).split(".")[0]),
593
+ );
551
594
  const specKeys = format.types.get(noteType).dataKeys;
552
595
  coverage.push({
553
596
  type: noteType,
@@ -44,7 +44,15 @@
44
44
  * | the per-type mapping | `shared source` | one claim per `system.*` cell |
45
45
  *
46
46
  * A mapping table's remaining header cells name the systems (`→ sohl`,
47
- * `→ hm3`), so the system vocabulary comes from the document too. A cell that
47
+ * `→ hm3`), so the system vocabulary comes from the document too.
48
+ *
49
+ * **A mapping table before the first `### type:` heading is the shared one.**
50
+ * The document states the rows every type maps identically once, at the top,
51
+ * and omits them from all sixteen per-type tables — so a parser that only ever
52
+ * looked inside a type's section could not see them, and the eight rows they
53
+ * cover were checked by nothing (#275). Position is the whole distinction:
54
+ * there is no marker to read and none is wanted, since the document's own
55
+ * argument for stating them once is that they belong to no type in particular. A cell that
48
56
  * names no field — `NA`, `**see above**`, a `flags.*` path — is not a claim,
49
57
  * and is skipped rather than reported: the check is about `system.*` targets,
50
58
  * and a column reading NA is the document saying this type produces no document
@@ -90,7 +98,13 @@ export const CONTENT_FORMAT_PATH = path.join(
90
98
  * One `system.*` target the specification names for one note type.
91
99
  *
92
100
  * @typedef {object} MappingClaim
93
- * @property {string} noteType - The type whose section makes the claim.
101
+ * @property {string} noteType - The type whose section makes the claim, or
102
+ * `the shared mappings` for a row of the shared tables — see `shared`.
103
+ * @property {boolean} [shared] - Whether the row came from a **shared** mapping
104
+ * table, which stands before the first `### type:` heading and states what
105
+ * every type maps identically (#275). Absent on a per-type row, so the two
106
+ * never mix: only a per-type row has a field declaration to be checked
107
+ * against.
94
108
  * @property {string} system - The system column it sits under, from the header.
95
109
  * @property {string} source - The shared source cell, stripped of its backticks.
96
110
  * @property {string} target - The dotted path, `system.` prefix included.
@@ -107,6 +121,18 @@ export const CONTENT_FORMAT_PATH = path.join(
107
121
  * @property {MappingClaim[]} claims - Every `system.*` target, in document order.
108
122
  */
109
123
 
124
+ /**
125
+ * What a shared row's `noteType` reads, in place of a type name.
126
+ *
127
+ * Phrased to be substituted into a diagnostic sentence — "the format maps `x`
128
+ * in the shared mappings to `y`" — because that is the only place it is ever
129
+ * read. Both shared tables use it: the second states what the actor types add,
130
+ * and a row of it is no more a `being`'s than a row of the first is.
131
+ *
132
+ * @type {string}
133
+ */
134
+ export const SHARED_SCOPE = "the shared mappings";
135
+
110
136
  /** A table row's cells, or `null` when the line is not a table row. */
111
137
  function cellsOf(line) {
112
138
  const trimmed = line.trim();
@@ -172,7 +198,7 @@ export function parseContentFormat(text, { file = CONTENT_FORMAT_PATH } = {}) {
172
198
  const lines = String(text ?? "").split("\n");
173
199
  /** @type {TypeSpec|undefined} */
174
200
  let current;
175
- /** @type {{kind: "data"|"mapping", systems: string[]}|undefined} */
201
+ /** @type {{kind: "data"|"mapping", systems: string[], shared?: boolean}|undefined} */
176
202
  let table;
177
203
 
178
204
  for (let i = 0; i < lines.length; i += 1) {
@@ -210,12 +236,15 @@ export function parseContentFormat(text, { file = CONTENT_FORMAT_PATH } = {}) {
210
236
  table = {
211
237
  kind: "mapping",
212
238
  systems: cells.slice(1).map((cell) => cell.replace(/^→\s*/, "").trim()),
239
+ // Before any type section, so the rows are every type's (#275).
240
+ ...(current ? {} : { shared: true }),
213
241
  };
214
242
  continue;
215
243
  }
216
- if (!table || !current) continue;
244
+ if (!table) continue;
217
245
 
218
246
  if (table.kind === "data") {
247
+ if (!current) continue;
219
248
  const declared = code(cells[0]);
220
249
  if (!declared) continue;
221
250
  current.dataPaths.add(declared);
@@ -223,13 +252,16 @@ export function parseContentFormat(text, { file = CONTENT_FORMAT_PATH } = {}) {
223
252
  continue;
224
253
  }
225
254
 
255
+ if (!table.shared && !current) continue;
256
+
226
257
  for (let c = 1; c < cells.length; c += 1) {
227
258
  const target = code(cells[c]);
228
259
  if (!target || !target.startsWith("system.")) continue;
229
260
  const system = table.systems[c - 1];
230
261
  if (!system) continue;
231
262
  claims.push({
232
- noteType: current.name,
263
+ noteType: table.shared ? SHARED_SCOPE : /** @type {TypeSpec} */ (current).name,
264
+ ...(table.shared ? { shared: true } : {}),
233
265
  system,
234
266
  source: code(cells[0]) ?? cells[0],
235
267
  target,