@heroiclands/package-build 19.0.0 → 20.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +624 -0
  2. package/CONTENT.md +79 -8
  3. package/bin/content-build.mjs +7 -1
  4. package/content-config.mjs +10 -1
  5. package/docs/content-format.md +394 -72
  6. package/engine/actor-compiler.mjs +197 -7
  7. package/engine/address-charset.mjs +23 -5
  8. package/engine/base-compiler.mjs +63 -2
  9. package/engine/bundles.mjs +9 -0
  10. package/engine/content-address.mjs +92 -1
  11. package/engine/content-format.mjs +102 -0
  12. package/engine/content-index.mjs +11 -8
  13. package/engine/content-links.mjs +37 -21
  14. package/engine/field-reference.mjs +57 -5
  15. package/engine/field-spec.mjs +214 -7
  16. package/engine/folder-notes.mjs +24 -1
  17. package/engine/foreign-catalog.mjs +4 -1
  18. package/engine/foundry-entries.mjs +14 -0
  19. package/engine/frontmatter-lint.mjs +186 -27
  20. package/engine/frontmatter.mjs +11 -11
  21. package/engine/generate.mjs +67 -10
  22. package/engine/helpers.mjs +86 -9
  23. package/engine/index.mjs +3 -0
  24. package/engine/item-compiler.mjs +37 -0
  25. package/engine/journals.mjs +21 -4
  26. package/engine/macros.mjs +8 -0
  27. package/engine/map-notes.mjs +7 -7
  28. package/engine/note-ids.mjs +25 -1
  29. package/engine/note-vocabulary.mjs +76 -9
  30. package/engine/retired-fields.mjs +57 -16
  31. package/engine/runtime-only-fields.mjs +204 -0
  32. package/engine/scenes.mjs +12 -19
  33. package/engine/schema-check.mjs +23 -1
  34. package/engine/site-index.mjs +17 -0
  35. package/engine/subtype-registry.mjs +30 -0
  36. package/engine/system-block.mjs +81 -3
  37. package/engine/web-wikilinks.mjs +33 -27
  38. package/engine/wikilink-syntax.mjs +7 -0
  39. package/engine/wikilinks.mjs +74 -16
  40. package/hm3/actors.mjs +63 -13
  41. package/package.json +2 -2
  42. package/sohl/actors.mjs +106 -7
  43. package/sohl/item-fields.mjs +203 -0
  44. package/sohl/note-schemas.mjs +6 -3
  45. package/types/engine/actor-compiler.d.mts +83 -3
  46. package/types/engine/address-charset.d.mts +22 -4
  47. package/types/engine/base-compiler.d.mts +54 -3
  48. package/types/engine/content-address.d.mts +64 -0
  49. package/types/engine/content-format.d.mts +9 -0
  50. package/types/engine/field-spec.d.mts +271 -3
  51. package/types/engine/folder-notes.d.mts +20 -0
  52. package/types/engine/foundry-entries.d.mts +6 -0
  53. package/types/engine/frontmatter-lint.d.mts +18 -2
  54. package/types/engine/frontmatter.d.mts +11 -11
  55. package/types/engine/generate.d.mts +27 -0
  56. package/types/engine/helpers.d.mts +37 -9
  57. package/types/engine/index.d.mts +1 -0
  58. package/types/engine/map-notes.d.mts +2 -2
  59. package/types/engine/note-ids.d.mts +14 -0
  60. package/types/engine/retired-fields.d.mts +29 -13
  61. package/types/engine/runtime-only-fields.d.mts +102 -0
  62. package/types/engine/schema-check.d.mts +10 -1
  63. package/types/engine/subtype-registry.d.mts +21 -0
  64. package/types/engine/system-block.d.mts +28 -2
  65. package/types/sohl/actors.d.mts +3 -3
