@heroiclands/package-build 9.0.0 → 10.0.1

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 (78) hide show
  1. package/CHANGELOG.md +721 -0
  2. package/CONTENT.md +273 -13
  3. package/bin/content-build.mjs +437 -7
  4. package/content-config.mjs +259 -28
  5. package/docs/content-format.md +1418 -0
  6. package/engine/address-charset.mjs +62 -0
  7. package/engine/alias-index.mjs +153 -0
  8. package/engine/base-compiler.mjs +194 -4
  9. package/engine/content-address.mjs +4 -4
  10. package/engine/content-format-check.mjs +570 -0
  11. package/engine/content-format.mjs +253 -0
  12. package/engine/content-links.mjs +132 -56
  13. package/engine/content-lint.mjs +8 -1
  14. package/engine/diagnostics.mjs +33 -0
  15. package/engine/document-subtypes.mjs +440 -0
  16. package/engine/field-spec.mjs +49 -43
  17. package/engine/frontmatter-lint.mjs +351 -28
  18. package/engine/generate.mjs +32 -4
  19. package/engine/helpers.mjs +41 -29
  20. package/engine/ids.mjs +19 -1
  21. package/engine/index.mjs +15 -0
  22. package/engine/item-registry.mjs +72 -5
  23. package/engine/kb-manifest.mjs +36 -7
  24. package/engine/map-notes.mjs +34 -18
  25. package/engine/note-claims.mjs +383 -0
  26. package/engine/note-vocabulary.mjs +678 -0
  27. package/engine/pack-config.mjs +39 -22
  28. package/engine/pack-router.mjs +17 -6
  29. package/engine/prose-lint.mjs +55 -3
  30. package/engine/retired-fields.mjs +117 -3
  31. package/engine/scenes.mjs +19 -1
  32. package/engine/schema-check.mjs +347 -3
  33. package/engine/site-build.mjs +1 -1
  34. package/engine/site-index.mjs +38 -21
  35. package/engine/system-block.mjs +513 -0
  36. package/engine/web-wikilinks.mjs +112 -80
  37. package/engine/wikilink-syntax.mjs +30 -0
  38. package/engine/wikilinks.mjs +67 -51
  39. package/package.json +6 -2
  40. package/sohl/actors.mjs +249 -36
  41. package/sohl/document-subtypes.mjs +82 -0
  42. package/sohl/index.mjs +3 -0
  43. package/sohl/items.mjs +110 -14
  44. package/sohl/note-schemas.mjs +11 -7
  45. package/types/content-config.d.mts +48 -4
  46. package/types/engine/address-charset.d.mts +45 -0
  47. package/types/engine/alias-index.d.mts +122 -0
  48. package/types/engine/base-compiler.d.mts +132 -4
  49. package/types/engine/content-address.d.mts +2 -2
  50. package/types/engine/content-format-check.d.mts +163 -0
  51. package/types/engine/content-format.d.mts +101 -0
  52. package/types/engine/content-links.d.mts +16 -1
  53. package/types/engine/content-lint.d.mts +6 -0
  54. package/types/engine/diagnostics.d.mts +29 -0
  55. package/types/engine/document-subtypes.d.mts +233 -0
  56. package/types/engine/field-spec.d.mts +76 -23
  57. package/types/engine/frontmatter-lint.d.mts +47 -2
  58. package/types/engine/generate.d.mts +14 -1
  59. package/types/engine/helpers.d.mts +21 -13
  60. package/types/engine/ids.d.mts +10 -0
  61. package/types/engine/index.d.mts +5 -0
  62. package/types/engine/item-registry.d.mts +21 -2
  63. package/types/engine/kb-manifest.d.mts +35 -8
  64. package/types/engine/map-notes.d.mts +21 -11
  65. package/types/engine/note-claims.d.mts +113 -0
  66. package/types/engine/note-vocabulary.d.mts +251 -0
  67. package/types/engine/pack-config.d.mts +4 -3
  68. package/types/engine/pack-router.d.mts +4 -4
  69. package/types/engine/prose-lint.d.mts +6 -2
  70. package/types/engine/retired-fields.d.mts +73 -2
  71. package/types/engine/schema-check.d.mts +182 -0
  72. package/types/engine/system-block.d.mts +281 -0
  73. package/types/engine/web-wikilinks.d.mts +23 -12
  74. package/types/engine/wikilink-syntax.d.mts +29 -0
  75. package/types/sohl/actors.d.mts +62 -6
  76. package/types/sohl/document-subtypes.d.mts +14 -0
  77. package/types/sohl/index.d.mts +1 -0
  78. package/types/sohl/items.d.mts +21 -0
