@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
@@ -40,15 +40,37 @@
40
40
  * string. Here both sides are whole documents, so the question is decidable.
41
41
  *
42
42
  * **A rename is told from a removal by the document id, and that is an identity
43
- * match rather than an inference.** A note authors its `_id` in frontmatter; it
44
- * is not derived from the shortcode, and the `Tabri` `Taburi` commit changed
45
- * the shortcode alone. So an address that disappeared while its document is
46
- * still published elsewhere *is* a rename not "probably" one. When the id is
47
- * published under no address at all, that is all this can say: **withdrawn**,
48
- * with no successor named. A split, a deletion and a merge are indistinguish-
49
- * able from one another at that point, and inventing a "did you mean" from
50
- * string similarity would be worse than saying nothing, because a wrong one
51
- * sends the reader to the wrong fix.
43
+ * match rather than an inference.** An address that disappeared while its
44
+ * document is still published elsewhere *is* a rename not "probably" one.
45
+ * When the id is published under no address at all, that is all this can say:
46
+ * **withdrawn**, with no successor named. A split, a deletion and a merge are
47
+ * indistinguishable from one another at that point, and inventing a "did you
48
+ * mean" from string similarity would be worse than saying nothing, because a
49
+ * wrong one sends the reader to the wrong fix.
50
+ *
51
+ * **#270 narrowed that match, and a declaration is what makes up the
52
+ * difference.** The join rested on the id being independent of the shortcode: a
53
+ * note authored its `_id`, so the `Tabri` → `Taburi` commit changed the
54
+ * shortcode alone and left the id to join the two sides. Since #270 an id is
55
+ * *derived from the canonical address*, which carries the shortcode — so
56
+ * renaming a shortcode moves the id too, both sides of the join move together,
57
+ * and the match finds nothing. It stays exact for a note that **pins** an `id`,
58
+ * and pinning is still how a document keeps its identity across a rename; what
59
+ * it cannot do is help the author who did not pin, because pinning has to
60
+ * happen before the rename, by someone who does not yet know they will make
61
+ * one.
62
+ *
63
+ * So an author who has just renamed a shortcode **says so**, with
64
+ * `renamedFrom:` on the note that made the change (#278, and see
65
+ * `engine/note-renames.mjs`). That is neither a guess nor an identity match but
66
+ * testimony from the only party that knows, and the diagnostic reports which of
67
+ * the two it had rather than blending them — a reader can verify a matched id
68
+ * in both artefacts, and can only take a declaration on its author's word.
69
+ *
70
+ * **Three joins, tried in that order of authority**: the document id, then a
71
+ * declaration, then nothing — which remains **withdrawn**. Nothing here
72
+ * infers, so a rename that is neither pinned nor declared is still reported as
73
+ * a withdrawal; that is the honest answer, not a gap.
52
74
  *
53
75
  * **Severity is decided per case.** A withdrawal is legitimate — content is
54
76
  * allowed to be retired — so it is reported and does not fail a build. A rename
@@ -68,7 +90,32 @@ import path from "node:path";
68
90
 
69
91
  import { formatDiagnostic, positionInFrontmatter } from "./diagnostics.mjs";
70
92
  import { positionOfLiteral } from "./diagnostics.mjs";
71
- import { walkMarkdownTree } from "./helpers.mjs";
93
+ import { assertStatedScope } from "./helpers.mjs";
94
+ // The corpus, read from the one pass that derives it (#243). Nothing in the
95
+ // index's import graph reaches this module — only `bin/` imports it — so this
96
+ // is a plain static import, as in the link checker.
97
+ import { indexRecordsFor, isNoteRecord, noteFile } from "./content-index.mjs";
98
+ import { renamedFrom } from "./note-renames.mjs";
99
+ import { referencedSubtype } from "./document-subtypes.mjs";
100
+ import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./subtype-registry.mjs";
101
+
102
+ /**
103
+ * The one spelling of an address in this space: `type:shortcode`.
104
+ *
105
+ * Written by both readers — the compiled packs on each side of the diff, and
106
+ * the declarations read out of the tree — so a predecessor a note names and an
107
+ * address a pack publishes cannot come apart over punctuation or case. The
108
+ * shortcode is used **verbatim**, not lowercased: `readItemAddresses` reads it
109
+ * off the compiled document, where `Tabri` is stored as authored, and folding
110
+ * case here would join two addresses the packs keep apart.
111
+ *
112
+ * @param {string} type - The Foundry document subtype, not the note type.
113
+ * @param {string} shortcode - The address's `system.shortcode`.
114
+ * @returns {string} The address key.
115
+ */
116
+ export function itemAddressKey(type, shortcode) {
117
+ return `${type}:${shortcode}`;
118
+ }
72
119
 
