@heroiclands/package-build 17.1.0 → 18.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 (153) hide show
  1. package/CHANGELOG.md +1773 -0
  2. package/CONTENT.md +288 -72
  3. package/README.md +1 -1
  4. package/bin/content-build.mjs +218 -137
  5. package/bin/package-build.mjs +43 -0
  6. package/content-config.mjs +62 -67
  7. package/docs/content-format.md +768 -58
  8. package/engine/actor-compiler.mjs +586 -0
  9. package/engine/address-charset.mjs +11 -8
  10. package/engine/address-diff.mjs +266 -22
  11. package/engine/anchored-sections.mjs +83 -0
  12. package/engine/anchors.mjs +83 -0
  13. package/engine/base-compiler.mjs +148 -18
  14. package/engine/bundle-notes.mjs +276 -0
  15. package/engine/bundles.mjs +307 -0
  16. package/engine/code-fences.mjs +103 -0
  17. package/engine/compile-corpus.mjs +89 -0
  18. package/engine/content-address.mjs +267 -5
  19. package/engine/content-format-check.mjs +54 -11
  20. package/engine/content-format.mjs +37 -5
  21. package/engine/content-index.mjs +394 -111
  22. package/engine/content-links.mjs +185 -77
  23. package/engine/content-lint.mjs +232 -14
  24. package/engine/content-package.mjs +2 -1
  25. package/engine/content-tables.mjs +99 -3
  26. package/engine/document-subtypes.mjs +49 -6
  27. package/engine/field-reference.mjs +1 -1
  28. package/engine/field-spec.mjs +56 -6
  29. package/engine/folder-notes.mjs +470 -0
  30. package/engine/foreign-catalog.mjs +195 -4
  31. package/engine/{manifest-emit.mjs → foundry-entries.mjs} +78 -81
  32. package/engine/frontmatter-lint.mjs +245 -19
  33. package/engine/frontmatter.mjs +88 -8
  34. package/engine/generate.mjs +304 -13
  35. package/engine/helpers.mjs +293 -64
  36. package/engine/ids.mjs +137 -2
  37. package/engine/index-records.mjs +126 -0
  38. package/engine/index.mjs +23 -5
  39. package/engine/item-compiler.mjs +349 -0
  40. package/engine/item-docs.mjs +7 -1
  41. package/engine/item-registry.mjs +6 -0
  42. package/engine/journals.mjs +93 -27
  43. package/engine/macros.mjs +4 -2
  44. package/engine/metadata-index.mjs +495 -0
  45. package/engine/note-claims.mjs +146 -25
  46. package/engine/note-ids.mjs +112 -0
  47. package/engine/note-renames.mjs +134 -0
  48. package/engine/note-schemas.mjs +25 -0
  49. package/engine/note-vocabulary.mjs +110 -27
  50. package/engine/pack-config.mjs +4 -0
  51. package/engine/pack-router.mjs +23 -0
  52. package/engine/retired-fields.mjs +137 -3
  53. package/engine/scenes.mjs +66 -34
  54. package/engine/schema-check.mjs +58 -17
  55. package/engine/site-build.mjs +71 -12
  56. package/engine/site-index.mjs +20 -7
  57. package/engine/sql-tables.mjs +485 -0
  58. package/engine/subtype-registry.mjs +102 -0
  59. package/engine/system-block.mjs +96 -12
  60. package/engine/systems.mjs +201 -0
  61. package/engine/web-wikilinks.mjs +50 -20
  62. package/engine/wikilink-syntax.mjs +17 -8
  63. package/engine/wikilinks.mjs +124 -51
  64. package/engine/yaml-lint.mjs +282 -0
  65. package/hm3/actors.mjs +352 -0
  66. package/hm3/default-item-art.mjs +75 -0
  67. package/hm3/document-subtypes.mjs +134 -0
  68. package/hm3/index.mjs +56 -0
  69. package/hm3/item-builders.mjs +84 -0
  70. package/hm3/item-fields.mjs +180 -0
  71. package/hm3/items.mjs +96 -0
  72. package/hm3/template-priority.mjs +85 -0
  73. package/labels.mjs +191 -0
  74. package/manifest.mjs +43 -2
  75. package/package.json +19 -4
  76. package/release.mjs +62 -7
  77. package/sohl/actors.mjs +33 -487
  78. package/sohl/being-info.mjs +16 -7
  79. package/sohl/default-item-art.mjs +14 -3
  80. package/sohl/document-subtypes.mjs +16 -10
  81. package/sohl/item-builders.mjs +14 -5
  82. package/sohl/item-fields.mjs +68 -7
  83. package/sohl/items.mjs +44 -258
  84. package/sohl/note-schemas.mjs +2 -2
  85. package/types/content-config.d.mts +21 -42
  86. package/types/engine/actor-compiler.d.mts +204 -0
  87. package/types/engine/address-charset.d.mts +11 -8
  88. package/types/engine/address-diff.d.mts +53 -5
  89. package/types/engine/anchored-sections.d.mts +21 -0
  90. package/types/engine/anchors.d.mts +20 -0
  91. package/types/engine/base-compiler.d.mts +17 -17
  92. package/types/engine/bundle-notes.d.mts +173 -0
  93. package/types/engine/bundles.d.mts +60 -0
  94. package/types/engine/code-fences.d.mts +43 -0
  95. package/types/engine/compile-corpus.d.mts +32 -0
  96. package/types/engine/content-address.d.mts +205 -5
  97. package/types/engine/content-format-check.d.mts +6 -2
  98. package/types/engine/content-format.d.mts +57 -1
  99. package/types/engine/content-index.d.mts +64 -80
  100. package/types/engine/content-links.d.mts +52 -7
  101. package/types/engine/content-lint.d.mts +10 -1
  102. package/types/engine/content-package.d.mts +2 -1
  103. package/types/engine/content-tables.d.mts +21 -39
  104. package/types/engine/document-subtypes.d.mts +37 -3
  105. package/types/engine/field-spec.d.mts +76 -5
  106. package/types/engine/folder-notes.d.mts +159 -0
  107. package/types/engine/foreign-catalog.d.mts +53 -0
  108. package/types/engine/{manifest-emit.d.mts → foundry-entries.d.mts} +16 -38
  109. package/types/engine/frontmatter-lint.d.mts +10 -2
  110. package/types/engine/frontmatter.d.mts +64 -0
  111. package/types/engine/generate.d.mts +38 -0
  112. package/types/engine/helpers.d.mts +94 -30
  113. package/types/engine/ids.d.mts +96 -0
  114. package/types/engine/index-records.d.mts +68 -0
  115. package/types/engine/index.d.mts +9 -3
  116. package/types/engine/item-compiler.d.mts +131 -0
  117. package/types/engine/journals.d.mts +47 -9
  118. package/types/engine/metadata-index.d.mts +226 -0
  119. package/types/engine/note-claims.d.mts +57 -11
  120. package/types/engine/note-ids.d.mts +38 -0
  121. package/types/engine/note-renames.d.mts +102 -0
  122. package/types/engine/note-vocabulary.d.mts +44 -8
  123. package/types/engine/retired-fields.d.mts +75 -0
  124. package/types/engine/scenes.d.mts +3 -2
  125. package/types/engine/schema-check.d.mts +25 -4
  126. package/types/engine/site-build.d.mts +4 -4
  127. package/types/engine/site-index.d.mts +1 -1
  128. package/types/engine/sql-tables.d.mts +185 -0
  129. package/types/engine/subtype-registry.d.mts +49 -0
  130. package/types/engine/system-block.d.mts +40 -1
  131. package/types/engine/systems.d.mts +106 -0
  132. package/types/engine/web-wikilinks.d.mts +4 -2
  133. package/types/engine/wikilink-syntax.d.mts +10 -3
  134. package/types/engine/wikilinks.d.mts +41 -13
  135. package/types/engine/yaml-lint.d.mts +107 -0
  136. package/types/hm3/actors.d.mts +48 -0
  137. package/types/hm3/default-item-art.d.mts +42 -0
  138. package/types/hm3/document-subtypes.d.mts +24 -0
  139. package/types/hm3/index.d.mts +7 -0
  140. package/types/hm3/item-builders.d.mts +11 -0
  141. package/types/hm3/item-fields.d.mts +12 -0
  142. package/types/hm3/items.d.mts +23 -0
  143. package/types/hm3/template-priority.d.mts +21 -0
  144. package/types/manifest.d.mts +22 -1
  145. package/types/release.d.mts +9 -4
  146. package/types/sohl/actors.d.mts +7 -74
  147. package/types/sohl/being-info.d.mts +8 -27
  148. package/types/sohl/default-item-art.d.mts +5 -3
  149. package/types/sohl/items.d.mts +17 -32
  150. package/engine/foreign-manifests.mjs +0 -126
  151. package/engine/kb-manifest.mjs +0 -490
  152. package/types/engine/foreign-manifests.d.mts +0 -43
  153. package/types/engine/kb-manifest.d.mts +0 -241
