@heroiclands/package-build 19.0.0 → 20.2.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 (85) hide show
  1. package/CHANGELOG.md +1100 -0
  2. package/CONTENT.md +264 -33
  3. package/README.md +43 -4
  4. package/bin/content-build.mjs +94 -3
  5. package/config.mjs +9 -1
  6. package/content-config.mjs +99 -19
  7. package/docs/content-format.md +394 -72
  8. package/e2e.mjs +297 -3
  9. package/engine/actor-compiler.mjs +197 -7
  10. package/engine/address-charset.mjs +23 -5
  11. package/engine/base-compiler.mjs +63 -2
  12. package/engine/bundles.mjs +9 -0
  13. package/engine/content-address.mjs +92 -1
  14. package/engine/content-charset.mjs +434 -0
  15. package/engine/content-format.mjs +102 -0
  16. package/engine/content-icons.mjs +388 -0
  17. package/engine/content-index.mjs +11 -8
  18. package/engine/content-links.mjs +37 -21
  19. package/engine/field-reference.mjs +57 -5
  20. package/engine/field-spec.mjs +214 -7
  21. package/engine/folder-notes.mjs +24 -1
  22. package/engine/foreign-catalog.mjs +112 -7
  23. package/engine/foundry-entries.mjs +14 -0
  24. package/engine/frontmatter-lint.mjs +377 -56
  25. package/engine/frontmatter.mjs +11 -11
  26. package/engine/generate.mjs +72 -12
  27. package/engine/helpers.mjs +96 -10
  28. package/engine/index.mjs +9 -0
  29. package/engine/item-compiler.mjs +37 -0
  30. package/engine/journals.mjs +21 -4
  31. package/engine/macros.mjs +8 -0
  32. package/engine/map-notes.mjs +7 -7
  33. package/engine/note-claims.mjs +208 -5
  34. package/engine/note-ids.mjs +25 -1
  35. package/engine/note-vocabulary.mjs +76 -9
  36. package/engine/pack-config.mjs +102 -12
  37. package/engine/pack-router.mjs +0 -0
  38. package/engine/prose-config.mjs +42 -0
  39. package/engine/prose-lint.mjs +126 -0
  40. package/engine/retired-fields.mjs +57 -16
  41. package/engine/runtime-only-fields.mjs +204 -0
  42. package/engine/scenes.mjs +12 -19
  43. package/engine/schema-check.mjs +23 -1
  44. package/engine/schema-extract.mjs +13 -0
  45. package/engine/site-index.mjs +17 -0
  46. package/engine/subtype-registry.mjs +30 -0
  47. package/engine/system-block.mjs +81 -3
  48. package/engine/web-wikilinks.mjs +33 -27
  49. package/engine/wikilink-syntax.mjs +7 -0
  50. package/engine/wikilinks.mjs +74 -16
  51. package/hm3/actors.mjs +63 -13
  52. package/package.json +2 -2
  53. package/sohl/actors.mjs +106 -7
  54. package/sohl/item-fields.mjs +203 -0
  55. package/sohl/note-schemas.mjs +6 -3
  56. package/types/config.d.mts +7 -0
  57. package/types/e2e.d.mts +130 -3
  58. package/types/engine/actor-compiler.d.mts +83 -3
  59. package/types/engine/address-charset.d.mts +22 -4
  60. package/types/engine/base-compiler.d.mts +54 -3
  61. package/types/engine/content-address.d.mts +64 -0
  62. package/types/engine/content-charset.d.mts +127 -0
  63. package/types/engine/content-format.d.mts +9 -0
  64. package/types/engine/content-icons.d.mts +151 -0
  65. package/types/engine/field-spec.d.mts +271 -3
  66. package/types/engine/folder-notes.d.mts +20 -0
  67. package/types/engine/foreign-catalog.d.mts +38 -2
  68. package/types/engine/foundry-entries.d.mts +6 -0
  69. package/types/engine/frontmatter-lint.d.mts +164 -30
  70. package/types/engine/frontmatter.d.mts +11 -11
  71. package/types/engine/generate.d.mts +27 -0
  72. package/types/engine/helpers.d.mts +45 -9
  73. package/types/engine/index.d.mts +3 -0
  74. package/types/engine/map-notes.d.mts +2 -2
  75. package/types/engine/note-claims.d.mts +67 -0
  76. package/types/engine/note-ids.d.mts +14 -0
  77. package/types/engine/pack-config.d.mts +35 -0
  78. package/types/engine/prose-config.d.mts +41 -0
  79. package/types/engine/prose-lint.d.mts +36 -0
  80. package/types/engine/retired-fields.d.mts +29 -13
  81. package/types/engine/runtime-only-fields.d.mts +102 -0
  82. package/types/engine/schema-check.d.mts +10 -1
  83. package/types/engine/subtype-registry.d.mts +21 -0
  84. package/types/engine/system-block.d.mts +28 -2
  85. package/types/sohl/actors.d.mts +3 -3