@@ -39,6 +39,8 @@ import { contentPackage, foundryPackageId } from "./content-package.mjs";
39
39
  import { searchableFrontmatter } from "./note-package.mjs";
40
40
  import { loadForeignManifests, PACKAGE_BASE } from "./kb-manifest.mjs";
41
41
  import { buildWikilinkIndex, convertWikilinks } from "./wikilinks.mjs";
42
+ // The alias sources every index shares (#131).
43
+ import { aliasesOf } from "./alias-index.mjs";
42
44
  import { expandContentTables } from "./content-tables.mjs";
43
45
  import { emitDiagnostic, positionInBody } from "./diagnostics.mjs";
44
46
  // The pure `sohl:` frontmatter readers live in a leaf module so the item-type
@@ -147,8 +149,8 @@ export function* walkMarkdownTree(
147
149
 
148
150
  /**
149
151
  * Resolve the required `sohl.archetype` frontmatter for an Item/Actor entry
150
- * (see the archetype contract, #604 — `flags.sohl.docArchetype`). The property
151
- * is a nullable number that authors must state explicitly:
152
+ * (the archetype contract, #604). The property is a nullable number that
153
+ * authors must state explicitly:
152
154
  * - a number → the document is an archetype of that priority.
153
155
  * - `null` → the document is not an archetype.
154
156
  * - absent → an authoring error (throws), so "not an archetype" is never
@@ -182,30 +184,29 @@ export function resolveArchetype(fm, label) {
182
184
  }
183
185
 
184
186
  /**
185
- * Merge the required `sohl.archetype` frontmatter into a document's `flags`,
186
- * returning a new object (the input is never mutated). A numeric archetype
187
- * seeds `flags.sohl.docArchetype`; `null` omits the flag (and clears any stale
188
- * `docArchetype` while preserving sibling `sohl` flags); an absent value
189
- * throws. See {@link resolveArchetype}.
190
- *
191
- * @param {object} fm Parsed frontmatter.
192
- * @param {object} [flags] The entry's existing flags (e.g. `fm.flags`).
193
- * @param {string} label Human-readable context for error messages.
194
- * @returns {object} The flags object with the archetype applied.
187
+ * The value a document's `system.archetype` carries, from the required
188
+ * `sohl.archetype` frontmatter (#126, sohl#1780).
189
+ *
190
+ * A **schema field**, so the tri-state is written out in full rather than
191
+ * expressed by a key's presence: a number is an archetype at that priority,
192
+ * and `null` is not an archetype. This is where {@link resolveArchetype}'s
193
+ * `undefined` becomes the field's `null` — an emitted `undefined` would be
194
+ * dropped by `JSON.stringify`, leaving the compiled document with no
195
+ * `archetype` at all and the tri-state readable as two.
196
+ *
197
+ * **`0` is an archetype.** It is the priority SoHL's own archetypes ship at,
198
+ * and it is falsy, so this returns it unchanged and every caller must ask
199
+ * `typeof v === "number"` rather than testing truthiness.
200
+ *
201
+ * @param {object} fm Parsed frontmatter.
202
+ * @param {string} label Human-readable context for error messages.
203
+ * @returns {number|null} The archetype priority, or `null` for a document
204
+ * that is not an archetype.
195
205
  * @throws {Error} When `sohl.archetype` is absent or invalid.
196
206
  */
197
- export function withArchetypeFlag(fm, flags, label) {
207
+ export function systemArchetype(fm, label) {
198
208
  const archetype = resolveArchetype(fm, label);
199
- const out = { ...(flags || {}) };
200
- const sohl = { ...(out.sohl || {}) };
201
- if (archetype === undefined) {
202
- delete sohl.docArchetype;
203
- } else {
204
- sohl.docArchetype = archetype;
205
- }
206
- if (Object.keys(sohl).length > 0) out.sohl = sohl;
207
- else delete out.sohl;
208
- return out;
209
+ return archetype === undefined ? null : archetype;
209
210
  }
210
211
 
211
212
  /**
@@ -449,12 +450,10 @@ export function buildContentLinkIndex(contentBase, router = packRouter()) {
449
450
  docPack: router.resolveOrNull(fm, "JournalEntry"),
450
451
  shortcode: fm.shortcode ?? null,
451
452
  name: fm.name?.full ?? base,
452
- aliases: [
453
- ...(Array.isArray(fm.aliases) ? fm.aliases : []),
454
- ...(fm.name?.full ? [fm.name.full] : []),
455
- ...(Array.isArray(fm.name?.aliases) ? fm.name.aliases : []),
456
- base,
457
- ].filter(Boolean),
453
+ // The shared alias sources (#131): `aliases`, `name.aliases` and
454
+ // `name.full`, and deliberately not the filename — see
455
+ // {@link aliasesOf} for what that admitted and why it went.
456
+ aliases: aliasesOf(fm),
458
457
  });
459
458
  }
460
459
  // Packages this build links *into* but does not publish. Their manifests
@@ -567,6 +566,19 @@ export function convertNoteWikilinks(
567
566
  `as [[type-shortcode|Text]].`,
568
567
  );
569
568
  }
569
+ // The author wrote a pipe, so they meant an address — and this target
570
+ // is not one. Its own message, because the correction is its own: a
571
+ // note *name* has to become an address, which is not the same job as
572
+ // fixing a shortcode that resolves nowhere (#131).
573
+ if (u.reason === "not-an-address") {
574
+ fail(
575
+ u,
576
+ `wikilink ${u.link} in "${name}" is written as an address — ` +
577
+ `the "|" says so — but "${u.target}" is not one. Write ` +
578
+ `[[type-shortcode|Text]], or drop the "|" to name it as ` +
579
+ `an alias within this note's own type.`,
580
+ );
581
+ }
570
582
  // A qualified address resolving nowhere is a typo, now that every
571
583
  // linkable package is either built here or vendored (#1499) — so it
572
584
  // fails the note rather than degrading to text. A bare alias stays a
package/engine/ids.mjs CHANGED
@@ -51,7 +51,18 @@ export function makeId(namespace, value) {
51
51
  *
52
52
  * @type {ReadonlySet<string>}
53
53
  */
54
- export const MAP_TYPES = Object.freeze(new Set(["battlemap", "localmap", "regionalmap"]));
54
+ export const MAP_TYPES = Object.freeze(new Set(["map"]));
55
+
56
+ /**
57
+ * The map subTypes, which differ only in the canvas defaults derived for them.
58
+ *
59
+ * They were three *types* until #174, which cost three entries in the pack
60
+ * router, three in the claims set and three in every consumer's section config
61
+ * — for one idea that the specification had always described as one type.
62
+ *
63
+ * @type {readonly string[]}
64
+ */
65
+ export const MAP_SUBTYPES = Object.freeze(["battlemap", "localmap", "regionalmap"]);
55
66
 
56
67
  /**
57
68
  * Content type → the pack its documents compile into, and the document type
@@ -95,6 +106,13 @@ export const PACK_BY_TYPE = Object.freeze({
95
106
  export const RETIRED_TYPES = Object.freeze({
96
107
  character: "being",
97
108
  creature: "being",
109
+ // The three map spellings, retired in favour of the single `map` whose
110
+ // subType they became (#174). Recorded rather than deleted for the same
111
+ // reason as the two above: an unnamed type falls through to the open item
112
+ // set, so a note or link left on one would be routed to the items pack.
113
+ battlemap: "map",
114
+ localmap: "map",
115
+ regionalmap: "map",
98
116
  });
99
117
 
100
118
  /**
package/engine/index.mjs CHANGED
@@ -36,6 +36,9 @@
36
36
  /** Deterministic document ids, the conventional pack map, and compendium UUIDs. */
37
37
  export * as ids from "./ids.mjs";
38
38
 
39
+ /** The per-system frontmatter block, and the declared shared→system mapping. */
40
+ export * as systemBlock from "./system-block.mjs";
41
+
39
42
  /** Fenced-code detection, so a rewrite never edits a code block. */
40
43
  export * as codeFences from "./code-fences.mjs";
41
44
 
@@ -51,6 +54,9 @@ export * as packConfig from "./pack-config.mjs";
51
54
  /** Which pack of a document type a note's document lands in (#1566). */
52
55
  export * as packRouter from "./pack-router.mjs";
53
56
 
57
+ /** Which note types a configuration compiles, and the notes nothing claims (#146). */
58
+ export * as noteClaims from "./note-claims.mjs";
59
+
54
60
  /** The content package a build compiles, and the Foundry package it ships in. */
55
61
  export * as contentPackage from "./content-package.mjs";
56
62
 
@@ -66,6 +72,9 @@ export * as homepage from "./homepage.mjs";
66
72
  /** The note types the engine itself declares, whatever a consumer registers. */
67
73
  export * as noteSchemas from "./note-schemas.mjs";
68
74
 
75
+ /** The closed frontmatter regions: the `data:` container and each type's `subType`. */
76
+ export * as noteVocabulary from "./note-vocabulary.mjs";
77
+
69
78
  /** The shipped Foundry manifest: locating it, reading it, guarding its id. */
70
79
 
71
80
  /** The URL a content note is published at — the one web-address rule. */
@@ -104,6 +113,9 @@ export * as helpers from "./helpers.mjs";
104
113
  /** The consumer's resolved item-type registry: the whitelist and its builders. */
105
114
  export * as itemRegistry from "./item-registry.mjs";
106
115
 
116
+ /** The per-system note-type → document-subtype map, and looking a note up in it. */
117
+ export * as documentSubtypes from "./document-subtypes.mjs";
118
+
107
119
  /** Which types carry documentation of their own, and where it is addressed. */
108
120
  export * as itemDocs from "./item-docs.mjs";
109
121
 
@@ -113,6 +125,9 @@ export * as wikilinks from "./wikilinks.mjs";
113
125
  /** What a `[[…]]` is, before either resolver decides where it points. */
114
126
  export * as wikilinkSyntax from "./wikilink-syntax.mjs";
115
127
 
128
+ /** The alias namespace: what a note may be called, and who may claim a name. */
129
+ export * as aliasIndex from "./alias-index.mjs";
130
+
116
131
  /** The address index a site build resolves its wikilinks against. */
117
132
  export * as siteIndex from "./site-index.mjs";
118
133
 
@@ -18,7 +18,11 @@
18
18
  * Both are read from the one resolved configuration, so they are literally the
19
19
  * same object's keys and values: a type cannot be whitelisted for compilation
20
20
  * without the builder that compiles it, which is the guarantee #1504 exists
21
- * for. The Item compiler dispatches through {@link itemBuilder}, so the table a
21
+ * for. Where a consumer declares **several** registries, one per system (#58),
22
+ * the vocabulary is their union and every lookup below takes the system that is
23
+ * asking — a type both systems declare has two builders, and answering with one
24
+ * of them because it was declared first is the silent-wrong-output failure this
25
+ * package spends its time removing. The Item compiler dispatches through {@link itemBuilder}, so the table a
22
26
  * consumer configured is the table its notes compile with — the whitelist and
23
27
  * the dispatch used to come from different places, and a consumer supplying its
24
28
  * own registry got the types it asked for and the builders it did not (#1563).
@@ -60,6 +64,43 @@ export function itemTypes() {
60
64
  return loadPackConfig().itemTypes;
61
65
  }
62
66
 
67
+ /**
68
+ * Look one type up in the table a system declared, or in the flat one.
69
+ *
70
+ * The flat table is the union with the first declaring registry winning, which
71
+ * is the right answer for every single-system build and no answer at all where
72
+ * two systems declare the type. So a contested type without a system **throws**
73
+ * rather than resolving: the alternative is a document built by one system's
74
+ * builder and stamped with another's, which is exactly the shape of defect the
75
+ * document-subtype map exists to stop (#79).
76
+ *
77
+ * @param {string} what - What is being looked up, for the message.
78
+ * @param {"itemBuilders"|"itemArt"|"itemFields"} table - Which table.
79
+ * @param {string} type - The item type.
80
+ * @param {string} [system] - The system asking, where a build has more than one.
81
+ * @returns {any} The entry, or `undefined`.
82
+ * @throws {Error} When several systems declare the type and none is named.
83
+ */
84
+ function lookup(what, table, type, system) {
85
+ const config = loadPackConfig();
86
+ if (system !== undefined) {
87
+ const perSystem = /** @type {Record<string, Record<string, unknown>>} */ (
88
+ config[`${table}BySystem`]
89
+ );
90
+ // A consumer declaring one, system-less registry keeps answering for
91
+ // every system: it *is* the only vocabulary in the build.
92
+ if (Object.keys(perSystem).length) return perSystem[system]?.[type];
93
+ } else if (config.itemTypesBySeveralSystems.has(type)) {
94
+ throw new Error(
95
+ `More than one declared registry defines the item type "${type}", so ` +
96
+ `the ${what} of a "${type}" depends on which system is asking. ` +
97
+ `Name the system at the call site, or stop declaring the type ` +
98
+ `twice.`,
99
+ );
100
+ }
101
+ return /** @type {Record<string, unknown>} */ (config[table])[type];
102
+ }
103
+
63
104
  /**
64
105
  * The builder the consuming repository registered for an item type.
65
106
  *
@@ -68,11 +109,16 @@ export function itemTypes() {
68
109
  * failing as an anonymous `is not a function` (#1504).
69
110
  *
70
111
  * @param {string} type - The note's `type` frontmatter.
112
+ * @param {string} [system] - The system compiling it, where a build declares
113
+ * more than one registry. Omitted, a type only one registry declares still
114
+ * resolves; a contested one throws rather than picking a side.
71
115
  * @returns {(fm: object) => object} The builder for that type.
72
116
  * @throws {Error} When the configuration registers no builder for `type`.
73
117
  */
74
- export function itemBuilder(type) {
75
- const builder = /** @type {Record<string, Function>} */ (loadPackConfig().itemBuilders)[type];
118
+ export function itemBuilder(type, system) {
119
+ const builder = /** @type {Function|undefined} */ (
120
+ lookup("builder", "itemBuilders", type, system)
121
+ );
76
122
  if (typeof builder !== "function") {
77
123
  throw new Error(
78
124
  `No builder registered for item type "${type}" — add one to the ` +
@@ -83,6 +129,25 @@ export function itemBuilder(type) {
83
129
  return /** @type {(fm: object) => object} */ (builder);
84
130
  }
85
131
 
132
+ /**
133
+ * The frontmatter fields a type's registry entry declares, if any.
134
+ *
135
+ * Sparse by design: a type whose entry declares none compiles normally and is
136
+ * simply undocumented (#22). What reads it is the `system`-block passthrough,
137
+ * which has to know which paths a declared field already writes before it
138
+ * writes the rest (#58).
139
+ *
140
+ * @param {string} type - The item type.
141
+ * @param {string} [system] - The system compiling it, where a build declares
142
+ * more than one registry.
143
+ * @returns {readonly object[]|undefined} The declaration, or `undefined`.
144
+ */
145
+ export function itemFields(type, system) {
146
+ return /** @type {readonly object[]|undefined} */ (
147
+ lookup("field declaration", "itemFields", type, system)
148
+ );
149
+ }
150
+
86
151
  /**
87
152
  * The default art for an item type — the image a note of that type is given
88
153
  * when it carries no `img:` of its own.
@@ -106,11 +171,13 @@ export function itemBuilder(type) {
106
171
  * One spelling, one meaning, wherever it is written.
107
172
  *
108
173
  * @param {string} type - the item type.
174
+ * @param {string} [system] - The system compiling it, where a build declares
175
+ * more than one registry.
109
176
  * @returns {string} The default image path for that type.
110
177
  * @throws {Error} When the type's registry entry pairs no `img`.
111
178
  */
112
- export function itemArt(type) {
113
- const art = /** @type {Record<string, string|undefined>} */ (loadPackConfig().itemArt)[type];
179
+ export function itemArt(type, system) {
180
+ const art = /** @type {string|undefined} */ (lookup("default art", "itemArt", type, system));
114
181
  if (!art) {
115
182
  throw new Error(
116
183
  `No default art for item type "${type}" — the note carries no ` +
@@ -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.
95
+ *
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.
94
99
  *
95
- * Unambiguous because no package, type or shortcode contains a hyphen — types
96
- * are bare words and shortcodes are `^[A-Za-z0-9]+$` (#1397).
100
+ * @type {number}
101
+ */
102
+ export const CANONICAL_KEY_SEGMENTS = 3;
103
+
104
+ /**
105
+ * Reads a canonical key back into its parts.
97
106
  *
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.
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 };
@@ -51,11 +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
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
62
 
60
63
  /* -------------------------------------------------------------------- */
61
64
  /* Note types and their canvas profiles */
@@ -63,7 +66,7 @@ import { CURATED_REGION_EVENTS, EXCLUDED_REGION_EVENTS } from "./region-events.m
63
66
 
64
67
  // The set itself lives in `ids.mjs`, a leaf both this module and the
65
68
  // doc-carrying type set in `item-docs.mjs` can depend on without a cycle.
66
- export { MAP_TYPES };
69
+ export { MAP_SUBTYPES, MAP_TYPES };
67
70
 
68
71
  /**
69
72
  * Whether a content note's type compiles into a Scene.
@@ -76,7 +79,7 @@ export function isMapType(type) {
76
79
  }
77
80
 
78
81
  /**
79
- * Per-type canvas defaults, emitted **explicitly** on every scene.
82
+ * Per-subtype canvas defaults, emitted **explicitly** on every scene.
80
83
  *
81
84
  * This is not a convenience. `grid.type`, `grid.distance` and `grid.units` all
82
85
  * declare `initial: () => game.system.grid.*`, and there is no `game` at build
@@ -86,7 +89,7 @@ export function isMapType(type) {
86
89
  *
87
90
  * @type {Readonly<Record<string, object>>}
88
91
  */
89
- export const MAP_TYPE_PROFILES = Object.freeze({
92
+ export const MAP_SUBTYPE_PROFILES = Object.freeze({
90
93
  battlemap: Object.freeze({
91
94
  grid: { type: 1 /* SQUARE */, distance: 5, units: "ft" },
92
95
  tokenVision: true,
@@ -108,18 +111,23 @@ export const MAP_TYPE_PROFILES = Object.freeze({
108
111
  });
109
112
 
110
113
  /**
111
- * The canvas profile for a map type.
114
+ * The canvas profile for a map subType.
112
115
  *
113
- * @param {string} type - The note's `type`.
114
- * @returns {object} The profile from {@link MAP_TYPE_PROFILES}.
115
- * @throws {Error} When the type is not a map type — the build's fail-fast
116
- * 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.
117
125
  */
118
- export function mapProfile(type) {
119
- const profile = MAP_TYPE_PROFILES[String(type)];
126
+ export function mapProfile(subType) {
127
+ const profile = MAP_SUBTYPE_PROFILES[String(subType)];
120
128
  if (!profile) {
121
129
  throw new Error(
122
- `unknown map type "${type}" — expected one of ${[...MAP_TYPES].join(", ")}`,
130
+ `unknown map subtype "${subType}" — expected one of ${MAP_SUBTYPES.join(", ")}`,
123
131
  );
124
132
  }
125
133
  return profile;
@@ -879,7 +887,7 @@ export function buildScene(fm, ctx) {
879
887
  const sohl = fm.sohl ?? {};
880
888
  const sceneId = fm.id;
881
889
  if (!sceneId) throw new Error("a map note needs an `id`");
882
- const profile = mapProfile(fm.type);
890
+ const profile = mapProfile(fm.subType);
883
891
 
884
892
  const dimensions = sohl.dimensions;
885
893
  if (
@@ -896,7 +904,11 @@ export function buildScene(fm, ctx) {
896
904
  "match the art",
897
905
  );
898
906
  }
899
- 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`");
900
912
 
901
913
  const warn = (message) => {
902
914
  if (ctx.warnings) ctx.warnings.push(message);
@@ -921,7 +933,7 @@ export function buildScene(fm, ctx) {
921
933
  tokenVision: profile.tokenVision,
922
934
  fog: { mode: profile.fog.mode },
923
935
  initialLevel: DEFAULT_LEVEL_ID,
924
- levels: [buildLevel(sohl, sceneId)],
936
+ levels: [buildLevel(sohl, sceneId, img)],
925
937
  drawings: [],
926
938
  tokens: [],
927
939
  lights: buildLights(sohl, geom, inner),
@@ -957,7 +969,7 @@ export function buildScene(fm, ctx) {
957
969
  }
958
970
 
959
971
  /**
960
- * Synthesise the scene's single embedded Level from `image:` / `overlay:`.
972
+ * Synthesise the scene's single embedded Level from `img:` / `overlay:`.
961
973
  *
962
974
  * Authors never write `levels:`. A scene must ship at least one Level — the
963
975
  * client-side `_preCreate` net that would create one does not run for offline
@@ -967,16 +979,20 @@ export function buildScene(fm, ctx) {
967
979
  *
968
980
  * @param {object} sohl - The note's `sohl:` block.
969
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).
970
986
  * @returns {object} The Level document, keyed for the pack.
971
987
  */
972
- export function buildLevel(sohl, sceneId) {
988
+ export function buildLevel(sohl, sceneId, img = readAliasedField({ sohl }, "img")) {
973
989
  const level = {
974
990
  _id: DEFAULT_LEVEL_ID,
975
991
  name: sohl.levelName ?? "Ground",
976
992
  elevation: { bottom: 0, top: 20 },
977
993
  background: {
978
994
  color: sohl.backgroundColor ?? "#999999",
979
- src: sohl.image,
995
+ src: img,
980
996
  },
981
997
  foreground: { src: sohl.overlay ?? null },
982
998
  sort: 0,