@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
@@ -61,15 +61,21 @@
61
61
  * @module
62
62
  */
63
63
 
64
- import { walkMarkdownTree } from "./helpers.mjs";
65
- import { MAP_TYPES, PACK_BY_TYPE, RETIRED_TYPES } from "./ids.mjs";
64
+ import { assertSuppliedCorpus } from "./helpers.mjs";
65
+ // The record accessors only: this module is imported by the content index, so
66
+ // importing the index back would close a cycle (#243).
67
+ import { authoredFrontmatter, isNoteRecord, noteFile } from "./index-records.mjs";
68
+ import { JOURNAL_TYPES, MAP_TYPES, PACK_BY_TYPE, RETIRED_TYPES, currentType } from "./ids.mjs";
66
69
  import { itemTypes } from "./item-registry.mjs";
67
70
  import { docEntryTypes } from "./item-docs.mjs";
68
71
  import { loadPackConfig } from "./pack-config.mjs";
69
72
  import { locateFrontmatterKey } from "./retired-fields.mjs";
70
73
  import { noteTypesFor, subtypeRow } from "./document-subtypes.mjs";
74
+ import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./subtype-registry.mjs";
71
75
  import { HOMEPAGE_TYPE } from "./homepage.mjs";
72
- import { SOHL_DOCUMENT_SUBTYPES } from "../sohl/document-subtypes.mjs";
76
+ import { FOLDER_TYPE } from "./folder-notes.mjs";
77
+ import { BUNDLE_TYPE } from "./bundle-notes.mjs";
78
+ import { NOTE_VOCABULARY } from "./note-vocabulary.mjs";
73
79
 
74
80
  /**
75
81
  * Note types that compile into **no compendium document, by design**.
@@ -84,19 +90,54 @@ import { SOHL_DOCUMENT_SUBTYPES } from "../sohl/document-subtypes.mjs";
84
90
  export const NEVER_PACKED_TYPES = Object.freeze(new Set([HOMEPAGE_TYPE]));
85
91
 
86
92
  /**
87
- * The note-type document-subtype maps this toolchain ships.
93
+ * Content types the specification states and this toolchain does not yet
94
+ * compile.
95
+ *
96
+ * **Stated, never inferred, and that distinction is the whole point.** An
97
+ * unimplemented type and a type somebody forgot to route look identical from
98
+ * the outside: both are documented, both validate, and neither reaches a pass.
99
+ * The only thing separating them is intent, so intent is written down here.
100
+ *
101
+ * Inferring it — "declared, but absent from the configured vocabulary" — reads
102
+ * correctly and is worthless, because the configured vocabulary is *derived
103
+ * from the routing*. Take a type's route away and it leaves the vocabulary too,
104
+ * so the inference excuses precisely the mistake it was meant to catch. That is
105
+ * not hypothetical: it is #241, where `place`, `lore` and `scenario` were
106
+ * declared, validated and unrouted, and every gate reported success until a
107
+ * downstream repository failed on 450 notes.
108
+ *
109
+ * A type leaves this set when it is implemented, the way `bundle` did in #259.
110
+ * The membership is asserted, so it cannot be forgotten in either direction.
88
111
  *
89
- * One today. `hm3/` is #139, and when it lands its map joins this list rather
90
- * than the claim table below growing a second copy of the same fact.
112
+ * @type {ReadonlySet<string>}
113
+ */
114
+ export const UNIMPLEMENTED_TYPES = Object.freeze(new Set(["vehicle"]));
115
+
116
+ /**
117
+ * Note types that reach a pack by a route **other than the pack router**.
118
+ *
119
+ * A folder is the only one, and it is not unclaimed: it compiles to a real
120
+ * `Folder` document. What it has no answer to is *which* pack claims it, because
121
+ * that is not a property of the note — a folder materialises in every pack
122
+ * holding a document that references it, and its ancestors with it (#257). So
123
+ * it is exempt from the claim check for the opposite reason a homepage is:
124
+ * a homepage is in no pack, and a folder may be in several.
125
+ *
126
+ * @type {ReadonlySet<string>}
127
+ */
128
+ export const DERIVED_PACKED_TYPES = Object.freeze(new Set([FOLDER_TYPE]));
129
+
130
+ /**
131
+ * The note-type → document-subtype maps this toolchain ships.
91
132
  *
92
- * `engine/` importing from `sohl/` is the arrangement `generate.mjs` already
93
- * has its `COMPILERS` table names the SoHL compilers by class and for the
94
- * same reason: the engine owns the *mechanism* that asks each system what it
95
- * compiles, and the systems own the answers.
133
+ * Declared in {@link module:engine/subtype-registry} and re-exported here,
134
+ * where it has always been read from. It moved to a leaf in #270 so that
135
+ * `helpers.mjs` could reach it: this module imports `walkMarkdownTree` from
136
+ * there, so a dependency the other way would have closed a cycle.
96
137
  *
97
138
  * @type {readonly import("./document-subtypes.mjs").DocumentSubtypeMap[]}
98
139
  */