@@ -38,6 +38,7 @@ import {
38
38
  MARKDOWNLINT_CONFIG,
39
39
  MARKDOWN_GLOBS,
40
40
  MARKDOWN_IGNORES,
41
+ sharedPrettierDivergence,
41
42
  sharedPrettierOptionsFor,
42
43
  } from "./prose-config.mjs";
43
44
 
@@ -78,6 +79,27 @@ const IGNORE_FILES = Object.freeze([".gitignore", ".prettierignore"]);
78
79
  */
79
80
  const MAX_FORMAT_PASSES = 3;
80
81
 
82
+ /**
83
+ * The two paths whose resolved configuration stands for the repository's.
84
+ *
85
+ * Prettier resolves a configuration *per file*, so asking what a repository is
86
+ * configured to do means asking about a file. These are the two answers that
87
+ * differ: markdown carries the shared `tabWidth` override and everything else
88
+ * does not, so a single probe would check half the conventions and miss the one
89
+ * most worth checking (#133).
90
+ *
91
+ * Ordinary names at the repository root, and neither has to exist —
92
+ * `resolveConfig` reads the path to walk up from it and to match `overrides`
93
+ * against it, never the file. That is also the limit of what this can say: it
94
+ * reports the configuration a file *at the root* resolves to, so an override a
95
+ * consumer scoped to some subtree of its own is outside the question being
96
+ * asked, and rightly so.
97
+ */
98
+ const CONVENTION_PROBES = Object.freeze({ code: "index.mjs", markdown: "README.md" });
99
+
100
+ /** The one line a consumer writes to adopt the shared configuration verbatim. */
101
+ const SHARED_CONFIG_RE_EXPORT = 'export { default } from "@heroiclands/package-build/prettier";';
102
+
81
103
  /**
82
104
  * Every file under a root, minus the directories nothing should walk.
83
105
  *
@@ -245,6 +267,110 @@ export async function checkFormatting(root, opts = {}) {
245
267
  return { findings, checked, written };
246
268
  }
247
269
 
270
+ /**
271
+ * One divergence as the sentence a diagnostic carries.
272
+ *
273
+ * The two cases read differently on purpose. A key set to something else is a
274
+ * choice someone made and can defend; a key that is simply absent is the
275
+ * silent half of #133 — the consumer did not choose Prettier's default, it
276
+ * arrived because declaring one option discards every option not restated.
277
+ *
278
+ * @param {{key: string, shared: unknown, local: unknown}} divergence - From
279
+ * {@link sharedPrettierDivergence}.
280
+ * @param {string} [scope=""] - Which files this is about, when it is not all of
281
+ * them. Prefixed to the key, so the line reads `markdown \`tabWidth\` …`.
282
+ * @returns {string} The message.
283
+ */
284
+ function divergenceMessage({ key, shared, local }, scope = "") {
285
+ const here =
286
+ local === undefined ?
287
+ "is not set here, so Prettier's own default applies"
288
+ : `is ${JSON.stringify(local)} here`;
289
+ return `${scope}\`${key}\` ${here}; the shared configuration says ${JSON.stringify(shared)}`;
290
+ }
291
+
292
+ /**
293
+ * Report where a repository's own Prettier configuration parts from the shared
294
+ * one — or that it has none at all (#133).
295
+ *
296
+ * **Warnings, every one of them.** A consumer's config wins by design and this
297
+ * does not change that; it only refuses to let the divergence be silent, which
298
+ * is the whole of what the issue asks for. Turning any of this into an error
299
+ * would make the shared conventions mandatory, and they are a default.
300
+ *
301
+ * The no-configuration case is the sharper one and is reported even though the
302
+ * command itself behaves correctly there: with no config file the shared
303
+ * conventions reach `content-build format` and reach *nothing else*, so an
304
+ * editor's format-on-save and a bare `npx prettier --check .` apply Prettier's
305
+ * own defaults to the same tree, and the two take turns rewriting the same
306
+ * lines. That is not hypothetical — it is what the config files in
307
+ * `sohl-thalorna` and `sohl-kethira-basic` were added to stop.
308
+ *
309
+ * @param {string} root - Repository to ask about.
310
+ * @param {object} [opts]
311
+ * @param {object} [opts.prettier] - The Prettier module, for tests.
312
+ * @returns {Promise<{findings: Array<{file?: string, severity: string,
313
+ * message: string}>, configFile: string|null}>} The findings and the config
314
+ * file they are about, which is `null` when the repository declares none. A
315
+ * finding about a missing file carries no `file`: #17's rule is to drop a
316
+ * field rather than invent one.
317
+ */
318
+ export async function checkPrettierConventions(root, opts = {}) {
319
+ const prettier = opts.prettier ?? (await import("prettier"));
320
+ const base = path.resolve(root);
321
+ const probe = (name) => path.join(base, name);
322
+
323
+ const configFile = await prettier.resolveConfigFile(probe(CONVENTION_PROBES.code));
324
+ if (!configFile) {
325
+ return {
326
+ findings: [
327
+ {
328
+ severity: "warning",
329
+ message:
330
+ "this repository declares no Prettier configuration, so `content-build " +
331
+ "format` applies the shared conventions while an editor and a bare `npx " +
332
+ "prettier` apply Prettier's own to the same tree; declare them in a " +
333
+ `prettier.config.mjs — ${SHARED_CONFIG_RE_EXPORT}`,
334
+ },
335
+ ],
336
+ configFile: null,
337
+ };
338
+ }
339
+
340
+ /** @param {string} name - One of {@link CONVENTION_PROBES}. */
341
+ const divergenceFor = async (name) =>
342
+ sharedPrettierDivergence(
343
+ await prettier.resolveConfig(probe(name), { editorconfig: false }),
344
+ probe(name),
345
+ );
346
+
347
+ const code = await divergenceFor(CONVENTION_PROBES.code);
348
+ const markdown = await divergenceFor(CONVENTION_PROBES.markdown);
349
+
350
+ const findings = code.map((divergence) => ({
351
+ file: configFile,
352
+ severity: "warning",
353
+ message: divergenceMessage(divergence),
354
+ }));
355
+ for (const divergence of markdown) {
356
+ // A key that resolves the same way everywhere is one finding, not two.
357
+ // Only what markdown alone gets wrong is worth a line of its own — and
358
+ // it is the line that matters most, `tabWidth` being the value a note
359
+ // moving between repositories reindents on.
360
+ const everywhere = code.some(
361
+ (other) => other.key === divergence.key && Object.is(other.local, divergence.local),
362
+ );
363
+ if (everywhere) continue;
364
+ findings.push({
365
+ file: configFile,
366
+ severity: "warning",
367
+ message: divergenceMessage(divergence, "markdown "),
368
+ });
369
+ }
370
+
371
+ return { findings, configFile };
372
+ }
373
+
248
374
  /**
249
375
  * One markdownlint result as a diagnostic.
250
376
  *
@@ -80,10 +80,19 @@
80
80
  * still compiles, correctly, and refusing it would fail a build over a document
81
81
  * that is not wrong. Those retire in the three steps `package:` took (#56), and
82
82
  * this module carries the **first**: both spellings are read, the current one
83
- * wins, and the retired one is *reported* rather than refused. The sweep and
84
- * the refusal come later, once no tree writes it. See
83
+ * wins, and the retired one is *reported* rather than refused. See
85
84
  * {@link RETIRED_FIELD_ALIASES}.
86
85
  *
86
+ * **The third step is deletion, and it needs no code (#149).** `image`, a map's
87
+ * background art, is the first rename to have run all three: reported (#142),
88
+ * swept (SoHL#1801 and the position move that followed), then dropped from the
89
+ * table. Removing the entry is the whole of it — with no alias, the spelling is
90
+ * an ordinary unknown key in the `sohl:` block, which the frontmatter lint
91
+ * already refuses as an error alongside the required field it failed to supply.
92
+ * So nothing here *records* a finished retirement: the absence of an entry is
93
+ * the record, and a retirement that needed a standing refusal would mean the
94
+ * replacement never arrived. Do not keep a tombstone for one.
95
+ *
87
96
  * **A retired *position* is the same case, and reads the same (#305).** A field
88
97
  * whose shared source moved under `data:` is not renamed — `data.species` and
89
98
  * `hm3.species` are one field written in two places — but the retirement has
@@ -93,6 +102,14 @@
93
102
  * {@link module:engine/system-block.resolveFieldValue}'s answer; this module
94
103
  * only says what an author is told about it.
95
104
  *
105
+ * **A field has two retiring positions, not one (#332).** The in-block key is
106
+ * the obvious one; the other is the note's **top level**, because #128 did not
107
+ * invent the facts `data:` holds — it gathered them from exactly there. So
108
+ * `portrait:` beside `img:` is the pre-`data:` spelling of `data.portrait`,
109
+ * read for the same reason and reported by {@link retiredTopLevelMessage}. Both
110
+ * are needed, and separately: a note may have moved one and not the other, and
111
+ * a single finding covering both would name the wrong line half the time.
112
+ *
96
113
  * @module
97
114
  */