73
120
  /**
74
121
  * The address space a set of compiled Item pack directories publishes.
@@ -114,7 +161,7 @@ export function readItemAddresses(dirs) {
114
161
  }
115
162
  const shortcode = doc?.system?.shortcode;
116
163
  if (!doc?.type || !shortcode || !doc?._id) continue;
117
- space.set(`${doc.type}:${shortcode}`, {
164
+ space.set(itemAddressKey(doc.type, shortcode), {
118
165
  id: doc._id,
119
166
  name: doc.name ?? "",
120
167
  type: doc.type,
@@ -126,6 +173,133 @@ export function readItemAddresses(dirs) {
126
173
  return space;
127
174
  }
128
175
 
176
+ /**
177
+ * Every rename the tree's notes **declare**, as old address → where it went.
178
+ *
179
+ * Read from the content tree rather than from compiled output, because a
180
+ * declaration is authored and the compiled document does not carry it: nothing
181
+ * downstream consumes `renamedFrom:`, so emitting it into every pack to let one
182
+ * diagnostic read it back would put a build-time note in shipped data forever.
183
+ * The tree is already read by this module for the same reason
184
+ * ({@link noteFilesById}) — to place a finding where its author can fix it.
185
+ *
186
+ * **A declaration is keyed by document subtype, not by note type.** The address
187
+ * space is the one consumers resolve against, and it is spelled in compiled
188
+ * documents: `hm3` compiles a `projectile` note into a `missilegear` item,
189
+ * so that is the address a rename of it moves. {@link referencedSubtype} is the
190
+ * function that already answers this for a being's embedded `(type, shortcode)`
191
+ * references, so both sides read the same rule rather than a second copy of it.
192
+ *
193
+ * **An entry is emitted for every system that maps the type**, whether or not
194
+ * the note declares that system's block. Over-emitting is inert — the diff uses
195
+ * an entry only when the baseline published the old address *and* this build
196
+ * publishes the new one, and a system the note does not compile for satisfies
197
+ * neither — while asking which blocks a note declares would put a second,
198
+ * subtly different answer to that question in a third place.
199
+ *
200
+ * **First claim wins on a collision.** Two notes naming one predecessor is a
201
+ * contradiction — an address has one successor — and it is reported as an error
202
+ * by `engine/content-lint.mjs`, where both notes are in hand and can both be
203
+ * named. Picking one here keeps this a map; it is not a resolution, and nothing
204
+ * rests on which one it picked.
205
+ *
206
+ * @param {string} contentBase - Root of the content tree.
207
+ * @param {object} opts
208
+ * @param {readonly string[]} [opts.skipDirectories] - The corpus scope. Stated
209
+ * by the caller, never defaulted — see {@link addressCorpus}.
210
+ * @param {readonly object[]} [opts.maps] - The document-subtype maps.
211
+ * @param {object} [opts.config] - The resolved build configuration.
212
+ * @param {readonly object[]} [opts.records] - Index records the caller already
213
+ * derived, shared with {@link noteFilesById} so one command reads one corpus.
214
+ * @returns {Map<string, {to: string, file: string, shortcode: string}>} Old
215
+ * address → the address the declaring note publishes at now, and that note.
216
+ */
217
+ /**
218
+ * The corpus both reads below share, as content-index records.
219
+ *
220
+ * **One walk, not two.** `addresses diff` reads the tree twice — once for the
221
+ * declarations and once to place its findings — and until #243 those were two
222
+ * independent walks that each parsed every note. They are now one derivation,
223
+ * shared: the caller derives the records and hands them to both, so the two
224
+ * halves of a single command cannot disagree about which files the corpus is,
225
+ * or about the ids in it.
226
+ *
227
+ * **The id is why it matters, and not only tidiness.** `noteFilesById` joins
228
+ * tree-side ids against ids read out of the *compiled packs*. Since #270 an id
229
+ * is derived from the canonical address, whose first segment is the content
230
+ * package — and the tree side used to derive it through `resolveNoteId(fm)`
231
+ * with no package, which falls back to `contentPackage()` and so to whichever
232
+ * configuration the working directory answers with. The compiled side is
233
+ * produced by a compiler running on the configuration the *build* resolved. Let
234
+ * those differ — under `PACKAGE_BUILD_CONFIG`, in a worktree, in a test — and
235
+ * every id fails to join, so every rename degrades to a withdrawal and every
236
+ * finding loses the note it should have been reported against. Reading the
237
+ * index derives both sides from the one resolved configuration.
238
+ *
239
+ * A tree that is not there yields no records rather than throwing, which is
240
+ * what the walk this replaces did: an absent tree is a caller's business, and
241
+ * these two functions have never been the ones to report it.
242
+ *
243
+ * @param {string} contentBase - Root of the content tree.
244
+ * @param {object} opts - Options.
245
+ * @param {readonly string[]} [opts.skipDirectories] - The scope, required
246
+ * unless `records` supplies the corpus outright.
247
+ * @param {object} [opts.config] - The resolved configuration.
248
+ * @param {readonly object[]} [opts.records] - Records the caller derived.
249
+ * @param {object[]} [opts.problems] - Collects the notes the index cannot
250
+ * record, so one of them does not abort the diff before it reports.
251
+ * @returns {readonly object[]} The index records.
252
+ */
253
+ function addressCorpus(contentBase, { skipDirectories, config, records, problems } = {}) {
254
+ if (records) return records;
255
+ assertStatedScope(skipDirectories, "reading the address corpus");
256
+ if (!fs.existsSync(contentBase)) return [];
257
+ return indexRecordsFor({ contentBase, config, skipDirectories, problems });
258
+ }
259
+
260
+ export function declaredPredecessors(
261
+ contentBase,
262
+ { skipDirectories, maps = KNOWN_DOCUMENT_SUBTYPE_MAPS, config, records, problems } = {},
263
+ ) {
264
+ const byOldAddress = new Map();
265
+ for (const record of addressCorpus(contentBase, {
266
+ skipDirectories,
267
+ config,
268
+ records,
269
+ problems,
270
+ })) {
271
+ // A documentation journal is a document this tree emits, not a note in
272
+ // it: it has no file and declares nothing.
273
+ if (!isNoteRecord(record)) continue;
274
+ // The record carries the note's frontmatter, so a declaration is read
275
+ // off it exactly as it was read off the parse.
276
+ const declared = renamedFrom(record);
277
+ if (!declared.length) continue;
278
+ const shortcode = typeof record.shortcode === "string" ? record.shortcode.trim() : "";
279
+ // A note with no address of its own has nowhere for a predecessor to
280
+ // have gone, so it declares a rename to nothing. Reported by the lint;
281
+ // silently skipped here rather than indexed as a rename to `type:`.
282
+ if (!shortcode) continue;
283
+ const absPath = noteFile(contentBase, record);
284
+ for (const map of maps) {
285
+ const { subType } = referencedSubtype(map, record.type, "Item");
286
+ if (!subType) continue;
287
+ const to = itemAddressKey(subType, shortcode);
288
+ for (const old of declared) {
289
+ const from = itemAddressKey(subType, old);
290
+ // A note naming its own current address declares nothing, and
291
+ // indexing it would make every such address look renamed to
292
+ // itself. The lint reports it.
293
+ if (from === to) continue;
294
+ if (!byOldAddress.has(from)) {
295
+ byOldAddress.set(from, { to, file: absPath, shortcode: old });
296
+ }
297
+ }
298
+ }
299
+ }
300
+ return byOldAddress;
301
+ }
302
+
129
303
  /**
130
304
  * Every address the baseline published that this build does not.
131
305
  *
@@ -138,11 +312,15 @@ export function readItemAddresses(dirs) {
138
312
  * @param {object} opts
139
313
  * @param {string} opts.baseline - What the baseline is, for the message —
140
314
  * conventionally `<package>@<version>`.
315
+ * @param {Map<string, {to: string, file: string}>} [opts.predecessors] - The
316
+ * declared renames, from {@link declaredPredecessors}. Omitted, the diff
317
+ * falls back to the id join alone and reports an unpinned rename as a
318
+ * withdrawal, which is what it did before #278.
141
319
  * @returns {Array<object>} One finding per departed address, in address order
142
- * so two runs read the same. `kind` is `"renamed"` (with `to`) or
143
- * `"withdrawn"`.
320
+ * so two runs read the same. `kind` is `"renamed"` (with `to`, and `declared`
321
+ * when it was the note's word rather than an id match) or `"withdrawn"`.
144
322
  */
