@heroiclands/package-build 23.1.1 → 23.2.1

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 (247) hide show
  1. package/CHANGELOG.md +69 -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/labels.mjs +2 -2
  159. package/manifest.mjs +11 -4
  160. package/markdownlint-config.mjs +1 -1
  161. package/package.json +6 -3
  162. package/release.mjs +21 -14
  163. package/sohl/actors.mjs +16 -27
  164. package/sohl/default-item-art.mjs +3 -3
  165. package/sohl/document-subtypes.mjs +1 -16
  166. package/sohl/infobox.mjs +2 -3
  167. package/sohl/item-builders.mjs +5 -8
  168. package/sohl/item-fields.mjs +5 -35
  169. package/sohl/items.mjs +0 -4
  170. package/sohl/kb-passes.mjs +3 -3
  171. package/types/container.d.mts +116 -5
  172. package/types/content-config.d.mts +83 -35
  173. package/types/deploy.d.mts +42 -6
  174. package/types/e2e.d.mts +35 -3
  175. package/types/engine/address-charset.d.mts +4 -17
  176. package/types/engine/address.d.mts +17 -8
  177. package/types/engine/art-fields.d.mts +7 -4
  178. package/types/engine/art-slots.d.mts +6 -0
  179. package/types/engine/base-compiler.d.mts +15 -17
  180. package/types/engine/content-address.d.mts +9 -12
  181. package/types/engine/content-embeds.d.mts +4 -2
  182. package/types/engine/content-format-check.d.mts +0 -64
  183. package/types/engine/content-index.d.mts +82 -3
  184. package/types/engine/content-links.d.mts +2 -4
  185. package/types/engine/culture-choice.d.mts +51 -8
  186. package/types/engine/data-keys.d.mts +2 -2
  187. package/types/engine/derived-fields.d.mts +1 -2
  188. package/types/engine/diagnostics.d.mts +27 -4
  189. package/types/engine/document-subtypes.d.mts +12 -31
  190. package/types/engine/event-views.d.mts +14 -2
  191. package/types/engine/exported-scene.d.mts +3 -2
  192. package/types/engine/field-spec.d.mts +13 -122
  193. package/types/engine/folder-notes.d.mts +0 -39
  194. package/types/engine/foreign-catalog.d.mts +2 -1
  195. package/types/engine/foundry-entries.d.mts +4 -5
  196. package/types/engine/frontmatter-lint.d.mts +2 -2
  197. package/types/engine/frontmatter.d.mts +19 -45
  198. package/types/engine/generate.d.mts +1 -1
  199. package/types/engine/helpers.d.mts +25 -42
  200. package/types/engine/homepage.d.mts +3 -10
  201. package/types/engine/ids.d.mts +0 -114
  202. package/types/engine/index-records.d.mts +6 -4
  203. package/types/engine/index.d.mts +0 -1
  204. package/types/engine/infobox-render.d.mts +7 -1
  205. package/types/engine/infobox.d.mts +3 -2
  206. package/types/engine/item-docs.d.mts +1 -1
  207. package/types/engine/item-registry.d.mts +2 -4
  208. package/types/engine/literature-notes.d.mts +1 -1
  209. package/types/engine/markdownlint-run.d.mts +40 -0
  210. package/types/engine/metadata-index.d.mts +9 -1
  211. package/types/engine/note-addresses.d.mts +27 -2
  212. package/types/engine/note-claims.d.mts +3 -5
  213. package/types/engine/note-ids.d.mts +2 -4
  214. package/types/engine/note-package.d.mts +1 -1
  215. package/types/engine/note-state.d.mts +1 -3
  216. package/types/engine/note-vocabulary.d.mts +28 -21
  217. package/types/engine/pack-config.d.mts +2 -6
  218. package/types/engine/pack-router.d.mts +4 -4
  219. package/types/engine/pathnames.d.mts +3 -3
  220. package/types/engine/pdf-build.d.mts +98 -12
  221. package/types/engine/pdf-coverage.d.mts +60 -0
  222. package/types/engine/pdf-index.d.mts +34 -5
  223. package/types/engine/pdf-pages.d.mts +143 -0
  224. package/types/engine/pdf-render.d.mts +45 -1
  225. package/types/engine/pdf-toc.d.mts +12 -14
  226. package/types/engine/prose-config.d.mts +1 -1
  227. package/types/engine/prose-lint.d.mts +11 -10
  228. package/types/engine/runtime-only-fields.d.mts +2 -2
  229. package/types/engine/site-build.d.mts +9 -5
  230. package/types/engine/site-index.d.mts +4 -5
  231. package/types/engine/site-root.d.mts +1 -1
  232. package/types/engine/sql-tables.d.mts +31 -2
  233. package/types/engine/standing-terms.d.mts +2 -1
  234. package/types/engine/standings.d.mts +7 -9
  235. package/types/engine/system-block.d.mts +10 -44
  236. package/types/engine/walk-files.d.mts +15 -0
  237. package/types/engine/wikilink-syntax.d.mts +18 -19
  238. package/types/hm3/actors.d.mts +1 -2
  239. package/types/hm3/item-fields.d.mts +2 -4
  240. package/types/hm3/template-priority.d.mts +6 -7
  241. package/types/manifest.d.mts +3 -2
  242. package/types/release.d.mts +8 -7
  243. package/types/sohl/actors.d.mts +2 -1
  244. package/types/sohl/document-subtypes.d.mts +1 -2
  245. package/types/sohl/item-fields.d.mts +3 -7
  246. package/engine/retired-fields.mjs +0 -631
  247. package/types/engine/retired-fields.d.mts +0 -354