98
115
 
@@ -100,6 +117,7 @@ import fs from "node:fs";
100
117
 
101
118
  import { positionInFrontmatter } from "./diagnostics.mjs";
102
119
  import { sohlField } from "./frontmatter.mjs";
120
+ import { retiredTopLevelKey } from "./system-block.mjs";
103
121
 
104
122
  /**
105
123
  * What a note declaring `draft:` is told, in one place.
@@ -417,8 +435,8 @@ export function locateFrontmatterKey(absPath, key, value = undefined, { topLevel
417
435
  * and what every reader asks for; the value is the spelling still honoured.
418
436
  * The table is therefore scoped by the schema without saying so twice: an alias
419
437
  * applies to a note only where that note's type declares the current field, so
420
- * `image` is retired on a map — which declares `img` — and remains an unknown
421
- * key anywhere else.
438
+ * `relation` is retired on an affiliation — which declares `relations` — and
439
+ * remains an unknown key anywhere else.
422
440
  *
423
441
  * **`templatePriority` (#266).** The number that decides which of several
424
442
  * competing templates the Create dialog offers was called `archetype`, and
@@ -433,18 +451,9 @@ export function locateFrontmatterKey(absPath, key, value = undefined, { topLevel
433
451
  * read past. Only `affiliation` declares the field, so the alias is reported
434
452
  * there and the old spelling stays an ordinary unknown key everywhere else.
435
453
  *
436
- * **`img` (#142).** Every note type names its artwork `img`, at the note's top
437
- * level, and resolves it the same way. A map alone named its background art
438
- * `image` and read it out of the `sohl:` block — two spellings for one idea,
439
- * with nothing to reconcile them, and a specification that had to hedge rather
440
- * than state a rule. Art is not system-specific: a Scene is a core Foundry
441
- * document and HM3 would want the identical one, so the field belongs beside
442
- * every other note's `img`, not inside a system block.
443
- *
444
454
  * @type {Readonly<Record<string, string>>}
445
455
  */
