@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
@@ -46,14 +46,35 @@ import { foreignItemCatalogDirs } from "./foreign-catalog.mjs";
46
46
  import { Items } from "../sohl/items.mjs";
47
47
  import { Journals } from "./journals.mjs";
48
48
  import { Actors } from "../sohl/actors.mjs";
49
+ import { Hm3Items } from "../hm3/items.mjs";
50
+ import { Hm3Actors } from "../hm3/actors.mjs";
49
51
  import { Macros } from "./macros.mjs";
50
52
  import { Scenes } from "./scenes.mjs";
51
- import { statsForPack, loadFolders, buildFolderResolver, writeFolderDocs } from "./helpers.mjs";
53
+ import { Bundles } from "./bundles.mjs";
54
+ import {
55
+ statsForPack,
56
+ loadFolders,
57
+ buildFolderResolver,
58
+ writeFolderDocs,
59
+ parseMarkdownFile,
60
+ folderFilename,
61
+ } from "./helpers.mjs";
62
+ import {
63
+ buildFolderNoteIndex,
64
+ collectFolderNotes,
65
+ FOLDER_TYPE,
66
+ folderDocument,
67
+ } from "./folder-notes.mjs";
52
68
  import { countContentNotes } from "./content-tree.mjs";
53
69
  import { emitDiagnostic } from "./diagnostics.mjs";
70
+ // The corpus every pass runs over, derived once (#243).
71
+ import { buildCompileCorpus } from "./compile-corpus.mjs";
72
+ // The record accessors only — see `engine/index-records.mjs` (#243).
73
+ import { isNoteRecord, noteFile } from "./index-records.mjs";
54
74
  import { loadPackConfig } from "./pack-config.mjs";
55
75
  import { routerFor } from "./pack-router.mjs";
56
76
  import { unclaimedNoteFindings } from "./note-claims.mjs";
77
+ import { contentPackage } from "./content-package.mjs";
57
78
 
58
79
  /**
59
80
  * The compiler class for each Foundry document type a pack may hold.
@@ -63,6 +84,15 @@ import { unclaimedNoteFindings } from "./note-claims.mjs";
63
84
  * compiler here. Unknown types fail the build rather than defaulting, so a pack
64
85
  * declaring a type nothing can compile is loud at the first pass instead of
65
86
  * shipping empty.
87
+ *
88
+ * **Two of the six are a system's, and the SoHL pair is not a default.** An
89
+ * Item or an Actor *is* a system's data — both passes declare
90
+ * `requiresSystemBlock` — so which compiler a pack gets is decided together
91
+ * with which system it declares; see {@link SYSTEM_COMPILERS}. The four
92
+ * system-neutral passes have one implementation because a JournalEntry, a
93
+ * Macro, a Scene and an Adventure are Foundry's documents rather than any
94
+ * system's — an `Adventure` does not even have a `system` field, which is why a
95
+ * bundle spanning two systems is two documents (#259).
66
96
  */
