@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
@@ -47,6 +47,15 @@ import { extractPack } from "@foundryvtt/foundryvtt-cli";
47
47
 
48
48
  import log from "loglevel";
49
49
 
50
+ import {
51
+ metadataRelationships,
52
+ metadataCacheDir,
53
+ metadataFileName,
54
+ newestVersionDir,
55
+ isComplete as metadataIsComplete,
56
+ markComplete as markMetadataComplete,
57
+ } from "./metadata-index.mjs";
58
+
50
59
  /** Written once a fetch completes, so a half-finished cache is never used. */
51
60
  const STAMP = ".complete";
52
61
 
@@ -426,6 +435,185 @@ export async function fetchCatalogFromPath(config, rel, source) {
426
435
  }
427
436
  }
428
437
 
438
+ /**
439
+ * Fetch one dependency's published content index (#239).
440
+ *
441
+ * **The chain is entirely declared.** The relationship names the dependency's
442
+ * manifest, the manifest advertises `flags.metadataUrl`, and that URL is the
443
+ * index — so nothing here holds an address of its own, and a dependency that
444
+ * moves its release assets does not break its consumers.
445
+ *
446
+ * Pinned by the same rule as the catalogue: `compatibility.verified` is the
447
+ * version this repository was built against, so a floating `releases/latest`
448
+ * URL is rewritten to it. A consumer resolving addresses against whatever the
449
+ * dependency published this morning is not reproducible.
450
+ *
451
+ * Idempotent: a complete cache for the resolved version is left alone.
452
+ *
453
+ * @param {object} config - The resolved build configuration.
454
+ * @param {{id: string, manifest: string, verified?: string}} rel - The declared
455
+ * relationship.
456
+ * @returns {Promise<string>} The cached index file.
457
+ */
458
+ export async function fetchMetadata(config, rel) {
459
+ const { url, pinned } = pinnedManifestUrl(rel.manifest, rel.verified);
460
+ const manifest = await fetchManifest(url);
461
+ const version = manifest.version;
462
+ if (!version) {
463
+ throw new Error(`${rel.id}: its manifest declares no \`version\``);
464
+ }
465
+ if (!pinned && rel.verified && version !== rel.verified) {
466
+ throw new Error(
467
+ `${rel.id}: declares \`compatibility.verified: ${rel.verified}\` but ` +
468
+ `${url} offers ${version}. Building against a moving target is ` +
469
+ `not reproducible — update \`verified\`, or point \`manifest\` ` +
470
+ `at a pinned release.`,
471
+ );
472
+ }
473
+
474
+ const dir = metadataCacheDir(config, rel.id, version);
475
+ const indexUrl = manifest.flags?.metadataUrl;
476
+ if (!indexUrl) {
477
+ throw new Error(
478
+ `${rel.id}@${version}: its manifest advertises no ` +
479
+ `\`flags.metadataUrl\`, so it publishes no content index and ` +
480
+ `nothing can link into it. It needs a release built with ` +
481
+ `package-build 18 or later.`,
482
+ );
483
+ }
484
+
485
+ if (metadataIsComplete(dir)) {
486
+ log.info(`${rel.id}@${version}: index already cached`);
487
+ return path.join(dir, path.basename(new URL(indexUrl).pathname));
488
+ }
489
+
490
+ // Rebuild from empty: a previous run may have died partway, and a stale
491
+ // half-written index is worse than none.
492
+ fs.rmSync(dir, { recursive: true, force: true });
493
+ fs.mkdirSync(dir, { recursive: true });
494
+
495
+ log.info(`${rel.id}@${version}: downloading ${indexUrl}`);
496
+ const res = await fetch(indexUrl, { redirect: "follow" });
497
+ if (!res.ok) {
498
+ throw new Error(
499
+ `${rel.id}@${version}: could not download its content index at ` +
500
+ `${indexUrl}: HTTP ${res.status} ${res.statusText}`,
501
+ );
502
+ }
503
+ const file = path.join(dir, path.basename(new URL(indexUrl).pathname));
504
+ fs.writeFileSync(file, Buffer.from(await res.arrayBuffer()));
505
+ markMetadataComplete(dir);
506
+ return file;
507
+ }
508
+
509
+ /**
510
+ * Fill the index cache from a locally built artifact rather than a release.
511
+ *
512
+ * The counterpart of {@link fetchCatalogFromPath}, and the same escape hatch
513
+ * for the same reason: two packages being changed together cannot each wait for
514
+ * the other to ship. The index is looked for beside the manifest — which is
515
+ * where a build leaves it and where the release publishes it — so a package
516
+ * directory and an unpacked zip are both usable as-is.
517
+ *
518
+ * @param {object} config - The resolved build configuration.
519
+ * @param {{id: string}} rel - The declared relationship.
520
+ * @param {string} source - Path to the artifact or its directory.
521
+ * @returns {Promise<string>} The cached index file.
522
+ */
523
+ export async function fetchMetadataFromPath(config, rel, source) {
524
+ if (!fs.existsSync(source)) {
525
+ throw new Error(`${rel.id}: nothing at ${source}`);
526
+ }
527
+
528
+ const staging = fs.mkdtempSync(path.join(os.tmpdir(), `content-build-meta-${rel.id}-`));
529
+ try {
530
+ let root = source;
531
+ if (!fs.statSync(source).isDirectory()) {
532
+ writeZipEntries(unzipSync(new Uint8Array(fs.readFileSync(source))), staging);
533
+ root = staging;
534
+ }
535
+
536
+ const manifest = readLocalManifest(root);
537
+ if (!manifest) {
538
+ throw new Error(
539
+ `${rel.id}: ${source} holds no system.json or module.json, so ` +
540
+ `its version cannot be read`,
541
+ );
542
+ }
543
+ if (manifest.id && manifest.id !== rel.id) {
544
+ throw new Error(`${rel.id}: ${source} is package "${manifest.id}", not "${rel.id}"`);
545
+ }
546
+ const version = manifest.version;
547
+ if (!version) {
548
+ throw new Error(`${rel.id}: ${source} declares no \`version\``);
549
+ }
550
+
551
+ // Named by the manifest where it advertises one, so a local artifact
552
+ // and a released one are cached under the same name; falling back to
553
+ // the id covers a build whose manifest predates the flag.
554
+ const name =
555
+ manifest.flags?.metadataUrl ?
556
+ path.basename(new URL(manifest.flags.metadataUrl).pathname)
557
+ : metadataFileName(rel.id);
558
+ const found = findLocalIndex(root, name);
559
+ if (!found) {
560
+ throw new Error(
561
+ `${rel.id}: ${source} holds no ${name}, so it publishes no ` +
562
+ `content index. Build it before fetching from it.`,
563
+ );
564
+ }
565
+
566
+ const dir = metadataCacheDir(config, rel.id, version);
567
+ fs.rmSync(dir, { recursive: true, force: true });
568
+ fs.mkdirSync(dir, { recursive: true });
569
+ const file = path.join(dir, name);
570
+ fs.copyFileSync(found, file);
571
+ markMetadataComplete(dir);
572
+ log.info(`${rel.id}@${version}: index cached from ${source}`);
573
+ return file;
574
+ } finally {
575
+ fs.rmSync(staging, { recursive: true, force: true });
576
+ }
577
+ }
578
+
579
+ /**
580
+ * Locate an index file in an unpacked artifact.
581
+ *
582
+ * Foundry archives are inconsistent about whether they nest their contents
583
+ * under a top-level directory, so try the root and then one level in — the same
584
+ * allowance {@link resolvePackPath} makes for packs.
585
+ *
586
+ * @param {string} root - The unpacked package root.
587
+ * @param {string} name - The index file name.
588
+ * @returns {string|null} The path, or null when absent.
589
+ */
590
+ function findLocalIndex(root, name) {
591
+ const direct = path.join(root, name);
592
+ if (fs.existsSync(direct)) return direct;
593
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
594
+ if (!entry.isDirectory()) continue;
595
+ const nested = path.join(root, entry.name, name);
596
+ if (fs.existsSync(nested)) return nested;
597
+ }
598
+ return null;
599
+ }
600
+
601
+ /**
602
+ * Fetch every declared dependency's content index.
603
+ *
604
+ * A wider set than {@link fetchAllCatalogs}: an index is fetched for *every*
605
+ * dependency, a catalogue only for those declaring `itemCatalog: true`. See
606
+ * {@link metadataRelationships} for why the two sets differ.
607
+ *
608
+ * @param {object} config - The resolved build configuration.
609
+ * @returns {Promise<number>} How many indexes were fetched.
610
+ */
611
+ export async function fetchAllMetadata(config) {
612
+ const rels = metadataRelationships(config);
613
+ for (const rel of rels) await fetchMetadata(config, rel);
614
+ return rels.length;
615
+ }
616
+
429
617
  /**
430
618
  * Fetch every opted-in dependency. The `deps fetch` command.
431
619
  *
@@ -470,10 +658,13 @@ export function foreignItemCatalogDirs(config) {
470
658
  `fetched. Run \`content-build deps fetch\` first.`,
471
659
  );
472
660
  }
473
- // Newest last wins if several versions are cached; a fetch always
474
- // writes the currently declared one, so that is the one to use.
475
- cached.sort();
476
- const items = itemsDir(cached[cached.length - 1]);
661
+ // Newest wins if several versions are cached; a fetch always writes
662
+ // the currently declared one, so that is the one to use. The
663
+ // comparison is the content-index cache's, shared rather than
664
+ // rewritten: a plain string sort would put `0.8.10` before `0.8.2` and
665
+ // silently resolve every embedded item against the older catalogue
666
+ // (#272).
667
+ const items = itemsDir(newestVersionDir(cached));
477
668
  for (const name of fs.readdirSync(items)) {
478
669
  dirs.push(path.join(items, name));
479
670
  }
@@ -14,7 +14,7 @@
14
14
  /**
15
15
  * Emitting this package's cross-package link manifest (#58).
16
16
  *
17
- * `engine/kb-manifest.mjs` owns the *format* — what an entry may say, how a
17
+ * `engine/content-address.mjs` owns the address *grammar* — how a key is
18
18
  * version is read, how a foreign file resolves. This module owns the *pass*:
19
19
  * walking a content tree and deriving, for every note it publishes, the
20
20
  * addresses that entry states. The two halves were split across the format
@@ -30,9 +30,12 @@
30
30
  * a fact it has to be told (#1465).
31
31
  *
32
32
  * **An entry's `path` is derivable from the key it is filed under** (#181).
33
- * `sohl-affliction-aconite` publishes at `affliction-aconite/`, because a page's
34
- * URL *is* its address; nothing in it comes from a display name, so a rename
35
- * moves no URL and no uniqueness check stands between the two. Every entry is
33
+ * `sohl-sohl-affliction-aconite` publishes at `affliction-aconite/` the key
34
+ * with its package and system segments dropped because a page's URL *is* its
35
+ * address; nothing in it comes from a display name, so a rename moves no URL and
36
+ * no uniqueness check stands between the two. The system segment goes with the
37
+ * package because a note publishes one page however many systems' documents it
38
+ * compiles into (#59). Every entry is
36
39
  * derivable that way since #204 retired the section landing, which was the one
37
40
  * that was not. The field is still written rather than left for a consumer to
38
41
  * compute, because an absent `path` already means something else entirely (a
@@ -55,10 +58,16 @@
55
58
  import fs from "node:fs";
56
59
  import path from "node:path";
57
60
 
58
- import { packageAddress } from "./content-address.mjs";
59
- import { canonicalKey, writeManifests } from "./kb-manifest.mjs";
61
+ import { canonicalKey, packageAddress } from "./content-address.mjs";
62
+ import { NO_SYSTEM, systemOf } from "./document-subtypes.mjs";
63
+ import {
64
+ KNOWN_DOCUMENT_SUBTYPE_MAPS,
65
+ NEVER_PACKED_TYPES,
66
+ DERIVED_PACKED_TYPES,
67
+ } from "./note-claims.mjs";
60
68
  import { walkMarkdownTree } from "./helpers.mjs";
61
- import { compendiumUuid, packForType, pageUuid } from "./ids.mjs";
69
+ import { resolveNoteId } from "./note-ids.mjs";
70
+ import { compendiumUuid, currentType, packForType, pageUuid } from "./ids.mjs";
62
71
  import { hasDocEntry, itemDocEntryId } from "./item-docs.mjs";
63
72
  import { isHomepage } from "./homepage.mjs";
64
73
  import { assertNoDeclaredPackage } from "./note-package.mjs";
@@ -66,6 +75,7 @@ import {
66
75
  assertNoAliasesField,
67
76
  assertNoDraftField,
68
77
  assertNoSectionField,
78
+ assertNoTraitsField,
69
79
  } from "./retired-fields.mjs";
70
80
  import { journalPageId, splitPages } from "./journals.mjs";
71
81
  import { routerFor } from "./pack-router.mjs";
@@ -100,7 +110,7 @@ export const LEAD_ANCHOR = "$lead";
100
110
  export function anchorsOf(entryUuid, entryId, body, name) {
101
111
  const anchors = {};
102
112
  splitPages(body, name).forEach((page, index) => {
103
- const uuid = pageUuid(entryUuid, journalPageId(entryId, page, index));
113
+ const uuid = pageUuid(entryUuid, journalPageId(entryId, page));
104
114
  if (index === 0) anchors[LEAD_ANCHOR] = uuid;
105
115
  if (page.anchorSlug) anchors[page.anchorSlug] = uuid;
106
116
  });
@@ -128,7 +138,12 @@ export function anchorsOf(entryUuid, entryId, body, name) {
128
138
  */
