@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
@@ -431,9 +431,10 @@ export function locateConfigError(err, configPath) {
431
431
  * - **`rootDir`** is the configuration's own directory, always. A data file
432
432
  * cannot write `import.meta.dirname`, and any absolute path it wrote instead
433
433
  * would be one machine's — so authoring it is rejected rather than honoured.
434
- * - **`itemBuilders`** is a *name* (`sohl`), resolved against the built-in
435
- * registries. A registry of a consumer's own is code, and code goes in an
436
- * `.mjs` configuration.
434
+ * - **`itemBuilders`** is a *name* (`sohl`) — or a list of names, for a tree
435
+ * feeding more than one system (#58) resolved against the built-in
436
+ * registries. A registry's name is the system it belongs to. A registry of a
437
+ * consumer's own is code, and code goes in an `.mjs` configuration.
437
438
  * - **`stats.systemVersion`** is derived from the adjacent `package.json` when
438
439
  * the configuration does not state it. Stating it is still allowed: a
439
440
  * repository shipping content *for* another package (a module declaring
@@ -482,26 +483,42 @@ export function configFromData(data, configPath) {
482
483
  input.foundryPackage = foundryPackageId(rootDir);
483
484
 
484
485
  if (input.itemBuilders !== undefined) {
485
- const named = input.itemBuilders;
486
+ const declared = input.itemBuilders;
486
487
  const known = Object.keys(ITEM_BUILDER_REGISTRIES).join(", ");
487
- if (typeof named !== "string") {
488
- throw new Error(
489
- `package-build: ${configPath} must name its \`itemBuilders\` ` +
490
- `registry as a string the registry is code, and data ` +
491
- `cannot carry it. Known registries: ${known}; a registry ` +
492
- `of your own goes in ${CONFIG_BASENAME}.mjs.`,
493
- );
494
- }
495
- const load = ITEM_BUILDER_REGISTRIES[named];
496
- if (!load) {
497
- throw new Error(
498
- `package-build: ${configPath} names the \`itemBuilders\` ` +
499
- `registry "${named}", which this package does not ship. ` +
500
- `Known registries: ${known}. To supply your own, declare ` +
501
- `it in ${CONFIG_BASENAME}.mjs.`,
502
- );
503
- }
504
- input.itemBuilders = load();
488
+ // One name or several. A repository feeding two systems needs both
489
+ // vocabularies, and one registry can only carry one (#58); the scalar
490
+ // form every existing configuration uses is the one-element case and
491
+ // means exactly what it always did.
492
+ const names = Array.isArray(declared) ? declared : [declared];
493
+ const resolved = names.map((named, index) => {
494
+ const where = Array.isArray(declared) ? `itemBuilders[${index}]` : "itemBuilders";
495
+ if (typeof named !== "string") {
496
+ throw new Error(
497
+ `package-build: ${configPath} must name its \`${where}\` ` +
498
+ `registry as a string — the registry is code, and data ` +
499
+ `cannot carry it. Known registries: ${known}; a registry ` +
500
+ `of your own goes in ${CONFIG_BASENAME}.mjs.`,
501
+ );
502
+ }
503
+ const load = ITEM_BUILDER_REGISTRIES[named];
504
+ if (!load) {
505
+ throw new Error(
506
+ `package-build: ${configPath} names the \`${where}\` ` +
507
+ `registry "${named}", which this package does not ship. ` +
508
+ `Known registries: ${known}. To supply your own, declare ` +
509
+ `it in ${CONFIG_BASENAME}.mjs.`,
510
+ );
511
+ }
512
+ // A shipped registry's *name* is the system it belongs to, which is
513
+ // what lets a data configuration declare a set without carrying the
514
+ // system id a second time.
515
+ return { system: named, builders: load() };
516
+ });
517
+ // The scalar form stays the flat, system-less registry it has always
518
+ // resolved to. Wrapping it as a one-entry set would be tidier and would
519
+ // change what every existing configuration means — `itemBuildersBySystem`
520
+ // would gain an entry the consumer never declared.
521
+ input.itemBuilders = Array.isArray(declared) ? resolved : resolved[0].builders;
505
522
  }
506
523
 
507
524
  const stats = input.stats;
@@ -47,12 +47,18 @@
47
47
  * - A note's **derived** documents are routed by the default of *their* type,
48
48
  * not by the note's declaration: an item note's prose compiles into a
49
49
  * JournalEntry, and `pack:` names where the *item* goes.
50
+ * - A note feeding **more than one system** declares `pack:` inside the block
51
+ * that differs (#58). `pack` needed no new mechanism for that: it is an
52
+ * ordinary shared property, so `<system>.pack` overrides the top-level one
53
+ * for that system's document and leaves it standing for every other. A note
54
+ * wanting one pack for both says it once at the top.
50
55
  *
51
56
  * @module
52
57
  */
53
58
 
54
59
  import { packForType } from "./ids.mjs";
55
60
  import { loadPackConfig } from "./pack-config.mjs";
61
+ import { blockProperty } from "./system-block.mjs";
56
62
 
57
63
  /**
58
64
  * A note that cannot be routed to a pack. Thrown rather than returned so no
@@ -84,8 +90,8 @@ export const PACK_FIELD = "pack";
84
90
  *
85
91
  * @param {readonly object[]} packs - The resolved `packs` list from
86
92
  * `defineConfig`.
87
- * @returns {{resolve: (fm: object, docType: string) => string,
88
- * resolveOrNull: (fm: object, docType: string) => string|undefined,
93
+ * @returns {{resolve: (fm: object, docType: string, system?: string) => string,
94
+ * resolveOrNull: (fm: object, docType: string, system?: string) => string|undefined,
89
95
  * packsOfType: (docType: string) => string[],
90
96
  * defaultOf: (docType: string) => string|undefined}} The router.
91
97
  */
@@ -124,11 +130,15 @@ export function createPackRouter(packs) {
124
130
  *
125
131
  * @param {object} fm - The note's frontmatter.
126
132
  * @param {string} docType - The document type the calling pass writes.
133
+ * @param {string} [system] - The system whose document is being routed. Its
134
+ * block's `pack:` wins over the shared one; without it only the shared
135
+ * declaration is read, which is every single-system build.
127
136
  * @returns {string} The pack name.
128
137
  * @throws {PackRoutingError} When the note routes nowhere.
129
138
  */
130
- function resolve(fm, docType) {
131
- const declared = fm?.[PACK_FIELD];
139
+ function resolve(fm, docType, system) {
140
+ const declared =
141
+ system === undefined ? fm?.[PACK_FIELD] : blockProperty(fm, system, PACK_FIELD);
132
142
  // The declaration names where the note's *own* document goes. A pass
133
143
  // writing a document derived from it — an item's prose becoming a
134
144
  // JournalEntry — is not what the author was addressing.
@@ -189,11 +199,12 @@ export function createPackRouter(packs) {
189
199
  *
190
200
  * @param {object} fm - The note's frontmatter.
191
201
  * @param {string} docType - The document type being addressed.
202
+ * @param {string} [system] - The system whose document is addressed.
192
203
  * @returns {string|undefined} The pack name, or `undefined`.
193
204
  */
194
- resolveOrNull(fm, docType) {
205
+ resolveOrNull(fm, docType, system) {
195
206
  try {
196
- return resolve(fm, docType);
207
+ return resolve(fm, docType, system);
197
208
  } catch {
198
209
  return undefined;
199
210
  }
@@ -61,6 +61,23 @@ const NEVER_WALK = Object.freeze(new Set([".git", "node_modules"]));
61
61
  */
62
62
  const IGNORE_FILES = Object.freeze([".gitignore", ".prettierignore"]);
63
63
 
64
+ /**
65
+ * How many times `--write` will format one file looking for a fixpoint.
66
+ *
67
+ * `format` is *assumed* idempotent and is not guaranteed to be: a single pass
68
+ * can leave text the next pass would still change, and a `--write` run that
69
+ * takes one pass then reports success has called such a file formatted while
70
+ * `prettier --check` still rejects it (#125). Formatting to a fixpoint removes
71
+ * the assumption — the file lands on the value repeated formatting converges
72
+ * to, whatever it took to get there.
73
+ *
74
+ * Three, not "until it stops": a file that oscillates would loop forever, and
75
+ * the cap turns that into a report. Three is enough for the case this is for
76
+ * (one pass short) with a pass to spare, and costs nothing on a tree that is
77
+ * already formatted, where the first pass converges immediately.
78
+ */
79
+ const MAX_FORMAT_PASSES = 3;
80
+
64
81
  /**
65
82
  * Every file under a root, minus the directories nothing should walk.
66
83
  *
@@ -106,11 +123,15 @@ function walkFiles(root) {
106
123
  * @param {readonly string[]} [opts.paths] - Files or directories to check
107
124
  * instead of the whole root.
108
125
  * @param {boolean} [opts.write=false] - Rewrite unformatted files in place
109
- * rather than reporting them.
126
+ * rather than reporting them. Each file is formatted to a fixpoint (up to
127
+ * {@link MAX_FORMAT_PASSES} passes), so a written tree is one a second run
128
+ * leaves alone; a file that will not converge is reported and left unchanged
129
+ * (#125).
110
130
  * @param {object} [opts.prettier] - The Prettier module, for tests.
111
131
  * @returns {Promise<{findings: Array<{file: string, severity: string,
112
132
  * message: string}>, checked: number, written: string[]}>} The findings, how
113
- * many files were considered, and what was rewritten.
133
+ * many files were considered, and what was rewritten. `--write` reports
134
+ * findings too — a file it cannot parse, or cannot format to a fixpoint.
114
135
  */
115
136
  export async function checkFormatting(root, opts = {}) {
116
137
  const { paths, write = false } = opts;
@@ -155,7 +176,38 @@ export async function checkFormatting(root, opts = {}) {
155
176
  // every other one.
156
177
  try {
157
178
  if (write) {
158
- const formatted = await prettier.format(source, options);
179
+ // Format to a fixpoint rather than once, so what lands on disk
180
+ // is what a second run would have produced (#125).
181
+ let formatted = source;
182
+ let converged = false;
183
+ for (let pass = 0; pass < MAX_FORMAT_PASSES; pass += 1) {
184
+ const next = await prettier.format(formatted, options);
185
+ if (next === formatted) {
186
+ converged = true;
187
+ break;
188
+ }
189
+ formatted = next;
190
+ }
191
+
192
+ if (!converged) {
193
+ // Nothing is written. A formatting the command cannot
194
+ // reproduce is not one to commit to disk — writing it
195
+ // would make `--write` churn the file on every run — and a
196
+ // file that never settles is a defect somewhere that has
197
+ // to be named rather than absorbed.
198
+ findings.push({
199
+ file,
200
+ severity: "error",
201
+ // No line or column: the verdict is about the whole
202
+ // file, and #17's rule is to drop a field rather than
203
+ // invent one.
204
+ message:
205
+ `did not converge after ${MAX_FORMAT_PASSES} formatting passes; ` +
206
+ "left unchanged",
207
+ });
208
+ continue;
209
+ }
210
+
159
211
  if (formatted !== source) {
160
212
  fs.writeFileSync(file, formatted);
161
213
  written.push(file);
@@ -34,12 +34,24 @@
34
34
  * *unresolvable*, silently — and it also suppressed real build failures, since
35
35
  * a note the compilers never reached could not fail on the defects it carried.
36
36
  *
37
+ * **A field retired in favour of another is a third case (#142).** `draft:` and
38
+ * `package:` were retired outright: nothing replaced them, so no value made
39
+ * writing one right and refusal was the only honest answer. A *renamed* field
40
+ * has a replacement, and the two spellings mean the same thing — so the note
41
+ * still compiles, correctly, and refusing it would fail a build over a document
42
+ * that is not wrong. Those retire in the three steps `package:` took (#56), and
43
+ * this module carries the **first**: both spellings are read, the current one
44
+ * wins, and the retired one is *reported* rather than refused. The sweep and
45
+ * the refusal come later, once no tree writes it. See
46
+ * {@link RETIRED_FIELD_ALIASES}.
47
+ *
37
48
  * @module
38
49
  */
39
50
 
40
51
  import fs from "node:fs";
41
52
 
42
53
  import { positionInFrontmatter } from "./diagnostics.mjs";
54
+ import { sohlField } from "./frontmatter.mjs";
43
55
 
44
56
  /**
45
57
  * What a note declaring `draft:` is told, in one place.
@@ -105,12 +117,15 @@ export function assertNoDraftField(fm, { file, absPath } = {}) {
105
117
  * both refusals need it and a second copy is a second thing to keep correct.
106
118
  *
107
119
  * @param {string|undefined} absPath - The note's file.
108
- * @param {string} key - The top-level frontmatter key.
120
+ * @param {string} key - The frontmatter key.
121
+ * @param {string} [value] - When given, prefer the occurrence whose line also
122
+ * carries this text — so a finding about one entry of a block opens on that
123
+ * entry rather than on the key that introduces it.
109
124
  * @returns {{line?: number, column?: number}|undefined} Spreadable position
110
125
  * fields, dropped rather than guessed when the file cannot be read or the key
111
126
  * cannot be found — as `formatDiagnostic` requires.
112
127
  */
113
- export function locateFrontmatterKey(absPath, key) {
128
+ export function locateFrontmatterKey(absPath, key, value = undefined) {
114
129
  if (!absPath) return undefined;
115
130
  let raw;
116
131
  try {
@@ -118,6 +133,105 @@ export function locateFrontmatterKey(absPath, key) {
118
133
  } catch {
119
134
  return undefined;
120
135
  }
121
- const at = positionInFrontmatter(raw, key);
136
+ const at = positionInFrontmatter(raw, key, value);
122
137
  return at.line === undefined ? undefined : at;
123
138
  }
139
+
140
+ /* -------------------------------------------------------------------- */
141
+ /* Retired *in favour of another field* */
142
+ /* -------------------------------------------------------------------- */
143
+
144
+ /**
145
+ * The current field name a retired spelling was renamed to.
146
+ *
147
+ * Keyed by the **current** name, because that is what a type's schema declares
148
+ * and what every reader asks for; the value is the spelling still honoured.
149
+ * The table is therefore scoped by the schema without saying so twice: an alias
150
+ * applies to a note only where that note's type declares the current field, so
151
+ * `image` is retired on a map — which declares `img` — and remains an unknown
152
+ * key anywhere else.
153
+ *
154
+ * **`img` (#142).** Every note type names its artwork `img`, at the note's top
155
+ * level, and resolves it the same way. A map alone named its background art
156
+ * `image` and read it out of the `sohl:` block — two spellings for one idea,
157
+ * with nothing to reconcile them, and a specification that had to hedge rather
158
+ * than state a rule. Art is not system-specific: a Scene is a core Foundry
159
+ * document and HM3 would want the identical one, so the field belongs beside
160
+ * every other note's `img`, not inside a system block.
161
+ *
162
+ * @type {Readonly<Record<string, string>>}
163
+ */
164
+ export const RETIRED_FIELD_ALIASES = Object.freeze({ img: "image" });
165
+
166
+ /**
167
+ * What a note writing a renamed field is told, in one place.
168
+ *
169
+ * Shared by the compile-time report and the frontmatter lint, because an author
170
+ * meets whichever runs first and they should read the same. It names the key to
171
+ * write rather than a value to correct — no value makes the retired spelling
172
+ * right — and it says the note compiles either way, so a reader knows this is a
173
+ * rename to schedule rather than a build to unbreak.
174
+ *
175
+ * @param {string} retired - The spelling the note used.
176
+ * @param {string} current - What to write instead.
177
+ * @param {string} [file] - The note's path, named in the message. Omit it where
178
+ * the caller emits through a diagnostic, whose locator already starts the
179
+ * line — repeating it prints the path twice.
180
+ * @returns {string} The message, unpunctuated at the end as a finding is.
181
+ */
182
+ export function retiredAliasMessage(retired, current, file) {
183
+ return (
184
+ `\`${retired}:\` is a retired frontmatter field — write \`${current}:\` ` +
185
+ `instead` +
186
+ (file ? ` — ${file}` : "") +
187
+ `. Both are read and \`${current}\` wins, so the note compiles ` +
188
+ `identically either way; \`${retired}\` is removed in a later release`
189
+ );
190
+ }
191
+
192
+ /**
193
+ * Whether a note writes the retired spelling of a field, wherever it put it.
194
+ *
195
+ * Both regions are searched, because {@link sohlField} reads both: a note that
196
+ * moved the key to the top level without renaming it has done half the
197
+ * migration, and should be told so rather than passing in silence.
198
+ *
199
+ * @param {object|null|undefined} fm - Parsed frontmatter.
200
+ * @param {string} current - The field's current name.
201
+ * @returns {boolean} Whether the retired spelling is declared.
202
+ */
203
+ export function declaresRetiredAlias(fm, current) {
204
+ const retired = RETIRED_FIELD_ALIASES[current];
205
+ if (!retired || !fm || typeof fm !== "object") return false;
206
+ const block = fm.sohl;
207
+ const inBlock =
208
+ block &&
209
+ typeof block === "object" &&
210
+ !Array.isArray(block) &&
211
+ Object.hasOwn(block, retired);
212
+ return Boolean(inBlock) || Object.hasOwn(fm, retired);
213
+ }
214
+
215
+ /**
216
+ * Read a field that has a retired spelling, the current name winning.
217
+ *
218
+ * This is the whole of the retirement window's behaviour, in one function, so
219
+ * the compiler and the linter cannot disagree about which value a note carries.
220
+ * Resolution within each spelling is {@link sohlField}'s — the `sohl:` block
221
+ * first, then the note's top level — so a renamed field keeps working wherever
222
+ * it was already written while the canonical home is the top level.
223
+ *
224
+ * A blank value counts as absent: `img:` cleared in an editor means the note
225
+ * names no art there, and falling through to the retired spelling is what an
226
+ * author part-way through the rename means by it.
227
+ *
228
+ * @param {object|null|undefined} fm - Parsed frontmatter.
229
+ * @param {string} current - The field's current name.
230
+ * @returns {any} The value, or `undefined` when neither spelling carries one.
231
+ */
232
+ export function readAliasedField(fm, current) {
233
+ const value = sohlField(fm, current, undefined);
234
+ if (value !== undefined && value !== null && value !== "") return value;
235
+ const retired = RETIRED_FIELD_ALIASES[current];
236
+ return retired ? sohlField(fm, retired, undefined) : undefined;
237
+ }
package/engine/scenes.mjs CHANGED
@@ -59,6 +59,13 @@ import { packRouter } from "./pack-router.mjs";
59
59
  import { foundryPackageId } from "./content-package.mjs";
60
60
  import { itemDocEntryId } from "./item-docs.mjs";
61
61
  import { behaviorDocId, buildScene, isMapType, regionDocId } from "./map-notes.mjs";
62
+ import {
63
+ RETIRED_FIELD_ALIASES,
64
+ declaresRetiredAlias,
65
+ locateFrontmatterKey,
66
+ readAliasedField,
67
+ retiredAliasMessage,
68
+ } from "./retired-fields.mjs";
62
69
 
63
70
  /**
64
71
  * Every SoHL action name this build knows about, for the `action:` warning on a
@@ -378,6 +385,17 @@ export class Scenes extends BasePackCompiler {
378
385
  // pass has to read the other's output.
379
386
  const entryId = hasBody ? itemDocEntryId(fm.id) : undefined;
380
387
  const folder = this.folderResolver(sohlField(fm, "folder", null));
388
+ // The retired spelling of the background art, reported where an author
389
+ // meets it soonest — every consumer runs the compile, and not every
390
+ // one runs the lint (#142). Located by reading the note back, which is
391
+ // what the other retired-field reports do: this is the one path that
392
+ // needs the position, so it is paid for only here.
393
+ if (declaresRetiredAlias(fm, "img")) {
394
+ this.noteWarn(
395
+ retiredAliasMessage(RETIRED_FIELD_ALIASES.img, "img"),
396
+ locateFrontmatterKey(this.currentNote?.absPath, RETIRED_FIELD_ALIASES.img),
397
+ );
398
+ }
381
399
  const warnings = [];
382
400
  const scene = buildScene(fm, {
383
401
  packageId: foundryPackageId(),
@@ -423,7 +441,7 @@ export class Scenes extends BasePackCompiler {
423
441
  this.places.set(placeKey, {
424
442
  key: placeKey,
425
443
  name: sohlField(fm, "placeName", null) || name,
426
- img: sohlField(fm, "image", null),
444
+ img: readAliasedField(fm, "img") ?? null,
427
445
  pinned: false,
428
446
  scenes: [],
429
447
  journal: [],