@heroiclands/package-build 18.2.0 → 20.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 (67) hide show
  1. package/CHANGELOG.md +692 -0
  2. package/CONTENT.md +81 -10
  3. package/bin/content-build.mjs +7 -1
  4. package/ci/ci-docker.mjs +21 -0
  5. package/content-config.mjs +26 -24
  6. package/docs/content-format.md +408 -85
  7. package/engine/actor-compiler.mjs +197 -7
  8. package/engine/address-charset.mjs +23 -5
  9. package/engine/base-compiler.mjs +65 -2
  10. package/engine/bundles.mjs +9 -0
  11. package/engine/content-address.mjs +92 -1
  12. package/engine/content-format.mjs +102 -0
  13. package/engine/content-index.mjs +11 -8
  14. package/engine/content-links.mjs +37 -21
  15. package/engine/field-reference.mjs +57 -5
  16. package/engine/field-spec.mjs +214 -7
  17. package/engine/folder-notes.mjs +88 -1
  18. package/engine/foreign-catalog.mjs +4 -1
  19. package/engine/foundry-entries.mjs +16 -0
  20. package/engine/frontmatter-lint.mjs +215 -28
  21. package/engine/frontmatter.mjs +12 -12
  22. package/engine/generate.mjs +78 -46
  23. package/engine/helpers.mjs +87 -128
  24. package/engine/index.mjs +3 -0
  25. package/engine/item-compiler.mjs +44 -9
  26. package/engine/journals.mjs +27 -16
  27. package/engine/macros.mjs +8 -0
  28. package/engine/map-notes.mjs +7 -7
  29. package/engine/note-ids.mjs +25 -1
  30. package/engine/note-vocabulary.mjs +76 -9
  31. package/engine/retired-fields.mjs +57 -16
  32. package/engine/runtime-only-fields.mjs +204 -0
  33. package/engine/scenes.mjs +19 -28
  34. package/engine/schema-check.mjs +23 -1
  35. package/engine/site-index.mjs +17 -0
  36. package/engine/subtype-registry.mjs +30 -0
  37. package/engine/system-block.mjs +81 -3
  38. package/engine/web-wikilinks.mjs +33 -27
  39. package/engine/wikilink-syntax.mjs +7 -0
  40. package/engine/wikilinks.mjs +74 -16
  41. package/hm3/actors.mjs +70 -23
  42. package/package.json +2 -2
  43. package/sohl/actors.mjs +106 -7
  44. package/sohl/item-fields.mjs +203 -0
  45. package/sohl/note-schemas.mjs +6 -3
  46. package/types/content-config.d.mts +0 -7
  47. package/types/engine/actor-compiler.d.mts +83 -3
  48. package/types/engine/address-charset.d.mts +22 -4
  49. package/types/engine/base-compiler.d.mts +54 -3
  50. package/types/engine/content-address.d.mts +64 -0
  51. package/types/engine/content-format.d.mts +9 -0
  52. package/types/engine/field-spec.d.mts +271 -3
  53. package/types/engine/folder-notes.d.mts +59 -0
  54. package/types/engine/foundry-entries.d.mts +6 -0
  55. package/types/engine/frontmatter-lint.d.mts +18 -2
  56. package/types/engine/frontmatter.d.mts +11 -11
  57. package/types/engine/generate.d.mts +27 -0
  58. package/types/engine/helpers.d.mts +37 -38
  59. package/types/engine/index.d.mts +1 -0
  60. package/types/engine/map-notes.d.mts +2 -2
  61. package/types/engine/note-ids.d.mts +14 -0
  62. package/types/engine/retired-fields.d.mts +29 -13
  63. package/types/engine/runtime-only-fields.d.mts +102 -0
  64. package/types/engine/schema-check.d.mts +10 -1
  65. package/types/engine/subtype-registry.d.mts +21 -0
  66. package/types/engine/system-block.d.mts +28 -2
  67. package/types/sohl/actors.d.mts +3 -3