446
456
  export const RETIRED_FIELD_ALIASES = Object.freeze({
447
- img: "image",
448
457
  templatePriority: "archetype",
449
458
  relations: "relation",
450
459
  });
@@ -507,6 +516,38 @@ export function legacyKeyMessage(block, field, file) {
507
516
  );
508
517
  }
509
518
 
519
+ /**
520
+ * What a note writing a field at the **top-level key `data:` gathered it off**
521
+ * is told (#332).
522
+ *
523
+ * {@link legacyKeyMessage}'s counterpart for the other retiring position. #128
524
+ * did not invent the facts `data:` holds — it collected them out of the note's
525
+ * open top level — so `portrait:` beside `img:` is the *pre-`data:`* spelling
526
+ * of `data.portrait`, and both are read for the same reason both in-block
527
+ * spellings are: a package moves its corpus when it is ready, not on a flag day.
528
+ *
529
+ * It says nothing about which value is emitted, because that is not what an
530
+ * author needs from it. The value is the same either way; what the finding
531
+ * counts is one more note still on the old position.
532
+ *
533
+ * @param {{name?: string}} field - The declaration, which names the current
534
+ * position; the retiring one is derived from it.
535
+ * @param {string} [file] - The note's path, named in the message. Omit it where
536
+ * the caller emits through a diagnostic, whose locator already starts the
537
+ * line — repeating it prints the path twice.
538
+ * @returns {string} The message, unpunctuated at the end as a finding is.
539
+ */
540
+ export function retiredTopLevelMessage(field, file) {
541
+ const retiring = retiredTopLevelKey(field);
542
+ return (
543
+ `top-level \`${retiring}:\` is the pre-\`data:\` position of the shared ` +
544
+ `\`${field.name}:\` — move it under \`data:\` instead` +
545
+ (file ? ` — ${file}` : "") +
546
+ `. Both are read and \`${field.name}\` wins, so the note compiles ` +
547
+ `identically either way; the top-level key is removed in a later release`
548
+ );
549
+ }
550
+
510
551
  /**
511
552
  * Whether a note writes the retired spelling of a field, wherever it put it.
512
553
  *
@@ -539,9 +580,9 @@ export function declaresRetiredAlias(fm, current) {
539
580
  * first, then the note's top level — so a renamed field keeps working wherever
540
581
  * it was already written while the canonical home is the top level.
541
582
  *
542
- * A blank value counts as absent: `img:` cleared in an editor means the note
543
- * names no art there, and falling through to the retired spelling is what an
544
- * author part-way through the rename means by it.
583
+ * A blank value counts as absent: `relations:` cleared in an editor means the
584
+ * note records no standings there, and falling through to the retired spelling
585
+ * is what an author part-way through the rename means by it.
545
586
  *
546
587
  * @param {object|null|undefined} fm - Parsed frontmatter.
547
588
  * @param {string} current - The field's current name.
@@ -0,0 +1,204 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * Schema fields a note may **never** author, because the document writes them
16
+ * in play (#330).
17
+ *
18
+ * `retired-fields.mjs` refuses a field a note may no longer declare; this
19
+ * refuses one it never could. The two read alike deliberately — a note says one
20
+ * thing, the build does another, and nothing says so — but they are different
21
+ * facts, and only one of them is ever swept: a retired field goes away, while
22
+ * `onsetDate` is a permanent part of `affliction`'s schema that simply is not
23
+ * content.
24
+ *
25
+ * ## What was going wrong
26
+ *
27
+ * A DataModel declares plenty of fields a *compiled* document has no business
28
+ * carrying. SoHL's timed phases are the case that prompted this: each phase of
29
+ * an `affliction` or a `trauma` stores a `{…DurationFormula, …DurationBase,
30
+ * …Date}` triplet, and the third of them is instance state, crystallized when
31
+ * the phase fires. World time does not exist while content is compiled, and `0`
32
+ * is itself a valid world time — which is why the field is nullable rather than
33
+ * sentinelled, and why no default could stand in for the missing value.
34
+ *
35
+ * Nothing stopped a note writing one, and the build emitted it. Three checks
36
+ * each declined to catch it, every one of them for its own correct reason:
37
+ *
38
+ * - {@link module:engine/system-block.unknownBlockKeys} inspects the **top
39
+ * level** of a system block and deliberately never descends into `system:`,
40
+ * which is a passthrough for the system's own vocabulary.
41
+ * - {@link module:engine/system-block.mergeSystemData} writes every authored
42
+ * `system.*` path that no declared field claims — and no field claimed
43
+ * `contractDate`, so it passed through verbatim.
44
+ * - The schema check's fatal direction is *undeclared* — emitting a key the
45
+ * system does not define. `contractDate` **is** in the schema, so as far as
46
+ * that check can see the emitted key is legitimate.
47
+ *
48
+ * The gap was that no rule expressed "declared by the system, but never
49
+ * authorable". This module is that rule, and it is **declarative**: it knows no
50
+ * field names, only the {@link module:engine/field-spec.FieldSpec} property
51
+ * `runtimeOnly`, so it holds for any future runtime-only field of any system
52
+ * without being taught about it.
53
+ *
54
+ * ## Why a refusal rather than a drop
55
+ *
56
+ * Declaring the field claims its path, so the passthrough would leave it alone
57
+ * and the authored value would simply vanish — which is the silent-disagreement
58
+ * failure this package spends its time removing. And the consequence of getting
59
+ * it wrong is not a missing value but a shipped one: a compiled document
60
+ * carrying a world-time stamp is one world's play state, installed into every
61
+ * world that loads the pack.
62
+ *
63
+ * @module
64
+ */
65
+
66
+ import { runtimeOnlyFields } from "./field-spec.mjs";
67
+ import { getFrontmatter } from "./frontmatter.mjs";
68
+ import { locateFrontmatterKey } from "./retired-fields.mjs";
69
+ import { SYSTEM_DATA_KEY, systemData } from "./system-block.mjs";
70
+
71
+ /**
72
+ * What a note authoring a runtime-only field is told, in one place.
73
+ *
74
+ * Shared by every caller that can meet one, because an author meets whichever
75
+ * runs first and they should read the same. It says what the field holds and
76
+ * that deleting the key is the whole fix — there is no value to correct, which
77
+ * is what separates this from an out-of-range one.
78
+ *
79
+ * The reason comes from the declaration rather than from here: this module
80
+ * knows no field names, and a message written per field would be a second
81
+ * statement of the fact the declaration already carries.
82
+ *
83
+ * @param {string} key - The **whole key the note wrote**, from the region it
84
+ * sits in down to the field: `sohl.system.onsetDate` on the item's own note,
85
+ * `sohl.items[2].system.contractDate` on an actor's embedded entry. Composed
86
+ * by the caller, because only it knows where it found the value — and a
87
+ * message naming the leaf alone leaves an author a note to search.
88
+ * @param {import("./field-spec.mjs").FieldSpec} field - The declaration, which
89
+ * carries the reason.
90
+ * @param {string} [file] - The note's path, named in the message. Omit it where
91
+ * the caller emits through a diagnostic, whose locator already starts the
92
+ * line — repeating it prints the path twice.
93
+ * @returns {string} The message, unpunctuated at the end as a finding is.
94
+ */
95
+ export function runtimeOnlyMessage(key, field, file) {
96
+ return (
97
+ `\`${key}:\` is runtime state, not ` +
98
+ `content — delete it` +
99
+ (file ? ` — ${file}` : "") +
100
+ `. It holds ${field.runtimeOnly}, so no value for it exists at compile ` +
101
+ `time, and a compiled document carrying one ships a fact about a world ` +
102
+ `the pack has never been loaded into. The key is left out of the ` +
103
+ `document entirely, so the DataModel's own initial value stands`
104
+ );
105
+ }
106
+
107
+ /**
108
+ * The runtime-only fields a note actually writes, in declaration order.
109
+ *
110
+ * **Presence is the whole test**, as it is for every retired field: an authored
111
+ * `onsetDate: null` is as much a claim about play state as a number is, and it
112
+ * is exactly the belief the message exists to correct. So the question is
113
+ * whether the path resolves to anything at all, never whether the value is a
114
+ * usable one.
115
+ *
116
+ * Only `<block>.system.<to>` is searched, because it is the only position a
117
+ * runtime-only field is reachable at. Such a declaration carries no `name`, so
118
+ * it has neither a legacy in-block key nor a shared top-level source — and a
119
+ * bare `<block>.onsetDate` is an unrecognized block key, which
120
+ * {@link module:engine/system-block.unknownBlockKeys} already reports as an
121
+ * error naming the note and the line.
122
+ *
123
+ * @param {object|null|undefined} fm - Parsed frontmatter.
124
+ * @param {readonly import("./field-spec.mjs").FieldSpec[]} [fields] - The
125
+ * type's field declaration.
126
+ * @param {object} options - Options.
127
+ * @param {string} options.block - The system block to look in.
128
+ * @returns {import("./field-spec.mjs").FieldSpec[]} The offending declarations.
129
+ */
130
+ export function authoredRuntimeOnlyFields(fm, fields, { block } = {}) {
131
+ if (!fm || typeof fm !== "object" || !block) return [];
132
+ return runtimeOnlyIn(systemData(fm, block), fields);
133
+ }
134
+
135
+ /**
136
+ * The same question asked of a `system` block directly.
137
+ *
138
+ * A note's own block is reached through {@link authoredRuntimeOnlyFields}, but
139
+ * it is not the only place an author writes one: an actor note's `items:`
140
+ * entries carry a `system:` overlay that is deep-merged onto the template
141
+ * verbatim, with no field declaration in the path at all. That overlay is a
142
+ * `system` block by every meaning except where it sits, and a `contractDate`
143
+ * written there ships exactly as one written on the trauma's own note.
144
+ *
145
+ * @param {Record<string, unknown>|null|undefined} data - The authored `system`
146
+ * data.
147
+ * @param {readonly import("./field-spec.mjs").FieldSpec[]} [fields] - The
148
+ * type's field declaration.
149
+ * @returns {import("./field-spec.mjs").FieldSpec[]} The offending declarations.
150
+ */
151
+ export function runtimeOnlyIn(data, fields) {
152
+ if (!data || typeof data !== "object") return [];
153
+ return runtimeOnlyFields(fields).filter(
154
+ (field) =>
155
+ typeof field.to === "string" &&
156
+ field.to !== "" &&
157
+ getFrontmatter(data, field.to, undefined) !== undefined,
158
+ );
159
+ }
160
+
161
+ /**
162
+ * Refuse a note that authors any of its type's runtime-only fields.
163
+ *
164
+ * Refused rather than reported: the note is not compiled, so nothing it would
165
+ * have emitted reaches a pack. What that costs is the caller's to decide — each
166
+ * of them counts the refused note and emits a located diagnostic, so a refusal
167
+ * is never a silent skip.
168
+ *
169
+ * The **first** offending field is thrown on. A note authoring a whole phase
170
+ * triplet would otherwise produce three findings that are one mistake, and the
171
+ * build stops on this note either way; the fix for the first is the fix for all
172
+ * of them.
173
+ *
174
+ * @param {object|null|undefined} fm - Parsed frontmatter, or nothing when it
175
+ * could not be parsed.
176
+ * @param {readonly import("./field-spec.mjs").FieldSpec[]} [fields] - The
177
+ * type's field declaration. A type that declares none — or declares no
178
+ * runtime-only field — passes.
179
+ * @param {object} options - Options.
180
+ * @param {string} options.block - The system block to look in.
181
+ * @param {string} [options.file] - The note's path, named in the message. Omit
182
+ * it where the caller emits through a diagnostic, which puts the locator at
183
+ * the start of the line already — repeating it prints the path twice.
184
+ * @param {string} [options.absPath] - The note's file on disk, read only on the
185
+ * failing path to locate the offending line and column. The position rides on
186
+ * the thrown error as `position`, for a caller that emits a diagnostic.
187
+ * @returns {void}
188
+ * @throws {Error} When the note authors one.
189
+ */
190
+ export function assertNoRuntimeOnlyFields(fm, fields, { block, file, absPath } = {}) {
191
+ const [field] = authoredRuntimeOnlyFields(fm, fields, { block });
192
+ if (!field) return;
193
+
194
+ const key = `${block}.${SYSTEM_DATA_KEY}.${field.to}`;
195
+ const err = new Error(`${runtimeOnlyMessage(key, field, file)}.`);
196
+ // The **leaf** of the destination path, which is the key as the note writes
197
+ // it: `to` is dotted for a nested field, and a locator handed
198
+ // `charges.value` would find nothing. Deliberately not anchored at column 1
199
+ // — the key lives two levels in, under `<block>.system`.
200
+ const leaf = /** @type {string} */ (field.to).split(".").pop();
201
+ const position = locateFrontmatterKey(absPath, /** @type {string} */ (leaf));
202
+ if (position) err.position = position;
203
+ throw err;
204
+ }
package/engine/scenes.mjs CHANGED
@@ -73,13 +73,6 @@ import { packRouter } from "./pack-router.mjs";
73
73
  import { foundryPackageId } from "./content-package.mjs";
