@heroiclands/package-build 23.1.1 → 23.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (246) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/bin/content-commands.mjs +68 -149
  3. package/bin/package-build.mjs +56 -10
  4. package/ci/check-banners.mjs +42 -6
  5. package/ci/confirm-npm-version.mjs +135 -0
  6. package/ci/content-format-reference.mjs +14 -5
  7. package/config.mjs +1 -0
  8. package/container.mjs +185 -4
  9. package/content-config.mjs +333 -210
  10. package/deploy.mjs +116 -10
  11. package/docs/api.md +238 -246
  12. package/docs/authoring/assets.md +11 -5
  13. package/docs/authoring/frontmatter.md +1 -1
  14. package/docs/authoring/links-and-markup.md +87 -0
  15. package/docs/commands.md +138 -99
  16. package/docs/configuration.md +277 -125
  17. package/docs/content-format.md +2 -0
  18. package/docs/diagnostics.md +48 -13
  19. package/docs/guides/book.md +341 -21
  20. package/docs/guides/e2e.md +13 -2
  21. package/docs/guides/icons.md +14 -4
  22. package/docs/guides/module-project.md +18 -8
  23. package/docs/guides/release-and-deploy.md +32 -0
  24. package/docs/guides/site.md +5 -2
  25. package/docs/project-setup.md +30 -10
  26. package/docs/reference/format-details.md +373 -170
  27. package/docs/reference/note-types.md +128 -109
  28. package/docs/reference/theme-pages.md +31 -6
  29. package/e2e.mjs +140 -28
  30. package/engine/actor-compiler.mjs +22 -31
  31. package/engine/address-charset.mjs +4 -17
  32. package/engine/address-fields.mjs +1 -1
  33. package/engine/address-render.mjs +12 -1
  34. package/engine/address-values.mjs +6 -0
  35. package/engine/address.mjs +46 -33
  36. package/engine/art-fields.mjs +30 -5
  37. package/engine/art-slots.mjs +10 -1
  38. package/engine/asset-bindings.mjs +12 -1
  39. package/engine/base-compiler.mjs +34 -65
  40. package/engine/being-age.mjs +2 -7
  41. package/engine/being-measurements.mjs +12 -1
  42. package/engine/calendar-artifacts.mjs +6 -0
  43. package/engine/calendar-choice.mjs +12 -1
  44. package/engine/calendar-format.mjs +12 -1
  45. package/engine/calendar-human.mjs +12 -1
  46. package/engine/compile-corpus.mjs +3 -1
  47. package/engine/content-address.mjs +9 -12
  48. package/engine/content-alerts.mjs +13 -1
  49. package/engine/content-blocks.mjs +14 -3
  50. package/engine/content-embeds.mjs +11 -3
  51. package/engine/content-figures.mjs +9 -0
  52. package/engine/content-footnotes.mjs +12 -1
  53. package/engine/content-format-check.mjs +11 -262
  54. package/engine/content-format.yaml +5 -2
  55. package/engine/content-images.mjs +3 -0
  56. package/engine/content-index.mjs +443 -20
  57. package/engine/content-links.mjs +29 -35
  58. package/engine/content-lint.mjs +2 -3
  59. package/engine/content-poetry.mjs +12 -1
  60. package/engine/content-slug.mjs +2 -14
  61. package/engine/content-spans.mjs +13 -1
  62. package/engine/content-tables.mjs +12 -1
  63. package/engine/culture-choice.mjs +210 -13
  64. package/engine/data-keys.mjs +19 -16
  65. package/engine/date-conversion.mjs +12 -1
  66. package/engine/declaration-check.mjs +9 -0
  67. package/engine/derived-fields.mjs +6 -7
  68. package/engine/diagnostics.mjs +34 -4
  69. package/engine/docs-checks.mjs +9 -0
  70. package/engine/document-subtypes.mjs +47 -34
  71. package/engine/event-views.mjs +139 -14
  72. package/engine/exported-scene.mjs +92 -28
  73. package/engine/extension-attributes.mjs +12 -1
  74. package/engine/field-spec.mjs +11 -102
  75. package/engine/folder-notes.mjs +0 -64
  76. package/engine/foreign-catalog.mjs +2 -1
  77. package/engine/format-generated.mjs +9 -0
  78. package/engine/foundry-entries.mjs +10 -28
  79. package/engine/frontmatter-lint.mjs +83 -392
  80. package/engine/frontmatter.mjs +20 -70
  81. package/engine/generate.mjs +10 -15
  82. package/engine/heading-attributes.mjs +12 -1
  83. package/engine/helpers.mjs +41 -102
  84. package/engine/holdings.mjs +1 -1
  85. package/engine/homepage.mjs +3 -10
  86. package/engine/ids.mjs +1 -167
  87. package/engine/index-records.mjs +6 -4
  88. package/engine/index.mjs +0 -3
  89. package/engine/infobox-render.mjs +15 -3
  90. package/engine/infobox.mjs +13 -14
  91. package/engine/item-compiler.mjs +4 -10
  92. package/engine/item-docs.mjs +3 -5
  93. package/engine/item-registry.mjs +2 -10
  94. package/engine/journals.mjs +1 -5
  95. package/engine/literature-notes.mjs +4 -12
  96. package/engine/macros.mjs +1 -2
  97. package/engine/map-graphviz.mjs +12 -1
  98. package/engine/markdown-expressions.mjs +12 -1
  99. package/engine/markdownlint-run.mjs +567 -0
  100. package/engine/metadata-index.mjs +42 -12
  101. package/engine/note-addresses.mjs +143 -35
  102. package/engine/note-claims.mjs +14 -28
  103. package/engine/note-dates.mjs +3 -3
  104. package/engine/note-frontmatter.mjs +6 -0
  105. package/engine/note-ids.mjs +8 -14
  106. package/engine/note-package.mjs +8 -25
  107. package/engine/note-state.mjs +1 -3
  108. package/engine/note-vocabulary.mjs +148 -84
  109. package/engine/number-words.mjs +12 -1
  110. package/engine/pack-config.mjs +15 -25
  111. package/engine/pack-router.mjs +10 -16
  112. package/engine/pathnames.mjs +8 -22
  113. package/engine/pdf-build.mjs +773 -121
  114. package/engine/pdf-coverage.mjs +188 -0
  115. package/engine/pdf-index.mjs +54 -7
  116. package/engine/pdf-pages.mjs +247 -0
  117. package/engine/pdf-render.mjs +114 -4
  118. package/engine/pdf-toc.mjs +29 -32
  119. package/engine/project-init.mjs +46 -6
  120. package/engine/prose-config.mjs +1 -1
  121. package/engine/prose-lint.mjs +52 -88
  122. package/engine/readability-lint.mjs +9 -0
  123. package/engine/reckoning-markers.mjs +12 -1
  124. package/engine/reference-targets.mjs +7 -0
  125. package/engine/runtime-only-fields.mjs +5 -8
  126. package/engine/scene-levels.mjs +2 -2
  127. package/engine/scenes.mjs +1 -1
  128. package/engine/shortcode-references.mjs +7 -0
  129. package/engine/site-build.mjs +30 -16
  130. package/engine/site-index.mjs +10 -16
  131. package/engine/site-preview.mjs +6 -0
  132. package/engine/site-root.mjs +8 -1
  133. package/engine/social-tie-terms.mjs +12 -1
  134. package/engine/social-ties.mjs +3 -0
  135. package/engine/sql-tables.mjs +56 -20
  136. package/engine/standing-terms.mjs +11 -1
  137. package/engine/standings.mjs +11 -27
  138. package/engine/system-block.mjs +39 -141
  139. package/engine/system-vocabulary.mjs +6 -0
  140. package/engine/walk-files.mjs +69 -0
  141. package/engine/web-wikilinks.mjs +23 -3
  142. package/engine/wikilink-syntax.mjs +32 -21
  143. package/engine/wikilinks.mjs +27 -16
  144. package/githooks/attribution.mjs +8 -0
  145. package/githooks/commit-message.mjs +9 -0
  146. package/hm3/actors.mjs +9 -48
  147. package/hm3/default-item-art.mjs +9 -9
  148. package/hm3/item-fields.mjs +2 -9
  149. package/hm3/template-priority.mjs +6 -7
  150. package/hugo-theme/data/banner-kinds.yaml +78 -0
  151. package/hugo-theme/data/banners.yaml +2 -3
  152. package/hugo-theme/layouts/404.html +3 -3
  153. package/hugo-theme/layouts/partials/banner-url.html +31 -7
  154. package/hugo-theme/layouts/partials/hero-banner.html +13 -14
  155. package/hugo-theme/layouts/partials/related.html +1 -1
  156. package/hugo-theme/layouts/partials/warn-missing-banner.html +1 -1
  157. package/hugo-theme/static/css/style.css +1 -1
  158. package/manifest.mjs +11 -4
  159. package/markdownlint-config.mjs +1 -1
  160. package/package.json +6 -3
  161. package/release.mjs +21 -14
  162. package/sohl/actors.mjs +16 -27
  163. package/sohl/default-item-art.mjs +3 -3
  164. package/sohl/document-subtypes.mjs +1 -16
  165. package/sohl/infobox.mjs +2 -3
  166. package/sohl/item-builders.mjs +5 -8
  167. package/sohl/item-fields.mjs +5 -35
  168. package/sohl/items.mjs +0 -4
  169. package/sohl/kb-passes.mjs +3 -3
  170. package/types/container.d.mts +116 -5
  171. package/types/content-config.d.mts +83 -35
  172. package/types/deploy.d.mts +42 -6
  173. package/types/e2e.d.mts +35 -3
  174. package/types/engine/address-charset.d.mts +4 -17
  175. package/types/engine/address.d.mts +17 -8
  176. package/types/engine/art-fields.d.mts +7 -4
  177. package/types/engine/art-slots.d.mts +6 -0
  178. package/types/engine/base-compiler.d.mts +15 -17
  179. package/types/engine/content-address.d.mts +9 -12
  180. package/types/engine/content-embeds.d.mts +4 -2
  181. package/types/engine/content-format-check.d.mts +0 -64
  182. package/types/engine/content-index.d.mts +82 -3
  183. package/types/engine/content-links.d.mts +2 -4
  184. package/types/engine/culture-choice.d.mts +51 -8
  185. package/types/engine/data-keys.d.mts +2 -2
  186. package/types/engine/derived-fields.d.mts +1 -2
  187. package/types/engine/diagnostics.d.mts +27 -4
  188. package/types/engine/document-subtypes.d.mts +12 -31
  189. package/types/engine/event-views.d.mts +14 -2
  190. package/types/engine/exported-scene.d.mts +3 -2
  191. package/types/engine/field-spec.d.mts +13 -122
  192. package/types/engine/folder-notes.d.mts +0 -39
  193. package/types/engine/foreign-catalog.d.mts +2 -1
  194. package/types/engine/foundry-entries.d.mts +4 -5
  195. package/types/engine/frontmatter-lint.d.mts +2 -2
  196. package/types/engine/frontmatter.d.mts +19 -45
  197. package/types/engine/generate.d.mts +1 -1
  198. package/types/engine/helpers.d.mts +25 -42
  199. package/types/engine/homepage.d.mts +3 -10
  200. package/types/engine/ids.d.mts +0 -114
  201. package/types/engine/index-records.d.mts +6 -4
  202. package/types/engine/index.d.mts +0 -1
  203. package/types/engine/infobox-render.d.mts +7 -1
  204. package/types/engine/infobox.d.mts +3 -2
  205. package/types/engine/item-docs.d.mts +1 -1
  206. package/types/engine/item-registry.d.mts +2 -4
  207. package/types/engine/literature-notes.d.mts +1 -1
  208. package/types/engine/markdownlint-run.d.mts +40 -0
  209. package/types/engine/metadata-index.d.mts +9 -1
  210. package/types/engine/note-addresses.d.mts +27 -2
  211. package/types/engine/note-claims.d.mts +3 -5
  212. package/types/engine/note-ids.d.mts +2 -4
  213. package/types/engine/note-package.d.mts +1 -1
  214. package/types/engine/note-state.d.mts +1 -3
  215. package/types/engine/note-vocabulary.d.mts +28 -21
  216. package/types/engine/pack-config.d.mts +2 -6
  217. package/types/engine/pack-router.d.mts +4 -4
  218. package/types/engine/pathnames.d.mts +3 -3
  219. package/types/engine/pdf-build.d.mts +98 -12
  220. package/types/engine/pdf-coverage.d.mts +60 -0
  221. package/types/engine/pdf-index.d.mts +34 -5
  222. package/types/engine/pdf-pages.d.mts +143 -0
  223. package/types/engine/pdf-render.d.mts +45 -1
  224. package/types/engine/pdf-toc.d.mts +12 -14
  225. package/types/engine/prose-config.d.mts +1 -1
  226. package/types/engine/prose-lint.d.mts +11 -10
  227. package/types/engine/runtime-only-fields.d.mts +2 -2
  228. package/types/engine/site-build.d.mts +9 -5
  229. package/types/engine/site-index.d.mts +4 -5
  230. package/types/engine/site-root.d.mts +1 -1
  231. package/types/engine/sql-tables.d.mts +31 -2
  232. package/types/engine/standing-terms.d.mts +2 -1
  233. package/types/engine/standings.d.mts +7 -9
  234. package/types/engine/system-block.d.mts +10 -44
  235. package/types/engine/walk-files.d.mts +15 -0
  236. package/types/engine/wikilink-syntax.d.mts +18 -19
  237. package/types/hm3/actors.d.mts +1 -2
  238. package/types/hm3/item-fields.d.mts +2 -4
  239. package/types/hm3/template-priority.d.mts +6 -7
  240. package/types/manifest.d.mts +3 -2
  241. package/types/release.d.mts +8 -7
  242. package/types/sohl/actors.d.mts +2 -1
  243. package/types/sohl/document-subtypes.d.mts +1 -2
  244. package/types/sohl/item-fields.d.mts +3 -7
  245. package/engine/retired-fields.mjs +0 -631
  246. package/types/engine/retired-fields.d.mts +0 -354
