@heroiclands/package-build 21.0.0 → 21.2.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 (112) hide show
  1. package/CHANGELOG.md +228 -0
  2. package/CONTENT.md +109 -7
  3. package/assets/fonts/libertinus/LibertinusMono-Regular.otf +0 -0
  4. package/assets/fonts/libertinus/LibertinusSans-Bold.otf +0 -0
  5. package/assets/fonts/libertinus/LibertinusSans-Italic.otf +0 -0
  6. package/assets/fonts/libertinus/LibertinusSans-Regular.otf +0 -0
  7. package/assets/fonts/libertinus/OFL.txt +94 -0
  8. package/assets/fonts/libertinus/provenance.yaml +11 -0
  9. package/assets/images/banners/afflictionbnr.webp +0 -0
  10. package/assets/images/banners/armorclothingbnr.webp +0 -0
  11. package/assets/images/banners/attributebnr.webp +0 -0
  12. package/assets/images/banners/containerbnr.webp +0 -0
  13. package/assets/images/banners/devdocsbnr.webp +0 -0
  14. package/assets/images/banners/miscgearbnr.webp +0 -0
  15. package/assets/images/banners/mysticalabilitybnr.webp +0 -0
  16. package/assets/images/banners/projectilebnr.webp +0 -0
  17. package/assets/images/banners/rulesbnr.webp +0 -0
  18. package/assets/images/banners/skillbnr.webp +0 -0
  19. package/assets/images/banners/traumabnr.webp +0 -0
  20. package/assets/images/banners/userguidebnr.webp +0 -0
  21. package/assets/images/banners/weapongearbnr.webp +0 -0
  22. package/assets/images/provenance.yaml +4 -0
  23. package/bin/content-build.mjs +19 -5
  24. package/ci/emit-asset-index.mjs +32 -0
  25. package/content-config.mjs +30 -1
  26. package/docs/api.md +100 -26
  27. package/docs/configuration.md +16 -7
  28. package/docs/content-format.md +512 -109
  29. package/engine/actor-compiler.mjs +58 -9
  30. package/engine/art-fields.mjs +292 -0
  31. package/engine/asset-index.mjs +397 -0
  32. package/engine/asset-types.mjs +192 -0
  33. package/engine/base-compiler.mjs +69 -1
  34. package/engine/bundles.mjs +5 -5
  35. package/engine/compile-corpus.mjs +4 -0
  36. package/engine/content-address.mjs +14 -2
  37. package/engine/content-charset.mjs +5 -1
  38. package/engine/content-embeds.mjs +314 -0
  39. package/engine/content-html.mjs +5 -1
  40. package/engine/content-icons.mjs +5 -1
  41. package/engine/content-images.mjs +26 -5
  42. package/engine/content-index.mjs +68 -33
  43. package/engine/content-links.mjs +164 -24
  44. package/engine/frontmatter-lint.mjs +36 -23
  45. package/engine/helpers.mjs +83 -31
  46. package/engine/index-records.mjs +63 -4
  47. package/engine/index.mjs +15 -0
  48. package/engine/infobox-registry.mjs +50 -3
  49. package/engine/infobox-render.mjs +4 -3
  50. package/engine/infobox.mjs +12 -8
  51. package/engine/item-compiler.mjs +10 -10
  52. package/engine/journals.mjs +3 -0
  53. package/engine/macros.mjs +16 -13
  54. package/engine/map-notes.mjs +29 -22
  55. package/engine/metadata-index.mjs +44 -36
  56. package/engine/note-vocabulary.mjs +71 -29
  57. package/engine/pack-router.mjs +14 -0
  58. package/engine/packagebuild-index.mjs +124 -0
  59. package/engine/packages.mjs +83 -0
  60. package/engine/pathnames.mjs +74 -0
  61. package/engine/pdf-build.mjs +133 -10
  62. package/engine/pdf-render.mjs +41 -41
  63. package/engine/scenes.mjs +12 -9
  64. package/engine/site-build.mjs +43 -16
  65. package/engine/site-index.mjs +10 -1
  66. package/engine/subtype-registry.mjs +21 -0
  67. package/engine/web-wikilinks.mjs +46 -12
  68. package/engine/wikilink-syntax.mjs +20 -2
  69. package/engine/wikilinks.mjs +15 -2
  70. package/hm3/actors.mjs +11 -38
  71. package/hm3/infobox.mjs +1 -1
  72. package/package.json +5 -2
  73. package/packagebuild-metadata.jsonl +13 -0
  74. package/sohl/actors.mjs +12 -86
  75. package/sohl/infobox.mjs +1 -1
  76. package/sohl/note-schemas.mjs +4 -16
  77. package/types/content-config.d.mts +8 -0
  78. package/types/engine/actor-compiler.d.mts +29 -0
  79. package/types/engine/art-fields.d.mts +192 -0
  80. package/types/engine/asset-index.d.mts +92 -0
  81. package/types/engine/asset-types.d.mts +110 -0
  82. package/types/engine/base-compiler.d.mts +42 -1
  83. package/types/engine/compile-corpus.d.mts +4 -1
  84. package/types/engine/content-charset.d.mts +4 -0
  85. package/types/engine/content-embeds.d.mts +218 -0
  86. package/types/engine/content-html.d.mts +4 -0
  87. package/types/engine/content-icons.d.mts +4 -0
  88. package/types/engine/content-images.d.mts +15 -2
  89. package/types/engine/content-index.d.mts +27 -15
  90. package/types/engine/frontmatter-lint.d.mts +11 -14
  91. package/types/engine/helpers.d.mts +7 -2
  92. package/types/engine/index-records.d.mts +44 -3
  93. package/types/engine/index.d.mts +5 -0
  94. package/types/engine/infobox-registry.d.mts +36 -1
  95. package/types/engine/infobox.d.mts +6 -4
  96. package/types/engine/macros.d.mts +9 -5
  97. package/types/engine/map-notes.d.mts +10 -6
  98. package/types/engine/metadata-index.d.mts +2 -17
  99. package/types/engine/note-vocabulary.d.mts +30 -0
  100. package/types/engine/packagebuild-index.d.mts +56 -0
  101. package/types/engine/packages.d.mts +62 -0
  102. package/types/engine/pathnames.d.mts +41 -81
  103. package/types/engine/pdf-build.d.mts +58 -1
  104. package/types/engine/pdf-render.d.mts +3 -3
  105. package/types/engine/scenes.d.mts +2 -1
  106. package/types/engine/site-build.d.mts +5 -1
  107. package/types/engine/site-index.d.mts +4 -1
  108. package/types/engine/subtype-registry.d.mts +20 -0
  109. package/types/engine/web-wikilinks.d.mts +14 -4
  110. package/types/engine/wikilink-syntax.d.mts +13 -1
  111. package/types/engine/wikilinks.d.mts +7 -1
  112. package/types/sohl/actors.d.mts +0 -1