129
139
  export function entriesForNote(fm, name, address, body, ctx) {
130
140
  const { contentPackage, foundryPackageId, packRouter } = ctx;
131
- const key = canonicalKey(contentPackage, fm.type, fm.shortcode);
141
+ const key = canonicalKey(
142
+ contentPackage,
143
+ systemOf(fm.type, KNOWN_DOCUMENT_SUBTYPE_MAPS),
144
+ fm.type,
145
+ fm.shortcode,
146
+ );
132
147
  // `buildManifest` records `packageRelative(url, base)`, so the pair it is
133
148
  // given has to round-trip. The address is already package-relative, so the
134
149
  // honest pair is the address under a base of `"/"` — which strips straight
@@ -141,7 +156,23 @@ export function entriesForNote(fm, name, address, body, ctx) {
141
156
  // a consumer resolves the UUID verbatim, and a repository may ship several
142
157
  // packs of one type (#1566).
143
158
  const uuidFor = (type, id, routeFm) =>
144
- id ?
159
+ // A type this cannot name a single compendium document for has no UUID
160
+ // to publish, whatever id it derives. That used to follow from such a
161
+ // note authoring no `id:`; since #270 every addressable note derives
162
+ // one, so "has an id" stopped being evidence a document exists and the
163
+ // rule is stated where it belongs — beside the addresses — rather than
164
+ // resting on an absent field. `collectFoundryEntries` skips such a note
165
+ // outright; the content index calls this function directly, so the
166
+ // guard has to live on this side of it.
167
+ //
168
+ // Two sets, for opposite reasons (see `note-claims.mjs`). A **homepage**
169
+ // is in no pack: it compiles to a page and there is nothing to address.
170
+ // A **folder** may be in several — it materialises in every pack holding
171
+ // a document that references it (#276) — so no one UUID identifies it,
172
+ // and its id is hashed under the `folder` namespace against its own
173
+ // address rather than under `document`. Emitting one would publish an
174
+ // `Item` UUID for a `Folder`, at an id no document carries.
175
+ id && !NEVER_PACKED_TYPES.has(String(type)) && !DERIVED_PACKED_TYPES.has(String(type)) ?
145
176
  compendiumUuid(
146
177
  foundryPackageId,
147
178
  type,
@@ -152,8 +183,15 @@ export function entriesForNote(fm, name, address, body, ctx) {
152
183
  )
153
184
  : undefined;
154
185
 
155
- if (hasDocEntry(fm.type)) {
156
- const docKey = canonicalKey(contentPackage, `doc${fm.type}`, fm.shortcode);
186
+ const carriesDoc =
187
+ ctx.docEntryTypes ?
188
+ ctx.docEntryTypes.has(String(currentType(fm.type)))
189
+ : hasDocEntry(fm.type);
190
+ if (carriesDoc) {
191
+ // `NO_SYSTEM`, whatever the item is: a documentation journal is a
192
+ // JournalEntry, which no game system defines, and there is one of them
193
+ // however many system blocks the note carries.
194
+ const docKey = canonicalKey(contentPackage, NO_SYSTEM, `doc${fm.type}`, fm.shortcode);
157
195
  const docEntryId = fm.id ? itemDocEntryId(fm.id) : undefined;
158
196
  const docUuid = uuidFor("doc", docEntryId);
159
197
  return [
@@ -214,7 +252,7 @@ export function entriesForNote(fm, name, address, body, ctx) {
214
252
  * @returns {{entries: Array<object>, notes: number,
215
253
  * skipped: Array<{file: string, reason: string}>}}
216
254
  */
217
- export function collectManifestEntries(contentBase, ctx) {
255
+ export function collectFoundryEntries(contentBase, ctx) {
218
256
  const entries = [];
219
257
  const skipped = [];
220
258
  // Counted separately because they are genuinely different numbers: an item
@@ -225,6 +263,10 @@ export function collectManifestEntries(contentBase, ctx) {
225
263
  skipDirectories: ctx.skipDirectories,
226
264
  })) {
227
265
  if (!fm) continue;
266
+ // Its authored pin, or the id derived from its canonical address
267
+ // (#270). Resolved before anything reads `fm.id`, so the UUID this
268
+ // pass publishes is the one the pack passes compiled under.
269
+ resolveNoteId(fm, { pkg: ctx.contentPackage });
228
270
  const rel = path.relative(contentBase, absPath);
229
271
  assertNoDeclaredPackage(fm, {
230
272
  file: rel,
@@ -234,6 +276,7 @@ export function collectManifestEntries(contentBase, ctx) {
234
276
  assertNoDraftField(fm, { file: rel, absPath });
235
277
  assertNoAliasesField(fm, { file: rel, absPath });
236
278
  assertNoSectionField(fm, { file: rel, absPath });
279
+ assertNoTraitsField(fm, { file: rel, absPath });
237
280
  if (!fm.type || !fm.shortcode) continue;
238
281
  // A homepage is addressed like every other note since #182, and a
239
282
  // shortcode alone would now put it here. It stays out for the reason it
@@ -270,83 +313,37 @@ export function collectManifestEntries(contentBase, ctx) {
270
313
  * @returns {{contentPackage: string, foundryPackageId: string, packRouter: object,
271
314
  * web: boolean, skipDirectories: readonly string[]}}
272
315
  */
273
- export function manifestContext(config = loadPackConfig()) {
316
+ export function foundryIdentities(config = loadPackConfig()) {
274
317
  return {
275
318
  contentPackage: config.contentPackage,
276
319
  foundryPackageId: config.foundryPackage,
277
320
  packRouter: routerFor(config),
278
- web: publishesContentPages(config),
279
- // The walk's own configuration, threaded through rather than left to
280
- // its default, so a caller that passes a config drives every read.
281
- skipDirectories: config.skipDirectories,
321
+ // Carried in the context rather than read from the global config at the
322
+ // call site, so the pass really is a pure function of what it is handed
323
+ // which is what lets the content index drive the same derivation with
324
+ // a configuration it resolved itself (#239).
325
+ docEntryTypes: config.docEntryTypes,
282
326
  };
283
327
  }
284
328
 
285
329
  /**
286
- * Emits this package's link manifest.
330
+ * The identities an emission runs against, from configuration.
287
331
  *
288
- * One package, because a configuration declares exactly one `contentPackage`
289
- * and nothing in the surface can express a second. {@link writeManifests} keeps
290
- * its package→entries map it is the general writer but there is no setting
291
- * here to choose with.
332
+ * {@link foundryIdentities} plus what only a *manifest* emission needs. The
333
+ * split is what lets the content index derive the same Foundry addresses from
334
+ * the same code without also depending on whether the package publishes pages,
335
+ * which is no part of a UUID (#239).
292
336
  *
293
- * @param {object} [options] - Options.
294
- * @param {string} [options.contentBase] - The content tree; defaults to the
295
- * configured `paths.content`.
296
- * @param {string} [options.outDir] - Where to write; defaults to the configured
297
- * `paths.manifestOut`.
298
- * @param {object} [options.config] - A resolved configuration; loaded when
299
- * omitted.
300
- * @returns {{written: Array<{package: string, file: string, count: number}>,
301
- * entries: number, notes: number,
302
- * skipped: Array<{file: string, reason: string}>}}
303
- * @throws {Error} When the repository does not declare that it publishes a
304
- * manifest, when the tree is absent, or when it yields no published note — a
305
- * manifest claiming this package publishes nothing is worse than none, since
306
- * a consumer reads it as authoritative and turns every link into this package
307
- * into a reported typo.
337
+ * @param {object} [config] - A resolved configuration; loaded when omitted.
338
+ * @returns {{contentPackage: string, foundryPackageId: string, packRouter: object,
339
+ * web: boolean, skipDirectories: readonly string[]}}
308
340
  */
309
- export function emitLinkManifest({ contentBase, outDir, config } = {}) {
310
- const resolved = config ?? loadPackConfig();
311
- const tree = contentBase ?? resolved.paths.content;
312
- const dir = outDir ?? resolved.paths.manifestOut;
313
- const ctx = manifestContext(resolved);
314
-
315
- // A repository that has not declared it publishes a manifest must not
316
- // produce one: the file is vendored by consumers and read as authoritative,
317
- // so emitting it is a statement about this package rather than a local
318
- // convenience. Checked here rather than in the command, so a library caller
319
- // cannot route around the declaration.
320
- if (!resolved.publish.manifests.publish) {
321
- throw new Error(
322
- `this repository does not publish a link manifest — set ` +
323
- `\`publish.manifests.publish: true\` in its content-build ` +
324
- `configuration to change that`,
325
- );
326
- }
327
-
328
- if (!fs.existsSync(tree)) {
329
- throw new Error(`no content tree at ${tree}`);
330
- }
331
-
332
- const { entries, notes, skipped } = collectManifestEntries(tree, ctx);
333
- if (entries.length === 0) {
334
- throw new Error(
335
- `${tree} yielded no published notes, so the manifest would ` +
336
- `claim this package publishes nothing`,
337
- );
338
- }
339
-
340
- const written = writeManifests(
341
- new Map([[ctx.contentPackage, entries]]),
342
- dir,
343
- // The one surviving role of a base: `undefined` is the statement "this
344
- // build publishes no pages", and no entry then carries a `path`
345
- // (#1516). The value itself cancels — every address above is already
346
- // package-relative — so it is a sentinel, not a location.
347
- ctx.web ? { [ctx.contentPackage]: "/" } : undefined,
348
- { [ctx.contentPackage]: ctx.foundryPackageId },
349
- );
350
-
351
- return { written, entries: entries.length, notes, skipped };
341
+ export function entryContext(config = loadPackConfig()) {
342
+ return {
343
+ ...foundryIdentities(config),
344
+ web: publishesContentPages(config),
345
+ // The walk's own configuration, threaded through rather than left to
346
+ // its default, so a caller that passes a config drives every read.
347
+ skipDirectories: config.skipDirectories,
348
+ };
352
349
  }