99
- export const KNOWN_DOCUMENT_SUBTYPE_MAPS = Object.freeze([SOHL_DOCUMENT_SUBTYPES]);
140
+ export { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./subtype-registry.mjs";
100
141
 
101
142
  /**
102
143
  * What a claim question is asked against.
@@ -166,7 +207,10 @@ function mappingSystems(maps, type) {
166
207
  * pack from appearing to answer for any note.
167
208
  *
168
209
  * @param {string} docType - The Foundry document type a pack holds.
169
- * @param {ClaimSources} [sources] - What to answer from. Defaults to the
210
+ * @param {ClaimSources} [sources] - What to answer from.
211
+ * @param {object} [opts] - Options.
212
+ * @param {readonly object[]} [opts.records] - The corpus, derived once by the
213
+ * compile and handed in — required, for the reason above (#243). Defaults to the
170
214
  * configured registries and the systems this toolchain ships.
171
215
  * @returns {ReadonlySet<string>} The note types such a pass would claim.
172
216
  */
@@ -188,15 +232,21 @@ export function noteTypesClaimedBy(docType, sources) {
188
232
  // The actors pass: every note type a system sends to an `Actor`.
189
233
  case "Actor":
190
234
  return Object.freeze(new Set(maps.flatMap((map) => noteTypesFor(map, "Actor"))));
191
- // The journals pass: free prose, plus every doc-carrying type an
192
- // item's, a macro's and a map note's description each compile into a
193
- // JournalEntry of their own.
235
+ // The journals pass: every type whose whole document is a journal,
236
+ // plus every doc-carrying type — an item's, a macro's and a map note's
237
+ // description each compile into a JournalEntry of their own.
194
238
  case "JournalEntry":
195
- return Object.freeze(new Set(["doc", ...docs]));
239
+ return Object.freeze(new Set([...JOURNAL_TYPES, ...docs]));
196
240
  case "Macro":
197
241
  return Object.freeze(new Set(["macro"]));
198
242
  case "Scene":
199
243
  return Object.freeze(new Set(MAP_TYPES));
244
+ // The bundles pass: an Adventure is what a `bundle` note compiles into
245
+ // (#259). A **prebuilt** Adventure pack still claims nothing —
246
+ // {@link claimedNoteTypes} passes over it, because no note is routed
247
+ // into a pack whose JSON is checked in rather than compiled.
248
+ case "Adventure":
249
+ return Object.freeze(new Set([BUNDLE_TYPE]));
200
250
  default:
201
251
  return Object.freeze(new Set());
202
252
  }
@@ -209,14 +259,27 @@ export function noteTypesClaimedBy(docType, sources) {
209
259
  * is claimed — which is what keeps a type deliberately unmapped for one system,
210
260
  * and claimed for another, silent (#79).
211
261
  *
262
+ * **A prebuilt pack claims nothing.** Its per-document JSON is checked in
263
+ * rather than compiled, so it has no pass and no note is routed into one —
264
+ * which `content-config.mjs` already states by refusing `default: true`
265
+ * alongside `prebuilt`. Counting it would tell an author their note is claimed
266
+ * by a pack that will never look at it. Before #259 the point could not arise:
267
+ * the only prebuilt pack in the wild is `harn-adventures`'s Adventure pack, and
268
+ * no compiler was registered for that document type, so the row answered for
269
+ * nothing whatever it was asked. Now one is.
270
+ *
212
271
  * @param {object} [config] - The resolved build configuration. Defaults to this
213
272
  * repository's.
214
273
  * @param {ClaimSources} [sources] - What to answer from.
274
+ * @param {object} [opts] - Options.
275
+ * @param {readonly object[]} [opts.records] - The corpus, derived once by the
276
+ * compile and handed in — required, for the reason above (#243).
215
277
  * @returns {ReadonlySet<string>} The claimed note types.
216
278
  */
217
279
  export function claimedNoteTypes(config = loadPackConfig(), sources) {
218
280
  const claimed = new Set();
219
281
  for (const pack of config.packs ?? []) {
282
+ if (pack.prebuilt) continue;
220
283
  for (const type of noteTypesClaimedBy(pack.type, sources)) claimed.add(type);
221
284
  }
222
285
  return Object.freeze(claimed);
@@ -232,6 +295,9 @@ export function claimedNoteTypes(config = loadPackConfig(), sources) {
232
295
  * declare on top.
233
296
  *
234
297
  * @param {ClaimSources} [sources] - What to answer from.
298
+ * @param {object} [opts] - Options.
299
+ * @param {readonly object[]} [opts.records] - The corpus, derived once by the
300
+ * compile and handed in — required, for the reason above (#243).
235
301
  * @returns {ReadonlySet<string>} The vocabulary.
236
302
  */
237
303
  export function noteTypeVocabulary(sources) {
@@ -284,14 +350,18 @@ function article(word) {
284
350
  * @returns {string} The message.
285
351
  */
286
352
  function configurationMessage(type, config, sources) {
353
+ // Every table below is keyed by the current spelling of a note type; the
354
+ // message quotes the authored one, which is what the reader has in front of
355
+ // them (#78).
356
+ const current = currentType(type);
287
357
  const documents = mappedDocuments(sources.maps, type);
288
- if (!documents.length && sources.itemTypes.has(type)) documents.push("Item");
289
- if (!documents.length && PACK_BY_TYPE[type]) documents.push(PACK_BY_TYPE[type].docType);
358
+ if (!documents.length && sources.itemTypes.has(current)) documents.push("Item");
359
+ if (!documents.length && PACK_BY_TYPE[current]) documents.push(PACK_BY_TYPE[current].docType);
290
360
 
291
361
  const systems = mappingSystems(sources.maps, type);
292
362
  const configured = new Set((config.packs ?? []).map((pack) => pack.type));
293
363
  const packless = documents.filter((document) => !configured.has(document));
294
- const needsBuilder = documents.includes("Item") && !sources.itemTypes.has(type);
364
+ const needsBuilder = documents.includes("Item") && !sources.itemTypes.has(current);
295
365
 
296
366
  const into = documents.map((document) => `${article(document)} ${document}`).join(" or ");
297
367
  const becomes =
@@ -322,6 +392,32 @@ function configurationMessage(type, config, sources) {
322
392
  );
323
393
  }
324
394
 
395
+ /**
396
+ * The **specification** finding: the format states the type, nothing compiles it.
397
+ *
398
+ * A third thing that can be wrong, and the only one that is not the author's
399
+ * fault. `docs/content-format.md` documents the type and the vocabulary declares
400
+ * its properties, so a note written against the published specification is
401
+ * correct — this toolchain simply has not implemented it yet.
402
+ *
403
+ * It earns its own message because the other two would both mislead here.
404
+ * Naming a missing pack or registry sends an author to
405
+ * `package-build.config.yaml`, where nothing they can write will help; saying
406
+ * the type is unknown flatly contradicts the specification they read it in.
407
+ *
408
+ * @param {string} type - The note's declared `type`.
409
+ * @returns {string} The message.
410
+ */
411
+ function specifiedMessage(type) {
412
+ return (
413
+ `no configured pack claims a note of type "${type}", so it compiles ` +
414
+ `into nothing. The content format specifies "${type}", so the note is ` +
415
+ `not wrong — this toolchain has not implemented the type yet. Nothing ` +
416
+ `in this repository's configuration will change that; do not author ` +
417
+ `the type until a release compiles it.`
418
+ );
419
+ }
420
+
325
421
  /**
326
422
  * The **authoring** finding: nothing anywhere knows the type.
327
423
  *
@@ -351,22 +447,41 @@ function authoringMessage(type) {
351
447
  * @param {object} [config] - The resolved build configuration. Defaults to this
352
448
  * repository's.
353
449
  * @param {ClaimSources} [sources] - What to answer from.
450
+ * @param {object} [opts] - Options.
451
+ * @param {readonly object[]} [opts.records] - The corpus, derived once by the
452
+ * compile and handed in — required, for the reason above (#243).
354
453
  * @returns {Array<{file: string, line?: number, column?: number,
355
454
  * severity: "error", message: string, type: string}>} One finding per note.
356
455
  */
357
- export function unclaimedNoteFindings(config = loadPackConfig(), sources) {
456
+ export function unclaimedNoteFindings(config = loadPackConfig(), sources, { records } = {}) {
358
457
  const resolved = resolveSources(sources);
359
458
  const claimed = claimedNoteTypes(config, resolved);
360
459
  const vocabulary = noteTypeVocabulary(resolved);
361
460
  const findings = [];
362
461
 
363
- for (const { frontmatter: fm, absPath } of walkMarkdownTree(config.paths.content)) {
462
+ // The corpus this compile derived once (#243), required rather than
463
+ // derived here: this module is imported *by* the content index, so it
464
+ // could not derive one without closing a cycle — and the caller that wants
465
+ // this answer is running a compile and already holds it.
466
+ assertSuppliedCorpus(records, "unclaimedNoteFindings");
467
+
468
+ for (const record of records) {
469
+ if (!isNoteRecord(record)) continue;
470
+ const fm = authoredFrontmatter(record);
471
+ const absPath = noteFile(config.paths.content, record);
364
472
  if (!fm) continue;
365
473
  const type = typeof fm.type === "string" ? fm.type.trim() : "";
366
474
  if (!type) continue;
367
475
  if (NEVER_PACKED_TYPES.has(type)) continue;
476
+ if (DERIVED_PACKED_TYPES.has(type)) continue;
368
477
  if (RETIRED_TYPES[type]) continue;
369
- if (claimed.has(type)) continue;
478
+ // A **renamed** spelling is a live type, not an unknown one: it resolves
479
+ // to the same row, the same registry entry and the same pack. So the
480
+ // claim is asked of the current spelling while the finding quotes the
481
+ // authored one (#78). The rename itself is reported by the frontmatter
482
+ // lint, which can say what to write instead.
483
+ const current = currentType(type);
484
+ if (claimed.has(current)) continue;
370
485
 
371
486
  findings.push({
372
487
  file: absPath,
@@ -374,9 +489,15 @@ export function unclaimedNoteFindings(config = loadPackConfig(), sources) {
374
489
  severity: /** @type {"error"} */ ("error"),
375
490
  type,
376
491
  message:
377
- vocabulary.has(type) ?
378
- configurationMessage(type, config, resolved)
379
- : authoringMessage(type),
492
+ // The unimplemented set is asked *first*: such a type is
493
+ // absent from the configured vocabulary precisely because
494
+ // nothing routes it, so the `vocabulary.has` branch would never
495
+ // reach it — and reading its absence as the reason is the
496
+ // inference {@link UNIMPLEMENTED_TYPES} exists to replace.
497
+ UNIMPLEMENTED_TYPES.has(current) ? specifiedMessage(type)
498
+ : vocabulary.has(current) ? configurationMessage(type, config, resolved)
499
+ : Object.hasOwn(NOTE_VOCABULARY, current) ? specifiedMessage(type)
500
+ : authoringMessage(type),
380
501
  });
381
502
  }
382
503
  return findings;
@@ -0,0 +1,112 @@
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 id a note's document is filed under, and the one rule that decides it.
16
+ *
17
+ * A note used to author its `id` — an opaque 16-character string, 6,343 of them
18
+ * across the four content trees, each a *second* identity for a thing that
19
+ * already had one. The note's canonical address (`sohl-none-miscgear-bowlcer`)
20
+ * says everything the id said, is readable, and is the identity `content-lint`
21
+ * already guards: a duplicate address is a build error, while a duplicate `id`
22
+ * was checked nowhere. So the derived id inherits a guarantee, where the
23
+ * authored one had none (#270).
24
+ *
25
+ * **An authored `id` always wins.** That is the pattern the map compiler
26
+ * already uses and documents — `regionDocId(sceneId, key, pinned)` returns
27
+ * `pinned || makeId(…)` — and it is the correction lever an author needs when a
28
+ * document must keep its identity across a shortcode rename.
29
+ *
30
+ * **This is one function because every pass must agree.** Half a dozen corpus
31
+ * readers ask what a note's document id is — the pack passes, the wikilink
32
+ * index, the content index, the Foundry-address pass, the address diff — and
33
+ * they never see each other's answer. They agreed before because they all read
34
+ * one authored field; they agree now because they all call this.
35
+ *
36
+ * `maps` and `pkg` are parameters with defaults rather than values resolved
37
+ * inside, so a test can pose a package and a registry this toolchain does not
38
+ * ship without a configuration on disk.
39
+ *
40
+ * @module
41
+ */
42
+
43
+ import { documentId } from "./content-address.mjs";
44
+ import { systemOf } from "./document-subtypes.mjs";
45
+ import { contentPackage } from "./content-package.mjs";
46
+ import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./subtype-registry.mjs";
47
+
48
+ /**
49
+ * A frontmatter value read as a non-blank string, or `undefined`.
50
+ *
51
+ * A blank `id:` is not a pin. It is an author who deleted the value and left
52
+ * the key, and treating `""` as pinned would file the document under the empty
53
+ * string — which the LevelDB packer reports only as an opaque key collision.
54
+ *
55
+ * @param {unknown} value - The authored value.
56
+ * @returns {string|undefined} The trimmed value, or `undefined` when blank.
57
+ */
58
+ function text(value) {
59
+ if (typeof value !== "string") return undefined;
60
+ const trimmed = value.trim();
61
+ return trimmed || undefined;
62
+ }
63
+
64
+ /**
65
+ * The document id a note compiles under: its pin, or its address.
66
+ *
67
+ * Returns `undefined` for a file with **no address** — no `type`, or no
68
+ * `shortcode`. Such a file is not an addressable note, so it has no document
69
+ * and inventing an id for one would file it under nothing. Every caller already
70
+ * had to handle an absent id (that is what the authored field's absence meant),
71
+ * so this reports the same thing rather than throwing where a walk used to
72
+ * skip; whether an id is *required* stays each pass's own decision
73
+ * (`BasePackCompiler.requiresId`).
74
+ *
75
+ * @param {object|null|undefined} fm - Parsed frontmatter.
76
+ * @param {object} [opts]
77
+ * @param {string} [opts.pkg] - The owning content package. Defaults to the
78
+ * configured one, which is the only package a tree's notes belong to (#56).
79
+ * @param {readonly object[]} [opts.maps] - The document-subtype maps, which
80
+ * decide the address's `<system>` segment.
81
+ * @returns {string|undefined} The document's `_id`, or `undefined` when the
82
+ * note has no address to derive one from.
83
+ */
84
+ export function noteDocId(fm, { pkg, maps = KNOWN_DOCUMENT_SUBTYPE_MAPS } = {}) {
85
+ if (!fm || typeof fm !== "object") return undefined;
86
+ const pinned = text(fm.id);
87
+ if (pinned) return pinned;
88
+ const type = text(fm.type);
89
+ const shortcode = text(fm.shortcode);
90
+ if (!type || !shortcode) return undefined;
91
+ return documentId(pkg ?? contentPackage(), systemOf(type, maps), type, shortcode);
92
+ }
93
+
94
+ /**
95
+ * Fill a note's `id` in place, so everything downstream reads one value.
96
+ *
97
+ * The corpus readers each hold their own parsed frontmatter and each ask for
98
+ * `fm.id` in several places; normalising the field once, where the note is
99
+ * read, is what makes "the id is derived" true for all of them rather than for
100
+ * whichever ones remembered to derive it. Idempotent, and a no-op for a note
101
+ * that authored an id or has no address.
102
+ *
103
+ * @param {object|null|undefined} fm - Parsed frontmatter, mutated in place.
104
+ * @param {object} [opts] - As {@link noteDocId}.
105
+ * @returns {object|null|undefined} `fm`, for chaining.
106
+ */
107
+ export function resolveNoteId(fm, opts) {
108
+ if (!fm || typeof fm !== "object") return fm;
109
+ const id = noteDocId(fm, opts);
110
+ if (id) fm.id = id;
111
+ return fm;
112
+ }
@@ -0,0 +1,134 @@
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 shortcodes a note declares it used to be published under (#278).
16
+ *
17
+ * A package's `(type, shortcode)` addresses are a published interface, and
18
+ * `addresses diff` reports what a build stopped publishing — telling a
19
+ * **rename** from a **withdrawal** by matching document ids across two
20
+ * releases. #270 removed the property that rested on. An id is now derived from
21
+ * the canonical address, which carries the shortcode, so renaming a shortcode
22
+ * moves the id too: both sides of the join move together, the match finds
23
+ * nothing, and a rename is reported as a withdrawal with no successor named.
24
+ *
25
+ * **The remaining lever needed foresight, which is the wrong thing to ask for.**
26
+ * An authored `id:` still wins, and pinning one does keep a document's identity
27
+ * across a rename — but it has to be written *before* the rename, by an author
28
+ * who does not yet know they will make one. An author who has just renamed a
29
+ * shortcode knows exactly what the old one was, and that is the only moment
30
+ * anyone does.
31
+ *
32
+ * So a note states it:
33
+ *
34
+ * ```yaml
35
+ * type: weapongear
36
+ * shortcode: Taburi
37
+ * renamedFrom: Tabri
38
+ * ```
39
+ *
40
+ * **A declaration is an assertion, not a match, and the diagnostic says which
41
+ * it had.** That distinction is the whole ethos of `address-diff.mjs`: it
42
+ * refuses to guess a successor from a similar-looking string, because a wrong
43
+ * one sends the reader to the wrong fix. An author's declaration is neither a
44
+ * guess nor an identity match — it is testimony from the one party that knows —
45
+ * so it is used, attributed, and never silently blended with an id match.
46
+ *
47
+ * **It is a list, because renames chain.** The diff is release-to-release and a
48
+ * shortcode may be renamed more than once between two releases; a baseline
49
+ * several releases back published a name two renames ago. A single value would
50
+ * report that as a withdrawal again, which is the bug this closes.
51
+ *
52
+ * **It is transient.** Once every baseline a build is compared against post-dates
53
+ * the rename, the declaration has nothing left to say and may be deleted. That
54
+ * is the difference from an `id:` pin, which is permanent, opaque, and a second
55
+ * identity for a thing that already has one (#270).
56
+ *
57
+ * **It is one key per note, at the top level, however many systems the note
58
+ * compiles into.** A shortcode is the note's, not a system block's: a note
59
+ * carrying `sohl:` and `hm3:` blocks compiles into two documents that share one
60
+ * shortcode, so a rename moves both and one declaration covers both.
61
+ *
62
+ * This module reads the key and says nothing about whether the values are
63
+ * *sound* — that a value is a well-formed shortcode, is not the note's own, and
64
+ * does not name an address some live note still publishes are cross-note
65
+ * questions, and `engine/content-lint.mjs` owns them. Composing a value into an
66
+ * address key is likewise not here: the diff defines that space and spells the
67
+ * key once, in `engine/address-diff.mjs`.
68
+ *
69
+ * @module
70
+ */
71
+
72
+ /**
73
+ * The `renamedFrom:` entries a note authors, exactly as authored.
74
+ *
75
+ * Scalar or list, because one predecessor is the overwhelmingly common case and
76
+ * requiring `- ` on it would be friction with nothing behind it; both normalize
77
+ * here so no reader has to ask which form it got.
78
+ *
79
+ * Entries are returned **unvalidated** — a number, a nested list, a blank
80
+ * string all come back as they were written. The lint needs to see them to
81
+ * report them, and a reader that quietly dropped them would report a correct
82
+ * tree clean while a typo silently did nothing.
83
+ *
84
+ * @param {object|null|undefined} fm - Parsed frontmatter.
85
+ * @returns {readonly unknown[]} The authored entries, in authored order; empty
86
+ * when the note declares none.
87
+ */
88
+ export function renamedFromEntries(fm) {
89
+ if (!fm || typeof fm !== "object") return [];
90
+ const raw = /** @type {{renamedFrom?: unknown}} */ (fm).renamedFrom;
91
+ if (raw == null) return [];
92
+ return Array.isArray(raw) ? raw : [raw];
93
+ }
94
+
95
+ /**
96
+ * The well-formed shortcodes among a note's `renamedFrom:` entries.
97
+ *
98
+ * Trimmed, de-duplicated, and in authored order. Anything that is not a
99
+ * non-blank string is skipped rather than coerced: it is reported by the lint,
100
+ * and a diff that guessed at what a number meant would claim a rename nobody
101
+ * declared.
102
+ *
103
+ * De-duplicating here rather than leaving it to callers is not tidiness — the
104
+ * predecessor index is a map, so a repeated entry would otherwise be indexed
105
+ * twice and the second write would look like a conflicting claim.
106
+ *
107
+ * @param {object|null|undefined} fm - Parsed frontmatter.
108
+ * @returns {string[]} The declared predecessor shortcodes.
109
+ */
110
+ export function renamedFrom(fm) {
111
+ const seen = new Set();
112
+ for (const entry of renamedFromEntries(fm)) {
113
+ if (typeof entry !== "string") continue;
114
+ const trimmed = entry.trim();
115
+ if (trimmed) seen.add(trimmed);
116
+ }
117
+ return [...seen];
118
+ }
119
+
120
+ /**
121
+ * Whether a note declares the key at all, however malformed its value.
122
+ *
123
+ * Separate from {@link renamedFrom} returning nothing, because the two mean
124
+ * opposite things to a lint: a note declaring no key is silent and correct,
125
+ * while one declaring `renamedFrom: []` — or a single blank string — has said
126
+ * something that does nothing, which is worth a word.
127
+ *
128
+ * @param {object|null|undefined} fm - Parsed frontmatter.
129
+ * @returns {boolean} `true` when the key is present and not null.
130
+ */
131
+ export function declaresRenamedFrom(fm) {
132
+ if (!fm || typeof fm !== "object") return false;
133
+ return /** @type {{renamedFrom?: unknown}} */ (fm).renamedFrom != null;
134
+ }
@@ -73,6 +73,29 @@ const JOURNAL_ONLY_FIELDS = Object.freeze([]);
73
73
  */
74
74
  const ARMORLOCATION_FIELDS = Object.freeze([]);
75
75
 
76
+ /**
77
+ * A `bundle` note — a set of documents taken as a unit, Foundry's `Adventure`.
78
+ *
79
+ * Like a folder, it declares no system-block fields: everything it says is a
80
+ * `data` property. Its `system` names which system's documents it may hold
81
+ * rather than which system it belongs to, so the note itself is system-neutral.
82
+ *
83
+ * @type {readonly import("./field-spec.mjs").FieldSpec[]}
84
+ */
85
+ const BUNDLE_FIELDS = Object.freeze([]);
86
+
87
+ /**
88
+ * A `folder` note — Foundry's `Folder`, the grouping documents are filed in.
89
+ *
90
+ * Empty for the same reason a bundle's is: a folder declares no system-block
91
+ * fields, because a `Folder` is a core Foundry document rather than any
92
+ * system's. Its two properties — `parent` and `color` — are `data:` keys, held
93
+ * closed by `engine/note-vocabulary.mjs` (#256).
94
+ *
95
+ * @type {readonly import("./field-spec.mjs").FieldSpec[]}
96
+ */
97
+ const FOLDER_FIELDS = Object.freeze([]);
98
+
76
99
  /**
77
100
  * Every engine-level content type, and what a note of that type may write.
78
101
  *
@@ -84,4 +107,6 @@ export const ENGINE_NOTE_SCHEMAS = Object.freeze({
84
107
  lore: JOURNAL_ONLY_FIELDS,
85
108
  scenario: JOURNAL_ONLY_FIELDS,
86
109
  armorlocation: ARMORLOCATION_FIELDS,
110
+ bundle: BUNDLE_FIELDS,
111
+ folder: FOLDER_FIELDS,
87
112
  });