@@ -42,24 +42,25 @@
42
42
  * transmission — and is true of it whichever system is reading. What each
43
43
  * system makes of that value is declared elsewhere, in that system's own half.
44
44
  *
45
- * **The type names here are today's**, which for four of them is not the name
46
- * the specification uses: `armor`, `weapon`, `projectile` and `concoction` are
47
- * still spelled `armorgear`, `weapongear`, `projectilegear` and
48
- * `concoctiongear`, and a `map` is still one of `battlemap` / `localmap` /
49
- * `regionalmap`. Those renames are a later slice (#78, #79), and declaring the
50
- * vocabulary under a name no note may yet carry would make it unreachable. The
51
- * specification's types with no name here at all — `place`, `scenario`,
52
- * `lore`, `vehicle`, `armorlocation` are likewise deferred: a note carrying
53
- * one is already reported as a type no schema declares, which is the finding it
54
- * deserves until the type exists.
45
+ * **The type names here are the specification's**, since #78 renamed `armor`,
46
+ * `projectile` and `concoction` off the `…gear` spellings that named a SoHL
47
+ * document subtype rather than the thing the note is about. `weapon` is the one
48
+ * the specification and this registry still spell differently: both systems
49
+ * call that document a `weapongear`, so the name says nothing system-specific
50
+ * and #78's table has no row for it. A note left on a renamed spelling still
51
+ * reaches its entry every type-keyed lookup normalises through
52
+ * `RENAMED_TYPES` — and is reported rather than refused until the content trees
53
+ * have swept.
55
54
  *
56
55
  * **A type name and a subType value are held to the address charset** (#206), so
57
56
  * both are `^[A-Za-z0-9]+$` — the charset `engine/address-charset.mjs` states
58
- * and the shortcode is already held to. For a type that is literal: it is the
59
- * first segment of every address (`type-shortcode`), the hyphen is the
60
- * separator between segments and can therefore never occur inside one, and a
61
- * hyphenated name would be read back as two segments and resolve to nothing,
62
- * reporting nothing about why.
57
+ * and the shortcode is already held to. For a type that is literal: it is a
58
+ * segment of every address the first of the short form an author writes
59
+ * (`type-shortcode`), the third of the canonical
60
+ * `package-system-type-shortcode` the hyphen is the separator between
61
+ * segments and can therefore never occur inside one, and a hyphenated name
62
+ * would be read back as two segments and resolve to nothing, reporting nothing
63
+ * about why.
63
64
  *
64
65
  * A subType reaches no address of its own. It did when this rule was written —
65
66
  * a `doc`'s was its section, a path segment — and #204 retired sections from
@@ -77,6 +78,10 @@
77
78
  // The one charset, read rather than restated. A second spelling of the pattern
78
79
  // is how the three disagreements found in #202/#203 happened.
79
80
  import { ADDRESS_SEGMENT_PATTERN, isAddressSegment } from "./address-charset.mjs";
81
+ // The retirement window for a renamed type, read rather than restated: a
82
+ // vocabulary that answered only to the current spelling would report every key
83
+ // of an unswept note as unknown (#78).
84
+ import { currentType } from "./ids.mjs";
80
85
 
81
86
  /**
82
87
  * One `data:` key a note type may carry.
@@ -90,12 +95,19 @@ import { ADDRESS_SEGMENT_PATTERN, isAddressSegment } from "./address-charset.mjs
90
95
  * @typedef {object} DataFieldSpec
91
96
  * @property {string} name - The key under `data:`, dotted for a nested one
92
97
  * (`charges.value`).
93
- * @property {"string"|"number"|"boolean"|"list"|"map"} [kind] - The value's
94
- * shape, for the lint. Absent means no claim is made about the value — which
95
- * is the honest answer wherever the specification's stated shape and the
96
- * shape notes are authored in today disagree.
98
+ * @property {"string"|"number"|"boolean"|"list"|"map"|"scalar-or-map"} [kind] -
99
+ * The value's shape, for the lint. Absent means no claim is made about the
100
+ * value — which is the honest answer wherever the specification's stated
101
+ * shape and the shape notes are authored in today disagree.
97
102
  * @property {string} [shape] - Human-readable shape, for a finding and for
98
103
  * documentation.
104
+ * @property {string} [entryShape] - For a `scalar-or-map` field, what one
105
+ * entry of the map is. A finding names the entry at fault rather than
106
+ * quoting the whole map back, so the string an author has to correct is the
107
+ * one the message holds.
108
+ * @property {"pack"} [keys] - For a `scalar-or-map` field, what its keys name.
109
+ * `"pack"` means each is a pack this package declares, so a key naming none
110
+ * is a finding of its own: it addresses a hierarchy nothing will ever read.
99
111
  * @property {string} describe - One line, for the author-facing reference.
100
112
  */
101
113
 
@@ -136,6 +148,26 @@ const LINK = Object.freeze({ shape: "a wikilink", kind: "string" });
136
148
  /** A list of wikilinks. */
137
149
  const LINKS = Object.freeze({ shape: "list of wikilinks", kind: "list" });
138
150
 
151
+ /**
152
+ * A single wikilink, or one per pack.
153
+ *
154
+ * The map form is not a convenience spelling of the scalar: it says something
155
+ * the scalar cannot, that the answer *differs by pack*. A folder's `parent` is
156
+ * the case it exists for — a folder's identity is one thing and its hierarchy
157
+ * another, and both large trees file the same folder under a different parent
158
+ * in the items pack and the journals pack (#276).
159
+ *
160
+ * Typing it as a bare {@link LINK} is what #288 was: the compiler read both
161
+ * forms and the lint rejected one of them, so every note using the form the
162
+ * specification prescribes was a finding and no note using it was not.
163
+ */
164
+ const LINK_BY_PACK = Object.freeze({
165
+ shape: "a wikilink, or a map of wikilinks keyed by pack",
166
+ kind: "scalar-or-map",
167
+ entryShape: "a wikilink",
168
+ keys: "pack",
169
+ });
170
+
139
171
  /** Whatever the author wrote — declared, but with no claim about its shape. */
140
172
  const ANY = Object.freeze({ shape: "as authored" });
141
173
 
@@ -558,7 +590,7 @@ export const NOTE_VOCABULARY = Object.freeze({
558
590
  ]),
559
591
  }),
