@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
@@ -43,9 +43,9 @@
43
43
  * @module
44
44
  */
45
45
 
46
- import { resolveFieldValue, setPath } from "./system-block.mjs";
46
+ import { legacyKeyOf, resolveFieldValue, setPath } from "./system-block.mjs";
47
47
 
48
- export { setPath };
48
+ export { legacyKeyOf, setPath };
49
49
 
50
50
  /**
51
51
  * @typedef {object} FieldSpec
@@ -62,9 +62,28 @@ export { setPath };
62
62
  * to share a name. They constantly do not — one shared `data.portrait` feeds
63
63
  * `sohl.system.portrait` *and* `hm3.system.bioImage` — so the source is
64
64
  * declared rather than matched by spelling (#58). The in-block position is
65
- * still read, second, until #126 moves the corpus off it.
65
+ * still read, second, until #126 moves the corpus off it — keyed on
66
+ * `legacyKey` where the two spellings differ.
66
67
  *
67
68
  * Absent means the value is not authored at all — see `value`.
69
+ * @property {string} [legacyKey] - **The key this field is authored at inside
70
+ * the system block** — the second position of the resolution order — when
71
+ * that is not `name`. Absent, the position is keyed on `name`.
72
+ *
73
+ * The two were one property until #305, which held only while a field's
74
+ * shared source and its in-block key were the same word. `data:` (#128) ended
75
+ * that: a shared source is a path into a container, so `data.species` and
76
+ * `species` name two different places and no single value reached both.
77
+ * `name: "species"` could not see `data.species`; `name: "data.species"`
78
+ * could not see `hm3.species`; and each yielded the field's **default**
79
+ * wherever only the other position was authored, with the note compiling and
80
+ * the value simply gone.
81
+ *
82
+ * Declaring both restores the shape every other retirement in this package
83
+ * uses — read both spellings, let the current one win, and *report* the
84
+ * retiring one — so a field can move into `data:` while the corpus catches
85
+ * up, instead of on a flag day across four repositories. A declaration that
86
+ * names one is mid-sweep by construction; see {@link readsLegacyKey}.
68
87
  * @property {string} [topLevelMeans] - **What the note's top-level key of this
69
88
  * name means instead** — declared only where it means something else, and
70
89
  * stating it removes the shared top-level position from this field's
@@ -187,6 +206,28 @@ export const BLANK_IS_DEFAULT = Object.freeze({
187
206
  /* Applying a declaration */
188
207
  /* --------------------------------------------------------------------- */
189
208
 
209
+ /**
210
+ * Whether a resolution read a field from the position it is being swept off.
211
+ *
212
+ * The sweep's progress signal, in one predicate so the compile-time report and
213
+ * the frontmatter lint cannot disagree about what counts — the role
214
+ * {@link module:engine/retired-fields.declaresRetiredAlias} plays for a renamed
215
+ * field.
216
+ *
217
+ * **Only for a field that declares a `legacyKey`.** Every other field's
218
+ * in-block position is simply where it lives; reporting those would put a
219
+ * finding on every field of every note in every tree, which is #126's corpus
220
+ * migration rather than a signal anyone could act on.
221
+ *
222
+ * @param {FieldSpec} field - The declaration.
223
+ * @param {import("./system-block.mjs").FieldSource} from - Where
224
+ * {@link resolveFieldValue} said the value came from.
225
+ * @returns {boolean} True when the value came from the retiring position.
226
+ */
227
+ export function readsLegacyKey(field, from) {
228
+ return field?.legacyKey !== undefined && from === "block";
229
+ }
230
+
190
231
  /**
191
232
  * Read one declared field out of a note's frontmatter.
192
233
  *
@@ -203,11 +244,17 @@ export const BLANK_IS_DEFAULT = Object.freeze({
203
244
  * @param {string} [options.block="sohl"] - Which system's block to resolve
204
245
  * against. The default is the one block every existing tree authors; a
205
246
  * second system passes its own.
247
+ * @param {(field: FieldSpec) => void} [options.onLegacyKey] - Called with each
248
+ * field read from the position it is being swept off (#305). A callback
249
+ * rather than a returned list because the caller is a compiler, which already
250
+ * knows the note and how to locate a key in it; this module knows neither and
251
+ * would have to invent a finding shape to say so.
206
252
  * @returns {any} The value to emit.
207
253
  */
