@heroiclands/package-build 17.2.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 (152) hide show
  1. package/CHANGELOG.md +1711 -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 +225 -127
  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} +55 -83
  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/manifest.mjs +43 -2
  74. package/package.json +18 -4
  75. package/release.mjs +62 -7
  76. package/sohl/actors.mjs +33 -487
  77. package/sohl/being-info.mjs +16 -7
  78. package/sohl/default-item-art.mjs +14 -3
  79. package/sohl/document-subtypes.mjs +16 -10
  80. package/sohl/item-builders.mjs +14 -5
  81. package/sohl/item-fields.mjs +68 -7
  82. package/sohl/items.mjs +44 -258
  83. package/sohl/note-schemas.mjs +2 -2
  84. package/types/content-config.d.mts +21 -42
  85. package/types/engine/actor-compiler.d.mts +204 -0
  86. package/types/engine/address-charset.d.mts +11 -8
  87. package/types/engine/address-diff.d.mts +53 -5
  88. package/types/engine/anchored-sections.d.mts +21 -0
  89. package/types/engine/anchors.d.mts +20 -0
  90. package/types/engine/base-compiler.d.mts +17 -17
  91. package/types/engine/bundle-notes.d.mts +173 -0
  92. package/types/engine/bundles.d.mts +60 -0
  93. package/types/engine/code-fences.d.mts +43 -0
  94. package/types/engine/compile-corpus.d.mts +32 -0
  95. package/types/engine/content-address.d.mts +205 -5
  96. package/types/engine/content-format-check.d.mts +6 -2
  97. package/types/engine/content-format.d.mts +57 -1
  98. package/types/engine/content-index.d.mts +54 -49
  99. package/types/engine/content-links.d.mts +52 -7
  100. package/types/engine/content-lint.d.mts +10 -1
  101. package/types/engine/content-package.d.mts +2 -1
  102. package/types/engine/content-tables.d.mts +21 -39
  103. package/types/engine/document-subtypes.d.mts +37 -3
  104. package/types/engine/field-spec.d.mts +76 -5
  105. package/types/engine/folder-notes.d.mts +159 -0
  106. package/types/engine/foreign-catalog.d.mts +53 -0
  107. package/types/engine/{manifest-emit.d.mts → foundry-entries.d.mts} +2 -43
  108. package/types/engine/frontmatter-lint.d.mts +10 -2
  109. package/types/engine/frontmatter.d.mts +64 -0
  110. package/types/engine/generate.d.mts +38 -0
  111. package/types/engine/helpers.d.mts +94 -30
  112. package/types/engine/ids.d.mts +96 -0
  113. package/types/engine/index-records.d.mts +68 -0
  114. package/types/engine/index.d.mts +9 -3
  115. package/types/engine/item-compiler.d.mts +131 -0
  116. package/types/engine/journals.d.mts +47 -9
  117. package/types/engine/metadata-index.d.mts +226 -0
  118. package/types/engine/note-claims.d.mts +57 -11
  119. package/types/engine/note-ids.d.mts +38 -0
  120. package/types/engine/note-renames.d.mts +102 -0
  121. package/types/engine/note-vocabulary.d.mts +44 -8
  122. package/types/engine/retired-fields.d.mts +75 -0
  123. package/types/engine/scenes.d.mts +3 -2
  124. package/types/engine/schema-check.d.mts +25 -4
  125. package/types/engine/site-build.d.mts +4 -4
  126. package/types/engine/site-index.d.mts +1 -1
  127. package/types/engine/sql-tables.d.mts +185 -0
  128. package/types/engine/subtype-registry.d.mts +49 -0
  129. package/types/engine/system-block.d.mts +40 -1
  130. package/types/engine/systems.d.mts +106 -0
  131. package/types/engine/web-wikilinks.d.mts +4 -2
  132. package/types/engine/wikilink-syntax.d.mts +10 -3
  133. package/types/engine/wikilinks.d.mts +41 -13
  134. package/types/engine/yaml-lint.d.mts +107 -0
  135. package/types/hm3/actors.d.mts +48 -0
  136. package/types/hm3/default-item-art.d.mts +42 -0
  137. package/types/hm3/document-subtypes.d.mts +24 -0
  138. package/types/hm3/index.d.mts +7 -0
  139. package/types/hm3/item-builders.d.mts +11 -0
  140. package/types/hm3/item-fields.d.mts +12 -0
  141. package/types/hm3/items.d.mts +23 -0
  142. package/types/hm3/template-priority.d.mts +21 -0
  143. package/types/manifest.d.mts +22 -1
  144. package/types/release.d.mts +9 -4
  145. package/types/sohl/actors.d.mts +7 -74
  146. package/types/sohl/being-info.d.mts +8 -27
  147. package/types/sohl/default-item-art.d.mts +5 -3
  148. package/types/sohl/items.d.mts +17 -32
  149. package/engine/foreign-manifests.mjs +0 -126
  150. package/engine/kb-manifest.mjs +0 -490
  151. package/types/engine/foreign-manifests.d.mts +0 -43
  152. 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,