@@ -59,7 +59,6 @@ import { CURATED_REGION_EVENTS, EXCLUDED_REGION_EVENTS } from "./region-events.m
59
59
  // A map's background art is `img`, as every other note type's art is. `image`,
60
60
  // the spelling a map alone once used, is retired and gone.
61
61
  import { sohlField } from "./frontmatter.mjs";
62
- import { resolveImg } from "./helpers.mjs";
63
62
 
64
63
  /* -------------------------------------------------------------------- */
65
64
  /* Note types and their canvas profiles */
@@ -880,7 +879,8 @@ function buildRegion(key, spec, geom, ctx) {
880
879
  * `packageId`, `journalEntryId`, `journalPack` (the pack the note's derived
881
880
  * JournalEntry landed in), `pageIds` (heading key → page id),
882
881
  * `resolveRegionRef` / `resolveBehaviorRef` / `resolveEffectRef` (address →
883
- * UUID), `knownActions`, `warnings`, and optionally `folder` and `stats`.
882
+ * UUID), `art` (an art address the path each surface serves),
883
+ * `knownActions`, `warnings`, and optionally `folder` and `stats`.
884
884
  * @returns {object} The Scene document, keyed for the pack.
885
885
  * @throws {Error} On any authoring mistake Foundry would accept silently.
886
886
  */
@@ -905,11 +905,11 @@ export function buildScene(fm, ctx) {
905
905
  "match the art",
906
906
  );
907
907
  }
908
- // Read from the note rather than from its `sohl:` block: art is not
909
- // system-specific, so `img` is authored at the top level like every other
910
- // type's, and `sohlField` honours the block for anything already there.
911
- const img = resolveImg(sohlField(fm, "img"));
912
- if (!img) throw new Error("a map note needs an `img`");
908
+ // A Scene has no `img`, so a map's background is its own slot, authored
909
+ // under `data:` with the other art. It is an `image` address like every
910
+ // other art reference, and the record it resolves to carries the path.
911
+ const img = ctx.art(fm.data?.bgImage, "bgImage", "image");
912
+ if (!img) throw new Error("a map note needs a `bgImage`");
913
913
 