@@ -68,14 +68,12 @@ function mapping(value) {
68
68
  /**
69
69
  * The standings a being authored, in one shape whichever shape it wrote.
70
70
  *
71
- * The list form names bodies and no standings; the map form names both. Three
72
- * spellings reach here and every reader downstream takes the same entries, so
73
- * none of them asks which it was handed:
71
+ * Two spellings reach here and every reader downstream takes the same entries,
72
+ * so none of them asks which it was handed:
74
73
  *
75
74
  * - an **{@link AddressEntries}** — the map after the note boundary decoded its
76
75
  * keys into typed Addresses, which is the shape every compile sees;
77
- * - a **plain map**, which is the shape a checker reading raw YAML sees;
78
- * - a **list**, which names bodies and says nothing about standing in them.
76
+ * - a **plain map**, which is the shape a checker reading raw YAML sees.
79
77
  *
80
78
  * An emptied map arrives from the property editor as `[]` and means what `{}`
81
79
  * means — this being authors no membership — so both read as no entries.
@@ -85,7 +83,7 @@ function mapping(value) {
85
83
  * Address is what a finding should name.
86
84
  *
87
85
  * @param {unknown} value - The authored `data.affiliations`.
88
- * @returns {{form: "absent"|"list"|"map"|"malformed",
86
+ * @returns {{form: "absent"|"map"|"malformed",
89
87
  * entries: Array<{body: unknown, sourceKey: string|number, standing: unknown}>}}
90
88
  * The form it was written in, and its entries.
91
89
  */
@@ -101,18 +99,9 @@ export function readStandings(value) {
101
99
  })),
102
100
  };
103
101
  }
104
- if (Array.isArray(value)) {
105
- // An empty list is an emptied map, not a membership stated as a list.
106
- if (value.length === 0) return { form: "map", entries: [] };
107
- return {
108
- form: "list",
109
- entries: value.map((body, position) => ({
110
- body,
111
- sourceKey: position,
112
- standing: undefined,
113
- })),
114
- };
115
- }
102
+ // An emptied map arrives from the property editor as `[]`; any other list
103
+ // is malformed.
104
+ if (Array.isArray(value) && value.length === 0) return { form: "map", entries: [] };
116
105
  if (!mapping(value) || isAddressTuple(value)) return { form: "malformed", entries: [] };