@@ -153,9 +184,14 @@ export function entriesForNote(fm, name, address, body, ctx) {
153
184
  : undefined;
154
185
 
155
186
  const carriesDoc =
156
- ctx.docEntryTypes ? ctx.docEntryTypes.has(String(fm.type)) : hasDocEntry(fm.type);
187
+ ctx.docEntryTypes ?
188
+ ctx.docEntryTypes.has(String(currentType(fm.type)))
189
+ : hasDocEntry(fm.type);
157
190
  if (carriesDoc) {
158
- const docKey = canonicalKey(contentPackage, `doc${fm.type}`, fm.shortcode);
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);
159
195
  const docEntryId = fm.id ? itemDocEntryId(fm.id) : undefined;
160
196
  const docUuid = uuidFor("doc", docEntryId);
161
197
  return [
@@ -216,7 +252,7 @@ export function entriesForNote(fm, name, address, body, ctx) {
216
252
  * @returns {{entries: Array<object>, notes: number,
217
253
  * skipped: Array<{file: string, reason: string}>}}
218
254
  */
219
- export function collectManifestEntries(contentBase, ctx) {
255
+ export function collectFoundryEntries(contentBase, ctx) {
220
256
  const entries = [];
221
257
  const skipped = [];
222
258
  // Counted separately because they are genuinely different numbers: an item
@@ -227,6 +263,10 @@ export function collectManifestEntries(contentBase, ctx) {
227
263
  skipDirectories: ctx.skipDirectories,
228
264
  })) {
229
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 });
230
270
  const rel = path.relative(contentBase, absPath);
231
271
  assertNoDeclaredPackage(fm, {
232
272
  file: rel,
@@ -236,6 +276,7 @@ export function collectManifestEntries(contentBase, ctx) {
236
276
  assertNoDraftField(fm, { file: rel, absPath });
237
277
  assertNoAliasesField(fm, { file: rel, absPath });
238
278
  assertNoSectionField(fm, { file: rel, absPath });
279
+ assertNoTraitsField(fm, { file: rel, absPath });
239
280
  if (!fm.type || !fm.shortcode) continue;
240
281
  // A homepage is addressed like every other note since #182, and a
241
282
  // shortcode alone would now put it here. It stays out for the reason it
@@ -297,7 +338,7 @@ export function foundryIdentities(config = loadPackConfig()) {
297
338
  * @returns {{contentPackage: string, foundryPackageId: string, packRouter: object,
298
339
  * web: boolean, skipDirectories: readonly string[]}}
299
340
  */
300
- export function manifestContext(config = loadPackConfig()) {
341
+ export function entryContext(config = loadPackConfig()) {
301
342
  return {
302
343
  ...foundryIdentities(config),
303
344
  web: publishesContentPages(config),
@@ -306,72 +347,3 @@ export function manifestContext(config = loadPackConfig()) {
306
347
  skipDirectories: config.skipDirectories,
307
348
  };
308
349
  }
309
-
310
- /**
311
- * Emits this package's link manifest.
312
- *
313
- * One package, because a configuration declares exactly one `contentPackage`
314
- * and nothing in the surface can express a second. {@link writeManifests} keeps
315
- * its package→entries map — it is the general writer — but there is no setting
316
- * here to choose with.
317
- *
318
- * @param {object} [options] - Options.
319
- * @param {string} [options.contentBase] - The content tree; defaults to the
320
- * configured `paths.content`.
321
- * @param {string} [options.outDir] - Where to write; defaults to the configured
322
- * `paths.manifestOut`.
323
- * @param {object} [options.config] - A resolved configuration; loaded when
324
- * omitted.
325
- * @returns {{written: Array<{package: string, file: string, count: number}>,
326
- * entries: number, notes: number,
327
- * skipped: Array<{file: string, reason: string}>}}
328
- * @throws {Error} When the repository does not declare that it publishes a
329
- * manifest, when the tree is absent, or when it yields no published note — a
330
- * manifest claiming this package publishes nothing is worse than none, since
331
- * a consumer reads it as authoritative and turns every link into this package
332
- * into a reported typo.
333
- */
334
- export function emitLinkManifest({ contentBase, outDir, config } = {}) {
335
- const resolved = config ?? loadPackConfig();
336
- const tree = contentBase ?? resolved.paths.content;
337
- const dir = outDir ?? resolved.paths.manifestOut;
338
- const ctx = manifestContext(resolved);
339
-
340
- // A repository that has not declared it publishes a manifest must not
341
- // produce one: the file is vendored by consumers and read as authoritative,
342
- // so emitting it is a statement about this package rather than a local
343
- // convenience. Checked here rather than in the command, so a library caller
344
- // cannot route around the declaration.
345
- if (!resolved.publish.manifests.publish) {
346
- throw new Error(
347
- `this repository does not publish a link manifest — set ` +
348
- `\`publish.manifests.publish: true\` in its content-build ` +
349
- `configuration to change that`,
350
- );
351
- }
352
-
353
- if (!fs.existsSync(tree)) {
354
- throw new Error(`no content tree at ${tree}`);
355
- }
356
-
357
- const { entries, notes, skipped } = collectManifestEntries(tree, ctx);
358
- if (entries.length === 0) {
359
- throw new Error(
360
- `${tree} yielded no published notes, so the manifest would ` +
361
- `claim this package publishes nothing`,
362
- );
363
- }
364
-
365
- const written = writeManifests(
366
- new Map([[ctx.contentPackage, entries]]),
367
- dir,
368
- // The one surviving role of a base: `undefined` is the statement "this
369
- // build publishes no pages", and no entry then carries a `path`
370
- // (#1516). The value itself cancels — every address above is already
371
- // package-relative — so it is a sentinel, not a location.
372
- ctx.web ? { [ctx.contentPackage]: "/" } : undefined,
373
- { [ctx.contentPackage]: ctx.foundryPackageId },
374
- );
375
-
376
- return { written, entries: entries.length, notes, skipped };
377
- }