74
74
  import { itemDocEntryId } from "./item-docs.mjs";
75
75
  import { behaviorDocId, buildScene, isMapType, regionDocId } from "./map-notes.mjs";
76
- import {
77
- RETIRED_FIELD_ALIASES,
78
- declaresRetiredAlias,
79
- locateFrontmatterKey,
80
- readAliasedField,
81
- retiredAliasMessage,
82
- } from "./retired-fields.mjs";
83
76
 
84
77
  /**
85
78
  * Every SoHL action name this build knows about, for the `action:` warning on a
@@ -127,6 +120,17 @@ export class Scenes extends BasePackCompiler {
127
120
  static id = "scenes";
128
121
  static label = "map";
129
122
 
123
+ /**
124
+ * A map note's `img` is its background art, and it is **required**: the map
125
+ * compiler refuses a note without one. It lands on the scene's level rather
126
+ * than on a property spelled `img`, which makes no difference to the
127
+ * question this declaration answers — the authored path reaches the output
128
+ * (#349). The place Adventure this pass bundles carries it too.
129
+ *
130
+ * @type {readonly string[]}
131
+ */
132
+ static emitsArt = Object.freeze(["img"]);
133
+
130
134
  /** @type {string} */
131
135
  adventureDir;
132
136
 
@@ -410,17 +414,6 @@ export class Scenes extends BasePackCompiler {
410
414
  const entryId = hasBody ? itemDocEntryId(fm.id) : undefined;
411
415
  const { value: authoredFolder } = folderField(fm);
412
416
  const folder = this.folderResolver(authoredFolder, { isAddress: true });
413
- // The retired spelling of the background art, reported where an author
414
- // meets it soonest — every consumer runs the compile, and not every
415
- // one runs the lint (#142). Located by reading the note back, which is
416
- // what the other retired-field reports do: this is the one path that
417
- // needs the position, so it is paid for only here.
418
- if (declaresRetiredAlias(fm, "img")) {
419
- this.noteWarn(
420
- retiredAliasMessage(RETIRED_FIELD_ALIASES.img, "img"),
421
- locateFrontmatterKey(this.currentNote?.absPath, RETIRED_FIELD_ALIASES.img),
422
- );
423
- }
424
417
  const warnings = [];
425
418
  const scene = buildScene(fm, {
426
419
  packageId: foundryPackageId(),
@@ -471,7 +464,7 @@ export class Scenes extends BasePackCompiler {
471
464
  this.places.set(placeKey, {
472
465
  key: placeKey,
473
466
  name: sohlField(fm, "placeName", null) || name,
474
- img: readAliasedField(fm, "img") ?? null,
467
+ img: sohlField(fm, "img", null),
475
468
  pinned: false,
476
469
  scenes: [],
477
470
  journal: [],
@@ -68,6 +68,9 @@ import path from "node:path";
68
68
  import { cachedSchemaPath, SCHEMA_ARTIFACT_FILE } from "./foreign-catalog.mjs";
69
69
  import { loadPackConfig } from "./pack-config.mjs";
70
70
  import { systemData, systemDataPaths, undeclaredPaths } from "./system-block.mjs";
71
+ // A field the document writes for itself in play: declared by the schema,
72
+ // emitted by no builder, and authored by no note (#330).
73
+ import { runtimeOnlyFields } from "./field-spec.mjs";
71
74
 
72
75
  /**
73
76
  * The artifact version this module reads.
@@ -133,12 +136,21 @@ export function declaredFields(artifact, documentType, subtype) {
133
136
  * the path beneath it separately, so a comparison that knew only the leaf would
134
137
  * report the container as unemitted and the leaf as undeclared.
135
138
  *
136
- * @param {readonly {to: string}[]} fields - A type's field declaration.
139
+ * **A runtime-only field is not in it** (#330). It declares a `to` in order to
140
+ * *claim* the path — so the verbatim passthrough leaves it alone and the
141
+ * refusal has something to name — and `buildFromFields` deliberately skips it,
142
+ * because the document writes that field in play. Counting it here would make
143
+ * the check assert the builder writes a key it never writes; the *unemitted*
144
+ * direction handles it instead, in {@link compareFields}.
145
+ *
146
+ * @param {readonly {to: string, runtimeOnly?: string}[]} fields - A type's
147
+ * field declaration.
137
148
  * @returns {Set<string>} The paths, parents included.
138
149
  */
139
150
  export function emittedFields(fields) {
140
151
  const out = new Set();
141
152
  for (const field of fields ?? []) {
153
+ if (field?.runtimeOnly) continue;
142
154
  if (typeof field?.to !== "string" || !field.to) continue;
143
155
  const parts = field.to.split(".");
144
156
  for (let i = 1; i <= parts.length; i++) {
@@ -221,6 +233,15 @@ export function compareFields({
221
233
  }
222
234
 
223
235
  const emitted = emittedFields(fields);
236
+ // Paths the declaration says the *document* writes in play (#330). They
237
+ // are neither emitted nor a defect, so they answer the unemitted
238
+ // question below rather than appearing in it: "every compiled document
239
+ // will carry the field's initial value" is exactly what a runtime-only
240
+ // field is for, and reporting it would leave a permanent warning that
241
+ // the correct declaration cannot clear.
242
+ const runtimeOnly = new Set(
243
+ runtimeOnlyFields(/** @type {never} */ (fields)).map((field) => field.to),
244
+ );
224
245
  for (const path of emitted) {
225
246
  if (declared.all.has(path)) continue;
226
247
  undeclared.push({
@@ -238,6 +259,7 @@ export function compareFields({
238
259
  // on a type that populates them correctly — two findings, both
239
260
  // false, on the first real schema this was run against.
240
261
  if (coveredByAncestor(path, emitted)) continue;
262
+ if (runtimeOnly.has(path)) continue;
241
263
  unemitted.push({
242
264
  type,
243
265
  subtype,
@@ -1,3 +1,16 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
1
14
  /**
2
15
  * Read a package's DataModel field sets out of its source, as data.
3
16
  *
@@ -52,6 +52,7 @@
52
52
  import path from "node:path";
53
53
 
54
54
  import { canonicalKey, readCanonicalKey } from "./content-address.mjs";
55
+ import { NO_SYSTEM } from "./systems.mjs";
55
56
  import { systemOf } from "./document-subtypes.mjs";
56
57
  import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./note-claims.mjs";
57
58
  import { hasDocEntry } from "./item-docs.mjs";
@@ -266,6 +267,17 @@ export function buildSiteIndex(entries, { foreignIndex = new Map() } = {}) {
266
267
  if (hasDocEntry(type)) {
267
268
  contentTypes.add(`doc${type}`);
268
269
  index.set(`doc${type}/${shortcode}`.toLowerCase(), value);
270
+ // The canonical documentation address too, so the page answers
271
+ // to the address a bare prose link expands to (#336): body
272
+ // prose is under no system block, so it defaults to `none`, and
273
+ // a system-bearing type's `none` address is its `doc<type>`
274
+ // one. In Foundry that names a second document; here it names
275
+ // this same page, which is what makes one authored link correct
276
+ // in both builds.
277
+ index.set(
278
+ canonicalKey(e.pkg ?? ownPackage, NO_SYSTEM, `doc${type}`, shortcode),
279
+ value,
280
+ );
269
281
  }
270
282
  }
271
283
  }
@@ -316,6 +328,11 @@ export function wikiContext(built, { src, file, type = null, errors, foreignInde
316
328
  sections: built.sections,
317
329
  contentTypes: built.contentTypes,
318
330
  packages: built.packages,
331
+ // The package a link written on this page defaults to when it names
332
+ // none (#336). Taken from the resolved configuration, the same source
333
+ // the index's own addresses are built from, so a bare link cannot
334
+ // resolve against a package the index never keyed.
335
+ contentPackage: contentPackage(),
319
336
  type,
320
337
  errors,
321
338
  src,