117
106
  return {
118
107
  form: "map",
@@ -270,7 +259,7 @@ export function standingLead(standing, digest) {
270
259
  * Five questions, and the last three are what keying the entry by its body
271
260
  * buys:
272
261
  *
273
- * 1. the value is a map keyed by Address (or the list this window still takes);
262
+ * 1. the value is a map keyed by Address;
274
263
  * 2. every key names an affiliation that resolves, once;
275
264
  * 3. the entry states a standing at all — what a stated standing holds is the
276
265
  * inner-key check's, read from {@link STANDING_FIELDS};
@@ -278,7 +267,7 @@ export function standingLead(standing, digest) {
278
267
  * 5. an `office` is a key of that body's `governance.offices`.
279
268
  *
280
269
  * Both of the last two are read from the named body's own frontmatter as the
281
- * lint runs, so a renamed rung or a renamed post fails on every being pointing
270
+ * lint runs, so a rung or a post that does not exist fails on every being pointing
282
271
  * at it rather than on a list somebody has to remember to edit.
283
272
  *
284
273
  * @param {object} note - The note.
@@ -301,18 +290,14 @@ export function checkStandings(note, { index } = {}) {
301
290
 
302
291
  const findings = [];
303
292
  const seen = new Map();
304
- // The list form names bodies and has nowhere to put a standing. Its entries
305
- // are still held to being affiliations that resolve, so a tree part-way
306
- // through the sweep keeps the check it had.
307
- const isKey = form === "map";
308
293
  for (const { body, sourceKey, standing } of entries) {
309
294
  const path = ["data", "affiliations", sourceKey];
310
295
  const resolved = resolveBody(note, index, body);
311
- findings.push(...bodyFindings(note, body, path, isKey, resolved));
296
+ findings.push(...bodyFindings(note, body, path, true, resolved));
312
297
  if (resolved.address) {
313
298
  if (seen.has(resolved.address)) {
314
299
  findings.push({
315
- ...position(note, path, isKey),
300
+ ...position(note, path, true),
316
301
  message:
317
302
  `\`data.affiliations\` names ${resolved.address} twice, as ` +
318
303
  `${JSON.stringify(String(seen.get(resolved.address)))} and ` +
@@ -322,7 +307,6 @@ export function checkStandings(note, { index } = {}) {
322
307
  }
323
308
  seen.set(resolved.address, sourceKey);
324
309
  }
325
- if (!isKey) continue;
326
310
  const named = resolved.address ?? String(body);
327
311
  // An entry with nothing after its key, and an emptied map arriving from
328
312
  // the property editor as `[]`, say exactly what an authored `{}` says:
@@ -37,68 +37,25 @@
37
37
  *
38
38
  * This is the load-bearing part, and the reason the whole module exists.
39
39
  * `sohl.system.portrait` and `hm3.system.bioImage` both default from one shared
40
- * top-level property — and they are two *real* fields with different names. The
41
- * two Actor schemas one `being` note has to feed share **no field name at all**,
42
- * so a rule that matched on spelling would not be a rule with exceptions; it
43
- * would be a rule that never fires.
40
+ * property — and they are two *real* fields with different names. The two Actor
41
+ * schemas one `being` note has to feed share **no field name at all**, so a rule
42
+ * that matched on spelling would not be a rule with exceptions; it would be a
43
+ * rule that never fires.
44
44
  *
45
45
  * So each field declares where its shared value comes from, and the resolution
46
46
  * order for a system `S` is:
47
47
  *
48
48
  * 1. `S.system.<to>` — authored directly, wins outright;
49
- * 2. `S.<legacyKey>` — the legacy in-block position the corpus still writes,
50
- * kept until the corpus moves it;
51
- * 3. the shared top-level property the field **declares** as its source, which
52
- * may be a dotted path (`data.portrait`) rather than a sibling key;
53
- * 3b. for a `data.` source, the bare top-level key that container gathered it
54
- * off — the retiring *shared* position, derived rather than declared;
49
+ * 2. `S.<name>` — the field's own name, written in the block. A field whose
50
+ * `name` is a path into `data:` has no in-block position, because its home
51
+ * is the container;
52
+ * 3. the shared property the field **declares** as its source, which may be a
53
+ * dotted path (`data.portrait`) rather than a sibling key;
55
54
  * 4. the field's own default.
56
55
  *
57
- * ## Steps 2 and 3 are two declarations, because they are two positions
58
- *
59
- * They are not one: keying both on `name` holds only while the
60
- * shared source and the in-block key were the same word. `data:` ended
61
- * that — a shared source is now a path *into* a container, so `data.species`
62
- * and `species` are two spellings of two different places, and no single value
63
- * of `name` reached both. `name: "species"` read `hm3.species` and could not
64
- * see `data.species`; `name: "data.species"` read the shared source and could
65
- * not see `hm3.species`. Each yielded the field's **default** wherever only the
66
- * other position was authored — silently, since the field compiles and the
67
- * document is emitted with the value simply gone.
68
- *
69
- * That made every move into `data:` a flag day. Each of this package's other
70
- * retirements — `package:`, `image`, `archetype`, `relation` — works because
71
- * *both spellings are read while the corpus moves*, and one property could not
72
- * offer that here.
73
- *
74
- * So a field declares `legacyKey` beside `name`: the key it is authored at
75
- * inside the block, which step 2 reads and which **falls back to `name`** when
76
- * absent, so every declaration written before this resolves unchanged. A field
77
- * that declares one is mid-sweep by construction, which is what
78
- * {@link module:engine/field-spec.readsLegacyKey} reports on.
79
- *
80
- * ## Step 3 has a retiring position too, and it is derived
81
- *
82
- * `legacyKey` retires the *in-block* position, and for a while that looked like
83
- * the whole of what `data:` left behind. It is not. The facts `data:` holds
84
- * were not invented by it — the sweep **gathered** them out of the note's open top
85
- * level, where `portrait:` sat beside `img:` and `shortcode:` — so a field that
86
- * declares `data.portrait` has two shared spellings to read, not one, and
87
- * reading only the current one is the same silent miss `legacyKey` exists to
88
- * prevent. It is worse here, because the caller's `?? default` cannot tell a
89
- * value that is absent from one that is merely unreachable: 646 `sohl-thalorna`
90
- * beings shipped the generic person icon over an authored path, and no tree
91
- * that still writes the top-level spelling — `sohl`'s own bestiary does —
92
- * looked any different from one that names no art at all.
93
- *
94
- * So step 3b reads it, and unlike `legacyKey` it is **derived**: the retiring
95
- * spelling of `data.<key>` is `<key>`, mechanically, because that is precisely
96
- * what the move did. A second declaration would be a second place for the same
97
- * fact to be stated, and the retirement it describes is one rule rather than a
98
- * per-field decision. {@link retiredTopLevelKey} is that derivation, and it
99
- * answers for `data.` sources alone — `protection.blunt` is a path into a
100
- * container notes have always written at the top level, never a `blunt:` that
101
- * moved.
56
+ * A key written at a position that is not on this list is not read. For a
57
+ * field whose home is `data:`, the frontmatter lint reports the key at the
58
+ * position it was written.
102
59
  *
103
60
  * ## A name that collides across the two vocabularies skips step 3
104
61
  *
@@ -108,28 +65,23 @@
108
65
  * all. They do not always. An `affiliation` item's `system.title` is the style
109
66
  * of address an office carries — "Ajaw", "Warden"; a note's top-level `title`
110
67
  * is refused outright by the closed top-level vocabulary, which has no `title`
111
- * entry. Nothing relates them, and step 3 fed the first from the second anyway.
112
- *
113
- * It was not a harmless coincidence either, because step 3 answers **without**
114
- * applying `field.default` — only step 2 does — so an authored `title: null`
115
- * reached the field's coercion unguarded and shipped as the literal string
116
- * `"null"` in fifteen documents.
68
+ * entry. Nothing relates them. Step 3 answers **without** applying
69
+ * `field.default`, so an authored `title: null` would reach the field's
70
+ * coercion unguarded.
117
71
  *
118
72
  * So a field may declare `topLevelMeans`: why the note's top-level key of this
119
73
  * name is not this field's source — declared wherever that key's spelling
120
74
  * means something else at the note level, or the closed top-level vocabulary
121
- * refuses it outright. Declaring it removes the whole shared level — step 3
122
- * and the retiring 3b alike, since both read the note's top level and the
123
- * objection is to that level, not to a spelling — leaving the two positions
124
- * that describe the document rather than the note. It is deliberately
125
- * a per-field opt-out rather than a change to the order — step 3 is right
75
+ * refuses it outright. Declaring it removes the shared level (step 3), leaving
76
+ * the positions that describe the document rather than the note. It is a
77
+ * per-field opt-out rather than a change to the order — step 3 is right
126
78
  * wherever the two levels state the same quantity, which is nearly everywhere —
127
79
  * and its value is the reason rather than a bare flag, so the collision is
128
80
  * legible where the field is declared. See {@link module:engine/field-spec.FieldSpec}.
129
81
  *
130
82
  * `sohlField()` — read `fm.sohl[key]`, fall back to `fm[key]` — is the
131
- * degenerate case where source and destination happen to share a name. It stops
132
- * being the general rule; {@link blockField} is what remains of it.
83
+ * degenerate case where source and destination happen to share a name. It is
84
+ * not the general rule; {@link blockField} is what remains of it.
133
85
  *
134
86
  * ## Nothing here knows a game system
135
87
  *
@@ -365,17 +317,14 @@ export function blockProperty(fm, block, key, defaultValue = undefined) {
365
317
  }
366
318
 
367
319
  /**
368
- * A system override followed by a shared `data:` value followed by the
369
- * retiring top-level spelling `data:` gathered it off, reporting which one
320
+ * A system override followed by a shared `data:` value, reporting which one
370
321
  * answered.
371
322
  *
372
323
  * {@link module:engine/field-spec.resolveFieldValue}'s counterpart for a
373
324
  * universal `data:` key — `pack` and `packFolder`, declared for every note
374
325
  * type rather than per type, so neither is a `FieldSpec` that resolver takes.
375
326
  * The order is the same shape: a system's own override, then the shared
376
- * `data:` position, then the bare top-level key a tree authored before
377
- * `data:` existed. The top-level value stays readable while a tree still on
378
- * it moves its authored fields.
327
+ * `data:` position.
379
328
  *
380
329
  * @param {object} fm - The note's frontmatter.
381
330
  * @param {string} block - The system block key.
@@ -389,8 +338,6 @@ export function resolveDataProperty(fm, block, key, defaultValue = undefined) {
389
338
  if (declared !== undefined && declared !== null) return { value: declared, from: "block" };
390
339
  const shared = isMapping(fm?.data) ? fm.data[key] : undefined;
391
340
  if (shared !== undefined && shared !== null) return { value: shared, from: "shared" };
392
- const legacy = isMapping(fm) ? fm[key] : undefined;
393
- if (legacy !== undefined && legacy !== null) return { value: legacy, from: "topLevel" };
394
341
  return { value: defaultValue, from: "default" };
395
342
  }
396
343
 
@@ -408,27 +355,6 @@ export function blockDataProperty(fm, block, key, defaultValue = undefined) {
408
355
  return resolveDataProperty(fm, block, key, defaultValue).value;
409
356
  }
410
357
 
411
- /**
412
- * The key a field is authored at **inside** a system block — step 2.
413
- *
414
- * `legacyKey` when the field declares one, and `name` otherwise. The fallback
415
- * is what makes this change invisible to every declaration written before it:
416
- * a field whose shared source and in-block key are the same word says so once,
417
- * as it always did.
418
- *
419
- * Named and exported rather than spelled inline because three readers ask the
420
- * question and must agree — the resolver here, the frontmatter lint building
421
- * the set of keys a block may carry, and the author-facing surfaces naming
422
- * where a value was written.
423
- *
424
- * @param {{name?: string, legacyKey?: string}} field - The declaration.
425
- * @returns {string|undefined} The in-block key, or `undefined` for a field that
426
- * is not authored at all.
427
- */
428
- export function legacyKeyOf(field) {
429
- return field?.legacyKey ?? field?.name;
430
- }
431
-
432
358
  /**
433
359
  * The `data:` container's prefix, as a shared source spells it.
434
360
  *
@@ -437,31 +363,20 @@ export function legacyKeyOf(field) {
437
363
  const DATA_PREFIX = "data.";
438
364
 
439
365
  /**
440
- * The bare top-level key a `data:`-sourced field is being swept off — step 3b.
441
- *
442
- * The retiring spelling of `data.portrait` is not a second declaration anyone
443
- * has to write — it is `portrait`, mechanically, and the same holds for every
444
- * other key the container gathered. Deriving it is what keeps the two spellings
445
- * of one field from disagreeing the way two declarations would.
446
- *
447
- * **Only a `data.` source has one.** `protection.blunt` and `impact.die` are
448
- * paths into containers a note has always written at the top level; they were
449
- * never `blunt:` or `die:`, and reading those would invent a position rather
450
- * than remember one.
366
+ * The key a field is authored at **inside** a system block — step 2.
451
367
  *
452
- * A field declaring {@link module:engine/field-spec.FieldSpec `topLevelMeans`}
453
- * has no shared position at all, retiring or otherwise — the resolver checks
454
- * that before asking.
368
+ * The field's `name`, except that a name which is a path into `data:` has no
369
+ * in-block position. Named and exported because the resolver and the
370
+ * frontmatter lint, which builds the set of keys a block may carry, must agree.
455
371
  *
456
372
  * @param {{name?: string}} field - The declaration.
457
- * @returns {string|undefined} The retiring top-level path, or `undefined` for a
458
- * field whose shared source never lived there.
373
+ * @returns {string|undefined} The in-block key, or `undefined` for a field that
374
+ * is not authored in the block.
459
375
  */
460
- export function retiredTopLevelKey(field) {
376
+ export function inBlockKey(field) {
461
377
  const name = field?.name;
462
- if (typeof name !== "string" || !name.startsWith(DATA_PREFIX)) return undefined;
463
- const rest = name.slice(DATA_PREFIX.length);
464
- return rest === "" ? undefined : rest;
378
+ if (typeof name !== "string" || name.startsWith(DATA_PREFIX)) return undefined;
379
+ return name;
465
380
  }
466
381
 
467
382
  /**
@@ -492,7 +407,7 @@ export function authoredKey(name) {
492
407
  * can distinguish a value an author wrote from one a default supplied, which
493
408
  * the value alone never says.
494
409
  *
495
- * @typedef {"system"|"block"|"shared"|"topLevel"|"default"|"value"} FieldSource
410
+ * @typedef {"system"|"block"|"shared"|"default"|"value"} FieldSource
496
411
  */
497
412
 
498
413
  /**
@@ -529,19 +444,16 @@ export function resolveFieldValue(field, fm, { block = "sohl" } = {}) {
529
444
  if (own !== undefined) return { value: own, from: "system" };
530
445
  }
531
446
 
532
- // 2. The legacy in-block position, keyed on `legacyKey` — the shared
533
- // source is a path into `data:` and the in-block key is a bare word, so
534
- // the two are declared separately. Every note in every tree
535
- // writes here today, and will until the corpus moves them; dropping it would
536
- // be a corpus migration disguised as a mechanism change.
447
+ // 2. The field's own name, written in the block. A name that is a path
448
+ // into `data:` has no in-block position: its home is the container.
537
449
  const declared = systemBlock(fm, block);
538
- const legacyKey = legacyKeyOf(field);
539
- if (declared && legacyKey) {
540
- if (legacyKey in declared) {
541
- const value = declared[legacyKey];
450
+ const key = inBlockKey(field);
451
+ if (declared && key !== undefined) {
452
+ if (key in declared) {
453
+ const value = declared[key];
542
454
  return { value: value ?? field.default, from: "block" };
543
455
  }
544
- const nested = getFrontmatter(declared, legacyKey, undefined);
456
+ const nested = getFrontmatter(declared, key, undefined);
545
457
  if (nested !== undefined) return { value: nested, from: "block" };
546
458
  }
547
459
 
@@ -551,20 +463,6 @@ export function resolveFieldValue(field, fm, { block = "sohl" } = {}) {
551
463
  if (field.topLevelMeans === undefined) {
552
464
  const shared = getFrontmatter(fm, field.name, undefined);
553
465
  if (shared !== undefined) return { value: shared, from: "shared" };
554
-
555
- // 3b. The bare top-level key the `data:` source was gathered off — the
556
- // retiring *shared* position, exactly as `legacyKey` is the
557
- // retiring *in-block* one. Without it a field declaring
558
- // `data.portrait` cannot see the `portrait:` every tree still
559
- // writes, and the miss arrives at the caller's `?? default` as an
560
- // ordinary absence: 646 `sohl-thalorna` beings compiled the generic
561
- // person icon over an authored path, deterministically and with
562
- // nothing said.
563
- const retiring = retiredTopLevelKey(field);
564
- if (retiring !== undefined) {
565
- const legacy = getFrontmatter(fm, retiring, undefined);
566
- if (legacy !== undefined) return { value: legacy, from: "topLevel" };
567
- }
568
466
  }
569
467
 
570
468
  // 4. The field's own default.
@@ -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
 
@@ -0,0 +1,69 @@
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
+ * The repository walk shared by the prose checks.
16
+ *
17
+ * @module
18
+ */
19
+
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+
23
+ /**
24
+ * Directories never walked, whatever the ignore files say.
25
+ *
26
+ * `node_modules` is in every consumer's `.gitignore` and would be excluded
27
+ * anyway; skipping it in the walk rather than per file is the difference
28
+ * between a check that takes a second and one that stats a hundred thousand
29
+ * files to be told each is ignored. `.git` is in nobody's `.gitignore`, so it
30
+ * has to be named.
31
+ */
32
+ const NEVER_WALK = Object.freeze(new Set([".git", "node_modules"]));
33
+
34
+ /**
35
+ * Every file under a root, minus the directories nothing should walk.
36
+ *
37
+ * @param {string} root - Absolute path to walk.
38
+ * @param {object} [hooks]
39
+ * @param {(dir: string) => void} [hooks.enter] - Called with each directory
40
+ * before its entries are read.
41
+ * @param {(abs: string, isDirectory: boolean) => boolean} [hooks.skip] -
42
+ * Whether to leave a path out; a skipped directory is not entered.
43
+ * @returns {string[]} Absolute file paths, in directory order.
44
+ */
45
+ export function walkFiles(root, hooks = {}) {
46
+ const out = [];
47
+ /** @param {string} dir - Directory to descend into. */
48
+ const descend = (dir) => {
49
+ hooks.enter?.(dir);
50
+ let entries;
51
+ try {
52
+ entries = fs.readdirSync(dir, { withFileTypes: true });
53
+ } catch {
54
+ // A directory that vanished or cannot be read is not a finding
55
+ // about prose; leave it to whatever owns it.
56
+ return;
57
+ }
58
+ for (const entry of entries) {
59
+ const abs = path.join(dir, entry.name);
60
+ if (entry.isDirectory()) {
61
+ if (!NEVER_WALK.has(entry.name) && !hooks.skip?.(abs, true)) descend(abs);
62
+ } else if (entry.isFile() && !hooks.skip?.(abs, false)) {
63
+ out.push(abs);
64
+ }
65
+ }
66
+ };
67
+ descend(root);
68
+ return out;
69
+ }
@@ -416,9 +416,8 @@ export function resolveWebWikilinks(body, ctx) {
416
416
  const label = authoredLabel({ display });
417
417
 
418
418
  // **Every link carries a label**. Without one there is nothing
419
- // to resolve against — the alias namespace a bare `[[Text]]` named is
420
- // retired — and nothing to show either, a shortcode being an address
421
- // rather than prose. Reported before the same-page form, because the
419
+ // to resolve against, and nothing to show either, a shortcode being an
420
+ // address rather than prose. Reported before the same-page form, because the
422
421
  // rule is about how the link is *written*: `[[#slug]]` needs the pipe
423
422
  // exactly as `[[skill-clmb]]` does.
424
423
  if (!parsed.labelled) {
@@ -531,6 +530,26 @@ export function resolveWebWikilinks(body, ctx) {
531
530
  read && !read.reason && read.package === ctx.contentPackage ?
532
531
  `${read.type}/${read.shortcode}`.toLowerCase()
533
532
  : rawKey;
533
+ // A capital in the shortcode is refused, never lowercased. The
534
+ // lowercase spelling is looked up only to name it in the finding.
535
+ if (read?.reason === "shortcode-not-lowercase") {
536
+ const lower = read.lowercase;
537
+ const declared =
538
+ lookupRead(ctx.index, lower, ctx.contentPackage) ??
539
+ (ctx.foreign && lower.package ?
540
+ lookupRead(ctx.foreign, lower, ctx.contentPackage)
541
+ : undefined);
542
+ return report(
543
+ all,
544
+ {
545
+ target,
546
+ reason: "shortcode-not-lowercase",
547
+ shortcode: read.shortcode,
548
+ ...(declared ? { declared: lower.shortcode } : {}),
549
+ },
550
+ label ?? target,
551
+ );
552
+ }
534
553
  const reason =
535
554
  ctx.collide?.has(collideKey) ? "ambiguous"
536
555
  // "It parsed as an address" is a property of the parse, not of
@@ -538,6 +557,7 @@ export function resolveWebWikilinks(body, ctx) {
538
557
  : read && !read.reason ? "unresolved"
539
558
  : read?.reason === "unknown-type" ? "unknown-type"
540
559
  : read?.reason === "no-content-index" ? "no-content-index"
560
+ : read?.reason === "not-lowercase" ? "not-lowercase"
541
561
  // Every link is an address, and this is not one. Distinct from
542
562
  // a dead address, because the fix is different: a name has to
543
563
  // become an address, not be corrected.
@@ -40,8 +40,8 @@ import { parseAddress, isAddressTuple, splitAnchor } from "./address.mjs";
40
40
  *
41
41
  * The one rule about a link's *shape* that both resolvers share also lives here
42
42
  * — **a link carries a label** ({@link unlabelledLinkMessage}) — because the
43
- * two used to state it in their own words and the author met whichever ran
44
- * first.
43
+ * two state it through one message and the author reads the same words
44
+ * whichever ran first.
45
45
  *
46
46
  * For the same reason, so does the **vocabulary of link findings**
47
47
  * ({@link LINK_FINDING_REASONS}) and the message each one reports through
@@ -145,15 +145,10 @@ export function authoredLabel({ display }) {
145
145
  * the form to write rather than a value to correct: there is no value that
146
146
  * makes an unlabelled link resolve.
147
147
  *
148
- * **Why there is nothing left for a bare link to mean**. The pipe used
149
- * to select between two namespaces — address and alias — and the alias one was
150
- * empty in practice: across 8,305 wikilinks in three content trees, not one
151
- * bare `[[Alias]]` resolved to a note. What the index it looked up in *did* do
152
- * was fold every note's `name.full` into itself, which forbade two notes of a
153
- * type from sharing a display name. So the namespace is gone, every
154
- * link is an address, and an address needs the pipe that says so.
148
+ * **A bare link has nothing to mean.** Every link is an address, and an
149
+ * address needs the pipe that says so.
155
150
  *
156
- * The **link part may still be an anchor**: `[[#slug|Text]]` addresses a
151
+ * The **link part may be an anchor**: `[[#slug|Text]]` addresses a
157
152
  * section of the page it is written on. It is the label that is required, not
158
153
  * a target.
159
154
  *
@@ -164,7 +159,7 @@ export function unlabelledLinkMessage(target) {
164
159
  return (
165
160
  `wikilink [[${target}]] carries no label, so it addresses nothing — ` +
166
161
  `write [[type-shortcode|Text]]. Every link is an address; the bare ` +
167
- `[[Name]] form named an alias, and the alias namespace is retired`
162
+ `[[Name]] form addresses nothing`
168
163
  );
169
164
  }
170
165
 
@@ -181,6 +176,8 @@ export function unlabelledLinkMessage(target) {
181
176
  * - `unlabelled` — no `|`, so the link addresses nothing.
182
177
  * - `not-an-address` — labelled, but the target does not parse as an address.
183
178
  * - `not-lowercase` — a package, system or type segment carries a capital.
179
+ * - `shortcode-not-lowercase` — the shortcode segment carries a capital. A
180
+ * shortcode is lowercase, and the link is refused rather than lowercased.
184
181
  * - `unknown-type` — definitely qualified, but names no type this build knows.
185
182
  * - `not-an-asset` — a real address, embedded where only a file can be drawn.
186
183
  * - `unresolved` — parses as an address, and nothing publishes it.
@@ -199,6 +196,7 @@ export const LINK_FINDING_REASONS = Object.freeze(
199
196
  "unlabelled",
200
197
  "not-an-address",
201
198
  "not-lowercase",
199
+ "shortcode-not-lowercase",
202
200
  "unknown-type",
203
201
  "not-an-asset",
204
202
  "unresolved",
@@ -240,13 +238,9 @@ export function isEventAnchor(hit, anchor) {
240
238
  * package publishing it is not a declared dependency — or is one whose index
241
239
  * has not been fetched — and the link itself looks identical in every case.
242
240
  *
243
- * As a **warning** in the checker and, in the site build, nothing
244
- * at all until every linkable package's manifest was accounted for — on the
245
- * reasoning that a bare `[[Name]]` might be a placeholder for a note nobody had
246
- * written yet. That reasoning was a property of the bare form, which is retired;
247
- * the intent behind it now has a real spelling, a `draft`-tagged note
248
- * that exists and resolves and renders marked. So an address naming no
249
- * note is a typo or an omission, both want fixing, and all three builds say so.
241
+ * An address naming no note is a typo or an omission: the intent of a
242
+ * placeholder for a note nobody has written yet is spelled as a `draft`-tagged
243
+ * note that exists, resolves and renders marked. Both want fixing, and all three builds say so.
250
244
  *
251
245
  * @param {string} target - The address as authored, named in the message.
252
246
  * @returns {string} The message, unpunctuated at the end as a finding is.
@@ -327,11 +321,24 @@ export function ambiguousAddressMessage(target, packages = []) {
327
321
  * @param {string} [finding.type] - For `not-an-asset`, the type the address
328
322
  * named.
329
323
  * @param {string} [finding.stub] - For `stub`, where the stub is.
324
+ * @param {string} [finding.shortcode] - For `shortcode-not-lowercase`, the
325
+ * shortcode as written.
326
+ * @param {string} [finding.declared] - For `shortcode-not-lowercase`, the
327
+ * lowercase spelling, when it is a declared shortcode.
330
328
  * @returns {string} The message.
331
329
  * @throws {Error} On a reason outside the closed set — a resolver inventing one
332
330
  * would otherwise report a link with no explanation at all.
333
331
  */
334
- export function linkFindingMessage({ reason, target, packages, anchor, type, stub }) {
332
+ export function linkFindingMessage({
333
+ reason,
334
+ target,
335
+ packages,
336
+ anchor,
337
+ type,
338
+ stub,
339
+ shortcode,
340
+ declared,
341
+ }) {
335
342
  switch (reason) {
336
343
  case "unlabelled":
337
344
  return unlabelledLinkMessage(target);
@@ -345,8 +352,12 @@ export function linkFindingMessage({ reason, target, packages, anchor, type, stu
345
352
  case "not-lowercase":
346
353
  return (
347
354
  `address [[${target}]] capitalises a package, system or type ` +
348
- `segment — those three are lowercase, and only the shortcode ` +
349
- `keeps its case`
355
+ `segment — every segment of an address is lowercase`
356
+ );
357
+ case "shortcode-not-lowercase":
358
+ return (
359
+ `address [[${target}]] shortcode \`${shortcode ?? target}\` must be ` +
360
+ `lowercase${declared ? `; the declared shortcode is \`${declared}\`` : ""}`
350
361
  );
351
362
  case "unknown-type":
352
363
  return `address [[${target}]] names no known content type`;