914
914
  const warn = (message) => {
915
915
  if (ctx.warnings) ctx.warnings.push(message);
@@ -934,7 +934,7 @@ export function buildScene(fm, ctx) {
934
934
  tokenVision: profile.tokenVision,
935
935
  fog: { mode: profile.fog.mode },
936
936
  initialLevel: DEFAULT_LEVEL_ID,
937
- levels: [buildLevel(sohl, sceneId, img)],
937
+ levels: [buildLevel(sohl, sceneId, img, ctx.art)],
938
938
  drawings: [],
939
939
  tokens: [],
940
940
  lights: buildLights(sohl, geom, inner),
@@ -980,13 +980,16 @@ export function buildScene(fm, ctx) {
980
980
  *
981
981
  * @param {object} sohl - The note's `sohl:` block.
982
982
  * @param {string} sceneId - The owning scene's `_id`.
983
- * @param {string} [img] - The background art, already resolved from the note.
984
- * Passed by {@link buildScene}, which reads it from the note rather than from
985
- * the block; defaults to the block's own `img`, so a direct two-argument call
986
- * still works.
983
+ * @param {string} img - The background art, already resolved from the note's
984
+ * `bgImage` address by {@link buildScene}. Passed rather than resolved here,
985
+ * because an address is answered by the compile's index and this function
986
+ * takes none.
987
+ * @param {(value: unknown, key: string, type: string) => string|null} [art] -
988
+ * The art resolver, for the foreground overlay. Omitted, a note naming one
989
+ * gets no overlay rather than a path nothing serves.
987
990
  * @returns {object} The Level document, keyed for the pack.
988
991
  */
989
- export function buildLevel(sohl, sceneId, img = resolveImg(sohlField({ sohl }, "img"))) {
992
+ export function buildLevel(sohl, sceneId, img, art = () => null) {
990
993
  const level = {
991
994
  _id: DEFAULT_LEVEL_ID,
992
995
  name: sohl.levelName ?? "Ground",
@@ -995,9 +998,9 @@ export function buildLevel(sohl, sceneId, img = resolveImg(sohlField({ sohl }, "
995
998
  color: sohl.backgroundColor ?? "#999999",
996
999
  src: img,
997
1000
  },
998
- // The overlay is a pathname like the background, and resolves by the
999
- // same rule — a scene draws the two from one authored statement each.
1000
- foreground: { src: resolveImg(sohl.overlay ?? null) },
1001
+ // The overlay is an `image` address like the background, and resolves
1002
+ // by the same rule — a scene draws the two from one statement each.
1003
+ foreground: { src: art(sohl.overlay ?? null, "overlay", "image") },
1001
1004
  sort: 0,
1002
1005
  _key: `!scenes.levels!${sceneId}.${DEFAULT_LEVEL_ID}`,
1003
1006
  };
@@ -1120,9 +1123,11 @@ export function buildTiles(sohl, geom, ctx) {
1120
1123
  rotation: spec.rotation ?? 0,
1121
1124
  alpha: spec.alpha ?? 1,
1122
1125
  sort: 0,
1123
- // A tile's texture is a pathname, resolved by the same rule the
1124
- // scene's own background is.
1125
- texture: { src: resolveImg(spec.image) },
1126
+ // A tile's texture is an address, resolved by the same rule the
1127
+ // scene's own background is. Its key is named for the type it
1128
+ // reaches, so a tile placing a glyph rather than artwork qualifies
1129
+ // — `sohl-none-icon-chest` — and the default needs no memorising.
1130
+ texture: { src: ctx.art(spec.image, "image", "image") },
1126
1131
  _key: `!scenes.tiles!${ctx.sceneId}.${id}`,
1127
1132
  };
1128
1133
  });
@@ -1143,7 +1148,7 @@ export function buildSounds(sohl, geom, ctx) {
1143
1148
  return Object.entries(sohl.sounds ?? {}).map(([key, spec]) => {
1144
1149
  const label = `sounds.${key}`;
1145
1150
  const [x, y] = requirePosition(spec.position, { ...geom, label });
1146
- if (!spec.path) throw new Error(`${label}: an ambient sound needs a path`);
1151
+ if (!spec.audio) throw new Error(`${label}: an ambient sound needs an \`audio\` address`);
1147
1152
  const id = spec._id || makeId("scene-sound", `${ctx.sceneId}:${key}`);
1148
1153
  return {
1149
1154
  _id: id,
@@ -1151,8 +1156,10 @@ export function buildSounds(sohl, geom, ctx) {
1151
1156
  x,
1152
1157
  y,
1153
1158
  radius: spec.radius ?? 0,
1154
- // An ambient sound is a file a package ships, like the pictures.
1155
- path: resolveImg(spec.path),
1159
+ // An ambient sound is a file a package ships, like the pictures, so
1160
+ // it is an address too. The emitted field stays Foundry's own
1161
+ // `path`, which is what a Scene's AmbientSound calls it.
1162
+ path: ctx.art(spec.audio, "audio", "audio"),
1156
1163
  repeat: spec.repeat ?? true,
1157
1164
  volume: spec.volume ?? 0.5,
1158
1165
  walls: spec.walls ?? true,
@@ -43,6 +43,12 @@ import fs from "node:fs";
43
43
  import path from "node:path";
44
44
 
45
45
  import { formatDiagnostic, positionOfLiteral } from "./diagnostics.mjs";
46
+ // The artifact's name, declared with the package registry so the toolchain's
47
+ // own index can name its file without importing this module back.
48
+ import { metadataFileName, PACKAGEBUILD_PACKAGE } from "./packages.mjs";
49
+ import { packageBuildRecords } from "./packagebuild-index.mjs";
50
+
51
+ export { metadataFileName };
46
52
  import { PACKAGE_BASE, readCanonicalKey, resolvePackageUrl } from "./content-address.mjs";
47
53
 
48
54
  /**
@@ -67,26 +73,6 @@ const STAMP = ".complete";
67
73
  */
68
74
  export const METADATA_RELATIONSHIP_KINDS = Object.freeze(["systems", "requires"]);
69
75
 
70
- /**
71
- * What a package's content index is called, wherever it is written or fetched.
72
- *
73
- * **The local index and the published artifact are one file.** A package emits
74
- * this, ships it as a release asset, and advertises it as `flags.metadataUrl`;
75
- * a consumer fetches that same file into its cache and reads it. Naming it in
76
- * one function is what keeps the emitter, the release and the fetcher from
77
- * drifting into three spellings of one artifact.
78
- *
79
- * The `-metadata` suffix earns its place: a bare `<package>.jsonl` says nothing
80
- * about what it holds, and these files land in a cache directory beside other
81
- * packages' artifacts where the name is all a reader has.
82
- *
83
- * @param {string} pkg - The content package name.
84
- * @returns {string} The file name, e.g. `sohl-metadata.jsonl`.
85
- */
86
- export function metadataFileName(pkg) {
87
- return `${pkg}-metadata.jsonl`;
88
- }
89
-
90
76
  /**
91
77
  * Every dependency whose published index this build resolves addresses through.
92
78
  *
@@ -312,22 +298,14 @@ export function loadForeignIndexes(config, localPackages, bases = PACKAGE_BASE)
312
298
  const packages = new Set();
313
299
  const stale = [];
314
300
 
315
- for (const file of cachedMetadataFiles(config)) {
316
- let records;
317
- try {
318
- records = fs
319
- .readFileSync(file, "utf8")
320
- .split("\n")
321
- .filter((line) => line.trim())
322
- .map((line) => JSON.parse(line));
323
- } catch (err) {
324
- stale.push({ package: packageOfCache(file), reason: `unreadable: ${err.message}` });
325
- continue;
326
- }
327
-
328
- const pkg = records[0]?.package ?? packageOfCache(file);
329
- if (local.has(pkg)) continue;
330
-
301
+ /**
302
+ * Fold one package's records into the index.
303
+ *
304
+ * @param {string} pkg - The package that published them.
305
+ * @param {Array<Record<string, any>>} records - Its index records.
306
+ * @returns {void}
307
+ */
308
+ const ingest = (pkg, records) => {
331
309
  // A base is only needed to resolve a page *URL*, so a pack-only
332
310
  // dependency — Foundry addresses and no site, which `kethira` is by
333
311
  // licensing rather than by accident — needs none. Demanding one would
@@ -367,10 +345,40 @@ export function loadForeignIndexes(config, localPackages, bases = PACKAGE_BASE)
367
345
  // an infobox sorting a being's skills — has no other way to
368
346
  // learn it about a note another package publishes.
369
347
  subType: record.subType ?? undefined,
348
+ // A file the other package ships, carried through whole. Its
349
+ // `path` is that package's path, so a consumer joins its own
350
+ // root onto it and resolves in one step — which is the point of
351
+ // the record naming a path at all.
352
+ asset: record.asset ?? undefined,
370
353
  package: pkg,
371
354
  });
372
355
  }
373
356
  packages.add(pkg);
357
+ };
358
+
359
+ // The toolchain's own files, first and unconditionally. `packagebuild` is
360
+ // an npm dependency of every consumer rather than a Foundry package, so
361
+ // there is nothing to declare and nothing to fetch — the tree is already on
362
+ // disk beside this module. The special case is entirely in the
363
+ // *acquisition*: the records join the index like any other package's, so
364
+ // every lookup stays one path.
365
+ ingest(PACKAGEBUILD_PACKAGE, packageBuildRecords());
366
+
367
+ for (const file of cachedMetadataFiles(config)) {
368
+ let records;
369
+ try {
370
+ records = fs
371
+ .readFileSync(file, "utf8")
372
+ .split("\n")
373
+ .filter((line) => line.trim())
374
+ .map((line) => JSON.parse(line));
375
+ } catch (err) {
376
+ stale.push({ package: packageOfCache(file), reason: `unreadable: ${err.message}` });
377
+ continue;
378
+ }
379
+ const pkg = records[0]?.package ?? packageOfCache(file);
380
+ if (local.has(pkg)) continue;
381
+ ingest(pkg, records);
374
382
  }
375
383
 
376
384
  return { index, packages, stale };
@@ -105,6 +105,10 @@ import { currentType } from "./ids.mjs";
105
105
  * entry of the map is. A finding names the entry at fault rather than
106
106
  * quoting the whole map back, so the string an author has to correct is the
107
107
  * one the message holds.
108
+ * @property {string} [ref] - The type a bare value takes. An art slot declares
109
+ * one — `icon` for `icon` and `tokenIcon`, `image` for `bgImage` and
110
+ * `banner` — so `icon: anvil` names `icon-anvil` while a value carrying the
111
+ * separator states its own address.
108
112
  * @property {"pack"} [keys] - For a `scalar-or-map` field, what its keys name.
109
113
  * `"pack"` means each is a pack this package declares, so a key naming none
110
114
  * is a finding of its own: it addresses a hierarchy nothing will ever read.
@@ -185,6 +189,57 @@ const TEMPLATE_PRIORITY = Object.freeze({
185
189
  describe: "Template priority; unset means the note is not a template.",
186
190
  });
187
191
 
192
+ /**
193
+ * What a token on the canvas wears — an Actor type's second piece of art.
194
+ *
195
+ * Declared once and shared by the two Actor types, beside
196
+ * {@link TEMPLATE_PRIORITY} and for the same reason. Unset it follows `icon`:
197
+ * a token has to read at grid scale and when a map is zoomed out, so it is an
198
+ * `icon` rather than an `image` and its fallback has to be `icon`-typed too.
199
+ *
200
+ * @type {DataFieldSpec}
201
+ */
202
+ const TOKEN_ICON = Object.freeze({
203
+ name: "tokenIcon",
204
+ ...LINK,
205
+ ref: "icon",
206
+ describe: "What a token on the canvas wears — an `icon` address; unset, it follows `icon`.",
207
+ });
208
+
209
+ /**
210
+ * The `data:` keys **every** note type accepts, whatever it is.
211
+ *
212
+ * `data:` is a closed container and the per-type vocabularies are the only
213
+ * lists there are, so a key legal on every type needs somewhere that is not one
214
+ * type's list — including the types whose own vocabulary is empty. Repeating a
215
+ * row in twenty-five tables would be twenty-five chances for one of them to
216
+ * disagree with the rest.
217
+ *
218
+ * Both are art slots, and they are legal everywhere for different reasons.
219
+ * `icon` is the document's profile art and most types compile into a document
220
+ * that carries one; where a type's passes emit none, the frontmatter lint says
221
+ * so as a warning rather than the vocabulary refusing the key, because the
222
+ * value may still be read by a page template. `banner` reaches no compiled
223
+ * document at all — it is the page's hero image — and a page is what every note
224
+ * publishes.
225
+ *
226
+ * @type {readonly DataFieldSpec[]}
227
+ */
228
+ export const SHARED_DATA_FIELDS = Object.freeze([
229
+ Object.freeze({
230
+ name: "icon",
231
+ ...LINK,
232
+ ref: "icon",
233
+ describe: "The document's profile art — an `icon` address, resolved into `img`.",
234
+ }),
235
+ Object.freeze({
236
+ name: "banner",
237
+ ...LINK,
238
+ ref: "image",
239
+ describe: "The page's hero image — an `image` address. Reaches no compiled document.",
240
+ }),
241
+ ]);
242
+
188
243
  /**
189
244
  * The four gear values every carried thing declares.
190
245
  *
@@ -477,14 +532,7 @@ export const NOTE_VOCABULARY = Object.freeze({
477
532
  // values here would put a second, weaker answer beside the real one.
478
533
  subTypes: null,
479
534
  data: Object.freeze([
480
- {
481
- name: "portrait",
482
- ...TEXT,
483
- describe:
484
- "Path to the portrait image. Its first segment says which package owns " +
485
- "the file: `systems/…` and `modules/…` are emitted unchanged, anything " +
486
- "else is this package's own and is rooted under its assets.",
487
- },
535
+ TOKEN_ICON,
488
536
  TEMPLATE_PRIORITY,
489
537
  { name: "archetypes", ...LIST, describe: "Archetypal behaviours the being fits." },
490
538
  { name: "occupation", ...TEXT, describe: "What the being does for a living." },
@@ -527,17 +575,7 @@ export const NOTE_VOCABULARY = Object.freeze({
527
575
 
528
576
  vehicle: Object.freeze({
529
577
  subTypes: null,
530
- data: Object.freeze([
531
- {
532
- name: "portrait",
533
- ...TEXT,
534
- describe:
535
- "Path to the portrait image. Its first segment says which package owns " +
536
- "the file: `systems/…` and `modules/…` are emitted unchanged, anything " +
537
- "else is this package's own and is rooted under its assets.",
538
- },
539
- TEMPLATE_PRIORITY,
540
- ]),
578
+ data: Object.freeze([TOKEN_ICON, TEMPLATE_PRIORITY]),
541
579
  }),
542
580
 
543
581
  /* ----- items ---------------------------------------------------- */
@@ -999,16 +1037,13 @@ export const NOTE_VOCABULARY = Object.freeze({
999
1037
  // derived for them, which is precisely what a subType decides.
1000
1038
  subTypes: Object.freeze(["battlemap", "localmap", "regionalmap"]),
1001
1039
  data: Object.freeze([
1002
- // `img`, as every other note type spells its artwork. A map alone
1003
- // read `image` out of its `sohl:` block; that spelling is retired
1004
- // and gone, so the two names are one again.
1040
+ // A Scene has no `img`, so the shared art key reaches nothing here:
1041
+ // a map's background is its own slot, and an `image` address.
1005
1042
  {
1006
- name: "img",
1007
- ...TEXT,
1008
- describe:
1009
- "Path to the map art, owned by whichever package its first segment " +
1010
- "names — `systems/…` and `modules/…` unchanged, anything else this " +
1011
- "package's own.",
1043
+ name: "bgImage",
1044
+ ...LINK,
1045
+ ref: "image",
1046
+ describe: "The map's background art an `image` address.",
1012
1047
  },
1013
1048
  {
1014
1049
  name: "dimensions",
@@ -1150,12 +1185,19 @@ assertVocabularyCharset(NOTE_VOCABULARY);
1150
1185
  * @param {string} type - The note's `type`.
1151
1186
  * @param {Readonly<Record<string, TypeVocabulary>>} [vocabulary] - The registry
1152
1187
  * to read, defaulting to {@link NOTE_VOCABULARY}.
1188
+ * {@link SHARED_DATA_FIELDS} come first, because they are part of every type's
1189
+ * declaration and a caller asking what a type accepts must be told all of it.
1190
+ *
1153
1191
  * @returns {readonly DataFieldSpec[]|undefined} The declaration, or `undefined`
1154
1192
  * when the type declares none — which is not the same as declaring an empty
1155
1193
  * one, and is why the lint makes no claim rather than refusing every key.
1156
1194
  */
1157
1195
  export function dataFields(type, vocabulary = NOTE_VOCABULARY) {
1158
- return vocabulary?.[currentType(type)]?.data;
1196
+ const entry = vocabulary?.[currentType(type)];
1197
+ if (!entry) return undefined;
1198
+ // The shared keys first, so a type's own declarations keep their authored
1199
+ // order behind them and a reader meets the art before the mechanics.
1200
+ return Object.freeze([...SHARED_DATA_FIELDS, ...(entry.data ?? [])]);
1159
1201
  }
1160
1202
 
1161
1203
  /**
@@ -371,6 +371,20 @@ export function createPackRouter(packs) {
371
371
  return out;
372
372
  },
373
373
 
374
+ /**
375
+ * The system one pack declares, if it declares one.
376
+ *
377
+ * A pack declaring none takes the compiler its document type falls
378
+ * back to, so `undefined` here is an answer rather than a gap: it says
379
+ * the pack's documents are whichever system that pass writes.
380
+ *
381
+ * @param {string} name - The pack name.
382
+ * @returns {string|undefined} Its `system:`, or `undefined`.
383
+ */
384
+ systemOf(name) {
385
+ return byName.get(name)?.system || undefined;
386
+ },
387
+
374
388
  /**
375
389
  * The pack of a type that receives notes declaring none.
376
390
  *
@@ -0,0 +1,124 @@
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 toolchain's own content index — the files it ships, addressed.
16
+ *
17
+ * package-build ships a set of images, section banners chiefly, that many
18
+ * packages draw on. `packagebuild-none-image-<shortcode>` lets a note reach one
19
+ * without declaring a dependency on some parent system or module it otherwise
20
+ * has no relationship with, which is the whole point: the alternative is every
21
+ * package depending on one of the others just to borrow a banner.
22
+ *
23
+ * **Every other package's index is fetched; this one is read from disk.**
24
+ * package-build is an npm dependency of every consumer rather than a Foundry
25
+ * package, so there is no release archive to fetch and no reason to fetch one —
26
+ * `node_modules/@heroiclands/package-build/assets/` is already there. That is
27
+ * the one difference, and it is entirely about *acquisition*: the records join
28
+ * `foreign.index` like any other package's, so every lookup stays one path.
29
+ *
30
+ * **A cold cache is not a failure mode here**, and the walk is why. The records
31
+ * are derived from the shipped tree each time they are asked for rather than
32
+ * read back from a file, so an installed copy and a git checkout answer alike
33
+ * and nothing can be half-fetched. {@link emitPackageBuildIndex} publishes the
34
+ * same walk as a file for the readers that are not this process — the website,
35
+ * the book, the Foundry runtime — and a guard holds the two together.
36
+ *
37
+ * **These addresses have no Foundry form, by construction.** Foundry installs
38
+ * no package for this one, so `packageAddresses` has no entry and
39
+ * `resolvePathname` yields `null` rather than deriving
40
+ * `modules/packagebuild/assets/…`, which installs nowhere. The only slot that
41
+ * names them is `banner`, which reaches no compiled document at all.
42
+ *
43
+ * @module
44
+ */
45
+
46
+ import fs from "node:fs";
47
+ import path from "node:path";
48
+ import { fileURLToPath } from "node:url";
49
+
50
+ import { collectAssetRecords } from "./asset-index.mjs";
51
+ import { recordPath, sortKeysDeep } from "./index-records.mjs";
52
+ import { PACKAGEBUILD_PACKAGE, metadataFileName } from "./packages.mjs";
53
+
54
+ /**
55
+ * This package's own root, wherever it is installed.
56
+ *
57
+ * Derived from this module's location rather than from a configuration: the
58
+ * files being indexed sit beside it in the same tarball, so the one reliable
59
+ * answer is "up from here". A consuming repository's `rootDir` names its own
60
+ * tree and says nothing about where its dependencies live.
61
+ *
62
+ * @type {string}
63
+ */
64
+ export const PACKAGEBUILD_ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
65
+
66
+ /**
67
+ * The asset directory this package ships.
68
+ *
69
+ * @type {string}
70
+ */
71
+ export const PACKAGEBUILD_ASSETS = path.join(PACKAGEBUILD_ROOT, "assets");
72
+
73
+ /**
74
+ * Where the published index sits, beside the files it describes.
75
+ *
76
+ * @type {string}
77
+ */
78
+ export const PACKAGEBUILD_INDEX_FILE = path.join(
79
+ PACKAGEBUILD_ROOT,
80
+ metadataFileName(PACKAGEBUILD_PACKAGE),
81
+ );
82
+
83
+ /**
84
+ * The index records for the files this package ships.
85
+ *
86
+ * Walked rather than read back, so an installed copy and a git checkout answer
87
+ * alike and there is no state to be stale. The tree is small — a handful of
88
+ * banners — so the walk costs nothing a build would notice.
89
+ *
90
+ * @param {string} [assetsBase] - The asset directory, for a caller testing
91
+ * against a tree of its own.
92
+ * @returns {Array<Record<string, any>>} One record per addressable file.
93
+ */
94
+ export function packageBuildRecords(assetsBase = PACKAGEBUILD_ASSETS) {
95
+ const records = collectAssetRecords(assetsBase, {
96
+ contentPackage: PACKAGEBUILD_PACKAGE,
97
+ }).map((record) => /** @type {Record<string, any>} */ (sortKeysDeep(record)));
98
+ // Ordered and key-sorted like every other index, and for the same reason:
99
+ // directory-read order is a fact about the filesystem, so two runs over an
100
+ // unchanged tree have to produce an identical file.
101
+ records.sort((a, b) => recordPath(a).localeCompare(recordPath(b), "en"));
102
+ return records;
103
+ }
104
+
105
+ /**
106
+ * Publish the index as a file, for the readers that are not this process.
107
+ *
108
+ * The website, the book and the Foundry runtime each read a package's index as
109
+ * JSON Lines; this package's has to be one too, and it ships in the tarball
110
+ * beside the images it describes.
111
+ *
112
+ * @param {object} [options] - Options.
113
+ * @param {string} [options.assetsBase] - The asset directory to walk.
114
+ * @param {string} [options.file] - Where to write it.
115
+ * @returns {{file: string, assets: number, bytes: number}} Where it was
116
+ * written, how many files it holds, and its size.
117
+ */
118
+ export function emitPackageBuildIndex({ assetsBase, file = PACKAGEBUILD_INDEX_FILE } = {}) {
119
+ const records = packageBuildRecords(assetsBase);
120
+ const text = records.length ? `${records.map((r) => JSON.stringify(r)).join("\n")}\n` : "";
121
+ fs.mkdirSync(path.dirname(file), { recursive: true });
122
+ fs.writeFileSync(file, text);
123
+ return { file, assets: records.length, bytes: Buffer.byteLength(text) };
124
+ }
@@ -0,0 +1,83 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * The `<package>` segment's own registry: the names no repository may claim.
16
+ *
17
+ * A content package names itself, so the registry is open — and exactly one
18
+ * name is held back from it.
19
+ *
20
+ * **`packagebuild` is a package that is not a package.** package-build is an npm
21
+ * package rather than a system or a module, and it ships a set of images —
22
+ * section banners chiefly — that many packages draw on. Addressing them as
23
+ * `packagebuild-none-image-<shortcode>` lets a note reach one without declaring a
24
+ * dependency on some parent system or module it otherwise has no relationship
25
+ * with, which is the whole point: the alternative is every package depending on
26
+ * one of the others just to borrow a banner.
27
+ *
28
+ * So nothing may create a real package that collides with the name, and its
29
+ * resolution is special-cased, because no installed directory sits behind it.
30
+ *
31
+ * This module is a **leaf with no local imports**, so the configuration
32
+ * validator can name it without closing a cycle around `content-config.mjs`.
33
+ *
34
+ * @module
35
+ */
36
+
37
+ /**
38
+ * The address namespace package-build's own assets publish under.
39
+ *
40
+ * @type {string}
41
+ */
42
+ export const PACKAGEBUILD_PACKAGE = "packagebuild";
43
+
44
+ /**
45
+ * Every package name a repository may not claim.
46
+ *
47
+ * @type {ReadonlySet<string>}
48
+ */
49
+ export const RESERVED_PACKAGES = Object.freeze(new Set([PACKAGEBUILD_PACKAGE]));
50
+
51
+ /**
52
+ * What a package's content index is called, wherever it is written or fetched.
53
+ *
54
+ * **The local index and the published artifact are one file.** A package emits
55
+ * this, ships it as a release asset, and advertises it as `flags.metadataUrl`;
56
+ * a consumer fetches that same file into its cache and reads it. Naming it in
57
+ * one function is what keeps the emitter, the release and the fetcher from
58
+ * drifting into three spellings of one artifact.
59
+ *
60
+ * The `-metadata` suffix earns its place: a bare `<package>.jsonl` says nothing
61
+ * about what it holds, and these files land in a cache directory beside other
62
+ * packages' artifacts where the name is all a reader has.
63
+ *
64
+ * It lives here rather than beside the loader because the loader reaches the
65
+ * toolchain's own index, which has to name its file without importing the
66
+ * loader back.
67
+ *
68
+ * @param {string} pkg - The content package name.
69
+ * @returns {string} The file name, e.g. `sohl-metadata.jsonl`.
70
+ */
71
+ export function metadataFileName(pkg) {
72
+ return `${pkg}-metadata.jsonl`;
73
+ }
74
+
75
+ /**
76
+ * Whether a package name is held back from the open registry.
77
+ *
78
+ * @param {unknown} pkg - The candidate `contentPackage`.
79
+ * @returns {boolean} True when the name is reserved.
80
+ */
81
+ export function isReservedPackage(pkg) {
82
+ return typeof pkg === "string" && RESERVED_PACKAGES.has(pkg.toLowerCase());
83
+ }