@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
@@ -121,24 +121,152 @@ export const UNIVERSAL_KEYS = Object.freeze(
121
121
  * The system blocks a build checks, and what each accepts beyond the shared
122
122
  * vocabulary.
123
123
  *
124
- * One entry, because one system is what every existing tree declares — and the
125
- * default is a *declaration*, not a hard-coded assumption: a build that ships
126
- * content for two systems passes both, and each block is then checked against
127
- * its own vocabulary rather than against the other's (#58). A block nothing
128
- * declares is not checked, because nothing can say what it may carry, and
129
- * inventing a rule for it would report a correct tree red.
130
- *
131
- * `fieldVocabulary` says the note type's own declared field names are keys of
132
- * this block. True for `sohl` and untrue in general: those names come from the
133
- * `itemBuilders` registry that this system declares, and a second system's
134
- * notes write a second system's fields.
135
- *
136
- * @type {Readonly<Record<string, {known?: readonly string[], fieldVocabulary?: boolean}>>}
124
+ * One entry, because one system is what every existing tree declares — and it
125
+ * is a *fallback*, not the rule. {@link systemBlocksFor} derives the map from
126
+ * the configuration, which is what makes the block a package actually ships for
127
+ * the block that gets checked; this is what a caller holding no configuration
128
+ * gets, which in practice is a unit test.
129
+ *
130
+ * A block nothing declares is not checked, because nothing can say what it may
131
+ * carry, and inventing a rule for it would report a correct tree red.
132
+ *
133
+ * Three ways a block may state its vocabulary, and a spec declares at most one:
134
+ *
135
+ * - `known` — an explicit list of keys, for a caller stating them outright.
136
+ * - `fieldVocabulary` — the note type's own declared field names, as the
137
+ * caller's `schemas` state them, are keys of this block. That holds for the
138
+ * **one** system a single-registry tree ships for, where `schemas` *is* that
139
+ * system's vocabulary, and in general holds for no other.
140
+ * - `fields` — type → that system's own declared fields, from the registry the
141
+ * system declares. What a second system's block is checked against, because a
142
+ * second system's notes write a second system's fields and the note-type
143
+ * schemas describe somebody else's.
144
+ *
145
+ * @type {Readonly<Record<string, SystemBlockSpec>>}
137
146
  */
138
147
  export const DEFAULT_SYSTEM_BLOCKS = Object.freeze({
139
148
  sohl: Object.freeze({ fieldVocabulary: true }),
140
149
  });
141
150
 
151
+ /**
152
+ * What one system block accepts beyond the keys every block carries.
153
+ *
154
+ * @typedef {object} SystemBlockSpec
155
+ * @property {readonly string[]} [known] - Keys stated outright.
156
+ * @property {boolean} [fieldVocabulary] - Whether the note type's declared field
157
+ * names, as the caller's `schemas` state them, are keys of this block.
158
+ * @property {Readonly<Record<string, readonly object[]>>} [fields] - Type → this
159
+ * system's own declared fields. A type it does not name is a type this system
160
+ * says nothing about, and its block is left unchecked on such a note rather
161
+ * than reported wholesale.
162
+ */
163
+
164
+ /**
165
+ * Every system a configuration says its tree carries (#58).
166
+ *
167
+ * **Which systems a package ships for is already declared**, in three places
168
+ * that answer different questions, so this reads all three rather than asking a
169
+ * new one:
170
+ *
171
+ * - `systems:` (#48) declares them without requiring one, which is how a
172
+ * package ships for several;
173
+ * - a **pack's** `system:` is the same statement made per pack, and it is the
174
+ * one some trees make: `harn-ensemble` declares an `actors-sohl` and an
175
+ * `actors-hm3` and nothing else about either system. It is already
176
+ * authoritative elsewhere — `eligibleFor` fails a note for want of the block
177
+ * a pack's `system:` names — so a lint that did not read it would refuse a
178
+ * note at compile for a block it never checked;
179
+ * - `stats.systemId` is the package-wide answer where there is one, and it has
180
+ * already absorbed every way of spelling that: a system package is its own
181
+ * system, and a module takes `requiresSystem`, its lone `systems:` entry, or
182
+ * its lone system relationship.
183
+ *
184
+ * A package naming a system in none of them is system-agnostic on purpose — its
185
+ * packs are core document types carrying no system data — so it carries no
186
+ * system block and naming one would invent it.
187
+ *
188
+ * @param {object} [config] - A resolved configuration from `defineConfig`.
189
+ * @returns {string[]} The system ids, deduplicated, in declared order.
190
+ */
191
+ export function declaredSystems(config) {
192
+ const out = [];
193
+ for (const system of Object.keys(config?.systems ?? {})) {
194
+ if (!out.includes(system)) out.push(system);
195
+ }
196
+ for (const pack of config?.packs ?? []) {
197
+ const system = pack?.system;
198
+ if (typeof system === "string" && system && !out.includes(system)) out.push(system);
199
+ }
200
+ if (out.length) return out;
201
+ const packageWide = config?.stats?.systemId;
202
+ return typeof packageWide === "string" && packageWide ? [packageWide] : [];
203
+ }
204
+
205
+ /**
206
+ * The system blocks a configuration says its tree carries, and what each
207
+ * accepts (#58).
208
+ *
209
+ * The lint checks the blocks its caller names, and for as long as there was one
210
+ * system the only caller named none — so every tree took the `sohl:` of
211
+ * {@link DEFAULT_SYSTEM_BLOCKS}, a constant, in a module whose whole discipline
212
+ * is that it states no vocabulary of its own. That is wrong in both directions
213
+ * the moment a second system exists, and the second direction is the worse:
214
+ *
215
+ * - a package shipping for `hm3` had its `hm3:` block **never looked at**, so
216
+ * every key in it was discarded at compile without a word — the silent-drop
217
+ * family this check exists to close;
218
+ * - and the block that *was* checked was named after a system that package does
219
+ * not ship for, so the one finding it could make was about nothing.
220
+ *
221
+ * **A block's vocabulary has two sources, and a system may have both.**
222
+ *
223
+ * - The **note schemas** the caller hands in as `schemas`. Those belong to one
224
+ * system — the CLI imports `sohl/note-schemas.mjs` — and `schemaSystem` is the
225
+ * caller naming which, because only the caller knows. It is the only source
226
+ * that reaches a type no item registry declares, which is to say `being`: the
227
+ * 2,512 notes `harn-ensemble` is made of, and the reason this is not an
228
+ * optional refinement.
229
+ * - The system's **own registry**, `itemFieldsBySystem`, keyed by system and
230
+ * until now read by nothing. This is what a *second* system's block is held
231
+ * to, since the note schemas describe its neighbour.
232
+ *
233
+ * A system with neither is left out: nothing can state what its block may
234
+ * carry, and holding it to an empty vocabulary would report every key in a
235
+ * correct tree. **That is a check that does not run**, which is
236
+ * indistinguishable from one that passed, so the caller says it out loud —
237
+ * {@link declaredSystems} is the other half of that comparison. `harn-ensemble`
238
+ * is the tree it names: two systems, and an `itemBuilders` registry for
239
+ * neither, so its `hm3:` block is unchecked until it declares one.
240
+ *
241
+ * An earlier draft of this took the note schemas for a system's vocabulary only
242
+ * where the package declared **one** system, on the reasoning that with several
243
+ * there is nothing to say which one they describe. There is: the caller, which
244
+ * chose them. The guess cost `harn-ensemble` its whole `sohl:` check — two
245
+ * systems declared, so the fallback never fired — which is the coverage this
246
+ * change exists to widen rather than narrow.
247
+ *
248
+ * @param {object} [config] - A resolved configuration from `defineConfig`.
249
+ * @param {object} [options] - Options.
250
+ * @param {string} [options.schemaSystem] - The system whose vocabulary the
251
+ * caller's `schemas` state. There are two systems, not an open set, so this is
252
+ * one word from the caller rather than a mechanism.
253
+ * @returns {Readonly<Record<string, SystemBlockSpec>>} The blocks to check, in
254
+ * declared order. A system nothing states the vocabulary of is absent.
255
+ */
256
+ export function systemBlocksFor(config, { schemaSystem } = {}) {
257
+ const byName = config?.itemFieldsBySystem ?? {};
258
+ /** @type {Record<string, SystemBlockSpec>} */
259
+ const blocks = {};
260
+ for (const system of declaredSystems(config)) {
261
+ /** @type {SystemBlockSpec} */
262
+ const spec = {};
263
+ if (system === schemaSystem) spec.fieldVocabulary = true;
264
+ if (byName[system]) spec.fields = byName[system];
265
+ if (Object.keys(spec).length) blocks[system] = Object.freeze(spec);
266
+ }
267
+ return Object.freeze(blocks);
268
+ }
269
+
142
270
  /**
143
271
  * Edit distance, capped — enough to answer "did you mean".
144
272
  *
@@ -591,30 +719,114 @@ function checkTags(note, { type }) {
591
719
  * note in any tree writes `img: ""` on a being; a check keyed on `img` alone
592
720
  * would have called that tree clean (#218).
593
721
  *
594
- * @type {readonly string[]}
722
+ * **Each carries where it is authored**, because the two no longer agree. The
723
+ * specification puts an actor's portrait under `data:` and leaves its token art
724
+ * at the note's top level, so `portrait` has a third position to read and `img`
725
+ * does not — and a check that read only the two they share would pass a
726
+ * `data.portrait: ""` it could not see (#332).
727
+ *
728
+ * @type {readonly {key: string, inData: boolean}[]}
729
+ */
730
+ const ART_FIELDS = Object.freeze([
731
+ Object.freeze({ key: "img", inData: false }),
732
+ Object.freeze({ key: "portrait", inData: true }),
733
+ ]);
734
+
735
+ /**
736
+ * The in-block keys a type's own declarations claim for a *different* quantity.
737
+ *
738
+ * {@link module:engine/field-spec.FieldSpec.topLevelMeans} read from the other
739
+ * side. That property says the note's top-level key of a field's name means
740
+ * something else, and `resolveFieldValue` honours it by refusing to read the
741
+ * shared position *for that field*. The statement is symmetric: if the two
742
+ * positions hold unrelated quantities, then the **block** position is not the
743
+ * note-level field either, and a check about the note-level field must not read
744
+ * it.
745
+ *
746
+ * `affiliation`'s `title` is the case that named this. A note's top-level
747
+ * `title` is its page heading, which the site emitter publishes as
748
+ * `fm.title ?? name`; `sohl.title` is the style of address an office carries —
749
+ * "Ajaw", "Warden". Twenty-eight `sohl-kethira-basic` affiliations author
750
+ * `sohl.title: ""` — an office with no style of address, which is ordinary —
751
+ * and every one of them was reported as publishing a page with no heading. None
752
+ * of them does; their pages take `name.full` exactly as intended (#312).
753
+ *
754
+ * Keyed on the **in-block** key — `legacyKey` where a field declares one, and
755
+ * its first segment where that is dotted — because that is the position a note
756
+ * authors, and so the position a note-level check would otherwise read.
757
+ *
758
+ * @param {readonly object[]|null|undefined} schema - The type's declarations.
759
+ * @returns {Set<string>} The in-block keys that are not the note-level field of
760
+ * the same name.
595
761
  */
596
- const ART_FIELDS = Object.freeze(["img", "portrait"]);
762
+ /**
763
+ * The keys a field declaration is authored at **inside a system block**.
764
+ *
765
+ * The first segment of each field's in-block key: `impact.die` is authored as
766
+ * `impact`, and a field whose shared source moved under `data:` is authored at
767
+ * the `legacyKey` it declares rather than at its dotted name (#305). Keying on
768
+ * the name instead would report `sohl.species` as a property no `being` has,
769
+ * against exactly the notes the sweep has not reached yet.
770
+ *
771
+ * Written once and read twice: the note type's own declaration answers for the
772
+ * system whose vocabulary the caller's `schemas` are, and a second system's
773
+ * registry answers for its block (#58). Two derivations of one thing would be
774
+ * free to disagree about which position a note authors.
775
+ *
776
+ * @param {readonly object[]|null|undefined} schema - A type's declarations.
777
+ * @returns {Set<string>} The in-block keys.
778
+ */
779
+ function inBlockKeys(schema) {
780
+ return new Set(authoredFields(schema ?? []).map((f) => legacyKeyOf(f).split(".")[0]));
781
+ }
782
+
783
+ function collidingBlockKeys(schema) {
784
+ const keys = new Set();
785
+ if (!Array.isArray(schema)) return keys;
786
+ for (const field of authoredFields(schema)) {
787
+ if (field.topLevelMeans === undefined) continue;
788
+ keys.add(String(legacyKeyOf(field)).split(".")[0]);
789
+ }
790
+ return keys;
791
+ }
597
792
 
598
793
  /**
599
- * Read a shared top-level field the way the compiler reads one: the `sohl:`
600
- * block first, then the note's top level.
794
+ * Read a shared field the way the compiler reads one: the `sohl:` block first,
795
+ * then `data:` where the field lives there, then the note's top level.
796
+ *
797
+ * The same order {@link module:engine/system-block.resolveFieldValue} uses,
798
+ * restated here rather than imported so this module stays a leaf the linter can
799
+ * load without a resolved build configuration. Unlike that resolver it
800
+ * distinguishes the two empties — an authored `""` comes back as `""` and an
801
+ * authored `null` as `null` — which is the whole point of the caller below
802
+ * (#218).
601
803
  *
602
- * The same order {@link module:engine/helpers.sohlField} uses, restated here
603
- * rather than imported so this module stays a leaf the linter can load without
604
- * a resolved build configuration. Unlike `sohlField` it distinguishes the two
605
- * empties an authored `""` comes back as `""` and an authored `null` as
606
- * `null` which is the whole point of the caller below (#218).
804
+ * **`blockCollides` drops the first position**, where the note's type declares
805
+ * a system field of that name meaning something else the resolver's
806
+ * `topLevelMeans` exemption, applied from the note-level side (#312). See
807
+ * {@link collidingBlockKeys}. The caller decides per key rather than this
808
+ * function deciding for itself, because this module knows no type's vocabulary:
809
+ * the declarations arrive from the caller, as `schemas` and `vocabulary` do.
607
810
  *
608
811
  * @param {object|null|undefined} fm - Parsed frontmatter.
609
812
  * @param {string} key - The field name.
610
- * @returns {any} The authored value, or `undefined` where neither position
611
- * declares one.
813
+ * @param {object} [options] - Options.
814
+ * @param {boolean} [options.inData=false] - Whether the field's shared source
815
+ * is `data.<key>` rather than the top-level key.
816
+ * @param {boolean} [options.blockCollides=false] - Whether `sohl.<key>` is a
817
+ * system field that merely shares this name, and so answers for nothing here.
818
+ * @returns {any} The authored value, or `undefined` where no position declares
819
+ * one.
612
820
  */
613
- function authoredValue(fm, key) {
614
- const block = fm?.sohl;
821
+ function authoredValue(fm, key, { inData = false, blockCollides = false } = {}) {
822
+ const block = blockCollides ? undefined : fm?.sohl;
615
823
  if (block && typeof block === "object" && !Array.isArray(block) && Object.hasOwn(block, key)) {
616
824
  return block[key];
617
825
  }
826
+ const data = inData ? fm?.data : undefined;
827
+ if (data && typeof data === "object" && !Array.isArray(data) && Object.hasOwn(data, key)) {
828
+ return data[key];
829
+ }
618
830
  return fm && Object.hasOwn(fm, key) ? fm[key] : undefined;
619
831
  }
620
832
 
@@ -709,9 +921,17 @@ function checkEmbeddedShortcodes(note, blockName) {
709
921
  * note against whatever its type declares and knows no type names of its
710
922
  * own. Its absence skips the `data:` and `subType` checks rather than
711
923
  * reporting every key as unknown.
712
- * @param {Readonly<Record<string, {known?: readonly string[], fieldVocabulary?: boolean}>>} [opts.systems]
713
- * The system blocks to check, and what each accepts. See
714
- * {@link DEFAULT_SYSTEM_BLOCKS}.
924
+ * @param {(type: string) => {document: string|null, art: readonly string[]}|null} [opts.emittedArt]
925
+ * What art a note of one type reaches its document through — the passes' own
926
+ * declaration, asked through `engine/generate.mjs`'s `emittedArtFor`.
927
+ * Supplied by the caller like `schemas`, so this module states no list of
928
+ * iconless types of its own; absent it, an inert `img:` goes unreported
929
+ * rather than every note's being (#349).
930
+ * @param {Readonly<Record<string, SystemBlockSpec>>} [opts.systems]
931
+ * The system blocks to check, and what each accepts. Supplied by the caller
932
+ * for the same reason `schemas` is — a build derives them from its
933
+ * configuration through {@link systemBlocksFor}, and this module states no
934
+ * system name of its own. See {@link DEFAULT_SYSTEM_BLOCKS} for the fallback.
715
935
  * @param {readonly string[]} [opts.packs] - The pack names this package
716
936
  * declares, for a `data:` field whose map is keyed by pack. Supplied by the
717
937
  * caller like `schemas` and `vocabulary`, and absent it no claim is made
@@ -720,13 +940,21 @@ function checkEmbeddedShortcodes(note, blockName) {
720
940
  */
721
941
  export function lintNote(
722
942
  note,
723
- { schemas, index, vocabulary, packs, systems = DEFAULT_SYSTEM_BLOCKS },
943
+ { schemas, index, vocabulary, packs, emittedArt, systems = DEFAULT_SYSTEM_BLOCKS },
724
944
  ) {
725
945
  const findings = [];
726
946
  const fm = note.fm ?? {};
727
947
  const type = String(fm.type ?? "");
728
948
  const raw = () => note.raw ?? "";
729
949
  const at = (key, literal) => positionInFrontmatter(raw(), key, literal ?? undefined);
950
+ /**
951
+ * The in-block keys this note's own type claims for something other than
952
+ * the note-level field of that name, which every note-level check below
953
+ * reads past (#312). Resolved once: the type is fixed for the note, and
954
+ * each check would otherwise ask the same question of the same
955
+ * declarations.
956
+ */
957
+ const blockCollisions = collidingBlockKeys(schemas?.[currentType(type)]);
730
958
 
731
959
  // The retired top-level fields, checked before the type: a note may carry
732
960
  // one whatever its type is, and each finding stands on its own. Reported
@@ -797,15 +1025,23 @@ export function lintNote(
797
1025
  // optional strings, and it is not — it belongs to `resolveImg`, and `title`
798
1026
  // never goes through it.
799
1027
  //
800
- // It once had a sharper reason, recorded here because it was load-bearing
801
- // and is now false: a note's top-level `title` was simultaneously the shared
802
- // source for an `affiliation` item's `system.title`, so asking an author for
803
- // `title: null` would have compiled the literal string `"null"` into the
804
- // document. That collision is gone the field declares `topLevelMeans` and
805
- // the top-level key is no longer a source for it so `title: null` is now
806
- // harmless. Whether `title: ""` deserves a warning of its own is a separate
807
- // question about the *page's* heading, still open on #218, and not settled
808
- // by extending an art-path check to it.
1028
+ // It once had a sharper reason: a note's top-level `title` was
1029
+ // simultaneously the shared source for an `affiliation` item's
1030
+ // `system.title`, so asking an author for `title: null` would have compiled
1031
+ // the literal string `"null"` into the document. The field declares
1032
+ // `topLevelMeans` now, so the top-level key is no longer a source for it and
1033
+ // `title: null` is harmless. `title: ""` is warned about on its own account
1034
+ // below, as the *page's* heading rather than as an art path.
1035
+ //
1036
+ // **The collision itself did not go away, and this was where that was
1037
+ // misread.** `topLevelMeans` settles which position the *emitted field*
1038
+ // reads; it says nothing about which position a *check* reads, and
1039
+ // `authoredValue` went on resolving through the block regardless — so an
1040
+ // office with no style of address answered for its note's heading, in
1041
+ // twenty-eight `sohl-kethira-basic` affiliations (#312). Hence
1042
+ // `blockCollisions`: a note-level check reads past a block key its type
1043
+ // claims for something else.
1044
+
809
1045
  // The template priority is a *shared source* — the specification states it
810
1046
  // once for every type, as it does `pack` — so its retirement is reported
811
1047
  // here rather than by the per-type loop below, which only reaches a field
@@ -829,8 +1065,70 @@ export function lintNote(
829
1065
  });
830
1066
  }
831
1067
 
832
- for (const key of ART_FIELDS) {
833
- if (authoredValue(fm, key) !== "") continue;
1068
+ // An art field a note's own type never emits (#349). `img` is a *shared
1069
+ // top-level* field `BLOCK_DOCUMENT_PROPERTIES` maps it onto
1070
+ // `document.img`, so it is legal on every note whatever the type — and a
1071
+ // note whose document has no such property authors it, validates, compiles,
1072
+ // and loses the value with nothing said. `Parrot` in `sohl-thalorna` had
1073
+ // declared `img: images/mystery/parrot.webp` since long before the art rule
1074
+ // was written and compiled `img: null`, exactly as a note declaring nothing
1075
+ // does. The author's only evidence was the absence of an icon somewhere
1076
+ // they were probably not looking.
1077
+ //
1078
+ // **Which types those are is not stated here.** It is asked of the passes,
1079
+ // through the `emittedArt` the caller supplies — a note's type routes to a
1080
+ // document, a document to the pass that compiles it, and the pass declares
1081
+ // its own art. A list of iconless types kept in the linter would be a list
1082
+ // free to drift from what is actually emitted, which is the defect rather
1083
+ // than the check. Absent the option no claim is made, on the pattern
1084
+ // `index` and `vocabulary` set.
1085
+ //
1086
+ // **Only an authored value, never `null`.** `null` is the blessed spelling
1087
+ // for "this note names no art" (#218), and on a type with no art that is a
1088
+ // true and harmless thing to say — it compiles identically to writing
1089
+ // nothing. Twenty-six `sohl-thalorna` place notes are in exactly that
1090
+ // state, and telling each of them to delete a key that already means
1091
+ // nothing would bury the fifty-seven that name a path they believe ships.
1092
+ //
1093
+ // A **warning**, as the `package:` and retired-alias sweeps are: the note
1094
+ // compiles correctly and the value is merely inert. Nor is it certainly
1095
+ // unwanted — a note's top level is the generated page's front matter as
1096
+ // well, so a template may read there what no document carries, which is a
1097
+ // judgement only the tree's author can make.
1098
+ const emitted = emittedArt ? emittedArt(currentType(type)) : null;
1099
+ /** The art fields this note's type reaches nothing through. */
1100
+ const inertArt = new Set(
1101
+ emitted ? ART_FIELDS.filter(({ key }) => !emitted.art.includes(key)).map((f) => f.key) : [],
1102
+ );
1103
+ for (const { key, inData } of ART_FIELDS) {
1104
+ if (!inertArt.has(key)) continue;
1105
+ const authored = authoredValue(fm, key, {
1106
+ inData,
1107
+ blockCollides: blockCollisions.has(key),
1108
+ });
1109
+ if (typeof authored !== "string") continue;
1110
+ findings.push({
1111
+ file: note.file,
1112
+ ...at(key),
1113
+ severity: "warning",
1114
+ message:
1115
+ `\`${key}:\` reaches no document from a \`${type}\` note — ` +
1116
+ (emitted?.document ?
1117
+ `it compiles into a ${emitted.document}, which carries no artwork`
1118
+ : "it compiles into a page rather than a compendium document") +
1119
+ ", so the path is dropped. Delete the key, or move the art onto " +
1120
+ "the note whose document is meant to show it; keep it only where " +
1121
+ "a page template reads it as a parameter",
1122
+ });
1123
+ }
1124
+
1125
+ for (const { key, inData } of ART_FIELDS) {
1126
+ // Reported above, and the distinction this draws does not exist there:
1127
+ // where nothing is emitted, `""` and `null` are equally inert and the
1128
+ // note has no default art to lose.
1129
+ if (inertArt.has(key)) continue;
1130
+ if (authoredValue(fm, key, { inData, blockCollides: blockCollisions.has(key) }) !== "")
1131
+ continue;
834
1132
  findings.push({
835
1133
  file: note.file,
836
1134
  ...at(key),
@@ -852,7 +1150,13 @@ export function lintNote(
852
1150
  //
853
1151
  // A warning rather than an error: the value is legal under the rule, and a
854
1152
  // note that genuinely wants no heading may keep it — it just has to mean it.
855
- if (authoredValue(fm, "title") === "") {
1153
+ //
1154
+ // **The emitter reads `fm.title`, so this reads the note level.** On an
1155
+ // `affiliation` `sohl.title` is the office's style of address, which the
1156
+ // heading has nothing to do with — and `blockCollisions` is what keeps the
1157
+ // two apart (#312). On every other type nothing claims the block key, so the
1158
+ // resolution is the unchanged one.
1159
+ if (authoredValue(fm, "title", { blockCollides: blockCollisions.has("title") }) === "") {
856
1160
  findings.push({
857
1161
  file: note.file,
858
1162
  ...at("title"),
@@ -1016,15 +1320,8 @@ export function lintNote(
1016
1320
  }
1017
1321
 
1018
1322
  const fields = authoredFields(schema);
1019
- /**
1020
- * First segment of the key each field is authored at **inside the block** —
1021
- * `impact.die` is authored as `impact`, and a field whose shared source
1022
- * moved under `data:` is authored at the `legacyKey` it declares rather
1023
- * than at its dotted name (#305). Keying this on the name would report
1024
- * `sohl.species` as a property no `being` has, against exactly the notes
1025
- * the sweep has not reached yet.
1026
- */
1027
- const declared = new Set(fields.map((f) => legacyKeyOf(f).split(".")[0]));
1323
+ /** The keys this type's own declaration is authored at inside a block. */
1324
+ const declared = inBlockKeys(schema);
1028
1325
 
1029
1326
  // The retired spelling of a field this type declares → what to write now.
1030
1327
  // Built from the type's own vocabulary, so a renamed field is retired
@@ -1056,16 +1353,33 @@ export function lintNote(
1056
1353
  // Every declared system's block, each against its own vocabulary (#58). A
1057
1354
  // block carries the shared keys any system's does — `system`, `type`,
1058
1355
  // `img`, `effects`, `flags`, `pack` — plus whatever that system declares:
1059
- // for `sohl`, the note type's own field names, which are still the position
1060
- // the corpus authors them at until #126 moves them.
1356
+ // the note type's own field names for the system those schemas describe,
1357
+ // and a second system's own registry for its block. Which systems arrive
1358
+ // here is the configuration's answer, not this module's; see
1359
+ // {@link systemBlocksFor}.
1061
1360
  for (const [blockName, spec] of Object.entries(systems ?? {})) {
1062
1361
  // Two embedded items denoting one entity (#228). Per block, because
1063
1362
  // `items` is a block key and a second system's actor carries its own.
1363
+ // Before the `continue` below, because it is a statement about the
1364
+ // block's *shape* and holds whether or not this system declares a
1365
+ // vocabulary for the note's type.
1064
1366
  findings.push(...checkEmbeddedShortcodes(note, blockName));
1367
+ // A block is checked only where its system speaks about this type. A
1368
+ // type a system's registry does not name is a type it says nothing
1369
+ // about — SoHL's `mysticalability` is not an HM3 type at all — and
1370
+ // holding the block to an empty vocabulary would report every key in
1371
+ // it, which is the correct tree reported red.
1372
+ //
1373
+ // `fieldVocabulary` reaches types no registry declares, `being` above
1374
+ // all, so a spec carrying it always speaks. Only a spec whose *sole*
1375
+ // statement is `fields` can fall silent here.
1376
+ const own = spec?.fields;
1377
+ if (own && !own[type] && !spec?.fieldVocabulary && !spec?.known) continue;
1065
1378
  const accepted = new Set([
1066
1379
  ...UNIVERSAL_KEYS,
1067
1380
  ...(spec?.known ?? []),
1068
1381
  ...(spec?.fieldVocabulary ? declared : []),
1382
+ ...(own ? inBlockKeys(own[type]) : []),
1069
1383
  ]);
1070
1384
  for (const key of unknownBlockKeys(fm, blockName, { known: accepted })) {
1071
1385
  // Reported above, with what to write instead — a retired spelling
@@ -1194,14 +1508,20 @@ export function lintNote(
1194
1508
  * @param {Record<string, object>} [opts.vocabulary] - Type → the closed regions
1195
1509
  * it declares (#128); see {@link lintNote}.
1196
1510
  * @param {boolean} [opts.references=true] - Whether to check references.
1197
- * @param {Readonly<Record<string, {known?: readonly string[], fieldVocabulary?: boolean}>>} [opts.systems]
1198
- * The system blocks to check. See {@link DEFAULT_SYSTEM_BLOCKS}.
1511
+ * @param {Readonly<Record<string, SystemBlockSpec>>} [opts.systems]
1512
+ * The system blocks to check; see {@link lintNote} and
1513
+ * {@link systemBlocksFor}.
1199
1514
  * @param {readonly string[]} [opts.packs] - The declared pack names; see
1200
1515
  * {@link lintNote}.
1516
+ * @param {(type: string) => {document: string|null, art: readonly string[]}|null} [opts.emittedArt]
1517
+ * What art a type reaches its document through; see {@link lintNote}.
1201
1518
  * @returns {{findings: object[], notes: number}} The findings, and how many
1202
1519
  * notes were inspected.
1203
1520
  */
1204
- export function lintFrontmatter(index, { schemas, vocabulary, packs, references = true, systems }) {
1521
+ export function lintFrontmatter(
1522
+ index,
1523
+ { schemas, vocabulary, packs, emittedArt, references = true, systems },
1524
+ ) {
1205
1525
  const findings = [];
1206
1526
  const notes = [...index.notes].sort((a, b) =>
1207
1527
  a.file < b.file ? -1
@@ -1214,6 +1534,7 @@ export function lintFrontmatter(index, { schemas, vocabulary, packs, references
1214
1534
  schemas,
1215
1535
  vocabulary,
1216
1536
  packs,
1537
+ emittedArt,
1217
1538
  index: references ? index : undefined,
1218
1539
  ...(systems ? { systems } : {}),
1219
1540
  }),
@@ -301,13 +301,15 @@ export function parseValueDesc(raw) {
301
301
  }
302
302
 
303
303
  /**
304
- * The compendium folder a note names, and how it named it.
304
+ * The compendium folder a note names.
305
305
  *
306
- * Two spellings, deliberately not merged into one value: `packFolder:` is a
307
- * folder note's **address** (`folder-poisonsandtoxins`) and `folder:` is a
308
- * Foundry **id** (`ONXsqZAIZr2qzxTb`). Which one a value is cannot be told from
309
- * the string both are alphanumeric so the field it was written in is what
310
- * says, and that answer is carried rather than re-derived (#251).
306
+ * **There is one spelling.** `packFolder:` is a folder note's **address**
307
+ * (`folder-poisonsandtoxins`), resolved through the address index the whole
308
+ * build shares. The `folder:` Foundry-id spelling this function once read
309
+ * beside it, and the per-pack `*-folders.yaml` that id was resolved against,
310
+ * are retired together (#260) a note declaring `folder:` is refused by
311
+ * {@link module:engine/folder-notes.assertNoDeclaredFolder} rather than
312
+ * reaching here, so there is no second source for a value to come from.
311
313
  *
312
314
  * **`packFolder` was a path for one release** (`Possessions/Misc_Gear/Cooking`)
313
315
  * and is an address now (#255). A path encoded the hierarchy *in the value*, so
@@ -318,13 +320,11 @@ export function parseValueDesc(raw) {
318
320
  * authors to migrate, which is the whole reason the change was cheap enough to
319
321
  * make.
320
322
  *
321
- * `packFolder` wins where both are present. Nothing about `folder` changes: a
322
- * note that names one is read, resolved and emitted exactly as before, until
323
- * #260 retires it.
324
- *
325
323
  * @param {object|null|undefined} fm - Parsed frontmatter.
326
324
  * @returns {{value: string|null, isAddress: boolean}} The authored value, and
327
- * whether it is a folder note's address.
325
+ * whether it is a folder note's address. `isAddress` is always `true` and is
326
+ * kept so a caller reads the same shape it always did; it distinguished the
327
+ * two spellings, and there is only one left to be.
328
328
  */
329
329
  export function folderField(fm) {
330
330
  const asAddress = sohlField(fm, "packFolder", null);