@@ -400,15 +400,92 @@ export function makeFilename(name, id) {
400
400
  * non-alphanumerics collapsed to single hyphens.
401
401
  */
402
402
 
403
+ /**
404
+ * The path prefixes that name a package other than the one being compiled.
405
+ *
406
+ * Foundry serves every installed package from a root named for its kind, so a
407
+ * path opening with one of these is already a served address and belongs to
408
+ * somebody else — most often `systems/sohl/assets/…`, where every default this
409
+ * toolchain ships lives, and which a module's content cites as readily as the
410
+ * system's own does.
411
+ *
412
+ * **Two, not "the ones we happen to use".** `worlds/` is left out on purpose: a
413
+ * package may not ship art out of a world, so a note that writes one has made a
414
+ * mistake, and prefixing it yields a plainly broken path rather than a
415
+ * plausible one that fails silently much later.
416
+ *
417
+ * @type {readonly string[]}
418
+ */
419
+ const FOREIGN_PACKAGE_ROOTS = Object.freeze(["systems/", "modules/"]);
420
+
421
+ /**
422
+ * Whether a path already addresses something this package does not own, and so
423
+ * must be emitted exactly as authored.
424
+ *
425
+ * Three shapes qualify, each a different kind of "not mine":
426
+ *
427
+ * - **Another package** — `systems/…` or `modules/…`, per
428
+ * {@link FOREIGN_PACKAGE_ROOTS}.
429
+ * - **Somewhere off this install** — a URI scheme (`https:`, `data:`) or a
430
+ * protocol-relative `//cdn…`.
431
+ * - **The data root itself** — a leading `/`, which Foundry serves from the
432
+ * install rather than from any package.
433
+ *
434
+ * @param {string} s - A non-empty authored path.
435
+ * @returns {boolean} Whether it passes through untranslated.
436
+ */
437
+ function addressesAnotherPackage(s) {
438
+ if (FOREIGN_PACKAGE_ROOTS.some((root) => s.startsWith(root))) return true;
439
+ // `//host/x.png` — protocol-relative, so it leaves this origin entirely.
440
+ // Checked before the single-slash case, which would otherwise claim it.
441
+ if (s.startsWith("//")) return true;
442
+ // `/x.png` — rooted at the Foundry data root, not at any package.
443
+ if (s.startsWith("/")) return true;
444
+ // `https://…`, `data:…`, `file:…` — a scheme, so not a path at all.
445
+ return /^[a-z][a-z0-9+.-]*:/i.test(s);
446
+ }
447
+
403
448
  /**
404
449
  * Translate a content-relative image path into its Foundry-relative form.
405
450
  *
406
451
  * Content frontmatter (`img` / `portrait`) authors a single path that has to
407
- * work for Foundry, the knowledgebase, and the website. For Foundry the bundled
408
- * asset roots `icons/...` and `images/...` are served from the package
409
- * directory, so they are rewritten to `<assetRoot>/<path>` — `systems/sohl/assets`
410
- * for this repository, `modules/<id>/assets` for a module (#1508). Any other
411
- * path (already package-rooted, an absolute URL) is returned unchanged.
452
+ * work for Foundry, the knowledgebase, and the website. **Its first segment
453
+ * says which package owns the file** (#331), and there are exactly three
454
+ * answers:
455
+ *
456
+ * | Authored path starts with | Owner | Emitted |
457
+ * | ------------------------- | --------------------- | -------------------- |
458
+ * | `systems/` | a separate **system** | unchanged |
459
+ * | `modules/` | a separate **module** | unchanged |
460
+ * | anything else | **this package** | `<assetRoot>/<path>` |
461
+ *
462
+ * So `icons/relic.svg` compiles to `systems/sohl/assets/icons/relic.svg` here
463
+ * and to `modules/sohl-thalorna/assets/icons/relic.svg` in a module — the asset
464
+ * root is derived from the configuration, and is the one place `systems/sohl`
465
+ * is ever spelled (#1508). An authored
466
+ * `systems/sohl/assets/icons/noun/shield.svg` is left exactly as written,
467
+ * whichever package is compiling it.
468
+ *
469
+ * **This is a rule about ownership, not an allowlist of directories.** It used
470
+ * to prefix `icons/…` and `images/…` and pass everything else through — the
471
+ * same answer for every path any tree authors today, and the wrong one for the
472
+ * next directory a package ships. `sohl-kethira-basic` keeps art under
473
+ * `assets/artwork/`, so an authored `artwork/deity.webp` would have shipped
474
+ * unprefixed: a 404 in Foundry, reported by nothing. That a package owns its
475
+ * own tree is the fact; the directory names inside it are that package's
476
+ * business (#331).
477
+ *
478
+ * **Off-install addresses pass through too**, which is the same rule rather
479
+ * than a fourth: a URL, a `data:` URI, or a `/`-rooted path names something no
480
+ * package owns. See {@link addressesAnotherPackage}.
481
+ *
482
+ * **`banner:` does not follow this rule, deliberately (#331).** It is not an
483
+ * asset path inside a Foundry install at all: it reaches no compiled document,
484
+ * and its only consumer is the Hugo theme, which prefixes a relative value with
485
+ * `images/` and joins it onto `params.cdnBaseURL`. The two fields look alike
486
+ * and address different places — `img:` a file Foundry serves, `banner:` a file
487
+ * the CDN serves — so they are documented apart rather than reconciled into one
488
+ * rule that would be true of neither.
412
489
  *
413
490
  * **Two empties, and they mean opposite things (#218).** `null` — or an absent
414
491
  * key, which reaches here as `undefined` — means _unset_: the note names no art
@@ -453,10 +530,10 @@ export function resolveImg(raw, config = loadPackConfig()) {
453
530
  const s = String(raw);
454
531
  // Blank on purpose — the caller's default must not apply.
455
532
  if (s === "") return "";
456
- if (s.startsWith("icons/") || s.startsWith("images/")) {
457
- return `${config.assetRoot}/${s}`;
458
- }
459
- return s;
533
+ // Somebody else's to serve — emit it exactly as authored.
534
+ if (addressesAnotherPackage(s)) return s;
535
+ // Ours, so root it where Foundry serves this package's files from.
536
+ return `${config.assetRoot}/${s}`;
460
537
  }
461
538
 
462
539
  /**
@@ -913,102 +990,9 @@ export function expandNoteTables(body, { docs, name, fm, bodyLine, sqlTables })
913
990
  }
914
991
 
915
992
  /* ------------------------------------------------------------------------ */
916
- /* Folder hierarchy: loading, resolution, emission */
993
+ /* Folder document filenames */
917
994
  /* ------------------------------------------------------------------------ */
918
995
 
919
- /**
920
- * Loads a folders.yaml file as an array of folder entries. Returns []
921
- * when the file is missing (logging a warning) so packs without folders
922
- * can opt out simply by not committing the file.
923
- */
924
- export function loadFolders(foldersFile) {
925
- if (!fs.existsSync(foldersFile)) {
926
- log.warn(`No folders.yaml at ${foldersFile}; no folders will be emitted`);
927
- return [];
928
- }
929
- const raw = fs.readFileSync(foldersFile, "utf8");
930
- const parsed = yaml.parse(raw);
931
- if (parsed == null) return [];
932
- if (!Array.isArray(parsed)) {
933
- throw new Error(`folders.yaml must contain a YAML list; got ${typeof parsed}`);
934
- }
935
- return parsed;
936
- }
937
-
938
- /**
939
- * Validates folder invariants and returns a resolver function that maps a
940
- * folder id to the same id (after verifying it exists). Returns `null` for
941
- * a null/empty input; throws for an unknown id.
942
- *
943
- * Invariants:
944
- * - Every folder must have a non-empty id
945
- * - Every folder must have a name
946
- * - Sibling folders (same parentFolderId) must have unique names
947
- * - Every parentFolderId must match an existing folder id (or be "")
948
- *
949
- * Returns { resolver, folders } where folders is the validated list.
950
- */
951
- export function buildFolderResolver(folders) {
952
- const byId = new Map();
953
- for (const f of folders) {
954
- if (!f.id) {
955
- throw new Error(`Folder missing id: ${JSON.stringify(f)}`);
956
- }
957
- if (!f.name) {
958
- throw new Error(`Folder ${f.id} missing name`);
959
- }
960
- if (byId.has(f.id)) {
961
- throw new Error(`Duplicate folder id ${f.id}`);
962
- }
963
- byId.set(f.id, f);
964
- }
965
-
966
- const siblingsByParent = new Map();
967
- for (const f of folders) {
968
- const parentId = f.parentFolderId || "";
969
- if (parentId && !byId.has(parentId)) {
970
- throw new Error(
971
- `Folder ${f.id} (${f.name}) references unknown parentFolderId ${parentId}`,
972
- );
973
- }
974
- if (!siblingsByParent.has(parentId)) {
975
- siblingsByParent.set(parentId, new Set());
976
- }
977
- const siblings = siblingsByParent.get(parentId);
978
- if (siblings.has(f.name)) {
979
- throw new Error(
980
- `Sibling folders share name "${f.name}" under parent ${parentId || "(root)"} — names must be unique among siblings`,
981
- );
982
- }
983
- siblings.add(f.name);
984
- }
985
-
986
- /**
987
- * The folder id a note names, by id.
988
- *
989
- * **Only by id.** This resolver answers for `folder:` alone; `packFolder:`
990
- * names a folder *note* and is resolved through the address index instead
991
- * (#255). The path lookup that briefly lived here is gone with the path
992
- * spelling it served — it was never released, so there is nothing to
993
- * deprecate.
994
- *
995
- * @param {string|null|undefined} value - As authored.
996
- * @returns {string|null} The id, or `null` for an absent value.
997
- * @throws {Error} When the id is not one this pack declares.
998
- */
999
- function resolver(value) {
1000
- if (value == null || value === "") return null;
1001
- const authored = String(value).trim();
1002
- if (!authored) return null;
1003
- if (!byId.has(authored)) {
1004
- throw new Error(`Unknown folder id "${authored}"`);
1005
- }
1006
- return authored;
1007
- }
1008
-
1009
- return { resolver, folders };
1010
- }
1011
-
1012
996
  /**
1013
997
  * Builds a compendium-source filename for a folder JSON document:
1014
998
  * `folder_Name_id.json` with non-alphanumeric runs replaced by
@@ -1017,28 +1001,3 @@ export function buildFolderResolver(folders) {
1017
1001
  export function folderFilename(name, id) {
1018
1002
  return `folder_${unidecode(name)}_${id}`.replace(/[^0-9a-zA-Z]+/g, "_") + ".json";
1019
1003
  }
1020
-
1021
- /**
1022
- * Writes one JSON document per folder into `destDir`. `documentType`
1023
- * determines the folder's Foundry `type` field — `"Item"` for the items
1024
- * pack, `"JournalEntry"` for the journals pack.
1025
- */
1026
- export function writeFolderDocs(folders, stats, destDir, documentType) {
1027
- for (const folder of folders) {
1028
- const doc = {
1029
- name: folder.name,
1030
- sorting: "a",
1031
- folder: folder.parentFolderId || null,
1032
- type: documentType,
1033
- _id: folder.id,
1034
- sort: 0,
1035
- color: folder.color,
1036
- flags: folder.flags || {},
1037
- _stats: stats,
1038
- _key: `!folders!${folder.id}`,
1039
- };
1040
- const outPath = path.join(destDir, folderFilename(folder.name, folder.id));
1041
- fs.writeFileSync(outPath, JSON.stringify(doc, null, 2), "utf8");
1042
- }
1043
- log.info(`Emitted ${folders.length} folder document(s) to ${destDir}`);
1044
- }
package/engine/index.mjs CHANGED
@@ -69,6 +69,9 @@ export * as notePackage from "./note-package.mjs";
69
69
  /** Frontmatter fields a note may no longer declare, and the refusal of them. */
70
70
  export * as retiredFields from "./retired-fields.mjs";
71
71
 
72
+ /** Schema fields a note may never declare, because play writes them. */
73
+ export * as runtimeOnlyFields from "./runtime-only-fields.mjs";
74
+
72
75
  /** The package homepage: the note type that compiles to a page, not a document. */
73
76
  export * as homepage from "./homepage.mjs";
74
77
 
@@ -66,6 +66,9 @@ import { documentSubtype, subtypeRow } from "./document-subtypes.mjs";
66
66
  // `system` verbatim, and `<system>.img` / `.effects` / `.flags` overriding
67
67
  // their shared top-level forms for this system alone (#58).
68
68
  import { blockField, blockProperty, claimedPaths, mergeSystemData } from "./system-block.mjs";
69
+ // The other direction of the same declaration: a field the *document* writes in
70
+ // play, which a note may not author and the builder does not emit (#330).
71
+ import { assertNoRuntimeOnlyFields } from "./runtime-only-fields.mjs";
69
72
 
70
73
  /**
71
74
  * The description an item carries: a pointer to its **item doc**, the
@@ -113,6 +116,17 @@ export class SystemItemCompiler extends BasePackCompiler {
113
116
  */
114
117
  static requiresSystemBlock = true;
115
118
 
119
+ /**
120
+ * An Item carries an `img` — its icon — which this pass writes from the
121
+ * note's own path, falling back to the type's default art.
122
+ *
123
+ * `portrait` is **not** among them: a portrait is a being's sheet picture,
124
+ * and an item has nowhere to put one.
125
+ *
126
+ * @type {readonly string[]}
127
+ */
128
+ static emitsArt = Object.freeze(["img"]);
129
+
116
130
  /**
117
131
  * The note-type → document-subtype map this pass compiles against.
118
132
  *
@@ -174,6 +188,29 @@ export class SystemItemCompiler extends BasePackCompiler {
174
188
  return !row || row.document === "Item";
175
189
  }
176
190
 
191
+ /**
192
+ * Refuse a note authoring one of its type's **runtime-only** fields (#330).
193
+ *
194
+ * A schema declares fields the document writes for itself — an affliction's
195
+ * `onsetDate` is the world time its onset fired at — and a note authoring
196
+ * one used to compile, because `<system>.system` is a verbatim passthrough
197
+ * and the field really is in the schema. The result was shipped content
198
+ * carrying one world's play state, with the build reporting success.
199
+ *
200
+ * The declaration says which, so nothing here knows a field name; see
201
+ * {@link module:engine/runtime-only-fields}.
202
+ *
203
+ * @param {object} fm - The note's frontmatter.
204
+ * @returns {void}
205
+ * @throws {Error} When the note authors one.
206
+ */
207
+ assertAuthorable(fm) {
208
+ assertNoRuntimeOnlyFields(fm, itemFields(fm.type, this.system), {
209
+ block: this.system,
210
+ absPath: this.currentNote?.absPath,
211
+ });
212
+ }
213
+
177
214
  /**
178
215
  * The Foundry Item subtype a note compiles into.
179
216
  *
@@ -296,15 +333,13 @@ export class SystemItemCompiler extends BasePackCompiler {
296
333
  });
297
334
 
298
335
  const effects = blockProperty(fm, system, "effects");
299
- // Read through the system block like every other item field, so both
300
- // spellings work wherever a note already writes one. `packFolder` is a
301
- // folder note's address and `folder` an id; which it is comes from the
302
- // field, never from the string (#251, #255).
303
- const packFolderAddress = blockField(fm, system, "packFolder", null);
304
- const folder =
305
- packFolderAddress ?
306
- this.folderResolver(packFolderAddress, { isAddress: true })
307
- : this.folderResolver(blockField(fm, system, "folder", null));
336
+ // Read through the system block like every other item field. There is
337
+ // one spelling: `packFolder` names a folder note by its address, the
338
+ // `folder:` id spelling having been retired with the per-pack YAML it
339
+ // resolved against (#251, #255, #260).
340
+ const folder = this.folderResolver(blockField(fm, system, "packFolder", null), {
341
+ isAddress: true,
342
+ });
308
343
 
309
344
  return {
310
345
  name,
@@ -30,10 +30,13 @@
30
30
  * any other: the macro pass reads the same page independently, and withholds
31
31
  * nothing from the journal (#1514).
32
32
  *
33
- * Folder placement is identical to the items pack: `sohl.folder` in
34
- * frontmatter is the target folder's id (from folders.yaml), resolved
35
- * against a folders.yaml list via the constructor's `folderResolver`. A
36
- * documentation entry reuses its document's folder id verbatim.
33
+ * Folder placement is identical to the items pack: `sohl.packFolder` in
34
+ * frontmatter is a folder **note's address**, resolved through the shared
35
+ * address index by the constructor's `folderResolver` (#255, #260). A folder
36
+ * materialises in every pack holding a document that names it, so a journals
37
+ * pack needs to declare nothing (#257) — which is what stopped this pass
38
+ * filing documentation into folders its own pack had never heard of. A
39
+ * documentation entry reuses its document's folder verbatim.
37
40
  *
38
41
  * Not a standalone script — exports the `Journals` compiler class, imported
39
42
  * and driven by `packages/content-build/engine/generate.mjs` (via `npm run build:compiledb`).
@@ -311,6 +314,20 @@ export class Journals extends BasePackCompiler {
311
314
  */
312
315
  static requiresId = false;
313
316
 
317
+ /**
318
+ * **None.** A JournalEntry has no artwork — no `img` property, and no
319
+ * nested place for one — so a note whose whole document is prose has
320
+ * nowhere to put an authored path (#349).
321
+ *
322
+ * The emptiness is the declaration, in the sense `JOURNAL_ONLY_FIELDS` is:
323
+ * it is what separates a pass that emits no art from one that has simply
324
+ * not said, and it is the fact the frontmatter lint reports a `lore` note's
325
+ * inert `img:` from.
326
+ *
327
+ * @type {readonly string[]}
328
+ */
329
+ static emitsArt = Object.freeze([]);
330
+
314
331
  /**
315
332
  * How many of the compiled entries were documentation for a document
316
333
  * compiled elsewhere, for the summary.
@@ -381,12 +398,8 @@ export class Journals extends BasePackCompiler {
381
398
 
382
399
  // A documentation entry is filed exactly where the document it
383
400
  // describes is, so the journals pack mirrors the items pack and a doc
384
- // sits under the same heading a reader found the item under. The id is
385
- // taken verbatim rather than through `folderResolver`, which validates
386
- // against this pack's own folders.yaml — an item folder is declared in
387
- // the items one, a macro folder in the macros one, and a map's in the
388
- // scenes one.
389
- const { value: authoredFolder, isAddress } = folderField(fm);
401
+ // sits under the same heading a reader found the item under.
402
+ //
390
403
  // An address is resolved wherever it is written, including here — and
391
404
  // resolving it *here* is what cures the defect this comment used to
392
405
  // describe. A folder note has one definition and one address, so the
@@ -395,12 +408,10 @@ export class Journals extends BasePackCompiler {
395
408
  // with the first, and so no arrangement to assume: the mirroring
396
409
  // failure is unrepresentable rather than merely reported.
397
410
  //
398
- // `folder:` is unchanged, and still crosses packs verbatim on the
399
- // assumption both declare it — the arrangement #260 retires.
400
- const folder =
401
- isAddress ? this.folderResolver(authoredFolder, { isAddress: true })
402
- : ownsDoc ? authoredFolder
403
- : this.folderResolver(authoredFolder);
411
+ // The id spelling used to cross packs verbatim here, on the assumption
412
+ // both declared it — the arrangement #260 retires with the YAML.
413
+ const { value: authoredFolder } = folderField(fm);
414
+ const folder = this.folderResolver(authoredFolder, { isAddress: true });
404
415
 
405
416
  return buildJournalEntry({
406
417
  id,
package/engine/macros.mjs CHANGED
@@ -295,6 +295,14 @@ export class Macros extends BasePackCompiler {
295
295
  */
296
296
  static convertsWikilinks = false;
297
297
 
298
+ /**
299
+ * A Macro carries an `img` — the tile art Foundry shows on the hotbar —
300
+ * defaulting to {@link DEFAULT_MACRO_IMG} where the note names none.
301
+ *
302
+ * @type {readonly string[]}
303
+ */
304
+ static emitsArt = Object.freeze(["img"]);
305
+
298
306
  /**
299
307
  * @param {object} fm - The note's frontmatter.
300
308
  * @returns {boolean} True for a `macro` note.
@@ -56,9 +56,9 @@ import { compendiumUuid, makeId, MAP_SUBTYPES, MAP_TYPES } from "./ids.mjs";
56
56
  // bridge (`SohlRegionTriggerBehavior`), so an event this build accepts is
57
57
  // exactly one the bridge forwards.
58
58
  import { CURATED_REGION_EVENTS, EXCLUDED_REGION_EVENTS } from "./region-events.mjs";
59
- // A map's background art is `img`, as every other note type's art is; `image`
60
- // is the retired spelling, still read through the retirement window (#142).
61
- import { readAliasedField } from "./retired-fields.mjs";
59
+ // A map's background art is `img`, as every other note type's art is. `image`,
60
+ // the spelling a map alone once used, is retired and gone (#149).
61
+ import { sohlField } from "./frontmatter.mjs";
62
62
 
63
63
  /* -------------------------------------------------------------------- */
64
64
  /* Note types and their canvas profiles */
@@ -907,7 +907,7 @@ export function buildScene(fm, ctx) {
907
907
  // Read from the note rather than from its `sohl:` block: art is not
908
908
  // system-specific, so `img` is authored at the top level like every other
909
909
  // type's, and `sohlField` honours the block for anything already there.
910
- const img = readAliasedField(fm, "img");
910
+ const img = sohlField(fm, "img");
911
911
  if (!img) throw new Error("a map note needs an `img`");
912
912
 
913
913
  const warn = (message) => {
@@ -981,11 +981,11 @@ export function buildScene(fm, ctx) {
981
981
  * @param {string} sceneId - The owning scene's `_id`.
982
982
  * @param {string} [img] - The background art, already resolved from the note.
983
983
  * Passed by {@link buildScene}, which reads it from the note rather than from
984
- * the block; defaults to whichever spelling the block itself carries, so a
985
- * direct two-argument call still works (#142).
984
+ * the block; defaults to the block's own `img`, so a direct two-argument call
985
+ * still works.
986
986
  * @returns {object} The Level document, keyed for the pack.
987
987
  */
988
- export function buildLevel(sohl, sceneId, img = readAliasedField({ sohl }, "img")) {
988
+ export function buildLevel(sohl, sceneId, img = sohlField({ sohl }, "img")) {
989
989
  const level = {
990
990
  _id: DEFAULT_LEVEL_ID,
991
991
  name: sohl.levelName ?? "Ground",
@@ -44,6 +44,11 @@ import { documentId } from "./content-address.mjs";
44
44
  import { systemOf } from "./document-subtypes.mjs";
45
45
  import { contentPackage } from "./content-package.mjs";
46
46
  import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./subtype-registry.mjs";
47
+ // The folder id's derivation, taken from the pass that owns it rather than
48
+ // restated here — see the `folder` branch below. `folder-notes.mjs` reaches
49
+ // only `content-address`, `address-charset`, `ids` and `retired-fields`, none
50
+ // of which reach this module, so the direction closes no cycle.
51
+ import { FOLDER_TYPE, folderDocId } from "./folder-notes.mjs";
47
52
 
48
53
  /**
49
54
  * A frontmatter value read as a non-blank string, or `undefined`.
@@ -64,6 +69,20 @@ function text(value) {
64
69
  /**
65
70
  * The document id a note compiles under: its pin, or its address.
66
71
  *
72
+ * **One type hashes its address differently, and that is not an exception to
73
+ * the rule but an application of it.** A `Folder` is a document of its own
74
+ * class, and its id is hashed under the `folder` namespace so that a folder and
75
+ * an item sharing a shortcode cannot derive one id — a collision Foundry would
76
+ * not report, since it keys folders and documents in separate collections
77
+ * (#258). So the answer for a folder comes from
78
+ * {@link module:engine/folder-notes.folderDocId}, the pass that emits those
79
+ * documents, rather than from a second derivation here.
80
+ *
81
+ * That this function ever answered differently was invisible from inside a
82
+ * build — no pass reads a folder's id from here — and surfaced only in the
83
+ * content index, which is read from outside and had no way to be checked
84
+ * against what shipped (#310).
85
+ *
67
86
  * Returns `undefined` for a file with **no address** — no `type`, or no
68
87
  * `shortcode`. Such a file is not an addressable note, so it has no document
69
88
  * and inventing an id for one would file it under nothing. Every caller already
@@ -88,7 +107,12 @@ export function noteDocId(fm, { pkg, maps = KNOWN_DOCUMENT_SUBTYPE_MAPS } = {})
88
107
  const type = text(fm.type);
89
108
  const shortcode = text(fm.shortcode);
90
109
  if (!type || !shortcode) return undefined;
91
- return documentId(pkg ?? contentPackage(), systemOf(type, maps), type, shortcode);
110
+ const owner = pkg ?? contentPackage();
111
+ // Lowercased because `collectFolderNotes` matches the type that way, and
112
+ // the two must answer alike about the same note or the divergence this
113
+ // branch closes reopens under a capitalised `type: Folder`.
114
+ if (type.toLowerCase() === FOLDER_TYPE) return folderDocId(owner, shortcode);
115
+ return documentId(owner, systemOf(type, maps), type, shortcode);
92
116
  }
93
117
 
94
118
  /**
@@ -422,7 +422,14 @@ export const NOTE_VOCABULARY = Object.freeze({
422
422
  // values here would put a second, weaker answer beside the real one.
423
423
  subTypes: null,
424
424
  data: Object.freeze([
425
- { name: "portrait", ...TEXT, describe: "Path to the portrait image." },
425
+ {
426
+ name: "portrait",
427
+ ...TEXT,
428
+ describe:
429
+ "Path to the portrait image. Its first segment says which package owns " +
430
+ "the file: `systems/…` and `modules/…` are emitted unchanged, anything " +
431
+ "else is this package's own and is rooted under its assets.",
432
+ },
426
433
  TEMPLATE_PRIORITY,
427
434
  { name: "archetypes", ...LIST, describe: "Archetypal behaviours the being fits." },
428
435
  { name: "occupation", ...TEXT, describe: "What the being does for a living." },
@@ -466,7 +473,14 @@ export const NOTE_VOCABULARY = Object.freeze({
466
473
  vehicle: Object.freeze({
467
474
  subTypes: null,
468
475
  data: Object.freeze([
469
- { name: "portrait", ...TEXT, describe: "Path to the portrait image." },
476
+ {
477
+ name: "portrait",
478
+ ...TEXT,
479
+ describe:
480
+ "Path to the portrait image. Its first segment says which package owns " +
481
+ "the file: `systems/…` and `modules/…` are emitted unchanged, anything " +
482
+ "else is this package's own and is rooted under its assets.",
483
+ },
470
484
  TEMPLATE_PRIORITY,
471
485
  ]),
472
486
  }),
@@ -577,16 +591,31 @@ export const NOTE_VOCABULARY = Object.freeze({
577
591
  ...TEXT,
578
592
  describe: "Roll formula for the delay between contraction and onset.",
579
593
  },
594
+ {
595
+ name: "onsetDurationBase",
596
+ ...NUM,
597
+ describe: "That delay in seconds, stated outright instead of rolled.",
598
+ },
580
599
  {
581
600
  name: "healingCheckDurationFormula",
582
601
  ...TEXT,
583
602
  describe: "Roll formula for the interval between healing checks.",
584
603
  },
604
+ {
605
+ name: "healingCheckDurationBase",
606
+ ...NUM,
607
+ describe: "That interval in seconds, stated outright instead of rolled.",
608
+ },
585
609
  {
586
610
  name: "resolutionDurationFormula",
587
611
  ...TEXT,
588
612
  describe: "Roll formula for the time from onset to resolution.",
589
613
  },
614
+ {
615
+ name: "resolutionDurationBase",
616
+ ...NUM,
617
+ describe: "That time in seconds, stated outright instead of rolled.",
618
+ },
590
619
  ]),
591
620
  }),
592
621
 
@@ -719,7 +748,39 @@ export const NOTE_VOCABULARY = Object.freeze({
719
748
  "shock",
720
749
  "coma",
721
750
  ]),
722
- data: Object.freeze([TEMPLATE_PRIORITY]),
751
+ data: Object.freeze([
752
+ TEMPLATE_PRIORITY,
753
+ {
754
+ name: "healingCheckDurationFormula",
755
+ ...TEXT,
756
+ describe: "Roll formula for the interval between healing checks.",
757
+ },
758
+ {
759
+ name: "healingCheckDurationBase",
760
+ ...NUM,
761
+ describe: "That interval in seconds, stated outright instead of rolled.",
762
+ },
763
+ {
764
+ name: "bloodLossAdvanceDurationFormula",
765
+ ...TEXT,
766
+ describe: "Roll formula for the interval between blood-loss advances.",
767
+ },
768
+ {
769
+ name: "bloodLossAdvanceDurationBase",
770
+ ...NUM,
771
+ describe: "That interval in seconds. Setting it is what makes the wound bleed.",
772
+ },
773
+ {
774
+ name: "courseDurationFormula",
775
+ ...TEXT,
776
+ describe: "Roll formula for the interval between course tests.",
777
+ },
778
+ {
779
+ name: "courseDurationBase",
780
+ ...NUM,
781
+ describe: "That interval in seconds, stated outright instead of rolled.",
782
+ },
783
+ ]),
723
784
  }),
724
785
 
725
786
  weapongear: Object.freeze({
@@ -809,6 +870,7 @@ export const NOTE_VOCABULARY = Object.freeze({
809
870
  "folk",
810
871
  "culture",
811
872
  "bestiary",
873
+ "gathering",
812
874
  ]),
813
875
  // Nothing of its own: a lore note is prose, and what it *is* about is
814
876
  // its subType. The specification declares an empty table for it, and
@@ -881,12 +943,17 @@ export const NOTE_VOCABULARY = Object.freeze({
881
943
  // derived for them, which is precisely what a subType decides (#174).
882
944
  subTypes: Object.freeze(["battlemap", "localmap", "regionalmap"]),
883
945
  data: Object.freeze([
884
- // The specification spells this `img`, matching every other
885
- // note type, while the map compiler reads `image` from
886
- // `sohl:` today. It says outright that one of the two has
887
- // to move; the container takes the specification's name,
888
- // and moving the authored key is the migration's business.
889
- { name: "img", ...TEXT, describe: "Path to the map art." },
946
+ // `img`, as every other note type spells its artwork. A map alone
947
+ // read `image` out of its `sohl:` block; that spelling is retired
948
+ // and gone (#149), so the two names are one again.
949
+ {
950
+ name: "img",
951
+ ...TEXT,
952
+ describe:
953
+ "Path to the map art, owned by whichever package its first segment " +
954
+ "names — `systems/…` and `modules/…` unchanged, anything else this " +
955
+ "package's own.",
956
+ },
890
957
  {
891
958
  name: "dimensions",
892
959
  ...LIST,