@heroiclands/package-build 8.1.0 → 10.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 (122) hide show
  1. package/CHANGELOG.md +748 -0
  2. package/CONTENT.md +273 -13
  3. package/bin/content-build.mjs +479 -123
  4. package/bin/package-build.mjs +27 -69
  5. package/bin/report.mjs +1 -2
  6. package/bundle.mjs +2 -10
  7. package/config.mjs +31 -106
  8. package/container.mjs +13 -57
  9. package/content-config.mjs +300 -188
  10. package/coverage.mjs +14 -55
  11. package/deploy.mjs +4 -13
  12. package/docs/content-format.md +1418 -0
  13. package/e2e.mjs +16 -55
  14. package/engine/address-charset.mjs +62 -0
  15. package/engine/address-diff.mjs +1 -4
  16. package/engine/alias-index.mjs +153 -0
  17. package/engine/base-compiler.mjs +203 -31
  18. package/engine/code-fences.mjs +4 -13
  19. package/engine/compendiums.mjs +13 -37
  20. package/engine/content-address.mjs +6 -10
  21. package/engine/content-format-check.mjs +570 -0
  22. package/engine/content-format.mjs +253 -0
  23. package/engine/content-links.mjs +144 -99
  24. package/engine/content-lint.mjs +12 -16
  25. package/engine/content-slug.mjs +2 -6
  26. package/engine/content-tables.mjs +26 -79
  27. package/engine/diagnostics.mjs +37 -15
  28. package/engine/document-subtypes.mjs +440 -0
  29. package/engine/field-reference.mjs +6 -20
  30. package/engine/field-spec.mjs +49 -45
  31. package/engine/foreign-catalog.mjs +7 -22
  32. package/engine/foreign-manifests.mjs +1 -4
  33. package/engine/frontmatter-lint.mjs +347 -43
  34. package/engine/frontmatter.mjs +3 -8
  35. package/engine/generate.mjs +36 -20
  36. package/engine/helpers.mjs +54 -81
  37. package/engine/homepage.mjs +4 -15
  38. package/engine/ids.mjs +21 -12
  39. package/engine/index.mjs +15 -0
  40. package/engine/item-registry.mjs +72 -9
  41. package/engine/journals.mjs +4 -14
  42. package/engine/kb-manifest.mjs +41 -24
  43. package/engine/macros.mjs +2 -10
  44. package/engine/manifest-emit.mjs +6 -17
  45. package/engine/map-notes.mjs +53 -87
  46. package/engine/note-claims.mjs +383 -0
  47. package/engine/note-package.mjs +1 -4
  48. package/engine/note-vocabulary.mjs +678 -0
  49. package/engine/pack-config.mjs +56 -60
  50. package/engine/pack-router.mjs +18 -8
  51. package/engine/prose-config.mjs +20 -4
  52. package/engine/prose-lint.mjs +61 -17
  53. package/engine/region-events.mjs +1 -3
  54. package/engine/retired-fields.mjs +117 -3
  55. package/engine/scene-levels.mjs +8 -22
  56. package/engine/scenes.mjs +31 -47
  57. package/engine/schema-check.mjs +348 -7
  58. package/engine/schema-extract.mjs +11 -39
  59. package/engine/site-build.mjs +13 -38
  60. package/engine/site-index.mjs +40 -35
  61. package/engine/system-block.mjs +513 -0
  62. package/engine/web-wikilinks.mjs +115 -92
  63. package/engine/wikilink-syntax.mjs +30 -0
  64. package/engine/wikilinks.mjs +147 -183
  65. package/index.mjs +1 -5
  66. package/lang.mjs +1 -3
  67. package/manifest.mjs +10 -37
  68. package/markdownlint-config.mjs +1 -5
  69. package/package.json +6 -2
  70. package/sohl/actors.mjs +251 -68
  71. package/sohl/being-info.mjs +3 -6
  72. package/sohl/document-subtypes.mjs +82 -0
  73. package/sohl/index.mjs +4 -6
  74. package/sohl/item-builders.mjs +1 -3
  75. package/sohl/item-fields.mjs +16 -34
  76. package/sohl/items.mjs +111 -17
  77. package/sohl/kb-passes.mjs +29 -39
  78. package/sohl/note-schemas.mjs +11 -7
  79. package/sohl/skill-base.mjs +7 -23
  80. package/stage.mjs +3 -13
  81. package/templates.mjs +4 -15
  82. package/types/bundle.d.mts +1 -1
  83. package/types/container.d.mts +2 -2
  84. package/types/content-config.d.mts +48 -4
  85. package/types/coverage.d.mts +1 -1
  86. package/types/e2e.d.mts +4 -4
  87. package/types/engine/address-charset.d.mts +45 -0
  88. package/types/engine/alias-index.d.mts +122 -0
  89. package/types/engine/base-compiler.d.mts +132 -4
  90. package/types/engine/content-address.d.mts +2 -2
  91. package/types/engine/content-format-check.d.mts +163 -0
  92. package/types/engine/content-format.d.mts +101 -0
  93. package/types/engine/content-links.d.mts +16 -1
  94. package/types/engine/content-lint.d.mts +6 -0
  95. package/types/engine/diagnostics.d.mts +29 -0
  96. package/types/engine/document-subtypes.d.mts +233 -0
  97. package/types/engine/field-spec.d.mts +76 -23
  98. package/types/engine/frontmatter-lint.d.mts +47 -2
  99. package/types/engine/generate.d.mts +15 -2
  100. package/types/engine/helpers.d.mts +22 -14
  101. package/types/engine/ids.d.mts +10 -0
  102. package/types/engine/index.d.mts +5 -0
  103. package/types/engine/item-registry.d.mts +21 -2
  104. package/types/engine/kb-manifest.d.mts +35 -8
  105. package/types/engine/map-notes.d.mts +21 -11
  106. package/types/engine/note-claims.d.mts +113 -0
  107. package/types/engine/note-vocabulary.d.mts +251 -0
  108. package/types/engine/pack-config.d.mts +4 -3
  109. package/types/engine/pack-router.d.mts +4 -4
  110. package/types/engine/prose-lint.d.mts +6 -2
  111. package/types/engine/retired-fields.d.mts +73 -2
  112. package/types/engine/schema-check.d.mts +182 -0
  113. package/types/engine/schema-extract.d.mts +1 -1
  114. package/types/engine/site-index.d.mts +1 -1
  115. package/types/engine/system-block.d.mts +281 -0
  116. package/types/engine/web-wikilinks.d.mts +23 -12
  117. package/types/engine/wikilink-syntax.d.mts +29 -0
  118. package/types/manifest.d.mts +1 -1
  119. package/types/sohl/actors.d.mts +62 -6
  120. package/types/sohl/document-subtypes.d.mts +14 -0
  121. package/types/sohl/index.d.mts +1 -0
  122. package/types/sohl/items.d.mts +21 -0