@@ -591,30 +591,93 @@ function checkTags(note, { type }) {
591
591
  * note in any tree writes `img: ""` on a being; a check keyed on `img` alone
592
592
  * would have called that tree clean (#218).
593
593
  *
594
- * @type {readonly string[]}
594
+ * **Each carries where it is authored**, because the two no longer agree. The
595
+ * specification puts an actor's portrait under `data:` and leaves its token art
596
+ * at the note's top level, so `portrait` has a third position to read and `img`
597
+ * does not — and a check that read only the two they share would pass a
598
+ * `data.portrait: ""` it could not see (#332).
599
+ *
600
+ * @type {readonly {key: string, inData: boolean}[]}
601
+ */
602
+ const ART_FIELDS = Object.freeze([
603
+ Object.freeze({ key: "img", inData: false }),
604
+ Object.freeze({ key: "portrait", inData: true }),
605
+ ]);
606
+
607
+ /**
608
+ * The in-block keys a type's own declarations claim for a *different* quantity.
609
+ *
610
+ * {@link module:engine/field-spec.FieldSpec.topLevelMeans} read from the other
611
+ * side. That property says the note's top-level key of a field's name means
612
+ * something else, and `resolveFieldValue` honours it by refusing to read the
613
+ * shared position *for that field*. The statement is symmetric: if the two
614
+ * positions hold unrelated quantities, then the **block** position is not the
615
+ * note-level field either, and a check about the note-level field must not read
616
+ * it.
617
+ *
618
+ * `affiliation`'s `title` is the case that named this. A note's top-level
619
+ * `title` is its page heading, which the site emitter publishes as
620
+ * `fm.title ?? name`; `sohl.title` is the style of address an office carries —
621
+ * "Ajaw", "Warden". Twenty-eight `sohl-kethira-basic` affiliations author
622
+ * `sohl.title: ""` — an office with no style of address, which is ordinary —
623
+ * and every one of them was reported as publishing a page with no heading. None
624
+ * of them does; their pages take `name.full` exactly as intended (#312).
625
+ *
626
+ * Keyed on the **in-block** key — `legacyKey` where a field declares one, and
627
+ * its first segment where that is dotted — because that is the position a note
628
+ * authors, and so the position a note-level check would otherwise read.
629
+ *
630
+ * @param {readonly object[]|null|undefined} schema - The type's declarations.
631
+ * @returns {Set<string>} The in-block keys that are not the note-level field of
632
+ * the same name.
595
633
  */
596
- const ART_FIELDS = Object.freeze(["img", "portrait"]);
634
+ function collidingBlockKeys(schema) {
635
+ const keys = new Set();
636
+ if (!Array.isArray(schema)) return keys;
637
+ for (const field of authoredFields(schema)) {
638
+ if (field.topLevelMeans === undefined) continue;
639
+ keys.add(String(legacyKeyOf(field)).split(".")[0]);
640
+ }
641
+ return keys;
642
+ }
597
643
 
598
644
  /**
599
- * Read a shared top-level field the way the compiler reads one: the `sohl:`
600
- * block first, then the note's top level.
645
+ * Read a shared field the way the compiler reads one: the `sohl:` block first,
646
+ * then `data:` where the field lives there, then the note's top level.
601
647
  *
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).
648
+ * The same order {@link module:engine/system-block.resolveFieldValue} uses,
649
+ * restated here rather than imported so this module stays a leaf the linter can
650
+ * load without a resolved build configuration. Unlike that resolver it
651
+ * distinguishes the two empties — an authored `""` comes back as `""` and an
652
+ * authored `null` as `null` — which is the whole point of the caller below
653
+ * (#218).
654
+ *
655
+ * **`blockCollides` drops the first position**, where the note's type declares
656
+ * a system field of that name meaning something else — the resolver's
657
+ * `topLevelMeans` exemption, applied from the note-level side (#312). See
658
+ * {@link collidingBlockKeys}. The caller decides per key rather than this
659
+ * function deciding for itself, because this module knows no type's vocabulary:
660
+ * the declarations arrive from the caller, as `schemas` and `vocabulary` do.
607
661
  *
608
662
  * @param {object|null|undefined} fm - Parsed frontmatter.
609
663
  * @param {string} key - The field name.
610
- * @returns {any} The authored value, or `undefined` where neither position
611
- * declares one.
664
+ * @param {object} [options] - Options.
665
+ * @param {boolean} [options.inData=false] - Whether the field's shared source
666
+ * is `data.<key>` rather than the top-level key.
667
+ * @param {boolean} [options.blockCollides=false] - Whether `sohl.<key>` is a
668
+ * system field that merely shares this name, and so answers for nothing here.
669
+ * @returns {any} The authored value, or `undefined` where no position declares
670
+ * one.
612
671
  */
613
- function authoredValue(fm, key) {
614
- const block = fm?.sohl;
672
+ function authoredValue(fm, key, { inData = false, blockCollides = false } = {}) {
673
+ const block = blockCollides ? undefined : fm?.sohl;
615
674
  if (block && typeof block === "object" && !Array.isArray(block) && Object.hasOwn(block, key)) {
616
675
  return block[key];
617
676
  }
677
+ const data = inData ? fm?.data : undefined;
678
+ if (data && typeof data === "object" && !Array.isArray(data) && Object.hasOwn(data, key)) {
679
+ return data[key];
680
+ }
618
681
  return fm && Object.hasOwn(fm, key) ? fm[key] : undefined;
619
682
  }
620
683
 
@@ -709,6 +772,12 @@ function checkEmbeddedShortcodes(note, blockName) {
709
772
  * note against whatever its type declares and knows no type names of its
710
773
  * own. Its absence skips the `data:` and `subType` checks rather than
711
774
  * reporting every key as unknown.
775
+ * @param {(type: string) => {document: string|null, art: readonly string[]}|null} [opts.emittedArt]
776
+ * What art a note of one type reaches its document through — the passes' own
777
+ * declaration, asked through `engine/generate.mjs`'s `emittedArtFor`.
778
+ * Supplied by the caller like `schemas`, so this module states no list of
779
+ * iconless types of its own; absent it, an inert `img:` goes unreported
780
+ * rather than every note's being (#349).
712
781
  * @param {Readonly<Record<string, {known?: readonly string[], fieldVocabulary?: boolean}>>} [opts.systems]
713
782
  * The system blocks to check, and what each accepts. See
714
783
  * {@link DEFAULT_SYSTEM_BLOCKS}.
@@ -720,13 +789,21 @@ function checkEmbeddedShortcodes(note, blockName) {
720
789
  */
721
790
  export function lintNote(
722
791
  note,
723
- { schemas, index, vocabulary, packs, systems = DEFAULT_SYSTEM_BLOCKS },
792
+ { schemas, index, vocabulary, packs, emittedArt, systems = DEFAULT_SYSTEM_BLOCKS },
724
793
  ) {
725
794
  const findings = [];
726
795
  const fm = note.fm ?? {};
727
796
  const type = String(fm.type ?? "");
728
797
  const raw = () => note.raw ?? "";
729
798
  const at = (key, literal) => positionInFrontmatter(raw(), key, literal ?? undefined);
799
+ /**
800
+ * The in-block keys this note's own type claims for something other than
801
+ * the note-level field of that name, which every note-level check below
802
+ * reads past (#312). Resolved once: the type is fixed for the note, and
803
+ * each check would otherwise ask the same question of the same
804
+ * declarations.
805
+ */
806
+ const blockCollisions = collidingBlockKeys(schemas?.[currentType(type)]);
730
807
 
731
808
  // The retired top-level fields, checked before the type: a note may carry
732
809
  // one whatever its type is, and each finding stands on its own. Reported
@@ -797,15 +874,23 @@ export function lintNote(
797
874
  // optional strings, and it is not — it belongs to `resolveImg`, and `title`
798
875
  // never goes through it.
799
876
  //
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.
877
+ // It once had a sharper reason: a note's top-level `title` was
878
+ // simultaneously the shared source for an `affiliation` item's
879
+ // `system.title`, so asking an author for `title: null` would have compiled
880
+ // the literal string `"null"` into the document. The field declares
881
+ // `topLevelMeans` now, so the top-level key is no longer a source for it and
882
+ // `title: null` is harmless. `title: ""` is warned about on its own account
883
+ // below, as the *page's* heading rather than as an art path.
884
+ //
885
+ // **The collision itself did not go away, and this was where that was
886
+ // misread.** `topLevelMeans` settles which position the *emitted field*
887
+ // reads; it says nothing about which position a *check* reads, and
888
+ // `authoredValue` went on resolving through the block regardless — so an
889
+ // office with no style of address answered for its note's heading, in
890
+ // twenty-eight `sohl-kethira-basic` affiliations (#312). Hence
891
+ // `blockCollisions`: a note-level check reads past a block key its type
892
+ // claims for something else.
893
+
809
894
  // The template priority is a *shared source* — the specification states it
810
895
  // once for every type, as it does `pack` — so its retirement is reported
811
896
  // here rather than by the per-type loop below, which only reaches a field
@@ -829,8 +914,70 @@ export function lintNote(
829
914
  });
830
915
  }
831
916
 
832
- for (const key of ART_FIELDS) {
833
- if (authoredValue(fm, key) !== "") continue;
917
+ // An art field a note's own type never emits (#349). `img` is a *shared
918
+ // top-level* field `BLOCK_DOCUMENT_PROPERTIES` maps it onto
919
+ // `document.img`, so it is legal on every note whatever the type — and a
920
+ // note whose document has no such property authors it, validates, compiles,
921
+ // and loses the value with nothing said. `Parrot` in `sohl-thalorna` had
922
+ // declared `img: images/mystery/parrot.webp` since long before the art rule
923
+ // was written and compiled `img: null`, exactly as a note declaring nothing
924
+ // does. The author's only evidence was the absence of an icon somewhere
925
+ // they were probably not looking.
926
+ //
927
+ // **Which types those are is not stated here.** It is asked of the passes,
928
+ // through the `emittedArt` the caller supplies — a note's type routes to a
929
+ // document, a document to the pass that compiles it, and the pass declares
930
+ // its own art. A list of iconless types kept in the linter would be a list
931
+ // free to drift from what is actually emitted, which is the defect rather
932
+ // than the check. Absent the option no claim is made, on the pattern
933
+ // `index` and `vocabulary` set.
934
+ //
935
+ // **Only an authored value, never `null`.** `null` is the blessed spelling
936
+ // for "this note names no art" (#218), and on a type with no art that is a
937
+ // true and harmless thing to say — it compiles identically to writing
938
+ // nothing. Twenty-six `sohl-thalorna` place notes are in exactly that
939
+ // state, and telling each of them to delete a key that already means
940
+ // nothing would bury the fifty-seven that name a path they believe ships.
941
+ //
942
+ // A **warning**, as the `package:` and retired-alias sweeps are: the note
943
+ // compiles correctly and the value is merely inert. Nor is it certainly
944
+ // unwanted — a note's top level is the generated page's front matter as
945
+ // well, so a template may read there what no document carries, which is a
946
+ // judgement only the tree's author can make.
947
+ const emitted = emittedArt ? emittedArt(currentType(type)) : null;
948
+ /** The art fields this note's type reaches nothing through. */
949
+ const inertArt = new Set(
950
+ emitted ? ART_FIELDS.filter(({ key }) => !emitted.art.includes(key)).map((f) => f.key) : [],
951
+ );
952
+ for (const { key, inData } of ART_FIELDS) {
953
+ if (!inertArt.has(key)) continue;
954
+ const authored = authoredValue(fm, key, {
955
+ inData,
956
+ blockCollides: blockCollisions.has(key),
957
+ });
958
+ if (typeof authored !== "string") continue;
959
+ findings.push({
960
+ file: note.file,
961
+ ...at(key),
962
+ severity: "warning",
963
+ message:
964
+ `\`${key}:\` reaches no document from a \`${type}\` note — ` +
965
+ (emitted?.document ?
966
+ `it compiles into a ${emitted.document}, which carries no artwork`
967
+ : "it compiles into a page rather than a compendium document") +
968
+ ", so the path is dropped. Delete the key, or move the art onto " +
969
+ "the note whose document is meant to show it; keep it only where " +
970
+ "a page template reads it as a parameter",
971
+ });
972
+ }
973
+
974
+ for (const { key, inData } of ART_FIELDS) {
975
+ // Reported above, and the distinction this draws does not exist there:
976
+ // where nothing is emitted, `""` and `null` are equally inert and the
977
+ // note has no default art to lose.
978
+ if (inertArt.has(key)) continue;
979
+ if (authoredValue(fm, key, { inData, blockCollides: blockCollisions.has(key) }) !== "")
980
+ continue;
834
981
  findings.push({
835
982
  file: note.file,
836
983
  ...at(key),
@@ -852,7 +999,13 @@ export function lintNote(
852
999
  //
853
1000
  // A warning rather than an error: the value is legal under the rule, and a
854
1001
  // note that genuinely wants no heading may keep it — it just has to mean it.
855
- if (authoredValue(fm, "title") === "") {
1002
+ //
1003
+ // **The emitter reads `fm.title`, so this reads the note level.** On an
1004
+ // `affiliation` `sohl.title` is the office's style of address, which the
1005
+ // heading has nothing to do with — and `blockCollisions` is what keeps the
1006
+ // two apart (#312). On every other type nothing claims the block key, so the
1007
+ // resolution is the unchanged one.
1008
+ if (authoredValue(fm, "title", { blockCollides: blockCollisions.has("title") }) === "") {
856
1009
  findings.push({
857
1010
  file: note.file,
858
1011
  ...at("title"),
@@ -1198,10 +1351,15 @@ export function lintNote(
1198
1351
  * The system blocks to check. See {@link DEFAULT_SYSTEM_BLOCKS}.
1199
1352
  * @param {readonly string[]} [opts.packs] - The declared pack names; see
1200
1353
  * {@link lintNote}.
1354
+ * @param {(type: string) => {document: string|null, art: readonly string[]}|null} [opts.emittedArt]
1355
+ * What art a type reaches its document through; see {@link lintNote}.
1201
1356
  * @returns {{findings: object[], notes: number}} The findings, and how many
1202
1357
  * notes were inspected.
1203
1358
  */
1204
- export function lintFrontmatter(index, { schemas, vocabulary, packs, references = true, systems }) {
1359
+ export function lintFrontmatter(
1360
+ index,
1361
+ { schemas, vocabulary, packs, emittedArt, references = true, systems },
1362
+ ) {
1205
1363
  const findings = [];
1206
1364
  const notes = [...index.notes].sort((a, b) =>
1207
1365
  a.file < b.file ? -1
@@ -1214,6 +1372,7 @@ export function lintFrontmatter(index, { schemas, vocabulary, packs, references
1214
1372
  schemas,
1215
1373
  vocabulary,
1216
1374
  packs,
1375
+ emittedArt,
1217
1376
  index: references ? index : undefined,
1218
1377
  ...(systems ? { systems } : {}),
1219
1378
  }),
@@ -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);
@@ -23,10 +23,12 @@
23
23
  * Each `*` compiler walks the whole content tree and selects its own entries by
24
24
  * the note's `type` — every note in the tree belongs to this repository's
25
25
  * `contentPackage` (#56) — so routing is directory-agnostic: a file lands in a
26
- * pack because of its `type`, not its location. Which packs exist and which
27
- * folder hierarchy each one loads are declared in
28
- * `package-build.config.yaml`; folder files live under the content root and are
29
- * referenced from entry frontmatter via `sohl.folder: <id>`.
26
+ * pack because of its `type`, not its location. Which packs exist is declared
27
+ * in `package-build.config.yaml`; the **folder hierarchy is not declared
28
+ * anywhere**. A folder is a note like any other (#256), named by `packFolder`
29
+ * its address and it materialises in every pack that holds a document
30
+ * naming it, its ancestors with it (#257). So no pack loads a folder list, and
31
+ * two packs can no longer disagree about a folder they both hold.
30
32
  *
31
33
  * **The order the passes run in is derived, not declared** — see
32
34
  * {@link orderPassesByDependency}. The declared list is the manifest's `packs`
@@ -66,7 +68,10 @@ import { buildCompileCorpus } from "./compile-corpus.mjs";
66
68
  import { isNoteRecord, noteFile } from "./index-records.mjs";
67
69
  import { loadPackConfig } from "./pack-config.mjs";
68
70
  import { routerFor } from "./pack-router.mjs";
69
- import { unclaimedNoteFindings } from "./note-claims.mjs";
71
+ import { NEVER_PACKED_TYPES, unclaimedNoteFindings } from "./note-claims.mjs";
72
+ // Which document a content type compiles into, so the art declaration below is
73
+ // answered from the same routing the compile uses (#349).
74
+ import { RETIRED_TYPES, currentType, packForType } from "./ids.mjs";
70
75
  import { contentPackage } from "./content-package.mjs";
71
76
 
72
77
  /**
@@ -131,6 +136,56 @@ export function compilerFor(docType, system = null) {
131
136
  return (system && SYSTEM_COMPILERS[system]?.[docType]) || COMPILERS[docType];
132
137
  }
133
138
 
139
+ /**
140
+ * The art fields a note of one content type reaches its document through, and
141
+ * the document it reaches (#349).
142
+ *
143
+ * **Derived, never listed.** A note's type routes to a document type
144
+ * ({@link packForType}), a document type routes to the pass that compiles it
145
+ * ({@link compilerFor}), and the pass declares which art it emits
146
+ * ({@link BasePackCompiler.emitsArt}). So the answer is assembled from the same
147
+ * three statements the compile itself follows, and a pass that starts or stops
148
+ * emitting art changes this by changing its own declaration. A second table of
149
+ * "types with no image" would be a table free to drift from what is emitted,
150
+ * which is the defect this exists to report rather than to reproduce.
151
+ *
152
+ * **The union across systems**, because a note is compiled by whichever pack
153
+ * claims it: a tree feeding both SoHL and HM3 has two Actor passes, and a field
154
+ * either of them emits is live for the note. Only a field *no* pass emits is
155
+ * inert, and that is the finding this supports.
156
+ *
157
+ * @param {string} type - The note's content type.
158
+ * @returns {{document: string|null, art: readonly string[]}|null} What the type
159
+ * compiles into and the art it carries there, or `null` where no claim can be
160
+ * made — a retired type, which is reported as retired instead.
161
+ */
162
+ export function emittedArtFor(type) {
163
+ const name = String(type ?? "");
164
+ if (!name || Object.hasOwn(RETIRED_TYPES, name)) return null;
165
+
166
+ // A homepage compiles into a *page*, not a compendium document, so nothing
167
+ // it authors reaches one. It is the one type whose absence from every pack
168
+ // is the intended state (`NEVER_PACKED_TYPES`).
169
+ if (NEVER_PACKED_TYPES.has(currentType(name))) return { document: null, art: [] };
170
+
171
+ // A folder reaches a pack by a route of its own — it materialises in every
172
+ // pack holding a document that references it — so `packForType` has no
173
+ // answer for it and no compiler class writes it. `folderDocument` does, and
174
+ // a Foundry `Folder` has no artwork at all.
175
+ if (currentType(name) === FOLDER_TYPE) return { document: "Folder", art: [] };
176
+
177
+ const { docType } = packForType(name);
178
+ const passes = [
179
+ COMPILERS[docType],
180
+ ...Object.values(SYSTEM_COMPILERS).map((bySystem) => bySystem[docType]),
181
+ ].filter(Boolean);
182
+ if (!passes.length) return null;
183
+
184
+ const art = new Set();
185
+ for (const pass of passes) for (const field of pass.emitsArt ?? []) art.add(field);
186
+ return { document: docType, art: Object.freeze([...art]) };
187
+ }
188
+
134
189
  /**
135
190
  * Root of the build-only JSON tree for one pack.
136
191
  *
@@ -656,11 +711,13 @@ export async function generatePacksJson({ only, config = loadPackConfig() } = {}
656
711
  // which wins over the id it derives under the folder namespace.
657
712
  // A record's `id` is not that: the index fills it in for every
658
713
  // addressable note (#270), so handing records straight over
659
- // would make every folder look pinned and file each one under a
660
- // different id than the packs address it by. The index cannot
661
- // tell a pin from a derivation, so the note is read and only
662
- // folder notes are, 79 of `sohl`'s 1,685 rather than all of
663
- // them.
714
+ // would make every folder look pinned. Since #310 the *value*
715
+ // would be right either way the index derives a folder's id
716
+ // under the folder namespace, so the two agreebut `derivedId`
717
+ // would not, and it is what tells an author whether a duplicate
718
+ // id was two pins or two addresses. The index cannot tell a pin
719
+ // from a derivation, so the note is read — and only folder notes
720
+ // are, 79 of `sohl`'s 1,685 rather than all of them.
664
721
  corpus.records
665
722
  .filter(
666
723
  (record) =>
@@ -400,15 +400,92 @@ export function makeFilename(name, id) {
400
400
  * non-alphanumerics collapsed to single hyphens.
401
401
  */
402
402
 
403
+ /**
404
+ * The path prefixes that name a package other than the one being compiled.
405
+ *
406
+ * Foundry serves every installed package from a root named for its kind, so a
407
+ * path opening with one of these is already a served address and belongs to
408
+ * somebody else — most often `systems/sohl/assets/…`, where every default this
409
+ * toolchain ships lives, and which a module's content cites as readily as the
410
+ * system's own does.
411
+ *
412
+ * **Two, not "the ones we happen to use".** `worlds/` is left out on purpose: a
413
+ * package may not ship art out of a world, so a note that writes one has made a
414
+ * mistake, and prefixing it yields a plainly broken path rather than a
415
+ * plausible one that fails silently much later.
416
+ *
417
+ * @type {readonly string[]}
418
+ */
419
+ const FOREIGN_PACKAGE_ROOTS = Object.freeze(["systems/", "modules/"]);
420
+
421
+ /**
422
+ * Whether a path already addresses something this package does not own, and so
423
+ * must be emitted exactly as authored.
424
+ *
425
+ * Three shapes qualify, each a different kind of "not mine":
426
+ *
427
+ * - **Another package** — `systems/…` or `modules/…`, per
428
+ * {@link FOREIGN_PACKAGE_ROOTS}.
429
+ * - **Somewhere off this install** — a URI scheme (`https:`, `data:`) or a
430
+ * protocol-relative `//cdn…`.
431
+ * - **The data root itself** — a leading `/`, which Foundry serves from the
432
+ * install rather than from any package.
433
+ *
434
+ * @param {string} s - A non-empty authored path.
435
+ * @returns {boolean} Whether it passes through untranslated.
436
+ */
437
+ function addressesAnotherPackage(s) {
438
+ if (FOREIGN_PACKAGE_ROOTS.some((root) => s.startsWith(root))) return true;
439
+ // `//host/x.png` — protocol-relative, so it leaves this origin entirely.
440
+ // Checked before the single-slash case, which would otherwise claim it.
441
+ if (s.startsWith("//")) return true;
442
+ // `/x.png` — rooted at the Foundry data root, not at any package.
443
+ if (s.startsWith("/")) return true;
444
+ // `https://…`, `data:…`, `file:…` — a scheme, so not a path at all.
445
+ return /^[a-z][a-z0-9+.-]*:/i.test(s);
446
+ }
447
+
403
448
  /**
404
449
  * Translate a content-relative image path into its Foundry-relative form.
405
450
  *
406
451
  * Content frontmatter (`img` / `portrait`) authors a single path that has to
407
- * work for Foundry, the knowledgebase, and the website. For Foundry the bundled
408
- * asset roots `icons/...` and `images/...` are served from the package
409
- * directory, so they are rewritten to `<assetRoot>/<path>` — `systems/sohl/assets`
410
- * for this repository, `modules/<id>/assets` for a module (#1508). Any other
411
- * path (already package-rooted, an absolute URL) is returned unchanged.
452
+ * work for Foundry, the knowledgebase, and the website. **Its first segment
453
+ * says which package owns the file** (#331), and there are exactly three
454
+ * answers:
455
+ *
456
+ * | Authored path starts with | Owner | Emitted |
457
+ * | ------------------------- | --------------------- | -------------------- |
458
+ * | `systems/` | a separate **system** | unchanged |
459
+ * | `modules/` | a separate **module** | unchanged |
460
+ * | anything else | **this package** | `<assetRoot>/<path>` |
461
+ *
462
+ * So `icons/relic.svg` compiles to `systems/sohl/assets/icons/relic.svg` here
463
+ * and to `modules/sohl-thalorna/assets/icons/relic.svg` in a module — the asset
464
+ * root is derived from the configuration, and is the one place `systems/sohl`
465
+ * is ever spelled (#1508). An authored
466
+ * `systems/sohl/assets/icons/noun/shield.svg` is left exactly as written,
467
+ * whichever package is compiling it.
468
+ *
469
+ * **This is a rule about ownership, not an allowlist of directories.** It used
470
+ * to prefix `icons/…` and `images/…` and pass everything else through — the
471
+ * same answer for every path any tree authors today, and the wrong one for the
472
+ * next directory a package ships. `sohl-kethira-basic` keeps art under
473
+ * `assets/artwork/`, so an authored `artwork/deity.webp` would have shipped
474
+ * unprefixed: a 404 in Foundry, reported by nothing. That a package owns its
475
+ * own tree is the fact; the directory names inside it are that package's
476
+ * business (#331).
477
+ *
478
+ * **Off-install addresses pass through too**, which is the same rule rather
479
+ * than a fourth: a URL, a `data:` URI, or a `/`-rooted path names something no
480
+ * package owns. See {@link addressesAnotherPackage}.
481
+ *
482
+ * **`banner:` does not follow this rule, deliberately (#331).** It is not an
483
+ * asset path inside a Foundry install at all: it reaches no compiled document,
484
+ * and its only consumer is the Hugo theme, which prefixes a relative value with
485
+ * `images/` and joins it onto `params.cdnBaseURL`. The two fields look alike
486
+ * and address different places — `img:` a file Foundry serves, `banner:` a file
487
+ * the CDN serves — so they are documented apart rather than reconciled into one
488
+ * rule that would be true of neither.
412
489
  *
413
490
  * **Two empties, and they mean opposite things (#218).** `null` — or an absent
414
491
  * key, which reaches here as `undefined` — means _unset_: the note names no art
@@ -453,10 +530,10 @@ export function resolveImg(raw, config = loadPackConfig()) {
453
530
  const s = String(raw);
454
531
  // Blank on purpose — the caller's default must not apply.
455
532
  if (s === "") return "";
456
- if (s.startsWith("icons/") || s.startsWith("images/")) {
457
- return `${config.assetRoot}/${s}`;
458
- }
459
- return s;
533
+ // Somebody else's to serve — emit it exactly as authored.
534
+ if (addressesAnotherPackage(s)) return s;
535
+ // Ours, so root it where Foundry serves this package's files from.
536
+ return `${config.assetRoot}/${s}`;
460
537
  }
461
538
 
462
539
  /**
package/engine/index.mjs CHANGED
@@ -69,6 +69,9 @@ export * as notePackage from "./note-package.mjs";
69
69
  /** Frontmatter fields a note may no longer declare, and the refusal of them. */
70
70
  export * as retiredFields from "./retired-fields.mjs";
71
71
 
72
+ /** Schema fields a note may never declare, because play writes them. */
73
+ export * as runtimeOnlyFields from "./runtime-only-fields.mjs";
74
+
72
75
  /** The package homepage: the note type that compiles to a page, not a document. */
73
76
  export * as homepage from "./homepage.mjs";
74
77
 
@@ -66,6 +66,9 @@ import { documentSubtype, subtypeRow } from "./document-subtypes.mjs";
66
66
  // `system` verbatim, and `<system>.img` / `.effects` / `.flags` overriding
67
67
  // their shared top-level forms for this system alone (#58).
68
68
  import { blockField, blockProperty, claimedPaths, mergeSystemData } from "./system-block.mjs";
69
+ // The other direction of the same declaration: a field the *document* writes in
70
+ // play, which a note may not author and the builder does not emit (#330).
71
+ import { assertNoRuntimeOnlyFields } from "./runtime-only-fields.mjs";
69
72
 
70
73
  /**
71
74
  * The description an item carries: a pointer to its **item doc**, the
@@ -113,6 +116,17 @@ export class SystemItemCompiler extends BasePackCompiler {
113
116
  */
114
117
  static requiresSystemBlock = true;
115
118
 
119
+ /**
120
+ * An Item carries an `img` — its icon — which this pass writes from the
121
+ * note's own path, falling back to the type's default art.
122
+ *
123
+ * `portrait` is **not** among them: a portrait is a being's sheet picture,
124
+ * and an item has nowhere to put one.
125
+ *
126
+ * @type {readonly string[]}
127
+ */
128
+ static emitsArt = Object.freeze(["img"]);
129
+
116
130
  /**
117
131
  * The note-type → document-subtype map this pass compiles against.
118
132
  *
@@ -174,6 +188,29 @@ export class SystemItemCompiler extends BasePackCompiler {
174
188
  return !row || row.document === "Item";
175
189
  }
176
190
 
191
+ /**
192
+ * Refuse a note authoring one of its type's **runtime-only** fields (#330).
193
+ *
194
+ * A schema declares fields the document writes for itself — an affliction's
195
+ * `onsetDate` is the world time its onset fired at — and a note authoring
196
+ * one used to compile, because `<system>.system` is a verbatim passthrough
197
+ * and the field really is in the schema. The result was shipped content
198
+ * carrying one world's play state, with the build reporting success.
199
+ *
200
+ * The declaration says which, so nothing here knows a field name; see
201
+ * {@link module:engine/runtime-only-fields}.
202
+ *
203
+ * @param {object} fm - The note's frontmatter.
204
+ * @returns {void}
205
+ * @throws {Error} When the note authors one.
206
+ */
207
+ assertAuthorable(fm) {
208
+ assertNoRuntimeOnlyFields(fm, itemFields(fm.type, this.system), {
209
+ block: this.system,
210
+ absPath: this.currentNote?.absPath,
211
+ });
212
+ }
213
+
177
214
  /**
178
215
  * The Foundry Item subtype a note compiles into.
179
216
  *