@@ -2,11 +2,17 @@
2
2
  * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
3
  * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
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
+ *
5
11
  * SPDX-License-Identifier: GPL-3.0-or-later
6
12
  */
7
13
 
8
14
  import { declaredSystems, systemBlocksFor } from "./system-vocabulary.mjs";
9
- import { RETIRED_FIELD_ALIASES } from "./retired-fields.mjs";
15
+ import { inBlockKey } from "./system-block.mjs";
10
16
  import {
11
17
  isAddressTuple,
12
18
  parseAddress,
@@ -23,6 +29,7 @@ import { SHIPPED_SYSTEMS } from "./subtype-registry.mjs";
23
29
  import { EVENT_ADDRESS_POSITIONS } from "./note-events.mjs";
24
30
 
25
31
  import { AddressEntries, AddressLink, readPublishedAddresses } from "./address-values.mjs";
32
+ import { formatDiagnostic } from "./diagnostics.mjs";
26
33
  export {
27
34
  AddressEntries,
28
35
  encodeAddresses,
@@ -72,12 +79,8 @@ export const STRUCTURED_ADDRESSES = Object.freeze([
72
79
  export function addressPositions(fm, context = {}) {
73
80
  const out = [];
74
81
  for (const field of dataFields(fm.type) ?? []) {
75
- // A field that admits both a list and a map declares both halves, and
76
- // which one a note wrote is not knowable from the declaration — so the
77
- // position says "either" and the conversion reads the value's shape.
78
82
  const shape =
79
83
  field.kind === "address" ? "value"
80
- : field.kind === "list-or-map" ? "keys-or-list"
81
84
  : field.keyKind === "address" ? "keys"
82
85
  : field.entryKind === "address" ? field.kind
83
86
  : undefined;
@@ -87,6 +90,7 @@ export function addressPositions(fm, context = {}) {
87
90
  shape,
88
91
  type: field.ref,
89
92
  accepts: field.accepts,
93
+ ...(field.declines === undefined ? {} : { declines: field.declines }),
90
94
  ...(field.anchors ? { anchors: field.anchors } : {}),
91
95
  ...(ASSET_TYPE_NAMES.has(field.ref) || field.ref === "folder" ?
92
96
  { system: "none" }
@@ -120,7 +124,6 @@ export function addressPositions(fm, context = {}) {
120
124
  path: [system, "items", "*", "model"],
121
125
  shape: "value",
122
126
  system,
123
- legacyShortcodeCase: true,
124
127
  });
125
128
  for (const slot of ART_SLOTS)
126
129
  out.push({
@@ -145,7 +148,7 @@ export function addressPositions(fm, context = {}) {
145
148
  system,
146
149
  types: new Set([...Object.keys(NOTE_VOCABULARY), ...ASSET_TYPE_NAMES]),
147
150
  },
148
- { declared: true, legacyShortcodeCase: true },
151
+ { declared: true },
149
152
  );
150
153
  const type = model && !model.reason ? model.type : entry?.type;
151
154
  for (const field of spec?.fields?.[type] ?? []) {
@@ -182,30 +185,90 @@ export function addressPositions(fm, context = {}) {
182
185
  : system,
183
186
  };
184
187
  out.push({ ...position, path: [system, "system", ...field.to.split(".")] });
185
- out.push({
186
- ...position,
187
- path: [system, ...(field.legacyKey ?? field.name).split(".")],
188
- });
188
+ const inBlock = inBlockKey(field);
189
+ if (inBlock !== undefined) {
190
+ out.push({ ...position, path: [system, ...inBlock.split(".")] });
191
+ }
189
192
  if (field.name?.startsWith("data.") && field.topLevelMeans === undefined)
190
193
  out.push({ ...position, path: field.name.slice(5).split(".") });
191
194
  }
192
195
  }
193
- for (const position of [...out]) {
194
- const last = position.path.at(-1);
195
- const retired = RETIRED_FIELD_ALIASES[last];
196
- if (retired) out.push({ ...position, path: [...position.path.slice(0, -1), retired] });
197
- }
198
196
  return out;
199
197
  }
200
198
 
199
+ /**
200
+ * The finding for a declared Address whose shortcode carries a capital.
201
+ *
202
+ * A shortcode is lowercase, and a declared Address is read as written, so the
203
+ * value is refused rather than folded. Where the lowercase spelling names
204
+ * something the context declares, the message names that shortcode.
205
+ *
206
+ * @param {import("./address.mjs").AddressProblem} problem - The parser's verdict.
207
+ * @param {Array<string|number>} path - The value's key path.
208
+ * @param {{shape?: string}} position - The Address position it sits in.
209
+ * @param {{declaresAddress?: (tuple: object) => boolean}} context - The
210
+ * decode context; `declaresAddress` answers whether a complete Address names
211
+ * a declared note or item.
212
+ * @returns {Error & {keyPath: Array<string|number>, addressKey: boolean}} The
213
+ * located error.
214
+ */
215
+ function shortcodeCaseError(problem, path, position, context) {
216
+ const declared =
217
+ context.declaresAddress?.(problem.lowercase) ?
218
+ `; the declared shortcode is \`${problem.lowercase.shortcode}\``
219
+ : "";
220
+ const error = new Error(
221
+ `\`${path.join(".")}\` shortcode \`${problem.shortcode}\` must be lowercase${declared}`,
222
+ );
223
+ error.keyPath = path;
224
+ error.addressKey = position.shape === "keys";
225
+ return /** @type {any} */ (error);
226
+ }
227
+
201
228
  /**
202
229
  * Decode declared properties at the note boundary. Raw mappings cannot impersonate tuples.
230
+ *
231
+ * A declared Address is read as written. A shortcode spelled with a capital is
232
+ * refused, and every such value in the note is collected before the decode
233
+ * throws, so each reference is reported at its own position: the thrown error
234
+ * describes the first, and its `findings` carry all of them.
235
+ *
203
236
  * @param {object} fm - Parsed YAML or an index record.
204
- * @param {object} context - The builder's package and known types.
237
+ * @param {object} context - The builder's package and known types, and
238
+ * optionally `declaresAddress`, which names the lowercase spelling of a
239
+ * refused shortcode when it is declared.
205
240
  * @returns {object} The same note carrying typed Address properties.
241
+ * @throws {Error} When a declared Address is refused; `keyPath` locates it, and
242
+ * `findings`, where present, lists every refused shortcode.
206
243
  */
207
244
  export function decodeNoteAddresses(fm, context) {
208
245
  if (!fm || typeof fm !== "object") return fm;
246
+ /** @type {Array<Error & {keyPath: Array<string|number>}>} */
247
+ const refused = [];
248
+ try {
249
+ decodePositions(fm, context, refused);
250
+ } catch (error) {
251
+ if (refused.length && error?.keyPath) error.findings = [...refused, error];
252
+ throw error;
253
+ }
254
+ if (refused.length) {
255
+ const [first] = refused;
256
+ /** @type {any} */ (first).findings = refused;
257
+ throw first;
258
+ }
259
+ return fm;
260
+ }
261
+
262
+ /**
263
+ * The body of {@link decodeNoteAddresses}: every Address position, decoded in
264
+ * place.
265
+ *
266
+ * @param {object} fm - The note.
267
+ * @param {object} context - The decode context.
268
+ * @param {Error[]} refused - Collects each refused shortcode.
269
+ * @returns {void}
270
+ */
271
+ function decodePositions(fm, context, refused) {
209
272
  const types = new Set([
210
273
  ...Object.keys(NOTE_VOCABULARY),
211
274
  ...ASSET_TYPE_NAMES,
@@ -223,11 +286,14 @@ export function decodeNoteAddresses(fm, context) {
223
286
  `\`${path.join(".")}\` ${description}: ${JSON.stringify(value)}`,
224
287
  );
225
288
  error.keyPath = path;
226
- error.addressKey = position.shape === "keys" || position.shape === "keys-or-list";
289
+ error.addressKey = position.shape === "keys";
227
290
  throw error;
228
291
  };
229
292
  const read = (value, path) => {
230
293
  if (value == null || value === "") return value;
294
+ // The word a position declines with is not an Address; it stays as
295
+ // written so every later stage can tell it from an image.
296
+ if (position.declines !== undefined && value === position.declines) return value;
231
297
  if (value instanceof AddressLink) {
232
298
  if (!position.anchors)
233
299
  fail(
@@ -248,10 +314,11 @@ export function decodeNoteAddresses(fm, context) {
248
314
  "takes no anchor — it names a whole note, so write the Address without `#…`",
249
315
  );
250
316
  const { address: written, anchor } = anchored ? splitAnchor(value) : { address: value };
251
- const tuple = parseAddress(written, defaults, {
252
- declared: true,
253
- legacyShortcodeCase: position.legacyShortcodeCase,
254
- });
317
+ const tuple = parseAddress(written, defaults, { declared: true });
318
+ if (tuple.reason === "shortcode-not-lowercase" && !position.lenient) {
319
+ refused.push(shortcodeCaseError(tuple, path, position, context));
320
+ return value;
321
+ }
255
322
  if (tuple.reason) fail(value, path, "is not an accepted Address");
256
323
  if (position.accepts && !acceptsType(tuple, position.accepts))
257
324
  fail(
@@ -269,15 +336,7 @@ export function decodeNoteAddresses(fm, context) {
269
336
  };
270
337
  const convert = (value, path) => {
271
338
  if (value == null || value === "") return value;
272
- // Whichever of the two shapes the note wrote, read as that one. An
273
- // empty list is the property editor's emptied map and converts to
274
- // nothing either way.
275
- const shape =
276
- position.shape === "keys-or-list" ?
277
- Array.isArray(value) && value.length ?
278
- "list"
279
- : "keys"
280
- : position.shape;
339
+ const shape = position.shape;
281
340
  if (shape === "keys") {
282
341
  if (value instanceof AddressEntries) return value;
283
342
  if (Array.isArray(value) && value.length === 0) return value;
@@ -338,7 +397,6 @@ export function decodeNoteAddresses(fm, context) {
338
397
  const decoded = visit(fm, position.path);
339
398
  Object.assign(fm, decoded);
340
399
  }
341
- return fm;
342
400
  }
343
401
 
344
402
  /**
@@ -378,11 +436,56 @@ export function noteAddressContext(config) {
378
436
  * {@link AddressLink} where it carries an anchor, before the declared
379
437
  * positions are read.
380
438
  *
439
+ * With a `source`, the record is a dependency's, read from its cached index.
440
+ * Every decode failure then leaves as a located finding that names the
441
+ * dependency, the cached file, the record, the field and the remedy, because
442
+ * the shape of a published index is the dependency's to correct and no note in
443
+ * the reading tree is at fault.
444
+ *
381
445
  * @param {object} record - The record.
382
446
  * @param {object} context - Builder defaults.
447
+ * @param {{package: string, file: string, line?: number}} [source] - The
448
+ * dependency and the cached index file the record was read from, with the
449
+ * record's 1-based line when known.
383
450
  * @returns {object} The typed record.
451
+ * @throws {Error} A located finding (`located: true`) when `source` is given
452
+ * and the record cannot be decoded; otherwise the decode error itself.
384
453
  */
385
- export function decodeIndexAddresses(record, context) {
454
+ export function decodeIndexAddresses(record, context, source) {
455
+ if (!source) return decodeIndexRecord(record, context);
456
+ try {
457
+ return decodeIndexRecord(record, context);
458
+ } catch (error) {
459
+ const field = error.keyPath?.join(".") ?? error.indexField;
460
+ const named = [record?.type, record?.shortcode].filter(Boolean).join(" ");
461
+ const detail = error.message.replace(/^Index /, "");
462
+ const error_ = new Error(
463
+ formatDiagnostic({
464
+ file: source.file,
465
+ ...(Number.isInteger(source.line) ? { line: source.line } : {}),
466
+ severity: "error",
467
+ message:
468
+ `${source.package} published an index this package-build does not read` +
469
+ (named ? ` (record ${named}` : " (record") +
470
+ (field ? `, field ${field}` : "") +
471
+ `): ${detail}. Take a release of ${source.package} built with this ` +
472
+ `package-build, then run \`package-build deps fetch\` again.`,
473
+ }),
474
+ { cause: error },
475
+ );
476
+ error_.located = true;
477
+ throw error_;
478
+ }
479
+ }
480
+
481
+ /**
482
+ * Decode one index record's Address properties.
483
+ *
484
+ * @param {object} record - The record.
485
+ * @param {object} context - Builder defaults.
486
+ * @returns {object} The typed record.
487
+ */
488
+ function decodeIndexRecord(record, context) {
386
489
  Object.assign(record, readPublishedAddresses(record));
387
490
  decodeNoteAddresses(record, context);
388
491
  const canonical = (value, keyPath) => {
@@ -402,8 +505,13 @@ export function decodeIndexAddresses(record, context) {
402
505
  if (record.address?.canonical != null)
403
506
  record.address.canonical = canonical(record.address.canonical, ["address", "canonical"]);
404
507
  for (const anchor of record.anchors ?? []) {
405
- if (anchor.link != null && !(anchor.link instanceof AddressLink))
406
- throw new Error(`Index anchor link is not an Address: ${JSON.stringify(anchor.link)}`);
508
+ if (anchor.link != null && !(anchor.link instanceof AddressLink)) {
509
+ const error = new Error(
510
+ `Index anchor link is not an Address: ${JSON.stringify(anchor.link)}`,
511
+ );
512
+ error.indexField = "anchors[].link";
513
+ throw error;
514
+ }
407
515
  }
408
516
  return record;
409
517
  }
@@ -87,12 +87,12 @@ import { assertSuppliedCorpus, parseMarkdownFile } from "./helpers.mjs";
87
87
  // The record accessors only: this module is imported by the content index, so
88
88
  // importing the index back would close a cycle.
89
89
  import { authoredFrontmatter, isNoteRecord, noteFile } from "./index-records.mjs";
90
- import { JOURNAL_TYPES, MAP_TYPES, PACK_BY_TYPE, RETIRED_TYPES, currentType } from "./ids.mjs";
90
+ import { JOURNAL_TYPES, MAP_TYPES, PACK_BY_TYPE } from "./ids.mjs";
91
91
  import { itemTypes } from "./item-registry.mjs";
92
92
  import { docEntryTypes } from "./item-docs.mjs";
93
93
  import { loadPackConfig } from "./pack-config.mjs";
94
94
  import { declaresNoPack } from "./pack-router.mjs";
95
- import { locateFrontmatterKey } from "./retired-fields.mjs";
95
+ import { locateFrontmatterKey } from "./diagnostics.mjs";
96
96
  import { noteTypesFor, subtypeRow } from "./document-subtypes.mjs";
97
97
  import {
98
98
  DEFAULT_DOCUMENT_SUBTYPES,
@@ -359,7 +359,7 @@ export const CLAIMABLE_DOCUMENT_TYPES = Object.freeze([
359
359
  * the answer is the type's full potential — every document such a note *could*
360
360
  * produce — which is what a caller asking about a type rather than a note wants.
361
361
  *
362
- * @param {string} type - The note's declared `type`, current spelling.
362
+ * @param {string} type - The note's declared `type`.
363
363
  * @param {ClaimSources} [sources] - What to answer from.
364
364
  * @param {object} [opts] - Options.
365
365
  * @param {(() => boolean)|boolean} [opts.hasProse] - Whether *this note* carries
@@ -497,18 +497,14 @@ function article(word) {
497
497
  * @returns {string} The message.
498
498
  */
499
499
  function configurationMessage(type, config, sources) {
500
- // Every table below is keyed by the current spelling of a note type; the
501
- // message quotes the authored one, which is what the reader has in front of
502
- // them.
503
- const current = currentType(type);
504
500
  const documents = mappedDocuments(sources.maps, type);
505
- if (!documents.length && sources.itemTypes.has(current)) documents.push("Item");
506
- if (!documents.length && PACK_BY_TYPE[current]) documents.push(PACK_BY_TYPE[current].docType);
501
+ if (!documents.length && sources.itemTypes.has(type)) documents.push("Item");
502
+ if (!documents.length && PACK_BY_TYPE[type]) documents.push(PACK_BY_TYPE[type].docType);
507
503
 
508
504
  const systems = mappingSystems(sources.maps, type);
509
505
  const configured = new Set((config.packs ?? []).map((pack) => pack.type));
510
506
  const packless = documents.filter((document) => !configured.has(document));
511
- const needsBuilder = documents.includes("Item") && !sources.itemTypes.has(current);
507
+ const needsBuilder = documents.includes("Item") && !sources.itemTypes.has(type);
512
508
 
513
509
  const into = documents.map((document) => `${article(document)} ${document}`).join(" or ");
514
510
  const becomes =
@@ -668,7 +664,7 @@ function packsServing(config, docType, system) {
668
664
  *
669
665
  * @param {object} fm - The note's frontmatter.
670
666
  * @param {string} absPath - The note's path, for the finding's position.
671
- * @param {string} type - The note's current type.
667
+ * @param {string} type - The note's type.
672
668
  * @param {object} config - The resolved build configuration.
673
669
  * @returns {Array<{file: string, line?: number, column?: number,
674
670
  * severity: "error", message: string, type: string}>} One finding per
@@ -708,12 +704,10 @@ function discardedSystemBlockFindings(fm, absPath, type, config) {
708
704
  * note's type and of a note's systems, and a note can fail either while passing
709
705
  * the other — see {@link discardedSystemBlockFindings}.
710
706
  *
711
- * Read-only: it walks the tree and reports, and writes nothing. Three kinds of
707
+ * Read-only: it walks the tree and reports, and writes nothing. Two kinds of
712
708
  * note are passed over, each for a stated reason rather than by omission — a
713
709
  * file with no frontmatter is not a note; a note with no `type:` is the
714
- * frontmatter linter's finding, which can say what a type is *for*; and a
715
- * retired type is answered by `assertTypeNotRetired` in `ids.mjs`, which names the
716
- * replacement.
710
+ * frontmatter linter's finding, which can say what a type is *for*.
717
711
  *
718
712
  * @param {object} [config] - The resolved build configuration. Defaults to this
719
713
  * repository's.
@@ -755,24 +749,16 @@ export function unclaimedNoteFindings(config = loadPackConfig(), sources, { reco
755
749
  if (!type) continue;
756
750
  if (NEVER_PACKED_TYPES.has(type)) continue;
757
751
  if (DERIVED_PACKED_TYPES.has(type)) continue;
758
- if (RETIRED_TYPES[type]) continue;
759
- // A **renamed** spelling is a live type, not an unknown one: it resolves
760
- // to the same row, the same registry entry and the same pack. So the
761
- // claim is asked of the current spelling while the finding quotes the
762
- // authored one. The rename itself is reported by the frontmatter
763
- // lint, which can say what to write instead.
764
- const current = currentType(type);
765
-
766
752
  // Asked first, and asked whatever the type's claim turns out to be: a
767
753
  // note whose documents all have packs may still carry a block none of
768
754
  // them reads, and that is invisible to every question below.
769
- findings.push(...discardedSystemBlockFindings(fm, absPath, current, config));
755
+ findings.push(...discardedSystemBlockFindings(fm, absPath, type, config));
770
756
 
771
757
  // Every document this note produces, against the classes this
772
758
  // configuration has a pack for. Three outcomes, and the middle one is
773
759
  // the half-claimed one — invisible while the question is only "is anything
774
760
  // claiming this note", because the answer there is yes.
775
- const produces = documentClassesFor(current, resolved, {
761
+ const produces = documentClassesFor(type, resolved, {
776
762
  // Lazy: only a doc-carrying type whose JournalEntry has nowhere to
777
763
  // go asks, so a tree with a JournalEntry pack — which is most of
778
764
  // them — reads no bodies at all.
@@ -824,9 +810,9 @@ export function unclaimedNoteFindings(config = loadPackConfig(), sources, { reco
824
810
  // nothing routes it, so the `vocabulary.has` branch would never
825
811
  // reach it — and reading its absence as the reason is the
826
812
  // inference {@link UNIMPLEMENTED_TYPES} exists to replace.
827
- UNIMPLEMENTED_TYPES.has(current) ? specifiedMessage(type)
828
- : vocabulary.has(current) ? configurationMessage(type, config, resolved)
829
- : Object.hasOwn(NOTE_VOCABULARY, current) ? specifiedMessage(type)
813
+ UNIMPLEMENTED_TYPES.has(type) ? specifiedMessage(type)
814
+ : vocabulary.has(type) ? configurationMessage(type, config, resolved)
815
+ : Object.hasOwn(NOTE_VOCABULARY, type) ? specifiedMessage(type)
830
816
  : authoringMessage(type),
831
817
  });
832
818
  }
@@ -430,9 +430,9 @@ export function parseNoteDate(value, options) {
430
430
  );
431
431
  }
432
432
  if (DOTLESS_TOKEN_PATTERN.test(trimmed)) {
433
- // The message an author who learned a retired spelling meets. No
434
- // table says which direction that spelling counted, so it states
435
- // the rule rather than guessing a concrete replacement.
433
+ // A token with no dot names no era. Nothing says which direction
434
+ // a bare token would count, so the message states the rule rather
435
+ // than guessing a concrete replacement.
436
436
  return refuse(
437
437
  `${subject} names no era — a reckoning is written ` +
438
438
  `\`<calendar shortcode>.<era shortcode>\`: a year before ` +
@@ -2,6 +2,12 @@
2
2
  * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
3
  * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
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
+ *
5
11
  * SPDX-License-Identifier: GPL-3.0-or-later
6
12
  */
7
13
 
@@ -14,13 +14,10 @@
14
14
  /**
15
15
  * The id a note's document is filed under, and the one rule that decides it.
16
16
  *
17
- * A note used to author its `id` — an opaque 16-character string, 6,343 of them
18
- * across the four content trees, each a *second* identity for a thing that
19
- * already had one. The note's canonical address (`sohl-none-miscgear-bowlcer`)
20
- * says everything the id said, is readable, and is the identity `content-lint`
21
- * already guards: a duplicate address is a build error, while a duplicate `id`
22
- * was checked nowhere. So the derived id inherits a guarantee, where the
23
- * authored one had none.
17
+ * A note's canonical address (`sohl-none-miscgear-bowlcer`) is the identity
18
+ * `content-lint` guards: a duplicate address is a build error. The id derived
19
+ * from it inherits that guarantee, and an authored `id` would be a second
20
+ * identity for a thing that already has one.
24
21
  *
25
22
  * **An authored `id` always wins.** That is the pattern the map compiler
26
23
  * already uses and documents — `regionDocId(sceneId, key, pinned)` returns
@@ -30,8 +27,7 @@
30
27
  * **This is one function because every pass must agree.** Half a dozen corpus
31
28
  * readers ask what a note's document id is — the pack passes, the wikilink
32
29
  * index, the content index, the Foundry-address pass, the address diff — and
33
- * they never see each other's answer. They agreed before because they all read
34
- * one authored field; they agree now because they all call this.
30
+ * they never see each other's answer. They agree because they all call this.
35
31
  *
36
32
  * `maps` and `pkg` are parameters with defaults rather than values resolved
37
33
  * inside, so a test can pose a package and a registry this toolchain does not
@@ -49,7 +45,7 @@ import { NOTE_SYSTEM } from "./systems.mjs";
49
45
  import { HOMEPAGE_TYPE } from "./homepage.mjs";
50
46
  // The folder id's derivation, taken from the pass that owns it rather than
51
47
  // restated here — see the `folder` branch below. `folder-notes.mjs` reaches
52
- // only `content-address`, `address-charset`, `ids` and `retired-fields`, none
48
+ // only `content-address`, `address-charset` and `ids`, none
53
49
  // of which reach this module, so the direction closes no cycle.
54
50
  import { FOLDER_TYPE, folderDocId } from "./folder-notes.mjs";
55
51
 
@@ -88,10 +84,8 @@ function text(value) {
88
84
  *
89
85
  * Returns `undefined` for a file with **no address** — no `type`, or no
90
86
  * `shortcode`. Such a file is not an addressable note, so it has no document
91
- * and inventing an id for one would file it under nothing. Every caller already
92
- * had to handle an absent id (that is what the authored field's absence meant),
93
- * so this reports the same thing rather than throwing where a walk used to
94
- * skip; whether an id is *required* stays each pass's own decision
87
+ * and inventing an id for one would file it under nothing. This reports the
88
+ * absence rather than throwing, so a walk skips such a file; whether an id is *required* stays each pass's own decision
95
89
  * (`BasePackCompiler.requiresId`).
96
90
  *
97
91
  * @param {object|null|undefined} fm - Parsed frontmatter.
@@ -19,28 +19,11 @@
19
19
  * single-sourced in the repository that ships it — so the package is a property
20
20
  * of the *repository*, not of the note, and no note declares it.
21
21
  *
22
- * It is not a property of the note, and not a **selector**: as one, the compile loop
23
- * read `package:` out of frontmatter and skipped anything that did not match
24
- * the configured value. That is the defect this module exists to remove.
25
- * The skip was silent and it was bucketed as "belongs to another pass",
26
- * so a tree whose notes named a package no configuration answered to compiled
27
- * **zero notes and exited 0** — which is exactly the state the un-migrated
28
- * `hm-loc-*` / `hm-adv-*` repositories are in today.
29
- *
30
- * The field was retired in three steps, and all three have landed:
31
- *
32
- * 1. **Optional** (3.3.0). An absent `package:` was normal and a present one
33
- * was accepted while it agreed. Non-breaking, so a consumer adopted it
34
- * before changing a note.
35
- * 2. **Swept** out of every content tree on the org — 6,235 notes across
36
- * `sohl`, `thalorna`, `kethira` and `harnensemble`.
37
- * 3. **Rejected outright**, as this major. A note declaring the field fails the
38
- * build naming the file, whatever the value says.
39
- *
40
- * Step 3 is deliberately a rejection rather than continued tolerance: a field
41
- * accepted while it agrees is a field that grows back, one note at a time, and
42
- * every one of them is a line restating a constant the configuration already
43
- * carries. There is no value that makes writing it correct, so the diagnostic
22
+ * It is not a property of the note, and not a **selector**: a compile loop that
23
+ * skipped notes naming another package would compile zero notes and exit 0 on a
24
+ * tree whose notes named a package no configuration answered to. A note
25
+ * declaring the field fails the build naming the file, whatever the value
26
+ * says: there is no value that makes writing it correct, so the diagnostic
44
27
  * says what to write instead rather than which value to change.
45
28
  *
46
29
  * SQL queries can refer to the configured package. Search readers receive that
@@ -50,7 +33,7 @@
50
33
  */
51
34
 
52
35
  import { contentPackage } from "./content-package.mjs";
53
- import { locateFrontmatterKey } from "./retired-fields.mjs";
36
+ import { locateFrontmatterKey } from "./diagnostics.mjs";
54
37
 
55
38
  /**
56
39
  * A copy of frontmatter carrying the configured package for query evaluation.
@@ -67,7 +50,7 @@ export function searchableFrontmatter(fm, configured) {
67
50
  * Refuse a note that declares `package:` at all.
68
51
  *
69
52
  * Presence is the whole test — a declaration that *agrees* with the
70
- * configuration is as retired as one that disagrees, and an empty one
53
+ * configuration is refused like one that disagrees, and an empty one
71
54
  * (`package:`, which parses as `null`) is still the field.
72
55
  *
73
56
  * @param {object|null|undefined} fm - Parsed frontmatter, or nothing when it
@@ -95,7 +78,7 @@ export function assertNoDeclaredPackage(fm, { file, absPath, configured } = {})
95
78
  : `\`package: ${declared}\``;
96
79
 
97
80
  const err = new Error(
98
- `${wrote} is a retired frontmatter field — delete it` +
81
+ `${wrote} is not a frontmatter field — delete it` +
99
82
  (file ? ` — ${file}` : "") +
100
83
  `. A note's package is this repository's configured ` +
101
84
  `\`contentPackage\` ("${target}", in package-build.config.yaml), ` +
@@ -62,9 +62,7 @@ export const NOTE_STATES = Object.freeze(["stub", "draft", "full"]);
62
62
  * either state.
63
63
  *
64
64
  * An HTML comment is deliberately not empty. An invisible marker deciding
65
- * whether a page exists is the failure the retired `draft:` field was removed
66
- * for, and reintroducing it in comment form would be worse, because nothing
67
- * would report it.
65
+ * whether a page exists would be a failure nothing reports.
68
66
  *
69
67
  * @param {string|null|undefined} body - The note body, frontmatter stripped.
70
68
  * @returns {boolean} Whether the body is empty.