@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
@@ -106,8 +106,7 @@ function noteContext(fm, type) {
106
106
  /** Aptitude weights per skill selector, validated as whole numbers. */
107
107
  const SKILL_APTITUDES = Object.freeze({
108
108
  shape: "map of skill selector → whole number",
109
- read: (_raw, { fm }) =>
110
- resolveSkillAptitudes(fm, noteContext(fm, "mystery")),
109
+ read: (_raw, { fm }) => resolveSkillAptitudes(fm, noteContext(fm, "mystery")),
111
110
  });
112
111
 
113
112
  /** Standings toward other affiliations, validated against the closed list. */
@@ -127,14 +126,10 @@ const STRIKE_MODES = Object.freeze({
127
126
  const seen = new Set();
128
127
  for (const { shortcode } of raw) {
129
128
  if (!shortcode) {
130
- throw new Error(
131
- "weapongear strikeModes array element requires a 'shortcode'",
132
- );
129
+ throw new Error("weapongear strikeModes array element requires a 'shortcode'");
133
130
  }
134
131
  if (seen.has(shortcode)) {
135
- throw new Error(
136
- `weapongear has duplicate strike-mode shortcode "${shortcode}"`,
137
- );
132
+ throw new Error(`weapongear has duplicate strike-mode shortcode "${shortcode}"`);
138
133
  }
139
134
  seen.add(shortcode);
140
135
  }
@@ -218,8 +213,7 @@ const GEAR_COMMON = Object.freeze([
218
213
  {
219
214
  to: "sharedWithCohortIds",
220
215
  value: () => [],
221
- describe:
222
- "Cohorts sharing the article. Possession state, never authored.",
216
+ describe: "Cohorts sharing the article. Possession state, never authored.",
223
217
  },
224
218
  {
225
219
  to: "containerId",
@@ -250,8 +244,7 @@ export const ITEM_FIELDS = Object.freeze({
250
244
  to: "subType",
251
245
  ...SUB_TYPE,
252
246
  required: true,
253
- describe:
254
- "Which kind of affiliation this is — the society's character.",
247
+ describe: "Which kind of affiliation this is — the society's character.",
255
248
  },
256
249
  {
257
250
  name: "society",
@@ -286,8 +279,7 @@ export const ITEM_FIELDS = Object.freeze({
286
279
  to: "relation",
287
280
  ...RELATION,
288
281
  default: {},
289
- describe:
290
- "How this society regards others: aligned, unaligned, rival or nemesis.",
282
+ describe: "How this society regards others: aligned, unaligned, rival or nemesis.",
291
283
  },
292
284
  ]),
293
285
 
@@ -371,16 +363,14 @@ export const ITEM_FIELDS = Object.freeze({
371
363
  to: "locations.flexible",
372
364
  ...BLANK_IS_DEFAULT,
373
365
  default: [],
374
- describe:
375
- "Body locations the article covers flexibly, by location shortcode.",
366
+ describe: "Body locations the article covers flexibly, by location shortcode.",
376
367
  },
377
368
  {
378
369
  name: "rigidloc",
379
370
  to: "locations.rigid",
380
371
  ...BLANK_IS_DEFAULT,
381
372
  default: [],
382
- describe:
383
- "Body locations the article covers rigidly, by location shortcode.",
373
+ describe: "Body locations the article covers rigidly, by location shortcode.",
384
374
  },
385
375
  {
386
376
  name: "facing",
@@ -463,16 +453,14 @@ export const ITEM_FIELDS = Object.freeze({
463
453
  to: "initDiceFormula",
464
454
  ...AS_AUTHORED,
465
455
  default: "",
466
- describe:
467
- "Dice expression rolled to generate the score during character creation.",
456
+ describe: "Dice expression rolled to generate the score during character creation.",
468
457
  },
469
458
  {
470
459
  name: "impairedByRoles",
471
460
  to: "impairedByRoles",
472
461
  ...AS_AUTHORED,
473
462
  default: [],
474
- describe:
475
- "Body-part roles whose impairment penalises tests against this attribute.",
463
+ describe: "Body-part roles whose impairment penalises tests against this attribute.",
476
464
  },
477
465
  ]),
478
466
 
@@ -701,24 +689,21 @@ export const ITEM_FIELDS = Object.freeze({
701
689
  ref: "skill",
702
690
  ...AS_AUTHORED,
703
691
  default: "",
704
- describe:
705
- "Shortcode of the skill this one specialises, for a specialisation.",
692
+ describe: "Shortcode of the skill this one specialises, for a specialisation.",
706
693
  },
707
694
  {
708
695
  name: "initSkillMult",
709
696
  to: "initSkillMult",
710
697
  ...NUMBER,
711
698
  default: 0,
712
- describe:
713
- "Multiplier applied to Skill Base when the skill opens on an actor.",
699
+ describe: "Multiplier applied to Skill Base when the skill opens on an actor.",
714
700
  },
715
701
  {
716
702
  name: "impairedByRoles",
717
703
  to: "impairedByRoles",
718
704
  ...AS_AUTHORED,
719
705
  default: [],
720
- describe:
721
- "Body-part roles whose impairment penalises tests against this skill.",
706
+ describe: "Body-part roles whose impairment penalises tests against this skill.",
722
707
  },
723
708
  ]),
724
709
 
@@ -742,8 +727,7 @@ export const ITEM_FIELDS = Object.freeze({
742
727
  to: "levelBase",
743
728
  ...NULLABLE_COUNT,
744
729
  default: null,
745
- describe:
746
- "Injury level. Unset on a descriptive condition, which has no level.",
730
+ describe: "Injury level. Unset on a descriptive condition, which has no level.",
747
731
  },
748
732
  {
749
733
  name: "healingRateBase",
@@ -757,16 +741,14 @@ export const ITEM_FIELDS = Object.freeze({
757
741
  to: "aspect",
758
742
  ...AS_AUTHORED,
759
743
  default: null,
760
- describe:
761
- "How the injury was inflicted. Unset on a descriptive condition.",
744
+ describe: "How the injury was inflicted. Unset on a descriptive condition.",
762
745
  },
763
746
  {
764
747
  name: "bodyLocationCode",
765
748
  to: "bodyLocationCode",
766
749
  ...AS_AUTHORED,
767
750
  default: null,
768
- describe:
769
- "Shortcode of the body location injured. Unset on a descriptive condition.",
751
+ describe: "Shortcode of the body location injured. Unset on a descriptive condition.",
770
752
  },
771
753
  ]),
772
754
 
package/sohl/items.mjs CHANGED
@@ -41,7 +41,7 @@ import {
41
41
  resolveName,
42
42
  resolveImg,
43
43
  defaultStats,
44
- withArchetypeFlag,
44
+ systemArchetype,
45
45
  } from "../engine/helpers.mjs";
46
46
  import { BasePackCompiler } from "../engine/base-compiler.mjs";
47
47
  // Per-type default art lives in one framework-free module shared with the
@@ -56,6 +56,15 @@ import { itemDocEntryId, itemDocPointer } from "../engine/item-docs.mjs";
56
56
  // them with are one table — the consuming repository's, not this package's
57
57
  // (#1504/#1563).
58
58
  import { itemTypes, itemBuilder, itemArt } from "../engine/item-registry.mjs";
59
+ // Which Foundry Item subtype a note's `type` compiles into. Looked up in the
60
+ // system's declared map, never inferred from the type itself (#79).
61
+ import { documentSubtype, subtypeRow } from "../engine/document-subtypes.mjs";
62
+ import { SOHL_DOCUMENT_SUBTYPES } from "./document-subtypes.mjs";
63
+ // The note-level `sohl:` block: `sohl.system` onto the document's `system`
64
+ // verbatim, and `sohl.img` / `sohl.effects` / `sohl.flags` overriding their
65
+ // shared top-level forms for this system alone (#58).
66
+ import { blockProperty, claimedPaths, mergeSystemData } from "../engine/system-block.mjs";
67
+ import { itemFields } from "../engine/item-registry.mjs";
59
68
 
60
69
  /**
61
70
  * The description an item carries: a pointer to its **item doc**, the
@@ -86,11 +95,19 @@ function itemDescription(markdown, fm, name) {
86
95
 
87
96
  /**
88
97
  * Build the `system.*` fields shared by every item type:
89
- * shortcode, actionDefs, notes, docHtml.
98
+ * shortcode, archetype, actionDefs, notes, docHtml.
99
+ *
100
+ * @param {object} fm - The note's frontmatter.
101
+ * @param {string} description - The item's documentation pointer.
102
+ * @param {string} label - Human-readable context for error messages.
103
+ * @returns {object} The shared `system` fields.
90
104
  */
91
- function commonSystem(fm, description) {
105
+ function commonSystem(fm, description, label) {
92
106
  return {
93
107
  shortcode: fm.shortcode,
108
+ // Required nullable number: a priority, or `null` for a document that
109
+ // is not an archetype (#126 / archetype contract #604).
110
+ archetype: systemArchetype(fm, label),
94
111
  actionDefs: Array.isArray(fm.actionDefs) ? fm.actionDefs : [],
95
112
  notes: "",
96
113
  docHtml: description || "",
@@ -105,10 +122,27 @@ function commonSystem(fm, description) {
105
122
  /* Compiler */
106
123
  /* -------------------------------------------------------------------- */
107
124
 
125
+ /**
126
+ * The system this pass compiles for — the block its notes write, and the
127
+ * registry its builders come from.
128
+ *
129
+ * Read from the map rather than spelled here, so the block name, the subtype
130
+ * map and the registry key are one statement (#58/#79).
131
+ *
132
+ * @type {string}
133
+ */
134
+ const SYSTEM = SOHL_DOCUMENT_SUBTYPES.block;
135
+
108
136
  export class Items extends BasePackCompiler {
109
137
  static id = "items";
110
138
  static label = "item";
111
139
 
140
+ /**
141
+ * An Item **is** a system's data, so this pack takes only notes carrying
142
+ * this system's block (#58).
143
+ */
144
+ static requiresSystemBlock = true;
145
+
112
146
  /**
113
147
  * How many of each item type this pass wrote, for the summary. Every type
114
148
  * is present from the start so the tally reads as a census of the
@@ -121,11 +155,47 @@ export class Items extends BasePackCompiler {
121
155
  /**
122
156
  * Every content type that compiles into an item.
123
157
  *
158
+ * The whitelist is the consuming repository's `itemBuilders` keys (#1504),
159
+ * and the system's own map is a second filter on top of it: a type SoHL
160
+ * maps onto some *other* document class is not an item however a registry
161
+ * spells it, which is the "no wrongly-typed document" half of #79. A type
162
+ * the map does not name at all is left to the registry — see
163
+ * {@link Items#itemSubtype}.
164
+ *
124
165
  * @param {object} fm - The note's frontmatter.
125
166
  * @returns {boolean} True for a whitelisted item type.
126
167
  */
127
168
  selects(fm) {
128
- return Boolean(fm.type) && itemTypes().has(fm.type);
169
+ if (!fm.type || !itemTypes().has(fm.type)) return false;
170
+ const row = subtypeRow(SOHL_DOCUMENT_SUBTYPES, fm.type);
171
+ return !row || row.document === "Item";
172
+ }
173
+
174
+ /**
175
+ * The Foundry Item subtype a note compiles into.
176
+ *
177
+ * **Looked up, not inferred.** For every type this system declares, the
178
+ * emitted subtype is the map's, so the note vocabulary and the document
179
+ * vocabulary are two separately-stated things rather than one string
180
+ * written twice (#79).
181
+ *
182
+ * **A type the map does not name belongs to the consumer**, and its
183
+ * registry entry is the declaration: a repository shipping an item type of
184
+ * its own writes it once, in the `itemBuilders` table of its
185
+ * `package-build.config.yaml`, and that key is what the document is a
186
+ * subtype of. That is an authored statement in the consumer's own
187
+ * configuration, not a coincidence inside this package's source — and
188
+ * refusing it here would silently drop every document of a type SoHL has
189
+ * no opinion about (#7/#1563).
190
+ *
191
+ * @param {object} fm - The note's frontmatter.
192
+ * @returns {string} The document's `type`.
193
+ */
194
+ itemSubtype(fm) {
195
+ const declared = documentSubtype(SOHL_DOCUMENT_SUBTYPES, fm.type, fm, {
196
+ absPath: this.currentNote?.absPath,
197
+ });
198
+ return declared ?? fm.type;
129
199
  }
130
200
 
131
201
  /** An item is named by its own type in the log, not by "item". */
@@ -147,26 +217,52 @@ export class Items extends BasePackCompiler {
147
217
  const name = resolveName(fm);
148
218
  const description = itemDescription(markdown, fm, name);
149
219
  const id = fm.id;
220
+ const subType = this.itemSubtype(fm);
150
221
  const system = {
151
- ...commonSystem(fm, description),
152
- ...itemBuilder(type)(fm),
222
+ ...commonSystem(fm, description, `item "${name}"`),
223
+ ...itemBuilder(type, SYSTEM)(fm),
153
224
  };
225
+ // Whatever the note authors under `sohl.system`, at the DataModel's own
226
+ // paths. A path a declared field already writes is left to that field:
227
+ // its value came from the same authored place and went through the
228
+ // field's own coercion (#58).
229
+ mergeSystemData(system, fm, {
230
+ block: SYSTEM,
231
+ claimed: claimedPaths(itemFields(type, SYSTEM)),
232
+ });
233
+ this.reportUndeclaredSystemData(fm, SYSTEM, "Item", subType);
234
+ // And what *this* pass wrote on its own initiative — `shortcode`,
235
+ // `archetype`, `actionDefs`, `notes`, `docHtml` — which no field
236
+ // declaration states and so no other check can see (#155). Read off the
237
+ // assembled block, so a key added to `commonSystem` is checked without
238
+ // anyone remembering to list it.
239
+ this.reportEmittedSystemData(system, {
240
+ fm,
241
+ block: SYSTEM,
242
+ documentType: "Item",
243
+ subType,
244
+ type,
245
+ fields: itemFields(type, SYSTEM),
246
+ });
154
247
 
155
- const effects = Array.isArray(fm.effects) ? [...fm.effects] : [];
156
-
248
+ const effects = blockProperty(fm, SYSTEM, "effects");
157
249
  const folderId = sohlField(fm, "folder", null);
158
250
  const folder = this.folderResolver(folderId);
159
251
 
160
252
  return {
161
253
  name,
162
- type,
163
- img: resolveImg(fm.img) || itemArt(type),
254
+ // The note's `type` addresses the builder and the default art —
255
+ // both registries are keyed by content type — while the document's
256
+ // own subtype comes from the system's map (#79).
257
+ type: subType,
258
+ img: resolveImg(blockProperty(fm, SYSTEM, "img")) || itemArt(type, SYSTEM),
164
259
  _id: id,
165
260
  system,
166
- effects,
167
- // `sohl.archetype` (required nullable number) drives
168
- // `flags.sohl.docArchetype` (#640 / archetype contract #604).
169
- flags: withArchetypeFlag(fm, fm.flags, `item "${name}"`),
261
+ effects: Array.isArray(effects) ? [...effects] : [],
262
+ // Whatever the note authors, and nothing else. `archetype` used to
263
+ // be spliced in here as `flags.sohl.docArchetype`; it is a schema
264
+ // field now and sits in `system` (#126).
265
+ flags: blockProperty(fm, SYSTEM, "flags", {}),
170
266
  _stats: this.stats,
171
267
  ownership: { default: 0 },
172
268
  folder,
@@ -189,8 +285,6 @@ export class Items extends BasePackCompiler {
189
285
 
190
286
  /** @inheritdoc */
191
287
  reportDetail(stats) {
192
- log.debug(
193
- `Skipped ${stats.skippedOther} non-item file(s) (no recognized type)`,
194
- );
288
+ log.debug(`Skipped ${stats.skippedOther} non-item file(s) (no recognized type)`);
195
289
  }
196
290
  }
@@ -90,8 +90,7 @@ function readSymbolMap(file, repoRoot) {
90
90
  raw = fs.readFileSync(resolved, "utf8");
91
91
  } catch (err) {
92
92
  throw new Error(
93
- `site.passOptions.symbolMap ${resolved} cannot be read: ` +
94
- `${err.message}`,
93
+ `site.passOptions.symbolMap ${resolved} cannot be read: ` + `${err.message}`,
95
94
  );
96
95
  }
97
96
 
@@ -100,8 +99,7 @@ function readSymbolMap(file, repoRoot) {
100
99
  parsed = JSON.parse(raw);
101
100
  } catch (err) {
102
101
  throw new Error(
103
- `site.passOptions.symbolMap ${resolved} is not valid JSON: ` +
104
- `${err.message}`,
102
+ `site.passOptions.symbolMap ${resolved} is not valid JSON: ` + `${err.message}`,
105
103
  );
106
104
  }
107
105
 
@@ -140,39 +138,34 @@ function readSymbolMap(file, repoRoot) {
140
138
  * @returns {string} The body with every tag resolved.
141
139
  */
142
140
  export function resolveApiLinks(body, symbols, apiBase) {
143
- return body.replace(
144
- /\{@link(code|plain)?\s+([^}]+)\}/g,
145
- (_m, kind, inner) => {
146
- inner = inner.trim();
147
- let target, text;
148
- const pipe = inner.indexOf("|");
149
- const space = inner.search(/\s/);
150
- if (pipe !== -1) {
151
- target = inner.slice(0, pipe).trim();
152
- text = inner.slice(pipe + 1).trim();
153
- } else if (space !== -1) {
154
- target = inner.slice(0, space);
155
- text = inner.slice(space + 1).trim();
156
- } else {
157
- target = inner;
158
- text = "";
159
- }
141
+ return body.replace(/\{@link(code|plain)?\s+([^}]+)\}/g, (_m, kind, inner) => {
142
+ inner = inner.trim();
143
+ let target, text;
144
+ const pipe = inner.indexOf("|");
145
+ const space = inner.search(/\s/);
146
+ if (pipe !== -1) {
147
+ target = inner.slice(0, pipe).trim();
148
+ text = inner.slice(pipe + 1).trim();
149
+ } else if (space !== -1) {
150
+ target = inner.slice(0, space);
151
+ text = inner.slice(space + 1).trim();
152
+ } else {
153
+ target = inner;
154
+ text = "";
155
+ }
160
156
 
161
- if (/^https?:\/\//.test(target)) {
162
- return `[${text || target}](${target})`;
163
- }
157
+ if (/^https?:\/\//.test(target)) {
158
+ return `[${text || target}](${target})`;
159
+ }
164
160
 
165
- const url = symbols[target];
166
- const display = text || target.split(".").pop();
167
- if (url) {
168
- const href = apiBase + url;
169
- return kind === "code" ?
170
- `[\`${display}\`](${href})`
171
- : `[${display}](${href})`;
172
- }
173
- return kind === "plain" ? display : `\`${display}\``;
174
- },
175
- );
161
+ const url = symbols[target];
162
+ const display = text || target.split(".").pop();
163
+ if (url) {
164
+ const href = apiBase + url;
165
+ return kind === "code" ? `[\`${display}\`](${href})` : `[${display}](${href})`;
166
+ }
167
+ return kind === "plain" ? display : `\`${display}\``;
168
+ });
176
169
  }
177
170
 
178
171
  /**
@@ -218,10 +211,7 @@ export function rewriteRepoLinks(body, docRel, options) {
218
211
  let out;
219
212
  if (repoRel.startsWith(`${docsRel}/`) && repoRel.endsWith(".md")) {
220
213
  const rel2 = repoRel.slice(docsRel.length + 1, -3).toLowerCase();
221
- const devPath =
222
- path.basename(rel2) === "readme" ?
223
- path.posix.dirname(rel2)
224
- : rel2;
214
+ const devPath = path.basename(rel2) === "readme" ? path.posix.dirname(rel2) : rel2;
225
215
  out = `${route}${devPath === "." ? "" : `${devPath}/`}${anchor}`;
226
216
  } else {
227
217
  out = `${blob}${repoRel}${anchor}`;
@@ -122,23 +122,29 @@ const BEING_FIELDS = Object.freeze([
122
122
  ]);
123
123
 
124
124
  /**
125
- * A map note — `battlemap`, `localmap` or `regionalmap`, each compiled into a
125
+ * A map note — one type whose `battlemap` / `localmap` / `regionalmap`
126
+ * subType decides the derived canvas (#174). Compiled into a
126
127
  * Foundry Scene.
127
128
  *
128
129
  * The three differ only in derived canvas defaults, which is the map compiler's
129
130
  * business; their authored vocabulary is the same, so they share one
130
131
  * declaration rather than three copies that could drift.
131
132
  *
132
- * `image` is the one required field — the compiler refuses a map note without
133
- * it, since a scene with no background is not a map.
133
+ * `img` is the one required field — the compiler refuses a map note without it,
134
+ * since a scene with no background is not a map. It was spelled `image` and
135
+ * read from the `sohl:` block until #142; both spellings still compile, and the
136
+ * retired one is reported rather than refused.
134
137
  *
135
138
  * @type {readonly import("../engine/field-spec.mjs").FieldSpec[]}
136
139
  */
137
140
  const MAP_FIELDS = Object.freeze([
138
141
  {
139
- name: "image",
142
+ name: "img",
140
143
  ...STRING,
141
144
  required: true,
145
+ // Art is not system-specific — a Scene is a core Foundry document, and
146
+ // every other note type carries its `img` at the note's top level.
147
+ shared: true,
142
148
  describe: "The scene's background image.",
143
149
  },
144
150
  {
@@ -324,7 +330,5 @@ export const NOTE_SCHEMAS = Object.freeze({
324
330
  doc: DOC_FIELDS,
325
331
  macro: MACRO_FIELDS,
326
332
  being: Object.freeze([...BEING_FIELDS, ...PRESENTATION_FIELDS.being]),
327
- battlemap: MAP_FIELDS,
328
- localmap: MAP_FIELDS,
329
- regionalmap: MAP_FIELDS,
333
+ map: MAP_FIELDS,
330
334
  });
@@ -116,9 +116,7 @@ function evalNode(node, attrs) {
116
116
  switch (node.type) {
117
117
  case "Literal": {
118
118
  if (typeof node.value !== "number") {
119
- throw new Error(
120
- `unsupported literal ${JSON.stringify(node.value)}`,
121
- );
119
+ throw new Error(`unsupported literal ${JSON.stringify(node.value)}`);
122
120
  }
123
121
  return node.value;
124
122
  }
@@ -126,9 +124,7 @@ function evalNode(node, attrs) {
126
124
  // Only `attr.<code>` and `attr["<code>"]`. Any other object, and
127
125
  // any computed key that is not a plain string, is out of scope.
128
126
  if (node.object?.type !== "Identifier") {
129
- throw new Error(
130
- "only `attr.<code>` member reads are supported",
131
- );
127
+ throw new Error("only `attr.<code>` member reads are supported");
132
128
  }
133
129
  if (node.object.name !== "attr") {
134
130
  throw new Error(
@@ -136,13 +132,8 @@ function evalNode(node, attrs) {
136
132
  );
137
133
  }
138
134
  if (node.computed) {
139
- if (
140
- node.property.type !== "Literal" ||
141
- typeof node.property.value !== "string"
142
- ) {
143
- throw new Error(
144
- "a computed `attr[...]` read needs a literal string shortcode",
145
- );
135
+ if (node.property.type !== "Literal" || typeof node.property.value !== "string") {
136
+ throw new Error("a computed `attr[...]` read needs a literal string shortcode");
146
137
  }
147
138
  return readAttr(attrs, node.property.value);
148
139
  }
@@ -150,15 +141,11 @@ function evalNode(node, attrs) {
150
141
  }
151
142
  case "CallExpression": {
152
143
  if (node.callee?.type !== "Identifier") {
153
- throw new Error(
154
- "only direct calls to a named helper are supported",
155
- );
144
+ throw new Error("only direct calls to a named helper are supported");
156
145
  }
157
146
  const helper = HELPERS[node.callee.name];
158
147
  if (!helper) {
159
- throw new Error(
160
- `unknown helper "${node.callee.name}()" in a skill base formula`,
161
- );
148
+ throw new Error(`unknown helper "${node.callee.name}()" in a skill base formula`);
162
149
  }
163
150
  return helper(...node.arguments.map((a) => evalNode(a, attrs)));
164
151
  }
@@ -200,10 +187,7 @@ export function evaluateSkillBase(formula, attrs = {}) {
200
187
  let node;
201
188
  try {
202
189
  const program = parse(source, { ecmaVersion: 2022 });
203
- if (
204
- program.body.length !== 1 ||
205
- program.body[0].type !== "ExpressionStatement"
206
- ) {
190
+ if (program.body.length !== 1 || program.body[0].type !== "ExpressionStatement") {
207
191
  return {
208
192
  value: 0,
209
193
  error: `skill base formula "${source}" is not a single expression`,
package/stage.mjs CHANGED
@@ -46,12 +46,7 @@ import path from "node:path";
46
46
  * beneath `site/` — but these four are common to all of them because they come
47
47
  * from the shared toolchain rather than from any one package's layout.
48
48
  */
49
- export const BUILD_ARTIFACT_DIRS = Object.freeze([
50
- "build",
51
- ".vite",
52
- ".vitepress",
53
- ".rollup.cache",
54
- ]);
49
+ export const BUILD_ARTIFACT_DIRS = Object.freeze(["build", ".vite", ".vitepress", ".rollup.cache"]);
55
50
 
56
51
  /**
57
52
  * A source that does not exist, described for a human.
@@ -66,9 +61,7 @@ export const BUILD_ARTIFACT_DIRS = Object.freeze([
66
61
  * @returns {string[]} Every source that is absent, in the order listed.
67
62
  */
68
63
  export function missingSources(entries, cwd = process.cwd()) {
69
- return entries
70
- .map(([src]) => src)
71
- .filter((src) => !fs.existsSync(path.resolve(cwd, src)));
64
+ return entries.map(([src]) => src).filter((src) => !fs.existsSync(path.resolve(cwd, src)));
72
65
  }
73
66
 
74
67
  /**
@@ -157,10 +150,7 @@ export function stageAssets(entries, { cwd = process.cwd(), transform } = {}) {
157
150
  * `distclean` case.
158
151
  * @returns {string[]} The directories removed, as listed.
159
152
  */
160
- export function cleanBuildArtifacts(
161
- root,
162
- { extra = [], includeNodeModules = false } = {},
163
- ) {
153
+ export function cleanBuildArtifacts(root, { extra = [], includeNodeModules = false } = {}) {
164
154
  const dirs = [
165
155
  ...BUILD_ARTIFACT_DIRS,
166
156
  ...extra,
package/templates.mjs CHANGED
@@ -107,10 +107,7 @@ function stripNonProse(source) {
107
107
  * @returns {boolean} Whether it should have been localized.
108
108
  */
109
109
  function isProse(text, allowed) {
110
- return (
111
- /[A-Za-z]{2}/.test(text.replace(/&[a-zA-Z]+;|&#\d+;/g, " ")) &&
112
- !allowed.has(text)
113
- );
110
+ return /[A-Za-z]{2}/.test(text.replace(/&[a-zA-Z]+;|&#\d+;/g, " ")) && !allowed.has(text);
114
111
  }
115
112
 
116
113
  /**
@@ -201,23 +198,15 @@ export function findTemplateSyntaxErrors(source) {
201
198
  // answer, in the only place this error carries it.
202
199
  const loc = err?.hash?.loc;
203
200
  const stated = /Parse error on line (\d+)/.exec(String(err?.message));
204
- const line =
205
- loc?.first_line ??
206
- err?.lineNumber ??
207
- (stated ? Number(stated[1]) : undefined);
201
+ const line = loc?.first_line ?? err?.lineNumber ?? (stated ? Number(stated[1]) : undefined);
208
202
  return [
209
203
  {
210
204
  ...(typeof line === "number" ? { line } : {}),
211
- ...((
212
- typeof line === "number" &&
213
- typeof loc?.first_column === "number"
214
- ) ?
205
+ ...(typeof line === "number" && typeof loc?.first_column === "number" ?
215
206
  { column: loc.first_column + 1 }
216
207
  : {}),
217
208
  severity: "error",
218
- message: `template does not compile: ${
219
- String(err).split("\n")[0]
220
- }`,
209
+ message: `template does not compile: ${String(err).split("\n")[0]}`,
221
210
  },
222
211
  ];
223
212
  }
@@ -45,7 +45,7 @@ export function globalDeclarations(source: string): Array<{
45
45
  * declaredAs: "esmodules"|"scripts"|"both"|"neither"}} The findings, empty
46
46
  * when the two agree, and how the entry was declared.
47
47
  */
48
- export function checkBundleLoading({ manifest, source, entry, manifestName, }: {
48
+ export function checkBundleLoading({ manifest, source, entry, manifestName }: {
49
49
  manifest: object;
50
50
  source: string;
51
51
  entry: string;
@@ -82,7 +82,7 @@ export function resolveStagePort(stage: string, { env, stages }?: {
82
82
  * @param {string} [opts.e2eStage] - Which stage the suite runs against.
83
83
  * @returns {string|null} The exact build, or `null`.
84
84
  */
85
- export function resolveFoundryVersion(stage: string, { env, stages, compatibilityMinimum, e2eStage, }?: {
85
+ export function resolveFoundryVersion(stage: string, { env, stages, compatibilityMinimum, e2eStage }?: {
86
86
  env?: NodeJS.ProcessEnv | undefined;
87
87
  stages?: Record<string, ContainerStage> | undefined;
88
88
  compatibilityMinimum?: string | null | undefined;
@@ -305,7 +305,7 @@ export function removeContainer(name: string, log?: (message: string) => void):
305
305
  * @returns {number} The exit status.
306
306
  * @throws {Error} On an unknown action, or a stage with no usable data root.
307
307
  */
308
- export function containerAction({ action, stage, config, env, log, }: {
308
+ export function containerAction({ action, stage, config, env, log }: {
309
309
  action: string;
310
310
  stage: string;
311
311
  config: object;