560
592
 
561
- armorgear: Object.freeze({
593
+ armor: Object.freeze({
562
594
  // Quantity is always one, so the specification refuses the key rather
563
595
  // than defaulting it.
564
596
  data: Object.freeze([TEMPLATE_PRIORITY, ...GEAR]),
@@ -573,7 +605,7 @@ export const NOTE_VOCABULARY = Object.freeze({
573
605
  data: Object.freeze([TEMPLATE_PRIORITY]),
574
606
  }),
575
607
 
576
- concoctiongear: Object.freeze({
608
+ concoction: Object.freeze({
577
609
  subTypes: Object.freeze(["mundane", "exotic", "elixir"]),
578
610
  data: Object.freeze([
579
611
  TEMPLATE_PRIORITY,
@@ -648,7 +680,7 @@ export const NOTE_VOCABULARY = Object.freeze({
648
680
  ]),
649
681
  }),
650
682
 
651
- projectilegear: Object.freeze({
683
+ projectile: Object.freeze({
652
684
  subTypes: Object.freeze(["none", "arrow", "bolt", "bullet", "dart", "other"]),
653
685
  data: Object.freeze([TEMPLATE_PRIORITY, ...GEAR, QUANTITY]),
654
686
  }),
@@ -711,6 +743,57 @@ export const NOTE_VOCABULARY = Object.freeze({
711
743
 
712
744
  macro: Object.freeze({ data: Object.freeze([]) }),
713
745
 
746
+ // Foundry's `Folder`, and the last document this package compiled from
747
+ // bespoke configuration rather than from a note (#256). It declares no
748
+ // system-block fields, like a bundle: a `Folder` is a core Foundry
749
+ // document, so its address carries the `none` system segment and
750
+ // everything it says is a `data` property.
751
+ //
752
+ // It carries **no prose**, which is the decision #256 left open: a folder
753
+ // is structure, not content, so it wants no documentation journal and takes
754
+ // no part in `docEntryTypes`.
755
+ folder: Object.freeze({
756
+ data: Object.freeze([
757
+ {
758
+ name: "parent",
759
+ ...LINK_BY_PACK,
760
+ describe:
761
+ "The folder this one sits in, as an address — or one " +
762
+ "address per pack, keyed by pack name with `default` for " +
763
+ "the rest. Unset at the root. A dead address is a " +
764
+ "dead-address finding and a cycle is refused, per pack.",
765
+ },
766
+ {
767
+ name: "color",
768
+ ...TEXT,
769
+ describe: "The folder's colour, as a CSS hex code. Unset for Foundry's default.",
770
+ },
771
+ ]),
772
+ }),
773
+
774
+ // Foundry's `Adventure`, named for what it is rather than what Foundry
775
+ // calls it: a set of documents taken as a unit. The document is an
776
+ // installer — it carries copies, and importing one creates or updates each
777
+ // document in the world — which is what separates a bundle from a folder,
778
+ // a live grouping that persists in the pack (#259).
779
+ //
780
+ // How many Adventures a bundle makes is decided by its system blocks, as
781
+ // for every other type, and not by a property: an `Adventure` has no
782
+ // `system` field, so a bundle spanning two systems is two documents, and
783
+ // the pack each is written to is what carries the system.
784
+ bundle: Object.freeze({
785
+ data: Object.freeze([
786
+ {
787
+ name: "contents",
788
+ ...LINKS,
789
+ describe:
790
+ "The documents the Adventure holds, as addresses. Empty " +
791
+ "when unstated. A document of neither `none` nor the " +
792
+ "system being compiled is left out rather than failing.",
793
+ },
794
+ ]),
795
+ }),
796
+
714
797
  lore: Object.freeze({
715
798
  subTypes: Object.freeze([
716
799
  "cosmology",
@@ -888,9 +971,9 @@ export function typeCharsetMessage(type) {
888
971
  return (
889
972
  `content type "${type}" is not an address segment — a type is letters ` +
890
973
  `and digits only (${ADDRESS_SEGMENT_PATTERN.source}), the same charset ` +
891
- `a shortcode is held to. A type is the first segment of every address ` +
892
- `("type-shortcode"), so a hyphenated one is read back as two segments ` +
893
- `and resolves to nothing`
974
+ `a shortcode is held to. A type is a segment of every address, and the ` +
975
+ `first of the short form an author writes ("type-shortcode"), so a ` +
976
+ `hyphenated one is read back as two segments and resolves to nothing`
894
977
  );
895
978
  }
896
979
 
@@ -948,7 +1031,7 @@ assertVocabularyCharset(NOTE_VOCABULARY);
948
1031
  * one, and is why the lint makes no claim rather than refusing every key.
949
1032
  */
950
1033
  export function dataFields(type, vocabulary = NOTE_VOCABULARY) {
951
- return vocabulary?.[type]?.data;
1034
+ return vocabulary?.[currentType(type)]?.data;
952
1035
  }
953
1036
 
954
1037
  /**
@@ -962,7 +1045,7 @@ export function dataFields(type, vocabulary = NOTE_VOCABULARY) {
962
1045
  * it has no `subType` at all — see {@link TypeVocabulary}.
963
1046
  */
964
1047
  export function subTypes(type, vocabulary = NOTE_VOCABULARY) {
965
- const entry = vocabulary?.[type];
1048
+ const entry = vocabulary?.[currentType(type)];
966
1049
  if (!entry || !Object.hasOwn(entry, "subTypes")) return undefined;
967
1050
  return entry.subTypes;
968
1051
  }
@@ -168,6 +168,10 @@ const ITEM_BUILDER_REGISTRIES = Object.freeze({
168
168
  /** @type {{ ITEM_BUILDERS: Record<string, unknown> }} */ (
169
169
  require("../sohl/item-builders.mjs")
170
170
  ).ITEM_BUILDERS,
171
+ hm3: () =>
172
+ /** @type {{ HM3_ITEM_BUILDERS: Record<string, unknown> }} */ (
173
+ require("../hm3/item-builders.mjs")
174
+ ).HM3_ITEM_BUILDERS,
171
175
  });
172
176
 
173
177
  /**
@@ -220,6 +220,29 @@ export function createPackRouter(packs) {
220
220
  return [...(byType.get(docType) ?? [])];
221
221
  },
222
222
 
223
+ /**
224
+ * The systems declared by the packs of one document type.
225
+ *
226
+ * What a pass asks to tell a note that belongs to *another system's*
227
+ * pass from one that is simply missing this system's block (#139). A
228
+ * repository feeding two systems declares an Item pack per system, so
229
+ * a note carrying only `hm3:` is not an incomplete SoHL note — it is a
230
+ * note the HM3 pass will claim, and the SoHL pass has to pass over it
231
+ * rather than fail it.
232
+ *
233
+ * @param {string} docType - The Foundry document type.
234
+ * @returns {string[]} The declared systems, deduplicated, in
235
+ * configured order. Empty where no pack of the type names one.
236
+ */
237
+ systemsOfType(docType) {
238
+ const out = [];
239
+ for (const name of byType.get(docType) ?? []) {
240
+ const system = byName.get(name)?.system;
241
+ if (system && !out.includes(system)) out.push(system);
242
+ }
243
+ return out;
244
+ },
245
+
223
246
  /**
224
247
  * The pack of a type that receives notes declaring none.
225
248
  *
@@ -23,8 +23,8 @@
23
23
  *
24
24
  * `package:` is retired the same way and is refused from `note-package.mjs`,
25
25
  * where the concept it belonged to still lives. `draft:`, the top-level
26
- * `aliases:` and `section:` have no such home — there is no surviving concept
27
- * any of them was part of — so they are refused here.
26
+ * `aliases:`, `section:` and `traits:` have no such home — there is no
27
+ * surviving concept any of them was part of — so they are refused here.
28
28
  *
29
29
  * **What `draft:` did (#69).** It excluded a note from the compiled packs, from
30
30
  * the link manifest and from a consuming site build. Nothing reported the
@@ -56,6 +56,23 @@
56
56
  * reserved.test.ts` pins that equivalence so a future reader cannot be added
57
57
  * by accident.
58
58
  *
59
+ * **What `traits:` did (#291).** It held a being's own description — gender,
60
+ * species, age, birthday, height, weight, frame and `appearance.*` — at the
61
+ * note's top level. The content format gives those a home: `data:`, the closed
62
+ * container for a subject's type-specific facts, which `being` declares every
63
+ * one of them in. #128 moved all 2,533 notes that carried one, across four
64
+ * repositories, and this is the third step of that retirement.
65
+ *
66
+ * Refusing it matters more than refusing an ordinary dead key, because top
67
+ * level is *deliberately open*: an unrecognised key there is passed through to
68
+ * Hugo, so a stray `traits:` would not be ignored loudly but would arrive on
69
+ * the page as a theme parameter, checked by nothing. The whole argument for
70
+ * `data:` being closed is the argument for refusing this.
71
+ *
72
+ * `sohl.traits` is a different field that shares the name — `projectilegear`
73
+ * declares one, and the theme's gear sidebar reads it — so the refusal is
74
+ * anchored at column 1 and never reaches inside a system block.
75
+ *
59
76
  * **A field retired in favour of another is a third case (#142).** `draft:` and
60
77
  * `package:` were retired outright: nothing replaced them, so no value made
61
78
  * writing one right and refusal was the only honest answer. A *renamed* field
@@ -67,6 +84,15 @@
67
84
  * the refusal come later, once no tree writes it. See
68
85
  * {@link RETIRED_FIELD_ALIASES}.
69
86
  *
87
+ * **A retired *position* is the same case, and reads the same (#305).** A field
88
+ * whose shared source moved under `data:` is not renamed — `data.species` and
89
+ * `hm3.species` are one field written in two places — but the retirement has
90
+ * the identical shape: both are read, the one the note carries wins, and the
91
+ * legacy in-block key is reported by {@link legacyKeyMessage} so the sweep has
92
+ * something to count down. Which position a value came from is
93
+ * {@link module:engine/system-block.resolveFieldValue}'s answer; this module
94
+ * only says what an author is told about it.
95
+ *
70
96
  * @module
71
97
  */
72
98
 
@@ -289,6 +315,65 @@ export function assertNoSectionField(fm, { file, absPath } = {}) {
289
315
  throw err;
290
316
  }
291
317
 
318
+ /**
319
+ * What a note writing a top-level `traits:` block is told.
320
+ *
321
+ * The message states the **mapping**, not just the destination, because three
322
+ * of the keys reshaped as well as moved: the block nested its measurements
323
+ * where the format flattens them. A bare "write `data:` instead" would send an
324
+ * author to author `data.height: {m: 1.78}`, which is a declared key holding an
325
+ * undeclared shape.
326
+ *
327
+ * @param {string} [file] - The note's path, named in the message. Omit it where
328
+ * the caller emits through a diagnostic, whose locator already starts the
329
+ * line — repeating it prints the path twice.
330
+ * @returns {string} The message, unpunctuated at the end as a finding is.
331
+ */
332
+ export function traitsRetiredMessage(file) {
333
+ return (
334
+ "`traits:` is a retired frontmatter block — move it into `data:`" +
335
+ (file ? ` — ${file}` : "") +
336
+ ". A being's own description belongs in the closed container the " +
337
+ "content format declares, where `being` declares every one of its " +
338
+ "keys; at the top level it was passed through to the page unchecked, " +
339
+ "so a misspelling became a theme parameter rather than a finding. " +
340
+ "`gender`, `species`, `age`, `birthday` and `appearance.*` move " +
341
+ "unchanged; three reshape — `traits.height.m` becomes `data.height` " +
342
+ "(metres), `traits.weight.kg` becomes `data.weight` (kilograms), and " +
343
+ "`traits.build.frame` becomes `data.frame`"
344
+ );
345
+ }
346
+
347
+ /**
348
+ * Refuse a note that declares a top-level `traits:` block at all.
349
+ *
350
+ * Presence is the whole test, as it is for `draft:` and `aliases:`: an empty
351
+ * `traits:` is still a note claiming a block that no longer exists.
352
+ *
353
+ * @param {object|null|undefined} fm - Parsed frontmatter, or nothing when it
354
+ * could not be parsed.
355
+ * @param {object} [options] - Options.
356
+ * @param {string} [options.file] - The note's path, named in the message. Omit
357
+ * it where the caller emits through a diagnostic, which puts the locator at
358
+ * the start of the line already.
359
+ * @param {string} [options.absPath] - The note's file on disk, read only on the
360
+ * failing path to locate the offending line and column. The position rides on
361
+ * the thrown error as `position`, for a caller that emits a diagnostic.
362
+ * @returns {void}
363
+ * @throws {Error} When the note declares the block.
364
+ */
365
+ export function assertNoTraitsField(fm, { file, absPath } = {}) {
366
+ if (!fm || typeof fm !== "object" || !Object.hasOwn(fm, "traits")) return;
367
+
368
+ const err = new Error(`${traitsRetiredMessage(file)}.`);
369
+ // Anchored at column 1. `sohl.traits` is a *different field that shares the
370
+ // name* — `projectilegear` declares one and the theme's gear sidebar reads
371
+ // it — so a finding about the top-level block must never open on it.
372
+ const position = locateFrontmatterKey(absPath, "traits", undefined, { topLevel: true });
373
+ if (position) err.position = position;
374
+ throw err;
375
+ }
376
+
292
377
  /**
293
378
  * A frontmatter key's position in a note's file, or nothing.
294
379
  *
@@ -335,6 +420,19 @@ export function locateFrontmatterKey(absPath, key, value = undefined, { topLevel
335
420
  * `image` is retired on a map — which declares `img` — and remains an unknown
336
421
  * key anywhere else.
337
422
  *
423
+ * **`templatePriority` (#266).** The number that decides which of several
424
+ * competing templates the Create dialog offers was called `archetype`, and
425
+ * `archetypes` — one letter apart — is a list of what *sort* a character is.
426
+ * A priority and a taxonomy cannot be told apart by a plural `s`, so the
427
+ * priority takes the name that says what it is. The specification already
428
+ * called it `templatePriority` on two of its three sides.
429
+ *
430
+ * **`relations` (SoHL#1781).** The field holds a *map* of standings, one per
431
+ * affiliation — its own description says so, and `resolveRelation` has always
432
+ * read it that way. `relation` named the many as one, which every author had to
433
+ * read past. Only `affiliation` declares the field, so the alias is reported
434
+ * there and the old spelling stays an ordinary unknown key everywhere else.
435
+ *
338
436
  * **`img` (#142).** Every note type names its artwork `img`, at the note's top
339
437
  * level, and resolves it the same way. A map alone named its background art
340
438
  * `image` and read it out of the `sohl:` block — two spellings for one idea,
@@ -345,7 +443,11 @@ export function locateFrontmatterKey(absPath, key, value = undefined, { topLevel
345
443
  *
346
444
  * @type {Readonly<Record<string, string>>}
347
445
  */
348
- export const RETIRED_FIELD_ALIASES = Object.freeze({ img: "image" });
446
+ export const RETIRED_FIELD_ALIASES = Object.freeze({
447
+ img: "image",
448
+ templatePriority: "archetype",
449
+ relations: "relation",
450
+ });
349
451
 
350
452
  /**
351
453
  * What a note writing a renamed field is told, in one place.
@@ -373,6 +475,38 @@ export function retiredAliasMessage(retired, current, file) {
373
475
  );
374
476
  }
375
477
 
478
+ /**
479
+ * What a note writing a field at its **legacy in-block position** is told.
480
+ *
481
+ * The same three-step retirement `retiredAliasMessage` announces, applied to a
482
+ * *position* rather than a spelling (#305). A field whose shared source moved
483
+ * under `data:` keeps reading the key its block still carries, that read wins,
484
+ * and it is reported here so a sweep has something to count down — without it,
485
+ * moving a field into `data:` would be a flag day across every repository that
486
+ * authors it.
487
+ *
488
+ * It names the block, because the key is `hm3.species` and not `species`: an
489
+ * author told to move "`species`" has two regions to guess between.
490
+ *
491
+ * @param {string} block - The system block the key was written in.
492
+ * @param {{name?: string, legacyKey?: string}} field - The declaration, which
493
+ * names both positions.
494
+ * @param {string} [file] - The note's path, named in the message. Omit it where
495
+ * the caller emits through a diagnostic, whose locator already starts the
496
+ * line — repeating it prints the path twice.
497
+ * @returns {string} The message, unpunctuated at the end as a finding is.
498
+ */
499
+ export function legacyKeyMessage(block, field, file) {
500
+ const legacy = `${block}.${field.legacyKey}`;
501
+ return (
502
+ `\`${legacy}:\` is the legacy position of the shared \`${field.name}:\` ` +
503
+ `— write it under \`data:\` instead` +
504
+ (file ? ` — ${file}` : "") +
505
+ `. Both are read and \`${legacy}\` wins, so the note compiles ` +
506
+ `identically either way; the in-block key is removed in a later release`
507
+ );
508
+ }
509
+
376
510
  /**
377
511
  * Whether a note writes the retired spelling of a field, wherever it put it.
378
512
  *
package/engine/scenes.mjs CHANGED
@@ -51,10 +51,24 @@ import fs from "fs";
51
51
  import path from "path";
52
52
  import log from "loglevel";
53
53
 
54
- import { walkMarkdownTree, sohlField, resolveName, slugify, defaultStats } from "./helpers.mjs";
54
+ import {
55
+ parseMarkdownFile,
56
+ sohlField,
57
+ resolveName,
58
+ slugify,
59
+ defaultStats,
60
+ folderField,
61
+ } from "./helpers.mjs";
55
62
  import { BasePackCompiler } from "./base-compiler.mjs";
63
+ // What an Adventure member may carry is one rule, and the module that owns the
64
+ // Adventure states it: the scenes pass bundles its pinned places, and the
65
+ // bundles pass compiles a note into one (#259).
66
+ import { stripAdventureKeys } from "./bundle-notes.mjs";
56
67
  import { buildJournalEntry, splitPages, journalPageId } from "./journals.mjs";
57
68
  import { compendiumUuid, makeId, packForType } from "./ids.mjs";
69
+ // The record accessors only — see `engine/index-records.mjs` for why they live
70
+ // apart from the index that builds them (#243).
71
+ import { authoredFrontmatter, isNoteRecord, noteFile } from "./index-records.mjs";
58
72
  import { packRouter } from "./pack-router.mjs";
59
73
  import { foundryPackageId } from "./content-package.mjs";
60
74
  import { itemDocEntryId } from "./item-docs.mjs";
@@ -109,28 +123,6 @@ export function collectKnownActionNames(repoRoot) {
109
123
  return names;
110
124
  }
111
125
 
112
- /**
113
- * Strip the LevelDB keys from a document tree.
114
- *
115
- * An Adventure's members are inline source data in a `SetField`, not sublevel
116
- * documents, so they carry no `_key` — the CLI's hierarchy does not recurse
117
- * into an adventure, and Foundry's schema has no such field to hold it.
118
- *
119
- * @param {*} value - A document, array, or scalar.
120
- * @returns {*} The same shape with every `_key` removed.
121
- */
122
- function stripKeys(value) {
123
- if (Array.isArray(value)) return value.map(stripKeys);
124
- if (value && typeof value === "object") {
125
- return Object.fromEntries(
126
- Object.entries(value)
127
- .filter(([k]) => k !== "_key")
128
- .map(([k, v]) => [k, stripKeys(v)]),
129
- );
130
- }
131
- return value;
132
- }
133
-
134
126
  export class Scenes extends BasePackCompiler {
135
127
  static id = "scenes";
136
128
  static label = "map";
@@ -148,11 +140,12 @@ export class Scenes extends BasePackCompiler {
148
140
  constructor({
149
141
  contentBase,
150
142
  dest,
143
+ skipDirectories,
151
144
  companionDests = {},
152
145
  folderResolver = () => null,
153
146
  repoRoot = process.cwd(),
154
147
  }) {
155
- super({ contentBase, dest, folderResolver });
148
+ super({ contentBase, dest, folderResolver, skipDirectories });
156
149
  if (!companionDests.adventures) {
157
150
  throw new Error("Scenes compiler requires an `adventures` companion destination");
158
151
  }
@@ -179,15 +172,41 @@ export class Scenes extends BasePackCompiler {
179
172
  *
180
173
  * @returns {{maps: Array<object>, effectsByAddress: Map<string, object>}}
181
174
  */
175
+ /**
176
+ * The router this pass resolves pack names through.
177
+ *
178
+ * `generatePack` hands every pass the one router the compile resolved, and
179
+ * that is the answer whenever a real compile is running — a second router
180
+ * is a second answer to where a document landed, built from whichever
181
+ * configuration the working directory offers (#243). A compiler
182
+ * constructed directly, as a consumer's or a test's is, has none, and falls
183
+ * back exactly as `prepare` falls back to deriving its own corpus.
184
+ *
185
+ * @returns {object} The pack router.
186
+ */
187
+ get #packRouter() {
188
+ return this.router ?? packRouter();
189
+ }
190
+
182
191
  #collect() {
183
192
  const maps = [];
184
193
  const effectsByAddress = new Map();
185
- for (const { frontmatter: fm, body, absPath } of walkMarkdownTree(this.contentBase)) {
194
+ // The corpus this compile derived once, not a walk of this pass's own
195
+ // (#243) — and a note is opened only when this pass needs its *prose*,
196
+ // which for a map note means three files in `sohl` rather than 1,685.
197
+ for (const record of this.corpus.records) {
198
+ if (!isNoteRecord(record)) continue;
186
199
  // No retired-field test: this pass's own walk — the shared compile
187
200
  // loop — is where a note still declaring `package:` (#56) or
188
201
  // `draft:` (#69) is reported, once. Repeating either check here
189
202
  // would double the diagnostic or throw past it. A refused note is
190
203
  // indexed and then never compiled, so it reaches no document.
204
+ const fm = authoredFrontmatter(record);
205
+ const absPath = noteFile(this.contentBase, record);
206
+ // The id is the index's, derived against the configuration this
207
+ // build resolved (#270, #243) — it was derived here through
208
+ // `resolveNoteId(fm)` with no package, which falls back to the
209
+ // ambient one. What remains unset is a file with no address.
191
210
  if (!fm || !fm.id) continue;
192
211
  if (fm.shortcode && Array.isArray(fm.effects) && fm.effects.length) {
193
212
  effectsByAddress.set(`${fm.type}-${fm.shortcode}`, {
@@ -196,12 +215,17 @@ export class Scenes extends BasePackCompiler {
196
215
  // Where the owning item landed, so a region behaviour's
197
216
  // effect reference addresses the right pack when a
198
217
  // repository ships several of one type (#1566).
199
- pack: packRouter().resolveOrNull(fm, packForType(fm.type).docType),
218
+ // This compile's router, not a freshly built one: a
219
+ // second router is a second answer to where the document
220
+ // landed, resolved from the working directory (#243).
221
+ pack: this.#packRouter.resolveOrNull(fm, packForType(fm.type).docType),
200
222
  effects: fm.effects,
201
223
  });
202
224
  }
203
225
  if (!isMapType(fm.type)) continue;
204
- maps.push({ fm, body, absPath });
226
+ // Read here, and only here: the index carries no note body, and a
227
+ // map's prose is what this pass compiles into its Scene.
228
+ maps.push({ fm, body: parseMarkdownFile(absPath).body, absPath });
205
229
  }
206
230
  return { maps, effectsByAddress };
207
231
  }
@@ -340,8 +364,8 @@ export class Scenes extends BasePackCompiler {
340
364
  */
341
365
  #pageIds(markdown, entryId, name) {
342
366
  const pageIds = new Map();
343
- splitPages(markdown, name).forEach((page, index) => {
344
- const id = journalPageId(entryId, page, index);
367
+ splitPages(markdown, name).forEach((page) => {
368
+ const id = journalPageId(entryId, page);
345
369
  if (page.anchorSlug) pageIds.set(page.anchorSlug, id);
346
370
  const slug = slugify(page.name);
347
371
  if (slug && !pageIds.has(slug)) pageIds.set(slug, id);
@@ -384,7 +408,8 @@ export class Scenes extends BasePackCompiler {
384
408
  // shared `docEntryTypes` arrangement (#1514) — so neither
385
409
  // pass has to read the other's output.
386
410
  const entryId = hasBody ? itemDocEntryId(fm.id) : undefined;
387
- const folder = this.folderResolver(sohlField(fm, "folder", null));
411
+ const { value: authoredFolder, isAddress: folderIsAddress } = folderField(fm);
412
+ const folder = this.folderResolver(authoredFolder, { isAddress: folderIsAddress });
388
413
  // The retired spelling of the background art, reported where an author
389
414
  // meets it soonest — every consumer runs the compile, and not every
390
415
  // one runs the lint (#142). Located by reading the note back, which is
@@ -406,7 +431,8 @@ export class Scenes extends BasePackCompiler {
406
431
  // A map note's prose is a derived JournalEntry: it lands in the
407
432
  // default JournalEntry pack, not in whichever Scene pack the map
408
433
  // itself was routed to (#1566).
409
- journalPack: packRouter().defaultOf("JournalEntry"),
434
+ // This compile's router, as everywhere else in this pass (#243).
435
+ journalPack: this.#packRouter.defaultOf("JournalEntry"),
410
436
  pageIds: hasBody ? this.#pageIds(markdown, entryId, name) : new Map(),
411
437
  knownActions: this.knownActions,
412
438
  warnings,
@@ -431,7 +457,13 @@ export class Scenes extends BasePackCompiler {
431
457
  name,
432
458
  markdown,
433
459
  leadName: name,
434
- folder: sohlField(fm, "folder", null),
460
+ // As in the journals pass: an id crosses packs verbatim,
461
+ // an address resolves in the pack that emits it — which is
462
+ // what makes the folder materialise there too (#257).
463
+ folder:
464
+ folderIsAddress ?
465
+ this.folderResolver(authoredFolder, { isAddress: true })
466
+ : authoredFolder,
435
467
  flags: fm.flags,
436
468
  })
437
469
  : null;
@@ -448,8 +480,8 @@ export class Scenes extends BasePackCompiler {
448
480
  });
449
481
  }
450
482
  const place = this.places.get(placeKey);
451
- place.scenes.push(stripKeys(scene));
452
- if (journal) place.journal.push(stripKeys(journal));
483
+ place.scenes.push(stripAdventureKeys(scene));
484
+ if (journal) place.journal.push(stripAdventureKeys(journal));
453
485
  if (Object.keys(fm.sohl?.locations ?? {}).length) {
454
486
  place.pinned = true;
455
487
  }