208
- export function readField(field, fm, { block = "sohl" } = {}) {
254
+ export function readField(field, fm, { block = "sohl", onLegacyKey } = {}) {
209
255
  const { value, from } = resolveFieldValue(field, fm, { block });
210
256
  if (from === "value") return value;
257
+ if (onLegacyKey && readsLegacyKey(field, from)) onLegacyKey(field);
211
258
  return field.read ? field.read(value, { fm, field }) : value;
212
259
  }
213
260
 
@@ -219,13 +266,16 @@ export function readField(field, fm, { block = "sohl" } = {}) {
219
266
  * @param {string} [options.block="sohl"] - Which system's block the builder
220
267
  * reads. One declaration compiles against any block, which is what lets two
221
268
  * systems declare the same shared source and different destinations.
269
+ * @param {(field: FieldSpec) => void} [options.onLegacyKey] - Passed through to
270
+ * {@link readField}: called with each field the note authored at the position
271
+ * it is being swept off (#305).
222
272
  * @returns {(fm: object) => object} A `system`-block builder.
223
273
  */
224
- export function buildFromFields(fields, { block = "sohl" } = {}) {
274
+ export function buildFromFields(fields, { block = "sohl", onLegacyKey } = {}) {
225
275
  return function buildDeclaredSystem(fm) {
226
276
  const out = {};
227
277
  for (const field of fields) {
228
- setPath(out, field.to, readField(field, fm, { block }));
278
+ setPath(out, field.to, readField(field, fm, { block, onLegacyKey }));
229
279
  }
230
280
  return out;
231
281
  };
@@ -0,0 +1,470 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * Folders, as notes.
16
+ *
17
+ * A `Folder` is a real Foundry document, and it was the last kind this package
18
+ * compiled from bespoke configuration — `*-folders.yaml`, five files per tree —
19
+ * rather than from a note. That was the one hole in the rule #243 establishes,
20
+ * *the compiler follows the index*: a pass cannot follow the index for things
21
+ * the index does not contain (#256).
22
+ *
23
+ * Three things follow from a folder being a note, and each is a defect that
24
+ * becomes unrepresentable rather than a tidiness win:
25
+ *
26
+ * 1. **`parent` is an address**, resolved and checked like every other
27
+ * reference. A dangling parent stops being a special-cased
28
+ * `Unknown folder id` and becomes an ordinary dead-address finding.
29
+ * 2. **Where a folder materialises is derived from what references it** (#257).
30
+ * A documentation journal is filed beside the item it describes, which used
31
+ * to mean passing the *items* pack's folder id into the *journals* pack —
32
+ * verbatim, validated nowhere, and correct only where the two folder files
33
+ * happened to mirror each other. They did in one tree of three. With one
34
+ * folder note and one address there is no second file to disagree with the
35
+ * first, so a pack cannot fail to declare a folder something in it points at.
36
+ * 3. **The Foundry id is derived from the address** (#258), the way a
37
+ * `JournalEntryPage` id is already hashed from its anchor — with an authored
38
+ * `id` still winning, so a world already holding these folders keeps
39
+ * resolving them.
40
+ *
41
+ * A folder note is addressed `<package>-none-folder-<shortcode>`: **`none`**,
42
+ * because a `Folder` is a core Foundry document like a `JournalEntry` or a
43
+ * `Scene`, not a system's.
44
+ *
45
+ * **A folder note carries no prose.** It is structure, not content, so it wants
46
+ * no documentation journal and takes no part in `docEntryTypes` — the one
47
+ * decision #256 left open, settled the way it recommended.
48
+ *
49
+ * @module
50
+ */
51
+
52
+ import path from "node:path";
53
+
54
+ import log from "loglevel";
55
+
56
+ import { NO_SYSTEM, canonicalKey } from "./content-address.mjs";
57
+ import { isAddressSegment } from "./address-charset.mjs";
58
+ import { makeId } from "./ids.mjs";
59
+
60
+ /**
61
+ * The note type a folder is authored as.
62
+ *
63
+ * @type {string}
64
+ */
65
+ export const FOLDER_TYPE = "folder";
66
+
67
+ /**
68
+ * The id namespace a derived folder id is hashed under.
69
+ *
70
+ * Distinct from every other document's namespace so a folder and an item
71
+ * sharing a shortcode cannot derive the same id — the collision would be
72
+ * silent, since Foundry keys folders and documents in separate collections and
73
+ * neither would complain.
74
+ *
75
+ * @type {string}
76
+ */
77
+ export const FOLDER_ID_NAMESPACE = "folder";
78
+
79
+ /**
80
+ * One folder note, read from the tree.
81
+ *
82
+ * @typedef {object} FolderNote
83
+ * @property {string} shortcode - Its `(type, shortcode)` identity.
84
+ * @property {string} address - The canonical `<pkg>-none-folder-<shortcode>`.
85
+ * @property {string} name - The display name.
86
+ * @property {string|null} color - CSS hex, or `null`.
87
+ * @property {Record<string, string|null>} parent - The parent's authored
88
+ * address per pack, keyed by pack name with {@link DEFAULT_PARENT} for the
89
+ * unstated case. A folder's identity is one thing; its hierarchy is per-pack.
90
+ * @property {string} id - The Foundry id: authored, or derived from `address`.
91
+ * @property {boolean} derivedId - Whether `id` was derived rather than authored.
92
+ * @property {string} absPath - The file it was read from, for diagnostics.
93
+ */
94
+
95
+ /**
96
+ * The key a per-pack `parent` map uses for "everywhere else".
97
+ *
98
+ * Spelled out rather than left as the absence of a key, so a map that states
99
+ * only exceptions still reads as a complete answer.
100
+ *
101
+ * @type {string}
102
+ */
103
+ export const DEFAULT_PARENT = "default";
104
+
105
+ /**
106
+ * Read one folder note's fields out of its frontmatter.
107
+ *
108
+ * `parent` and `color` are `data:` properties, which is where the
109
+ * specification's `### type: folder` table puts them — the closed container,
110
+ * so a misspelled `colour` is a finding rather than a theme parameter. They are
111
+ * accepted at the top level too, because that is where #256's own example wrote
112
+ * them and an author following the issue rather than the specification should
113
+ * get a folder, not a silent default.
114
+ *
115
+ * **`parent` may be a map keyed by pack**, because a folder's *hierarchy* is
116
+ * per-pack even though its identity is not. Both large trees rely on that
117
+ * deliberately: this repository files the three item roots one level deeper in
118
+ * the journals pack (under `Rules/Descriptions`, beside `Rules/Combat`), and
119
+ * `sohl-thalorna` groups the items pack by document kind and the journals pack
120
+ * by setting geography — 46 of its 75 shared folders sit under a different
121
+ * parent in each. A single scalar cannot express either, and flattening to one
122
+ * hierarchy would silently reorganise both compendiums.
123
+ *
124
+ * A scalar stays the everyday spelling, and is exactly `{ default: value }`.
125
+ *
126
+ * @param {object} fm - Parsed frontmatter.
127
+ * @returns {{parent: Record<string, string|null>, color: string|null}} The
128
+ * parent by pack — always a map, with {@link DEFAULT_PARENT} for the
129
+ * unstated case — and the colour.
130
+ */
131
+ function folderFields(fm) {
132
+ const data = fm?.data && typeof fm.data === "object" ? fm.data : {};
133
+ const read = (key) => data[key] ?? fm?.[key];
134
+ const text = (value) => {
135
+ if (value == null) return null;
136
+ const trimmed = String(value).trim();
137
+ return trimmed === "" ? null : trimmed;
138
+ };
139
+
140
+ const authored = read("parent");
141
+ /** @type {Record<string, string|null>} */
142
+ const parent = {};
143
+ if (authored != null && typeof authored === "object" && !Array.isArray(authored)) {
144
+ // An explicit `~` under a pack key means "at the root *there*", which
145
+ // is a different statement from saying nothing — so the key is kept
146
+ // with a null value rather than dropped.
147
+ for (const [pack, value] of Object.entries(authored)) parent[pack] = text(value);
148
+ } else {
149
+ parent[DEFAULT_PARENT] = text(authored);
150
+ }
151
+
152
+ return { parent, color: text(read("color")) };
153
+ }
154
+
155
+ /**
156
+ * An authored `parent` with any wikilink brackets and label stripped.
157
+ *
158
+ * The specification types `parent` as a `WikiLink`, and a frontmatter link is
159
+ * written as a bare address — but `[[address]]` is what an author reaches for,
160
+ * and Obsidian wrote that form for years. Accepting both costs one regex and
161
+ * removes a failure whose message would have to explain the difference.
162
+ *
163
+ * @param {string|null} value - As authored.
164
+ * @returns {string|null} The bare address.
165
+ */
166
+ export function bareAddress(value) {
167
+ if (value == null) return null;
168
+ const text = String(value).trim();
169
+ if (!text) return null;
170
+ const unwrapped = text.replace(/^\[\[(.*)\]\]$/s, "$1");
171
+ // A label is presentation; the address is everything before the pipe.
172
+ const [target] = unwrapped.split("|");
173
+ return target.trim() || null;
174
+ }
175
+
176
+ /**
177
+ * The canonical address of a folder note in this package.
178
+ *
179
+ * @param {string} pkg - The content package.
180
+ * @param {string} shortcode - The folder's shortcode.
181
+ * @returns {string} `<pkg>-none-folder-<shortcode>`.
182
+ */
183
+ export function folderAddress(pkg, shortcode) {
184
+ return canonicalKey(pkg, NO_SYSTEM, FOLDER_TYPE, shortcode);
185
+ }
186
+
187
+ /**
188
+ * Collect every folder note in a content tree.
189
+ *
190
+ * The walk is the caller's to supply, so this stays testable without a tree on
191
+ * disk and so one build cannot disagree with another about what the corpus is
192
+ * (#243).
193
+ *
194
+ * @param {Iterable<{frontmatter: object|null, absPath: string}>} notes - As
195
+ * yielded by `walkMarkdownTree`.
196
+ * @param {string} pkg - The content package, for the canonical address.
197
+ * @returns {FolderNote[]} One record per folder note, in walk order.
198
+ * @throws {Error} When a folder note is unusable on its own terms — no
199
+ * shortcode, a shortcode that is not an address segment, or no name.
200
+ */
201
+ export function collectFolderNotes(notes, pkg) {
202
+ /** @type {FolderNote[]} */
203
+ const folders = [];
204
+ for (const { frontmatter: fm, absPath } of notes) {
205
+ if (!fm || String(fm.type ?? "").toLowerCase() !== FOLDER_TYPE) continue;
206
+
207
+ const shortcode = fm.shortcode == null ? "" : String(fm.shortcode).trim();
208
+ if (!shortcode) {
209
+ throw Object.assign(new Error(`folder note has no shortcode: ${absPath}`), {
210
+ absPath,
211
+ });
212
+ }
213
+ // The charset rule is load-bearing here rather than tidy: the address
214
+ // is parsed by counting separators, so a hyphenated shortcode would be
215
+ // read back as two segments and resolve to nothing, reporting nothing
216
+ // about why (#1397, #273).
217
+ if (!isAddressSegment(shortcode)) {
218
+ throw Object.assign(
219
+ new Error(
220
+ `folder shortcode "${shortcode}" is not strictly ` +
221
+ `alphanumeric, so its address would not parse: ${absPath}`,
222
+ ),
223
+ { absPath },
224
+ );
225
+ }
226
+
227
+ const name = fm.name?.full ?? path.basename(absPath, ".md").replace(/_/g, " ");
228
+ if (!name) {
229
+ throw Object.assign(new Error(`folder note "${shortcode}" has no name`), { absPath });
230
+ }
231
+
232
+ const { parent, color } = folderFields(fm);
233
+ const address = folderAddress(pkg, shortcode);
234
+ const authoredId = fm.id == null ? "" : String(fm.id).trim();
235
+
236
+ folders.push({
237
+ shortcode,
238
+ address,
239
+ name: String(name),
240
+ color,
241
+ parent: Object.fromEntries(
242
+ Object.entries(parent).map(([pack, value]) => [pack, bareAddress(value)]),
243
+ ),
244
+ // An authored id is kept, and a folder without one derives a
245
+ // stable one from its address (#258). Keeping the authored id is
246
+ // what makes this a build change rather than a world migration: a
247
+ // world already holding these folders goes on resolving them.
248
+ id: authoredId || makeId(FOLDER_ID_NAMESPACE, address),
249
+ derivedId: !authoredId,
250
+ absPath,
251
+ });
252
+ }
253
+ return folders;
254
+ }
255
+
256
+ /**
257
+ * Index folder notes by every form an author may address one by, and check the
258
+ * invariants that make the index sound.
259
+ *
260
+ * Three keys per folder, and no more: the canonical address, the
261
+ * `folder-<shortcode>` short form, and the bare shortcode. They are the
262
+ * suffixes of the canonical address the grammar admits (#273) — a `packFolder`
263
+ * or `parent` field supplies the type itself, so a bare shortcode is a complete
264
+ * address there.
265
+ *
266
+ * @param {FolderNote[]} folders - From {@link collectFolderNotes}.
267
+ * @returns {{byKey: Map<string, FolderNote>, folders: FolderNote[],
268
+ * resolve: (value: string) => FolderNote, ancestorsOf: (folder: FolderNote)
269
+ * => FolderNote[], parentOf: (folder: FolderNote) => FolderNote|null}}
270
+ * @throws {Error} On a duplicate shortcode, a duplicate id, a dead `parent`,
271
+ * or a parent cycle.
272
+ */
273
+ export function buildFolderNoteIndex(folders) {
274
+ /** @type {Map<string, FolderNote>} */
275
+ const byKey = new Map();
276
+ /** @type {Map<string, FolderNote>} */
277
+ const byShortcode = new Map();
278
+ /** @type {Map<string, FolderNote>} */
279
+ const byId = new Map();
280
+
281
+ for (const folder of folders) {
282
+ const key = folder.shortcode.toLowerCase();
283
+ const clash = byShortcode.get(key);
284
+ if (clash) {
285
+ throw Object.assign(
286
+ new Error(
287
+ `two folder notes share the shortcode "${folder.shortcode}" ` +
288
+ `— ${clash.absPath} and ${folder.absPath}`,
289
+ ),
290
+ { absPath: folder.absPath },
291
+ );
292
+ }
293
+ byShortcode.set(key, folder);
294
+
295
+ // A collision here is a build error rather than a last-write-wins: two
296
+ // folders with one id are one folder in Foundry, and the documents
297
+ // filed in the loser would land somewhere their author never named.
298
+ const idClash = byId.get(folder.id);
299
+ if (idClash) {
300
+ const how =
301
+ folder.derivedId && idClash.derivedId ?
302
+ "both derived from their addresses"
303
+ : "one of them authored";
304
+ throw Object.assign(
305
+ new Error(
306
+ `folder id "${folder.id}" is claimed twice (${how}) — ` +
307
+ `"${idClash.shortcode}" (${idClash.absPath}) and ` +
308
+ `"${folder.shortcode}" (${folder.absPath})`,
309
+ ),
310
+ { absPath: folder.absPath },
311
+ );
312
+ }
313
+ byId.set(folder.id, folder);
314
+
315
+ byKey.set(folder.address.toLowerCase(), folder);
316
+ byKey.set(`${FOLDER_TYPE}-${folder.shortcode}`.toLowerCase(), folder);
317
+ byKey.set(key, folder);
318
+ }
319
+
320
+ /**
321
+ * The folder an address names.
322
+ *
323
+ * @param {string} value - A folder address, in any admitted form.
324
+ * @returns {FolderNote} The folder.
325
+ * @throws {Error} When nothing answers to it.
326
+ */
327
+ function resolve(value) {
328
+ const address = bareAddress(value);
329
+ if (!address) {
330
+ throw new Error("a folder reference is blank");
331
+ }
332
+ const hit = byKey.get(address.toLowerCase());
333
+ if (!hit) {
334
+ const known = [...byShortcode.values()].map((f) => f.shortcode).sort();
335
+ throw new Error(
336
+ `no folder note is addressed "${address}" — this package ` +
337
+ `declares ${known.length} folder(s)` +
338
+ (known.length ? `: ${known.join(", ")}` : ""),
339
+ );
340
+ }
341
+ return hit;
342
+ }
343
+
344
+ /**
345
+ * The parent a folder has **in one pack**, or `null` at the root there.
346
+ *
347
+ * A folder's identity is one thing and its hierarchy is another: the same
348
+ * folder is filed under a different parent in the items pack and the
349
+ * journals pack throughout both large trees, deliberately. So every
350
+ * question about the chain is asked of a pack, and a folder note that
351
+ * states one scalar answers the same way for all of them.
352
+ *
353
+ * @param {FolderNote} folder - The folder.
354
+ * @param {string} [pack] - The pack being compiled.
355
+ * @returns {FolderNote|null} Its parent there.
356
+ */
357
+ function parentOf(folder, pack) {
358
+ const authored =
359
+ pack != null && Object.hasOwn(folder.parent, pack) ?
360
+ folder.parent[pack]
361
+ : folder.parent[DEFAULT_PARENT];
362
+ if (!authored) return null;
363
+ try {
364
+ return resolve(authored);
365
+ } catch (err) {
366
+ throw Object.assign(
367
+ new Error(
368
+ `folder "${folder.shortcode}" names a parent that ${err.message}` +
369
+ (pack != null ? ` (in pack "${pack}")` : ""),
370
+ ),
371
+ { absPath: folder.absPath },
372
+ );
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Every ancestor of a folder in one pack, nearest first.
378
+ *
379
+ * A folder cannot materialise without them: a `Folder` whose parent is
380
+ * absent from the pack is an orphan Foundry renders at the root, so the
381
+ * tree would be broken at the top rather than merely incomplete (#257).
382
+ *
383
+ * @param {FolderNote} folder - The folder.
384
+ * @param {string} [pack] - The pack being compiled.
385
+ * @returns {FolderNote[]} Its ancestors there.
386
+ * @throws {Error} On a parent cycle.
387
+ */
388
+ function ancestorsOf(folder, pack) {
389
+ /** @type {FolderNote[]} */
390
+ const chain = [];
391
+ const seen = new Set([folder.address]);
392
+ let current = parentOf(folder, pack);
393
+ while (current) {
394
+ if (seen.has(current.address)) {
395
+ throw Object.assign(
396
+ new Error(
397
+ `folder "${folder.shortcode}" sits in a parent cycle` +
398
+ (pack != null ? ` in pack "${pack}"` : "") +
399
+ `: ${[...seen, current.address].join(" → ")}`,
400
+ ),
401
+ { absPath: folder.absPath },
402
+ );
403
+ }
404
+ seen.add(current.address);
405
+ chain.push(current);
406
+ current = parentOf(current, pack);
407
+ }
408
+ return chain;
409
+ }
410
+
411
+ // Every parent is resolved and every chain walked once, here, so a dead or
412
+ // circular `parent` is reported when the index is built rather than when
413
+ // some note happens to reference the folder that carries it. A tree whose
414
+ // folders are all reachable but one is still a broken tree.
415
+ //
416
+ // Every *declared* pack is walked, not just the default: a chain that is
417
+ // sound by default and circular in the journals pack is still a broken
418
+ // tree, and nothing else would look at it until that pack compiled.
419
+ const declaredPacks = new Set();
420
+ for (const folder of folders) {
421
+ for (const pack of Object.keys(folder.parent)) {
422
+ if (pack !== DEFAULT_PARENT) declaredPacks.add(pack);
423
+ }
424
+ }
425
+ for (const folder of folders) {
426
+ ancestorsOf(folder);
427
+ for (const pack of declaredPacks) ancestorsOf(folder, pack);
428
+ }
429
+
430
+ log.debug(
431
+ `Folder notes: ${folders.length} folder(s) indexed` +
432
+ (declaredPacks.size ? `, ${declaredPacks.size} with a per-pack parent` : ""),
433
+ );
434
+ return { byKey, folders, resolve, ancestorsOf, parentOf };
435
+ }
436
+
437
+ /**
438
+ * The Foundry `Folder` document one folder note compiles to, for one pack.
439
+ *
440
+ * The same folder materialises in several packs — the items pack and the
441
+ * journals pack both hold it when both hold something filed in it — and each
442
+ * copy differs only in `type`, which is the document class the folder holds.
443
+ * The `_id` is shared deliberately: a documentation journal filed beside its
444
+ * item is the whole point, and two ids would put them in two folders that
445
+ * merely looked alike.
446
+ *
447
+ * @param {FolderNote} folder - The folder note.
448
+ * @param {FolderNote|null} parent - Its parent, already resolved.
449
+ * @param {string} documentType - `"Item"`, `"JournalEntry"`, …
450
+ * @param {object} stats - The `_stats` block every emitted document carries.
451
+ * @returns {object} The Folder document.
452
+ */
453
+ export function folderDocument(folder, parent, documentType, stats) {
454
+ return {
455
+ name: folder.name,
456
+ sorting: "a",
457
+ folder: parent ? parent.id : null,
458
+ type: documentType,
459
+ _id: folder.id,
460
+ sort: 0,
461
+ // `undefined`, not `null`, when a folder declares no colour: an absent
462
+ // key is what the YAML emitter produced and what Foundry reads as "no
463
+ // colour set". `JSON.stringify` drops the key entirely, so a swept tree
464
+ // emits the same bytes an unswept one does.
465
+ color: folder.color ?? undefined,
466
+ flags: {},
467
+ _stats: stats,
468
+ _key: `!folders!${folder.id}`,
469
+ };
470
+ }