145
- export function diffItemAddresses(baseline, current, { baseline: label }) {
323
+ export function diffItemAddresses(baseline, current, { baseline: label, predecessors }) {
146
324
  // A baseline that yields no address at all cannot produce a finding, so it
147
325
  // reports a clean result for every possible input — the one failure a check
148
326
  // like this can never catch, and the same one `foreign-manifests.mjs` exists
@@ -166,11 +344,28 @@ export function diffItemAddresses(baseline, current, { baseline: label }) {
166
344
  const findings = [];
167
345
  for (const [address, entry] of baseline) {
168
346
  if (current.has(address)) continue;
169
- const to = currentById.get(entry.id);
347
+
348
+ // The id first, because it is the strongest answer available: a match
349
+ // is an identity, and a reader can check it in both artefacts.
350
+ const matched = currentById.get(entry.id);
351
+ // Then the author's word, and only where it checks out — the note that
352
+ // claims this address as a predecessor must itself be publishing now.
353
+ // A declaration pointing at an address this build does not publish
354
+ // describes a rename that did not survive to the packs, and naming it
355
+ // as the successor would send the reader somewhere nothing is.
356
+ const claim = matched ? undefined : predecessors?.get(address);
357
+ const declared = claim && current.has(claim.to) ? claim : undefined;
358
+ const to = matched ?? declared?.to;
359
+
170
360
  findings.push({
171
361
  kind: to ? "renamed" : "withdrawn",
172
362
  address,
173
363
  ...(to ? { to } : {}),
364
+ // Set only on a declared rename, so a reader of a finding can tell
365
+ // testimony from an identity match without re-deriving which it was.
366
+ ...(matched ? {}
367
+ : declared ? { declared: true, noteFile: declared.file }
368
+ : {}),
174
369
  id: entry.id,
175
370
  name: entry.name,
176
371
  shortcode: entry.shortcode,
@@ -192,14 +387,33 @@ export function diffItemAddresses(baseline, current, { baseline: label }) {
192
387
  *
193
388
  * @param {string} contentBase - Root of the content tree.
194
389
  * @param {object} [opts]
195
- * @param {readonly string[]} [opts.skipDirectories] - Passed to the walk.
390
+ * @param {readonly string[]} [opts.skipDirectories] - The corpus scope, stated
391
+ * by the caller — see {@link addressCorpus}.
392
+ * @param {object} [opts.config] - The resolved build configuration, which the
393
+ * id is derived against. See {@link addressCorpus} for why that matters.
394
+ * @param {readonly object[]} [opts.records] - Index records the caller already
395
+ * derived, shared with {@link declaredPredecessors}.
196
396
  * @returns {Map<string, string>} Document id → the note's absolute path.
197
397
  */
198
- export function noteFilesById(contentBase, { skipDirectories } = {}) {
398
+ export function noteFilesById(contentBase, { skipDirectories, config, records, problems } = {}) {
199
399
  const byId = new Map();
200
- const walkOpts = skipDirectories ? { skipDirectories } : undefined;
201
- for (const { frontmatter: fm, absPath } of walkMarkdownTree(contentBase, walkOpts)) {
202
- if (fm?.id && !byId.has(fm.id)) byId.set(fm.id, absPath);
400
+ for (const record of addressCorpus(contentBase, {
401
+ skipDirectories,
402
+ config,
403
+ records,
404
+ problems,
405
+ })) {
406
+ // A documentation journal shares its note's file and has no id of its
407
+ // own, so indexing it would file one path under two identities.
408
+ if (!isNoteRecord(record)) continue;
409
+ // Derived by the index, against the configuration this build resolved —
410
+ // which is the same configuration the compiled ids on the other side of
411
+ // the join were produced under. See {@link addressCorpus}.
412
+ //
413
+ // First record wins, and the records are in content-path order, so
414
+ // which note answers for a duplicated id is now a stable fact about the
415
+ // tree rather than an artefact of directory-read order.
416
+ if (record.id && !byId.has(record.id)) byId.set(record.id, noteFile(contentBase, record));
203
417
  }
204
418
  return byId;
205
419
  }
@@ -214,12 +428,28 @@ export function noteFilesById(contentBase, { skipDirectories } = {}) {
214
428
  * address existing. When neither is readable the position is **dropped**, never
215
429
  * defaulted to `1:1`.
216
430
  *
431
+ * A **declared** rename knows its note without any lookup — the declaration is
432
+ * how it was found — and is reported at the `renamedFrom:` line rather than the
433
+ * `shortcode:` line, because that is the line the finding is about and the one
434
+ * the author deletes once the declaration has done its work (#278).
435
+ *
217
436
  * @param {object} finding - One finding from {@link diffItemAddresses}.
218
437
  * @param {Map<string, string>} noteFiles - From {@link noteFilesById}.
219
438
  * @returns {{file?: string, line?: number, column?: number}} Spreadable
220
439
  * position fields for {@link formatDiagnostic}.
221
440
  */
222
441
  export function locateAddressFinding(finding, noteFiles) {
442
+ if (finding.declared && finding.noteFile) {
443
+ try {
444
+ const raw = fs.readFileSync(finding.noteFile, "utf8");
445
+ return {
446
+ file: finding.noteFile,
447
+ ...positionInFrontmatter(raw, "renamedFrom", finding.shortcode),
448
+ };
449
+ } catch {
450
+ return { file: finding.noteFile };
451
+ }
452
+ }
223
453
  const note = noteFiles?.get(finding.id);
224
454
  if (note) {
225
455
  try {
@@ -250,15 +480,29 @@ export function locateAddressFinding(finding, noteFiles) {
250
480
  * known — and says so, rather than leaving the reader to wonder whether one was
251
481
  * looked for.
252
482
  *
483
+ * **A declared rename says it is declared** (#278). The two claims are not
484
+ * equally checkable: an id match is a fact in the artefacts, while a
485
+ * declaration is an author's word, and a reader deciding whether to trust the
486
+ * successor needs to know which one they have. Saying "the same document" of a
487
+ * declared rename would spend the id match's credibility on it.
488
+ *
489
+ * The consequence sentence is the same for both, because it is the same
490
+ * consequence: the old address stops resolving either way, and that is what
491
+ * the reader has to act on.
492
+ *
253
493
  * @param {object} finding - One finding from {@link diffItemAddresses}.
254
494
  * @returns {string} The message.
255
495
  */
256
496
  export function addressFindingMessage(finding) {
257
497
  if (finding.kind === "renamed") {
498
+ const how =
499
+ finding.declared ?
500
+ `the note now published as ${finding.to} declares it was ` +
501
+ `renamed from ${finding.shortcode}`
502
+ : `the same document (${finding.id}) is now published as ${finding.to}`;
258
503
  return (
259
504
  `since ${finding.baseline}, ${finding.address} is no longer ` +
260
- `published; the same document (${finding.id}) is now published ` +
261
- `as ${finding.to}. Every package that resolves ${finding.address} ` +
505
+ `published; ${how}. Every package that resolves ${finding.address} ` +
262
506
  `breaks when it moves past ${finding.baseline}`
263
507
  );
264
508
  }
@@ -0,0 +1,83 @@
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
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * **Anchored body sections** — the `# Heading {#anchor}` convention, and how a
16
+ * compiler pulls one section out of a note's prose.
17
+ *
18
+ * The content format gives three anchors a document meaning: `{#appearance}`,
19
+ * `{#dossier}` and `{#spoilers}`. *Which field* each lands in is a system's
20
+ * business — SoHL writes the first to an actor's `appearance`, HM3 to an
21
+ * actor's and an item's `description` — but *finding* it is not, so the
22
+ * extraction lives here where every compiler reaches it (#139).
23
+ *
24
+ * It was a pair of private functions inside the SoHL actors pass, which is
25
+ * where the convention was first needed and not where it belongs: the anchors
26
+ * are stated in `docs/content-format.md`, alongside secret fences and heading
27
+ * attributes, as part of the format every note is written in.
28
+ *
29
+ * @module
30
+ */
31
+
32
+ import { md } from "./helpers.mjs";
33
+
34
+ /**
35
+ * Extract the body of an H1 section whose heading carries the explicit
36
+ * anchor decorator `{#<anchorId>}`. Captures every line after the H1 up
37
+ * to (but not including) the next H1 — nested H2/H3 etc. and their bodies
38
+ * are included. The H1 line itself is discarded. Returns "" if no such
39
+ * heading exists. Fenced code blocks are respected so `# foo` inside
40
+ * ``` blocks does not trigger a match.
41
+ *
42
+ * @param {string} body - The note body.
43
+ * @param {string} anchorId - The anchor to find.
44
+ * @returns {string} The section's markdown, or "".
45
+ */
46
+ export function extractAnchorSection(body, anchorId) {
47
+ const lines = String(body ?? "").split("\n");
48
+ const captured = [];
49
+ let inCodeBlock = false;
50
+ let capturing = false;
51
+ const wanted = String(anchorId).toLowerCase();
52
+ for (const line of lines) {
53
+ if (line.trim().startsWith("```")) {
54
+ inCodeBlock = !inCodeBlock;
55
+ if (capturing) captured.push(line);
56
+ continue;
57
+ }
58
+ const h1Match = !inCodeBlock ? line.match(/^\s*#\s+(.+?)\s*#*\s*$/) : null;
59
+ if (h1Match) {
60
+ const anchor = h1Match[1].match(/\{#([^}]+)\}\s*$/);
61
+ const id = anchor?.[1]?.trim().toLowerCase() || null;
62
+ if (capturing) break;
63
+ if (id === wanted) {
64
+ capturing = true;
65
+ continue;
66
+ }
67
+ }
68
+ if (capturing) captured.push(line);
69
+ }
70
+ return captured.join("\n").trim();
71
+ }
72
+
73
+ /**
74
+ * Render an extracted markdown section to HTML, or "" if empty.
75
+ *
76
+ * @param {string} body - The note body.
77
+ * @param {string} anchorId - The anchor to find.
78
+ * @returns {string} The rendered HTML, or "".
79
+ */
80
+ export function renderSection(body, anchorId) {
81
+ const slice = extractAnchorSection(body, anchorId);
82
+ return slice ? md.render(slice) : "";
83
+ }
@@ -0,0 +1,83 @@
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
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * The anchors a note declares on its headings — read in one place (#243).
16
+ *
17
+ * **A leaf, deliberately.** This is asked by the link checker, by the content
18
+ * index, and by the builds that emit a link, and they cannot all import one
19
+ * another: `helpers.mjs` is imported by the compilers, while the index imports
20
+ * the manifest emitter, which imports them back. A reader that imports nothing
21
+ * can be shared by all three — which is the point, because the question "what
22
+ * anchors does this note declare?" had two answers and they disagreed on
23
+ * `{#CalendarFormat}`.
24
+ *
25
+ * @module
26
+ */
27
+
28
+ /**
29
+ * A heading, and the `{#slug}` anchor it declares.
30
+ *
31
+ * Kept identical to the pair {@link splitPages} matches, because the two must
32
+ * agree about what an anchor is: that pass decides which sections become
33
+ * addressable journal pages, and an index naming an anchor it does not produce
34
+ * would advertise a link that resolves nowhere. `tests/content-index.test.ts`
35
+ * asserts the two find the same anchors, so drift fails the suite rather than
36
+ * shipping.
37
+ */
38
+ const HEADING = /^\s*(#{1,6})\s+(.+?)\s*#*\s*$/;
39
+ const ANCHOR = /^(.*?)\s*\{#([^}]+)\}\s*$/;
40
+ /**
41
+ * The `{#slug}` anchors a note's body declares, with where each one sits.
42
+ *
43
+ * Only headings carrying an explicit anchor are collected. A bare `#` heading
44
+ * also starts a journal page, but it declares no slug, so nothing can address
45
+ * it with `#…` — listing it would offer a link that cannot be written.
46
+ *
47
+ * @param {string} body - The note's markdown body, frontmatter already removed.
48
+ * @param {number} [bodyLine] - The 1-based file line the body starts on, from
49
+ * `parseMarkdownFile`. Anchors are reported at their position in the **file**,
50
+ * so an editor can jump straight to one; passing nothing numbers from the body.
51
+ * @returns {Array<{slug: string, name: string, level: number, line: number}>}
52
+ * In document order.
53
+ */
54
+ export function collectAnchors(body, bodyLine = 1) {
55
+ const anchors = [];
56
+ let inCodeBlock = false;
57
+ const lines = String(body ?? "").split("\n");
58
+
59
+ for (let i = 0; i < lines.length; i++) {
60
+ // A fenced block's contents are not headings, and `#` is a comment in
61
+ // most of what gets fenced.
62
+ if (lines[i].trim().startsWith("```")) {
63
+ inCodeBlock = !inCodeBlock;
64
+ continue;
65
+ }
66
+ if (inCodeBlock) continue;
67
+
68
+ const heading = HEADING.exec(lines[i]);
69
+ if (!heading) continue;
70
+ const anchor = ANCHOR.exec(heading[2].trim());
71
+ if (!anchor) continue;
72
+
73
+ const slug = anchor[2].trim();
74
+ if (!slug) continue;
75
+ anchors.push({
76
+ slug,
77
+ name: anchor[1].trim(),
78
+ level: heading[1].length,
79
+ line: bodyLine + i,
80
+ });
81
+ }
82
+ return anchors;
83
+ }