@@ -45,13 +45,7 @@
45
45
 
46
46
  import log from "loglevel";
47
47
 
48
- import {
49
- sohlField,
50
- makeId,
51
- resolveName,
52
- defaultStats,
53
- md,
54
- } from "./helpers.mjs";
48
+ import { sohlField, makeId, resolveName, defaultStats, md } from "./helpers.mjs";
55
49
  import { BasePackCompiler } from "./base-compiler.mjs";
56
50
  import { anchorPageId } from "./wikilinks.mjs";
57
51
  import { hasDocEntry, itemDocEntryId } from "./item-docs.mjs";
@@ -98,12 +92,10 @@ export function splitPages(body, leadName = "Introduction") {
98
92
  // An H1 starts a page, as does any heading carrying an `{#slug}`
99
93
  // anchor: a Foundry UUID can only address a page, so a linkable
100
94
  // section has to be one.
101
- const headingMatch =
102
- !inCodeBlock ? line.match(/^\s*(#{1,6})\s+(.+?)\s*#*\s*$/) : null;
95
+ const headingMatch = !inCodeBlock ? line.match(/^\s*(#{1,6})\s+(.+?)\s*#*\s*$/) : null;
103
96
  const rawHeading = headingMatch?.[2]?.trim();
104
97
  const anchorMatch = rawHeading?.match(/^(.*?)\s*\{#([^}]+)\}\s*$/);
105
- const startsPage =
106
- headingMatch && (headingMatch[1].length === 1 || anchorMatch);
98
+ const startsPage = headingMatch && (headingMatch[1].length === 1 || anchorMatch);
107
99
  if (startsPage) {
108
100
  closeCurrent();
109
101
  current = {
@@ -371,8 +363,6 @@ export class Journals extends BasePackCompiler {
371
363
 
372
364
  /** @inheritdoc */
373
365
  reportDetail(stats) {
374
- log.debug(
375
- `Skipped ${stats.skippedOther} non-doc file(s) (not type:doc)`,
376
- );
366
+ log.debug(`Skipped ${stats.skippedOther} non-doc file(s) (not type:doc)`);
377
367
  }
378
368
  }
@@ -90,18 +90,47 @@ export function canonicalKey(pkg, type, shortcode) {
90
90
  }
91
91
 
92
92
  /**
93
- * Reads a canonical key back into its parts.
93
+ * How many segments a canonical key has, and therefore how many the reader
94
+ * below counts.
94
95
  *
95
- * Unambiguous because no package, type or shortcode contains a hyphen types
96
- * are bare words and shortcodes are `^[A-Za-z0-9]+$` (#1397).
96
+ * Named rather than written as a literal because it is the *grammar*, not an
97
+ * implementation detail of one function: it is the number a change to the
98
+ * address form would move, and the thing a reader of that change has to find.
97
99
  *
98
- * @param {string} key - A canonical key.
99
- * @returns {{package: string, type: string, shortcode: string}|null} The parts,
100
- * or `null` when the key is not in canonical form.
100
+ * @type {number}
101
+ */
102
+ export const CANONICAL_KEY_SEGMENTS = 3;
103
+
104
+ /**
105
+ * Reads a canonical key back into its parts.
106
+ *
107
+ * Parsing is plain positional counting: split on the separator, require
108
+ * {@link CANONICAL_KEY_SEGMENTS} of them, and assign each position its field.
109
+ * **The charset rule is what makes that sound** — every segment is
110
+ * `^[A-Za-z0-9]+$` (`ADDRESS_SEGMENT_PATTERN` in `engine/address-charset.mjs`),
111
+ * so the hyphen is purely a separator and the count alone determines every
112
+ * field. That is enforced at each of the three sources rather than assumed of
113
+ * the data: shortcodes by `content-lint.mjs` (#1397), `contentPackage` by
114
+ * `defineConfig` (#59), and types are bare words. Were any of them free to
115
+ * carry a hyphen, no amount of counting would recover the fields and the reader
116
+ * would need a vocabulary to match against instead.
117
+ *
118
+ * **Nothing to read and nothing readable are different answers.** A key that
119
+ * cannot be canonical — `harn-adventures-skill-melee`, four segments — yields
120
+ * `null`, while an absent or blank input yields `undefined`. Both are falsy, so
121
+ * every call site (all of which test the result for truthiness) is unaffected;
122
+ * the distinction is there so a caller reporting "this key is unreadable" can
123
+ * tell that it has a key to report about.
124
+ *
125
+ * @param {unknown} key - A canonical key, or nothing.
126
+ * @returns {{package: string, type: string, shortcode: string}|null|undefined}
127
+ * The parts; `null` when there is a string that is not in canonical form;
128
+ * `undefined` when there is no key at all.
101
129
  */
102
130
  export function readCanonicalKey(key) {
131
+ if (key == null || key === "") return undefined;
103
132
  const parts = String(key).split("-");
104
- if (parts.length !== 3) return null;
133
+ if (parts.length !== CANONICAL_KEY_SEGMENTS) return null;
105
134
  const [pkg, type, shortcode] = parts;
106
135
  if (!pkg || !type || !shortcode) return null;
107
136
  return { package: pkg, type, shortcode };
@@ -180,9 +209,7 @@ export const PACKAGE_BASE = Object.freeze({
180
209
  */
181
210
  function checkBase(base, what) {
182
211
  if (typeof base !== "string" || !base.endsWith("/")) {
183
- throw new Error(
184
- `${what}: package base ${JSON.stringify(base)} must end in a slash`,
185
- );
212
+ throw new Error(`${what}: package base ${JSON.stringify(base)} must end in a slash`);
186
213
  }
187
214
  return base;
188
215
  }
@@ -226,8 +253,7 @@ export function resolvePackageUrl(rel, base) {
226
253
  checkBase(base, "resolvePackageUrl");
227
254
  if (typeof rel !== "string" || !rel || rel.startsWith("/")) {
228
255
  throw new Error(
229
- `resolvePackageUrl: ${JSON.stringify(rel)} is not a package-` +
230
- `relative address`,
256
+ `resolvePackageUrl: ${JSON.stringify(rel)} is not a package-` + `relative address`,
231
257
  );
232
258
  }
233
259
  return `${base}${rel}`;
@@ -289,8 +315,7 @@ export function buildManifest(pkg, entries, base, foundryPackage) {
289
315
  // address, so there is no fact being restated, and an anchor is not
290
316
  // required to live inside its own entry. Publishing the complete link
291
317
  // also keeps the page-id hash out of the published contract entirely.
292
- if (e.anchors && Object.keys(e.anchors).length)
293
- entry.anchors = e.anchors;
318
+ if (e.anchors && Object.keys(e.anchors).length) entry.anchors = e.anchors;
294
319
  out[e.key ?? canonicalKey(pkg, type, shortcode)] = entry;
295
320
  }
296
321
  return {
@@ -327,12 +352,7 @@ export function writeManifests(entriesByPackage, dir, bases, foundryPackages) {
327
352
  fs.mkdirSync(dir, { recursive: true });
328
353
  const written = [];
329
354
  for (const [pkg, entries] of entriesByPackage) {
330
- const doc = buildManifest(
331
- pkg,
332
- entries,
333
- bases?.[pkg],
334
- foundryPackages?.[pkg],
335
- );
355
+ const doc = buildManifest(pkg, entries, bases?.[pkg], foundryPackages?.[pkg]);
336
356
  const file = path.join(dir, `${pkg}.json`);
337
357
  fs.writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`);
338
358
  written.push({
@@ -430,10 +450,7 @@ export function loadForeignManifests(dir, localPackages, bases = PACKAGE_BASE) {
430
450
  // Absent for an entry with no page. A consumer must
431
451
  // tolerate that rather than invent an href, exactly as
432
452
  // it already tolerates an entry with no `uuid`.
433
- url:
434
- v.path == null ?
435
- undefined
436
- : resolvePackageUrl(v.path, base),
453
+ url: v.path == null ? undefined : resolvePackageUrl(v.path, base),
437
454
  uuid: v.uuid,
438
455
  doc: v.doc,
439
456
  anchors: v.anchors,
package/engine/macros.mjs CHANGED
@@ -51,12 +51,7 @@
51
51
 
52
52
  import log from "loglevel";
53
53
 
54
- import {
55
- sohlField,
56
- resolveName,
57
- resolveImg,
58
- defaultStats,
59
- } from "./helpers.mjs";
54
+ import { sohlField, resolveName, resolveImg, defaultStats } from "./helpers.mjs";
60
55
  import { BasePackCompiler } from "./base-compiler.mjs";
61
56
  import { splitPages } from "./journals.mjs";
62
57
 
@@ -256,10 +251,7 @@ export function resolveMacroScope(fm, label) {
256
251
  * @returns {MacroDocument} The Macro document.
257
252
  * @throws {Error} When the frontmatter's macro type or scope is unusable.
258
253
  */
259
- export function buildMacroEntry(
260
- fm,
261
- { command, folder = null, stats = defaultStats() },
262
- ) {
254
+ export function buildMacroEntry(fm, { command, folder = null, stats = defaultStats() }) {
263
255
  const name = resolveName(fm);
264
256
  const id = fm.id;
265
257
  return {
@@ -142,11 +142,7 @@ export function entriesForNote(fm, name, address, body, ctx) {
142
142
  : undefined;
143
143
 
144
144
  if (hasDocEntry(fm.type)) {
145
- const docKey = canonicalKey(
146
- contentPackage,
147
- `doc${fm.type}`,
148
- fm.shortcode,
149
- );
145
+ const docKey = canonicalKey(contentPackage, `doc${fm.type}`, fm.shortcode);
150
146
  const docEntryId = fm.id ? itemDocEntryId(fm.id) : undefined;
151
147
  const docUuid = uuidFor("doc", docEntryId);
152
148
  return [
@@ -166,10 +162,7 @@ export function entriesForNote(fm, name, address, body, ctx) {
166
162
  // documentation, so both addresses resolve to the same URL.
167
163
  url,
168
164
  uuid: docUuid,
169
- anchors:
170
- docUuid ?
171
- anchorsOf(docUuid, docEntryId, body ?? "", name)
172
- : undefined,
165
+ anchors: docUuid ? anchorsOf(docUuid, docEntryId, body ?? "", name) : undefined,
173
166
  },
174
167
  ];
175
168
  }
@@ -184,10 +177,7 @@ export function entriesForNote(fm, name, address, body, ctx) {
184
177
  name,
185
178
  url,
186
179
  uuid: own,
187
- anchors:
188
- own && fm.type === "doc" ?
189
- anchorsOf(own, fm.id, body ?? "", name)
190
- : undefined,
180
+ anchors: own && fm.type === "doc" ? anchorsOf(own, fm.id, body ?? "", name) : undefined,
191
181
  },
192
182
  ];
193
183
  }
@@ -221,10 +211,9 @@ export function collectManifestEntries(contentBase, ctx) {
221
211
  // note yields two entries, so reporting one as the other overstates how
222
212
  // much of the tree is published.
223
213
  let notes = 0;
224
- for (const { frontmatter: fm, body, absPath } of walkMarkdownTree(
225
- contentBase,
226
- { skipDirectories: ctx.skipDirectories },
227
- )) {
214
+ for (const { frontmatter: fm, body, absPath } of walkMarkdownTree(contentBase, {
215
+ skipDirectories: ctx.skipDirectories,
216
+ })) {
228
217
  if (!fm) continue;
229
218
  const rel = path.relative(contentBase, absPath);
230
219
  assertNoDeclaredPackage(fm, {
@@ -51,14 +51,14 @@
51
51
 
52
52
  import crypto from "crypto";
53
53
 
54
- import { compendiumUuid, makeId, MAP_TYPES } from "./ids.mjs";
54
+ import { compendiumUuid, makeId, MAP_SUBTYPES, MAP_TYPES } from "./ids.mjs";
55
55
  // The curated region-event vocabulary is shared verbatim with the runtime
56
56
  // bridge (`SohlRegionTriggerBehavior`), so an event this build accepts is
57
57
  // exactly one the bridge forwards.
58
- import {
59
- CURATED_REGION_EVENTS,
60
- EXCLUDED_REGION_EVENTS,
61
- } from "./region-events.mjs";
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";
62
62
 
63
63
  /* -------------------------------------------------------------------- */
64
64
  /* Note types and their canvas profiles */
@@ -66,7 +66,7 @@ import {
66
66
 
67
67
  // The set itself lives in `ids.mjs`, a leaf both this module and the
68
68
  // doc-carrying type set in `item-docs.mjs` can depend on without a cycle.
69
- export { MAP_TYPES };
69
+ export { MAP_SUBTYPES, MAP_TYPES };
70
70
 
71
71
  /**
72
72
  * Whether a content note's type compiles into a Scene.
@@ -79,7 +79,7 @@ export function isMapType(type) {
79
79
  }
80
80
 
81
81
  /**
82
- * Per-type canvas defaults, emitted **explicitly** on every scene.
82
+ * Per-subtype canvas defaults, emitted **explicitly** on every scene.
83
83
  *
84
84
  * This is not a convenience. `grid.type`, `grid.distance` and `grid.units` all
85
85
  * declare `initial: () => game.system.grid.*`, and there is no `game` at build
@@ -89,7 +89,7 @@ export function isMapType(type) {
89
89
  *
90
90
  * @type {Readonly<Record<string, object>>}
91
91
  */
92
- export const MAP_TYPE_PROFILES = Object.freeze({
92
+ export const MAP_SUBTYPE_PROFILES = Object.freeze({
93
93
  battlemap: Object.freeze({
94
94
  grid: { type: 1 /* SQUARE */, distance: 5, units: "ft" },
95
95
  tokenVision: true,
@@ -111,18 +111,23 @@ export const MAP_TYPE_PROFILES = Object.freeze({
111
111
  });
112
112
 
113
113
  /**
114
- * The canvas profile for a map type.
114
+ * The canvas profile for a map subType.
115
115
  *
116
- * @param {string} type - The note's `type`.
117
- * @returns {object} The profile from {@link MAP_TYPE_PROFILES}.
118
- * @throws {Error} When the type is not a map type — the build's fail-fast
119
- * contract, so a typo never ships a scene with Foundry's own defaults.
116
+ * Keyed on the subType rather than the type since #174: every map note is
117
+ * `type: map`, and which canvas it derives is the one thing the three
118
+ * spellings ever decided.
119
+ *
120
+ * @param {string} subType - The note's `subType`.
121
+ * @returns {object} The profile from {@link MAP_SUBTYPE_PROFILES}.
122
+ * @throws {Error} When the subType is not a map subType — the build's
123
+ * fail-fast contract, so a typo never ships a scene with Foundry's own
124
+ * defaults.
120
125
  */
121
- export function mapProfile(type) {
122
- const profile = MAP_TYPE_PROFILES[String(type)];
126
+ export function mapProfile(subType) {
127
+ const profile = MAP_SUBTYPE_PROFILES[String(subType)];
123
128
  if (!profile) {
124
129
  throw new Error(
125
- `unknown map type "${type}" — expected one of ${[...MAP_TYPES].join(", ")}`,
130
+ `unknown map subtype "${subType}" — expected one of ${MAP_SUBTYPES.join(", ")}`,
126
131
  );
127
132
  }
128
133
  return profile;
@@ -266,10 +271,7 @@ export function assertPixelGeometry(coords, geom) {
266
271
  * @throws {Error} When the coordinates read as pixels.
267
272
  */
268
273
  export function assertGridLocation(at, geom) {
269
- const [gx, gy] = [
270
- geom.dimensions[0] / geom.pxPerGrid,
271
- geom.dimensions[1] / geom.pxPerGrid,
272
- ];
274
+ const [gx, gy] = [geom.dimensions[0] / geom.pxPerGrid, geom.dimensions[1] / geom.pxPerGrid];
273
275
  if (at[0] > gx || at[1] > gy) {
274
276
  throw new Error(
275
277
  `${geom.label}: [${at.join(", ")}] lies outside the map's ` +
@@ -408,9 +410,7 @@ function buildWall(segment, spec, geom, { sceneId, id }) {
408
410
  * The shape forms a map note may author. `rect` is the short spelling of
409
411
  * `rectangle`, as the design's own examples use.
410
412
  */
411
- const SHAPE_FORMS = Object.freeze(
412
- new Set(["rect", "rectangle", "circle", "ellipse", "polygon"]),
413
- );
413
+ const SHAPE_FORMS = Object.freeze(new Set(["rect", "rectangle", "circle", "ellipse", "polygon"]));
414
414
 
415
415
  /**
416
416
  * Compile one authored shape into a Foundry shape record.
@@ -448,20 +448,14 @@ export function buildShape(spec, geom) {
448
448
  }
449
449
  const raw = spec[form];
450
450
  if (!Array.isArray(raw) || raw.some((n) => !Number.isFinite(n))) {
451
- throw new Error(
452
- `${geom.label}: ${form} takes a flat list of numbers in pixels`,
453
- );
451
+ throw new Error(`${geom.label}: ${form} takes a flat list of numbers in pixels`);
454
452
  }
455
453
  assertPixelGeometry(raw, geom);
456
454
 
457
455
  switch (form) {
458
456
  case "rect":
459
457
  case "rectangle":
460
- expectLength(
461
- raw,
462
- 4,
463
- `${geom.label}: rectangle is [x, y, width, height]`,
464
- );
458
+ expectLength(raw, 4, `${geom.label}: rectangle is [x, y, width, height]`);
465
459
  return {
466
460
  type: "rectangle",
467
461
  x: raw[0],
@@ -506,8 +500,7 @@ export function buildShape(spec, geom) {
506
500
  // which is a line segment and encloses nothing.
507
501
  if (raw.length < 6) {
508
502
  throw new Error(
509
- `${geom.label}: a polygon needs at least 3 points; got ` +
510
- `${raw.length / 2}`,
503
+ `${geom.label}: a polygon needs at least 3 points; got ` + `${raw.length / 2}`,
511
504
  );
512
505
  }
513
506
  return { type: "polygon", points: [...raw], hole };
@@ -601,9 +594,7 @@ const BEHAVIOR_SPECS = Object.freeze({
601
594
  *
602
595
  * @type {ReadonlySet<string>}
603
596
  */
604
- export const REGION_BEHAVIOR_TYPES = Object.freeze(
605
- new Set(Object.keys(BEHAVIOR_SPECS)),
606
- );
597
+ export const REGION_BEHAVIOR_TYPES = Object.freeze(new Set(Object.keys(BEHAVIOR_SPECS)));
607
598
 
608
599
  /**
609
600
  * Behaviour types a map note may **never** carry, and why.
@@ -657,8 +648,7 @@ function buildBehavior(key, spec, ctx) {
657
648
  const [type] = types;
658
649
 
659
650
  const banned = BANNED_REGION_BEHAVIOR_TYPES.get(type);
660
- if (banned)
661
- throw new Error(`${label}: "${type}" is not permitted — ${banned}`);
651
+ if (banned) throw new Error(`${label}: "${type}" is not permitted — ${banned}`);
662
652
  const behaviorSpec = BEHAVIOR_SPECS[type];
663
653
  if (!behaviorSpec) {
664
654
  throw new Error(
@@ -680,13 +670,7 @@ function buildBehavior(key, spec, ctx) {
680
670
  }
681
671
  }
682
672
 
683
- const system = compileBehaviorSystem(
684
- type,
685
- authored,
686
- behaviorSpec,
687
- label,
688
- ctx,
689
- );
673
+ const system = compileBehaviorSystem(type, authored, behaviorSpec, label, ctx);
690
674
  const id = behaviorDocId(ctx.regionId, key, spec._id);
691
675
  const doc = {
692
676
  _id: id,
@@ -730,23 +714,17 @@ function compileBehaviorSystem(type, authored, behaviorSpec, label, ctx) {
730
714
  const { to, ...rest } = authored;
731
715
  return {
732
716
  ...rest,
733
- destinations: toList(to).map((addr) =>
734
- ctx.resolveRegionRef(addr, label),
735
- ),
717
+ destinations: toList(to).map((addr) => ctx.resolveRegionRef(addr, label)),
736
718
  };
737
719
  }
738
720
  case "toggleBehavior": {
739
721
  const { enable, disable, ...rest } = authored;
740
722
  const out = { ...rest, events: [...authored.events] };
741
723
  if (enable) {
742
- out.enable = toList(enable).map((a) =>
743
- ctx.resolveBehaviorRef(a, label),
744
- );
724
+ out.enable = toList(enable).map((a) => ctx.resolveBehaviorRef(a, label));
745
725
  }
746
726
  if (disable) {
747
- out.disable = toList(disable).map((a) =>
748
- ctx.resolveBehaviorRef(a, label),
749
- );
727
+ out.disable = toList(disable).map((a) => ctx.resolveBehaviorRef(a, label));
750
728
  }
751
729
  return out;
752
730
  }
@@ -765,9 +743,7 @@ function compileBehaviorSystem(type, authored, behaviorSpec, label, ctx) {
765
743
  }
766
744
  case "applyActiveEffect": {
767
745
  return {
768
- effects: toList(authored.effects).map((a) =>
769
- ctx.resolveEffectRef(a, label),
770
- ),
746
+ effects: toList(authored.effects).map((a) => ctx.resolveEffectRef(a, label)),
771
747
  };
772
748
  }
773
749
  default:
@@ -821,13 +797,7 @@ const REGION_VISIBILITY = Object.freeze({
821
797
  });
822
798
 
823
799
  /** `CONST.EDGE_RESTRICTION_TYPES`. */
824
- const RESTRICTION_TYPES = Object.freeze([
825
- "light",
826
- "darkness",
827
- "sight",
828
- "sound",
829
- "move",
830
- ]);
800
+ const RESTRICTION_TYPES = Object.freeze(["light", "darkness", "sight", "sound", "move"]);
831
801
 
832
802
  /**
833
803
  * Compile one authored region into a Region document with its behaviours.
@@ -917,7 +887,7 @@ export function buildScene(fm, ctx) {
917
887
  const sohl = fm.sohl ?? {};
918
888
  const sceneId = fm.id;
919
889
  if (!sceneId) throw new Error("a map note needs an `id`");
920
- const profile = mapProfile(fm.type);
890
+ const profile = mapProfile(fm.subType);
921
891
 
922
892
  const dimensions = sohl.dimensions;
923
893
  if (
@@ -925,9 +895,7 @@ export function buildScene(fm, ctx) {
925
895
  dimensions.length !== 2 ||
926
896
  !dimensions.every((n) => Number.isInteger(n) && n > 0)
927
897
  ) {
928
- throw new Error(
929
- "`dimensions` is [width, height] in whole pixels — the map's own size",
930
- );
898
+ throw new Error("`dimensions` is [width, height] in whole pixels — the map's own size");
931
899
  }
932
900
  const pxPerGrid = sohl.pxPerGrid;
933
901
  if (!Number.isInteger(pxPerGrid) || pxPerGrid <= 0) {
@@ -936,7 +904,11 @@ export function buildScene(fm, ctx) {
936
904
  "match the art",
937
905
  );
938
906
  }
939
- if (!sohl.image) throw new Error("a map note needs an `image`");
907
+ // Read from the note rather than from its `sohl:` block: art is not
908
+ // system-specific, so `img` is authored at the top level like every other
909
+ // type's, and `sohlField` honours the block for anything already there.
910
+ const img = readAliasedField(fm, "img");
911
+ if (!img) throw new Error("a map note needs an `img`");
940
912
 
941
913
  const warn = (message) => {
942
914
  if (ctx.warnings) ctx.warnings.push(message);
@@ -961,7 +933,7 @@ export function buildScene(fm, ctx) {
961
933
  tokenVision: profile.tokenVision,
962
934
  fog: { mode: profile.fog.mode },
963
935
  initialLevel: DEFAULT_LEVEL_ID,
964
- levels: [buildLevel(sohl, sceneId)],
936
+ levels: [buildLevel(sohl, sceneId, img)],
965
937
  drawings: [],
966
938
  tokens: [],
967
939
  lights: buildLights(sohl, geom, inner),
@@ -981,9 +953,7 @@ export function buildScene(fm, ctx) {
981
953
 
982
954
  if (ctx.journalEntryId) {
983
955
  if (!ctx.packageId) {
984
- throw new Error(
985
- "a map note with a journal needs `packageId` in its compile context",
986
- );
956
+ throw new Error("a map note with a journal needs `packageId` in its compile context");
987
957
  }
988
958
  // `Scene.journal` is a plain ForeignDocumentField, which
989
959
  // `ForeignDocumentField#initialize` unconditionally nulls inside a
@@ -992,19 +962,14 @@ export function buildScene(fm, ctx) {
992
962
  // pack needs the flag to find the entry at all.
993
963
  scene.journal = ctx.journalEntryId;
994
964
  scene.flags[ctx.packageId] = {
995
- docUuid: compendiumUuid(
996
- ctx.packageId,
997
- "doc",
998
- ctx.journalEntryId,
999
- ctx.journalPack,
1000
- ),
965
+ docUuid: compendiumUuid(ctx.packageId, "doc", ctx.journalEntryId, ctx.journalPack),
1001
966
  };
1002
967
  }
1003
968
  return scene;
1004
969
  }
1005
970
 
1006
971
  /**
1007
- * Synthesise the scene's single embedded Level from `image:` / `overlay:`.
972
+ * Synthesise the scene's single embedded Level from `img:` / `overlay:`.
1008
973
  *
1009
974
  * Authors never write `levels:`. A scene must ship at least one Level — the
1010
975
  * client-side `_preCreate` net that would create one does not run for offline
@@ -1014,16 +979,20 @@ export function buildScene(fm, ctx) {
1014
979
  *
1015
980
  * @param {object} sohl - The note's `sohl:` block.
1016
981
  * @param {string} sceneId - The owning scene's `_id`.
982
+ * @param {string} [img] - The background art, already resolved from the note.
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).
1017
986
  * @returns {object} The Level document, keyed for the pack.
1018
987
  */
1019
- export function buildLevel(sohl, sceneId) {
988
+ export function buildLevel(sohl, sceneId, img = readAliasedField({ sohl }, "img")) {
1020
989
  const level = {
1021
990
  _id: DEFAULT_LEVEL_ID,
1022
991
  name: sohl.levelName ?? "Ground",
1023
992
  elevation: { bottom: 0, top: 20 },
1024
993
  background: {
1025
994
  color: sohl.backgroundColor ?? "#999999",
1026
- src: sohl.image,
995
+ src: img,
1027
996
  },
1028
997
  foreground: { src: sohl.overlay ?? null },
1029
998
  sort: 0,
@@ -1050,9 +1019,7 @@ export function buildWalls(sohl, geom, ctx) {
1050
1019
  const label = `walls.${key}`;
1051
1020
  const segments = toList(spec.segments);
1052
1021
  if (!segments.length) {
1053
- throw new Error(
1054
- `${label}: a wall group needs at least one segment`,
1055
- );
1022
+ throw new Error(`${label}: a wall group needs at least one segment`);
1056
1023
  }
1057
1024
  segments.forEach((segment, i) => {
1058
1025
  const id = makeId("scene-wall", `${ctx.sceneId}:${key}:${i}`);
@@ -1171,8 +1138,7 @@ export function buildSounds(sohl, geom, ctx) {
1171
1138
  return Object.entries(sohl.sounds ?? {}).map(([key, spec]) => {
1172
1139
  const label = `sounds.${key}`;
1173
1140
  const [x, y] = requirePosition(spec.position, { ...geom, label });
1174
- if (!spec.path)
1175
- throw new Error(`${label}: an ambient sound needs a path`);
1141
+ if (!spec.path) throw new Error(`${label}: an ambient sound needs a path`);
1176
1142
  const id = spec._id || makeId("scene-sound", `${ctx.sceneId}:${key}`);
1177
1143
  return {
1178
1144
  _id: id,