67
97
  const COMPILERS = {
68
98
  Item: Items,
@@ -70,8 +100,44 @@ const COMPILERS = {
70
100
  Actor: Actors,
71
101
  Macro: Macros,
72
102
  Scene: Scenes,
103
+ Adventure: Bundles,
73
104
  };
74
105
 
106
+ /**
107
+ * The system-specific compilers, by the system a pack declares (#139).
108
+ *
109
+ * A repository feeding two systems declares one Item pack and one Actor pack
110
+ * per system — `harn-ensemble` has `actors-hm3` and `actors-sohl` — and each
111
+ * pack's `system:` is what says whose data model its documents are shaped for.
112
+ * That is the same field the `_stats` stamp, the item-catalogue scope and the
113
+ * `itemBuilders` lookup already read, so nothing new is declared to make the
114
+ * compiler follow it.
115
+ *
116
+ * A system with no entry — or a pack that declares none — falls back to
117
+ * {@link COMPILERS}. That keeps every single-system configuration meaning
118
+ * exactly what it did: SoHL's passes were the only ones, so they stay the
119
+ * answer where nothing says otherwise.
120
+ *
121
+ * @type {Readonly<Record<string, Readonly<Record<string, Function>>>>}
122
+ */
123
+ const SYSTEM_COMPILERS = Object.freeze({
124
+ sohl: Object.freeze({ Item: Items, Actor: Actors }),
125
+ hm3: Object.freeze({ Item: Hm3Items, Actor: Hm3Actors }),
126
+ });
127
+
128
+ /**
129
+ * The compiler class a pack of one document type and one system gets.
130
+ *
131
+ * @param {string} docType - The Foundry document type the pack holds.
132
+ * @param {string|null} [system] - The system the pack declares, if any.
133
+ * @returns {Function|undefined} The compiler class, or `undefined` for a
134
+ * document type nothing here compiles — which {@link generatePack} reports
135
+ * rather than defaulting past.
136
+ */
137
+ export function compilerFor(docType, system = null) {
138
+ return (system && SYSTEM_COMPILERS[system]?.[docType]) || COMPILERS[docType];
139
+ }
140
+
75
141
  /**
76
142
  * Root of the build-only JSON tree for one pack.
77
143
  *
@@ -122,6 +188,45 @@ export function itemPackJsonDirs(config = loadPackConfig(), system = null) {
122
188
  .map((pack) => packJsonDir(pack.name, config));
123
189
  }
124
190
 
191
+ /**
192
+ * The compiled JSON a bundle may hold copies of, by document type.
193
+ *
194
+ * An `Adventure` carries **copies**, not references, so a bundle resolves its
195
+ * `contents` against compiled output rather than against the content tree — the
196
+ * same arrangement the actors pass has for `itemsSourceDirs`, generalised to
197
+ * every document class an Adventure can hold (#259).
198
+ *
199
+ * Two kinds of pack are left out, each because it holds nothing a note
200
+ * addresses. A **prebuilt** pack's JSON is checked in rather than compiled, so
201
+ * no note is routed into it and nothing in it answers to an address. An
202
+ * **Adventure** pack holds Adventures, and Foundry's `contentFields` has no
203
+ * field for one — a bundle of bundles is not a shape the document admits.
204
+ *
205
+ * **Scoped to one system when the pack has one**, exactly as
206
+ * {@link itemPackJsonDirs} is: a pack declaring `system: sohl` reads that
207
+ * system's packs and the system-neutral ones, so a `(type, shortcode)` that
208
+ * exists in two systems is read out of the right catalogue. Asking for no
209
+ * system reads them all, which is every single-system build.
210
+ *
211
+ * @param {object} [config] - The resolved build configuration. Defaults to this
212
+ * repository's.
213
+ * @param {string|null} [system] - The system whose documents are wanted.
214
+ * Omitted or `null`, every pack is read.
215
+ * @returns {Record<string, string[]>} Each readable pack's JSON directory, by
216
+ * the Foundry document type it holds.
217
+ */
218
+ export function bundleSourceJsonDirs(config = loadPackConfig(), system = null) {
219
+ /** @type {Record<string, string[]>} */
220
+ const dirs = {};
221
+ for (const pack of config.packs) {
222
+ if (pack.prebuilt) continue;
223
+ if (pack.type === "Adventure") continue;
224
+ if (system != null && pack.system && pack.system !== system) continue;
225
+ (dirs[pack.type] ??= []).push(packJsonDir(pack.name, config));
226
+ }
227
+ return dirs;
228
+ }
229
+
125
230
  /**
126
231
  * The document types whose compiled output a pass of this type reads.
127
232
  *
@@ -258,11 +363,13 @@ async function generatePack(
258
363
  config,
259
364
  router,
260
365
  routingReporter,
366
+ folderNotes,
367
+ corpus,
261
368
  ) {
262
369
  const contentBase = config.paths.content;
263
370
  const dest = packJsonDir(name, config);
264
371
 
265
- const packClass = COMPILERS[type];
372
+ const packClass = compilerFor(type, system ?? null);
266
373
  if (!packClass) {
267
374
  log.error(
268
375
  `Pack ${name}: no compiler for document type "${type}" — the ` +
@@ -274,15 +381,55 @@ async function generatePack(
274
381
  log.info(`Pack ${name}: ${contentBase} → ${dest}`);
275
382
 
276
383
  let folderList;
277
- let resolver;
384
+ let yamlResolver;
278
385
  try {
279
386
  folderList = folders ? loadFolders(path.join(contentBase, folders)) : [];
280
- ({ resolver } = buildFolderResolver(folderList));
387
+ ({ resolver: yamlResolver } = buildFolderResolver(folderList));
281
388
  } catch (err) {
282
389
  log.error(`${name} ${folders} validation failed: ${err.message}`);
283
390
  return { errors: 1, compiled: 0 };
284
391
  }
285
392
 
393
+ // Which folder notes this pack turned out to hold something for. A folder
394
+ // materialises in every pack holding a document that references it, so the
395
+ // set is not knowable until the pass has compiled — which is why these
396
+ // documents are written after `compile()` and the YAML ones before it
397
+ // (#257).
398
+ /** @type {Set<import("./folder-notes.mjs").FolderNote>} */
399
+ const referencedFolders = new Set();
400
+
401
+ /**
402
+ * The Foundry folder id a note names, by address or by id.
403
+ *
404
+ * The two spellings resolve against two different sources and always did:
405
+ * `packFolder` names a folder **note**, resolved through the address index
406
+ * shared by the whole build, and `folder` names a Foundry **id** declared
407
+ * in this pack's own YAML. Which one applies is the field the value was
408
+ * written in, never the string (#251).
409
+ *
410
+ * @param {string|null|undefined} value - As authored.
411
+ * @param {object} [opts]
412
+ * @param {boolean} [opts.isAddress] - Whether `value` is a folder address.
413
+ * @returns {string|null} The folder id, or `null` for an absent value.
414
+ */
415
+ const resolver = (value, { isAddress = false } = {}) => {
416
+ if (value == null || value === "") return null;
417
+ if (!isAddress) return yamlResolver(value);
418
+ const folder = folderNotes.resolve(value);
419
+ // Its ancestors with it: a `Folder` whose parent is absent from the
420
+ // pack is an orphan Foundry renders at the root, so materialising a
421
+ // folder without its chain breaks the tree at the top rather than
422
+ // merely leaving it incomplete.
423
+ referencedFolders.add(folder);
424
+ // Asked of *this* pack: a folder's hierarchy is per-pack even though
425
+ // its identity is not, so the chain that has to come with it is the
426
+ // chain it has here.
427
+ for (const ancestor of folderNotes.ancestorsOf(folder, name)) {
428
+ referencedFolders.add(ancestor);
429
+ }
430
+ return folder.id;
431
+ };
432
+
286
433
  // Wipe and recreate so removed content notes leave no stale JSON.
287
434
  fs.rmSync(dest, { recursive: true, force: true });
288
435
  fs.mkdirSync(dest, { recursive: true });
@@ -304,6 +451,8 @@ async function generatePack(
304
451
  const pack = new packClass({
305
452
  contentBase,
306
453
  dest,
454
+ // The corpus this compile derived once, shared by every pass (#243).
455
+ corpus,
307
456
  companionDests,
308
457
  // The actors pass resolves each being's embedded items against the items
309
458
  // passes' output. That used to be an unwritten sibling-directory contract
@@ -320,7 +469,15 @@ async function generatePack(
320
469
  // cache throws naming `content-build deps fetch` rather than
321
470
  // downloading inside a compile.
322
471
  foreignSourceDirs: foreignItemCatalogDirs(config),
472
+ // The bundles pass resolves each Adventure's members against the output
473
+ // of every pass that produces one. Stated from the configured pack list
474
+ // for the same reason `itemsSourceDirs` is (#1508), and scoped to this
475
+ // pack's system so a bundle holds the catalogue it is compiled for.
476
+ bundleSourceDirs: bundleSourceJsonDirs(config, system ?? null),
323
477
  folderResolver: resolver,
478
+ // One answer to "which files are the corpus?", from the configuration
479
+ // this build resolved rather than from the working directory (#243).
480
+ skipDirectories: config.skipDirectories,
324
481
  packName: name,
325
482
  // Which system this pack's documents are stamped for (#48).
326
483
  packSystem: system ?? null,
@@ -329,9 +486,60 @@ async function generatePack(
329
486
  routingReporter,
330
487
  });
331
488
  await pack.compile();
489
+
490
+ // After the pass, because only now is it known what this pack references.
491
+ // One folder note materialises in several packs — the items pack and the
492
+ // journals pack both hold it when both hold something filed in it — and
493
+ // every copy carries the same `_id`, which is what files a documentation
494
+ // journal beside the item it describes rather than in a folder that merely
495
+ // looks alike (#257).
496
+ writeFolderNoteDocs(
497
+ referencedFolders,
498
+ folderNotes,
499
+ statsForPack(system, config),
500
+ dest,
501
+ type,
502
+ name,
503
+ );
504
+
332
505
  return { errors: pack.errorCount, compiled: pack.compiledCount };
333
506
  }
334
507
 
508
+ /**
509
+ * Write one `Folder` document per referenced folder note into a pack.
510
+ *
511
+ * Emitted in address order rather than in the order the pass happened to
512
+ * reference them, so the same tree compiles to the same bytes on every run.
513
+ *
514
+ * @param {Set<import("./folder-notes.mjs").FolderNote>} referenced - The folder
515
+ * notes this pack holds something for, ancestors included.
516
+ * @param {object} folderNotes - The folder-note index.
517
+ * @param {object} stats - The `_stats` block every emitted document carries.
518
+ * @param {string} dest - The pack's JSON directory.
519
+ * @param {string} documentType - The document class the pack holds.
520
+ * @param {string} packName - Which pack, so each folder takes the parent it has
521
+ * *here* rather than the one it has by default.
522
+ * @returns {void}
523
+ */
524
+ function writeFolderNoteDocs(referenced, folderNotes, stats, dest, documentType, packName) {
525
+ if (referenced.size === 0) return;
526
+ const ordered = [...referenced].sort((a, b) => (a.address < b.address ? -1 : 1));
527
+ for (const folder of ordered) {
528
+ const doc = folderDocument(
529
+ folder,
530
+ folderNotes.parentOf(folder, packName),
531
+ documentType,
532
+ stats,
533
+ );
534
+ fs.writeFileSync(
535
+ path.join(dest, folderFilename(folder.name, folder.id)),
536
+ JSON.stringify(doc, null, 2),
537
+ "utf8",
538
+ );
539
+ }
540
+ log.info(`Emitted ${ordered.length} folder-note document(s) to ${dest}`);
541
+ }
542
+
335
543
  /**
336
544
  * The passes that compiled nothing when they were expected to compile
337
545
  * something — a build failure, not a quiet no-op.
@@ -399,6 +607,33 @@ export async function generatePacksJson({ only, config = loadPackConfig() } = {}
399
607
  }
400
608
  log.info(`Content tree: ${noteCount} note(s) at ${contentBase}`);
401
609
 
610
+ // One router per configuration, so every pass agrees about where a note
611
+ // goes, and the first pack of each document type owns the error message for
612
+ // a note of that type that goes nowhere. Resolved here because the corpus
613
+ // below is derived against it, and the corpus is what every reader from
614
+ // this point on reads (#243).
615
+ const router = routerFor(config);
616
+
617
+ // The corpus every pass runs over, and the three whole-tree indexes built
618
+ // over it, derived **once** for the whole compile (#243). Each is a pure
619
+ // function of (tree, scope, router), none of which varies between passes —
620
+ // `router` is one object, handed to all of them — so the passes were
621
+ // deriving the same answers over and over. Compiling `sohl` read every note
622
+ // twenty times before this: four per pass, five passes.
623
+ //
624
+ // Derived here, before the first reader: the unclaimed-type check below is
625
+ // one, and a check that walked the tree itself would be answering about a
626
+ // different corpus from the one the passes then compile.
627
+ const corpusProblems = [];
628
+ const corpus = await buildCompileCorpus({
629
+ contentBase,
630
+ skipDirectories: config.skipDirectories,
631
+ router,
632
+ config,
633
+ problems: corpusProblems,
634
+ });
635
+ for (const problem of corpusProblems) emitDiagnostic(problem);
636
+
402
637
  // A note whose `type:` no configured pack claims compiles into nothing, and
403
638
  // used to say nothing (#146) — no pass got far enough to reject it, so the
404
639
  // silence had no owner. Asked once, of the whole configuration, because
@@ -406,23 +641,77 @@ export async function generatePacksJson({ only, config = loadPackConfig() } = {}
406
641
  // every type a system deliberately does not map, which is exactly the
407
642
  // silence #79 requires. Independent of `only`, since it is a fact about the
408
643
  // configured pack list rather than about which passes this run executes.
409
- const unclaimed = unclaimedNoteFindings(config);
644
+ const unclaimed = unclaimedNoteFindings(config, undefined, { records: corpus.records });
410
645
  for (const finding of unclaimed) emitDiagnostic(finding);
411
646
 
412
647
  fs.mkdirSync(config.paths.packJson, { recursive: true });
413
648
 
414
649
  // A companion pack has no pass of its own — naming it selects the pass that
415
650
  // writes it, so `compile adventures` is not a silent no-op.
651
+ //
652
+ // A **prebuilt** pack has no pass either, and for a plainer reason: its
653
+ // per-document JSON is checked in. Passed over rather than compiled — which
654
+ // it could not be before #259, since the only prebuilt pack in the wild
655
+ // holds Adventures and no compiler was registered for that document type,
656
+ // so the pack failed the build with "no compiler for document type". Now
657
+ // one is registered, and running it would wipe `build/packs-json/<name>/`
658
+ // and write nothing into it — then report the empty pass as an error.
416
659
  const packs = config.packs.filter(
417
660
  (pack) =>
418
- !only ||
419
- pack.name === only ||
420
- pack.companions.some((companion) => companion.name === only),
661
+ !pack.prebuilt &&
662
+ (!only ||
663
+ pack.name === only ||
664
+ pack.companions.some((companion) => companion.name === only)),
421
665
  );
422
- // One router per configuration, so every pass agrees about where a note
423
- // goes, and the first pack of each document type owns the error message for
424
- // a note of that type that goes nowhere.
425
- const router = routerFor(config);
666
+ // Built once for the whole build, not once per pack: a folder note is one
667
+ // definition with one address, and every pass resolves against the same
668
+ // index. A dangling `parent` or a parent cycle is therefore reported once,
669
+ // as a fact about the tree, rather than once per pass that happened to walk
670
+ // it (#256).
671
+ let folderNotes;
672
+ try {
673
+ folderNotes = buildFolderNoteIndex(
674
+ collectFolderNotes(
675
+ // The corpus this compile derived picks the notes (#243); the
676
+ // file supplies their frontmatter, and this is one of the few
677
+ // places where that distinction is load-bearing rather than
678
+ // incidental.
679
+ //
680
+ // `collectFolderNotes` treats `fm.id` as an **authored pin**,
681
+ // which wins over the id it derives under the folder namespace.
682
+ // A record's `id` is not that: the index fills it in for every
683
+ // addressable note (#270), so handing records straight over
684
+ // would make every folder look pinned and file each one under a
685
+ // different id than the packs address it by. The index cannot
686
+ // tell a pin from a derivation, so the note is read — and only
687
+ // folder notes are, 79 of `sohl`'s 1,685 rather than all of
688
+ // them.
689
+ corpus.records
690
+ .filter(
691
+ (record) =>
692
+ isNoteRecord(record) &&
693
+ String(record.type ?? "").toLowerCase() === FOLDER_TYPE,
694
+ )
695
+ .map((record) => {
696
+ const absPath = noteFile(contentBase, record);
697
+ return { frontmatter: parseMarkdownFile(absPath).frontmatter, absPath };
698
+ }),
699
+ // The package this build resolved, not the ambient accessor:
700
+ // they are the same value in a real repository and different
701
+ // ones under `PACKAGE_BUILD_CONFIG`, in a worktree, or in a
702
+ // test (#243).
703
+ config.contentPackage,
704
+ ),
705
+ );
706
+ } catch (err) {
707
+ emitDiagnostic({
708
+ file: err.absPath ?? contentBase,
709
+ severity: "error",
710
+ message: err.message,
711
+ });
712
+ return 1;
713
+ }
714
+
426
715
  const firstOfType = new Map();
427
716
  for (const pack of config.packs) {
428
717
  if (!firstOfType.has(pack.type)) firstOfType.set(pack.type, pack.name);
@@ -450,7 +739,7 @@ export async function generatePacksJson({ only, config = loadPackConfig() } = {}
450
739
  return unsatisfied.length + unclaimed.length;
451
740
  }
452
741
 
453
- let totalErrors = unclaimed.length;
742
+ let totalErrors = unclaimed.length + corpusProblems.length;
454
743
  const passes = [];
455
744
  for (const pack of ordered) {
456
745
  const { errors, compiled } = await generatePack(
@@ -458,6 +747,8 @@ export async function generatePacksJson({ only, config = loadPackConfig() } = {}
458
747
  config,
459
748
  router,
460
749
  firstOfType.get(pack.type) === pack.name,
750
+ folderNotes,
751
+ corpus,
461
752
  );
462
753
  totalErrors += errors;
463
754
  passes.push({