@heroiclands/package-build 23.1.0 → 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 (248) hide show
  1. package/CHANGELOG.md +71 -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 +54 -6
  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/engine/yaml-lint.mjs +7 -4
  145. package/githooks/attribution.mjs +8 -0
  146. package/githooks/commit-message.mjs +9 -0
  147. package/hm3/actors.mjs +9 -48
  148. package/hm3/default-item-art.mjs +9 -9
  149. package/hm3/item-fields.mjs +2 -9
  150. package/hm3/template-priority.mjs +6 -7
  151. package/hugo-theme/data/banner-kinds.yaml +78 -0
  152. package/hugo-theme/data/banners.yaml +2 -3
  153. package/hugo-theme/layouts/404.html +3 -3
  154. package/hugo-theme/layouts/partials/banner-url.html +31 -7
  155. package/hugo-theme/layouts/partials/hero-banner.html +13 -14
  156. package/hugo-theme/layouts/partials/related.html +1 -1
  157. package/hugo-theme/layouts/partials/warn-missing-banner.html +1 -1
  158. package/hugo-theme/static/css/style.css +1 -1
  159. package/manifest.mjs +11 -4
  160. package/markdownlint-config.mjs +1 -1
  161. package/package.json +6 -3
  162. package/release.mjs +83 -18
  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/engine/yaml-lint.d.mts +5 -4
  239. package/types/hm3/actors.d.mts +1 -2
  240. package/types/hm3/item-fields.d.mts +2 -4
  241. package/types/hm3/template-priority.d.mts +6 -7
  242. package/types/manifest.d.mts +3 -2
  243. package/types/release.d.mts +12 -7
  244. package/types/sohl/actors.d.mts +2 -1
  245. package/types/sohl/document-subtypes.d.mts +1 -2
  246. package/types/sohl/item-fields.d.mts +3 -7
  247. package/engine/retired-fields.mjs +0 -631
  248. package/types/engine/retired-fields.d.mts +0 -354
@@ -1,631 +0,0 @@
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
- * Frontmatter fields a note may no longer declare.
16
- *
17
- * A retired field has exactly two possible fates, and only one of them is
18
- * honest. Left honoured, it keeps doing whatever it did — which is why it was
19
- * retired. Left *ignored*, it reads to its author as though it still works: the
20
- * note says one thing and the build does another, and nothing says so. So a
21
- * retired field is **refused**, naming the file and the line, and the message
22
- * says what to write instead rather than which value to correct.
23
- *
24
- * `package:` is retired the same way and is refused from `note-package.mjs`,
25
- * where the concept it belonged to still lives. `draft:`, the top-level
26
- * `aliases:`, `section:` and `traits:` have no such home — there is no
27
- * surviving concept any of them was part of — so they are refused here.
28
- *
29
- * **What `draft:` did.** It excluded a note from the compiled packs, from
30
- * the link manifest and from a consuming site build. Nothing reported the
31
- * consequence: `content-links.mjs`, `site-index.mjs` and `content-lint.mjs`
32
- * never read the field, so a link into a drafted note was indistinguishable
33
- * from a link to a note that does not exist, and the checkers could not say
34
- * which. The field's entire effect was to move a note from *published* to
35
- * *unresolvable*, silently — and it also suppressed real build failures, since
36
- * a note the compilers never reached could not fail on the defects it carried.
37
- *
38
- * **What `aliases:` did.** It fed the alias index, which is what a bare
39
- * `[[Alias]]` was looked up in. That form resolved to nothing anywhere in the
40
- * corpus, while the collision rule guarding it folded in every note's
41
- * `name.full` and so decided what a note could be named. The form and
42
- * the index are retired together, leaving the field with no reader at all.
43
- *
44
- * **What `section:` did.** It named the section a `collection` note
45
- * headed, under the `collection` landing rule — its only reader anywhere. That
46
- * rule is retired, a section being landed by the `README.md` in its directory,
47
- * so the field has none. No schema or vocabulary ever declared it either, and
48
- * nothing checks unrecognized top-level keys, so left in place it would be
49
- * silently ignored rather than reported.
50
- *
51
- * **`name.aliases` lists alternate names.** The note infobox shows them in
52
- * authored order. They do not create addresses or change link resolution.
53
- *
54
- * **What `traits:` did.** It held a being's own description — gender,
55
- * species, age, birth date, height, weight, frame and `appearance.*` — at the
56
- * note's top level. The content format gives those a home: `data:`, the closed
57
- * container for a subject's type-specific facts, which `being` declares every
58
- * one of them in. Every note that carried one has been moved, across four
59
- * repositories, and this is the third step of that retirement.
60
- *
61
- * Refusing it by name rather than as an unknown key is what names the home the
62
- * facts have: the finding says which `data:` key each one becomes, which an
63
- * author cannot read off a list of the keys the region declares.
64
- *
65
- * `sohl.traits` is a different field that shares the name — `projectilegear`
66
- * declares one, and the theme's gear sidebar reads it — so the refusal is
67
- * anchored at column 1 and never reaches inside a system block.
68
- *
69
- * **A field retired in favour of another is a third case.** `draft:` and
70
- * `package:` were retired outright: nothing replaced them, so no value made
71
- * writing one right and refusal was the only honest answer. A *renamed* field
72
- * has a replacement, and the two spellings mean the same thing — so the note
73
- * still compiles, correctly, and refusing it would fail a build over a document
74
- * that is not wrong. Those retire in the three steps `package:` took, and
75
- * this module carries the **first**: both spellings are read, the current one
76
- * wins, and the retired one is *reported* rather than refused. See
77
- * {@link RETIRED_FIELD_ALIASES}.
78
- *
79
- * **The third step is deletion, and it needs no code.** `image`, a map's
80
- * background art, illustrates all three states: reported,
81
- * swept, then dropped from the
82
- * table. Removing the entry is the whole of it — with no alias, the spelling is
83
- * an ordinary unknown key in the `sohl:` block, which the frontmatter lint
84
- * already refuses as an error alongside the required field it failed to supply.
85
- * So nothing here *records* a finished retirement: the absence of an entry is
86
- * the record, and a retirement that needed a standing refusal would mean the
87
- * replacement never arrived. Do not keep a tombstone for one.
88
- *
89
- * **A retired *position* is the same case, and reads the same.** A field
90
- * whose shared source moved under `data:` is not renamed — `data.species` and
91
- * `hm3.species` are one field written in two places — but the retirement has
92
- * the identical shape: both are read, the one the note carries wins, and the
93
- * legacy in-block key is reported by {@link legacyKeyMessage} so the sweep has
94
- * something to count down. Which position a value came from is
95
- * {@link module:engine/system-block.resolveFieldValue}'s answer; this module
96
- * only says what an author is told about it.
97
- *
98
- * **A field has two retiring positions, not one.** The in-block key is
99
- * the obvious one; the other is the note's **top level**, because the sweep
100
- * did not
101
- * invent the facts `data:` holds — it gathered them from exactly there. So
102
- * `portrait:` beside `img:` is the pre-`data:` spelling of `data.portrait`,
103
- * read for the same reason and reported by {@link retiredTopLevelMessage}. Both
104
- * are needed, and separately: a note may have moved one and not the other, and
105
- * a single finding covering both would name the wrong line half the time.
106
- *
107
- * **A `data:` field's retired spelling is written in `data:`**, and nowhere
108
- * else. The container is closed, so a key in it is either one the type declares
109
- * or one refused by name — which makes the retired spelling of a declared key
110
- * an error the moment the replacement lands, unless the container check knows
111
- * to read it as a rename. {@link declaresRetiredAlias} and
112
- * {@link readAliasedField} therefore take `inData`, and search that one region
113
- * rather than the block and the top level a system field is written in.
114
- *
115
- * @module
116
- */
117
-
118
- import fs from "node:fs";
119
-
120
- import { positionInFrontmatter } from "./diagnostics.mjs";
121
- import { getFrontmatter, sohlField } from "./frontmatter.mjs";
122
- import { retiredTopLevelKey } from "./system-block.mjs";
123
-
124
- /**
125
- * What a note declaring `draft:` is told, in one place.
126
- *
127
- * Written once and shared by the compile-time refusal and the frontmatter lint,
128
- * because an author meets whichever of the two runs first and they should read
129
- * the same. It says what the field did and what to write instead, rather than
130
- * which value to correct: no value makes declaring it right.
131
- *
132
- * @param {string} [file] - The note's path, named in the message. Omit it where
133
- * the caller emits through a diagnostic, whose locator already starts the
134
- * line — repeating it prints the path twice.
135
- * @returns {string} The message, unpunctuated at the end as a finding is.
136
- */
137
- export function draftRetiredMessage(file) {
138
- return (
139
- "`draft:` is a retired frontmatter field — delete it" +
140
- (file ? ` — ${file}` : "") +
141
- ". It excluded the note from the compiled packs, the link manifest " +
142
- "and the site, and no checker reported the exclusion, so every " +
143
- "wikilink into it read as a link to a note that does not exist. To " +
144
- "mark a note as unfinished, tag it `#draft` instead: the note still " +
145
- "compiles and publishes, a link into it renders marked, and a " +
146
- "`FROM #draft` query still finds it"
147
- );
148
- }
149
-
150
- /**
151
- * Refuse a note that declares `draft:` at all.
152
- *
153
- * Presence is the whole test. `draft: false` is as retired as `draft: true` —
154
- * it reads as "publish this note", which is what happens either way, and is
155
- * exactly the belief the message exists to correct.
156
- *
157
- * @param {object|null|undefined} fm - Parsed frontmatter, or nothing when it
158
- * could not be parsed.
159
- * @param {object} [options] - Options.
160
- * @param {string} [options.file] - The note's path, named in the message. Omit
161
- * it where the caller emits through a diagnostic, which puts the locator at
162
- * the start of the line already — repeating it prints the path twice.
163
- * @param {string} [options.absPath] - The note's file on disk, read only on the
164
- * failing path to locate the offending line and column. The position rides on
165
- * the thrown error as `position`, for a caller that emits a diagnostic.
166
- * @returns {void}
167
- * @throws {Error} When the note declares the field.
168
- */
169
- export function assertNoDraftField(fm, { file, absPath } = {}) {
170
- if (!fm || typeof fm !== "object" || !Object.hasOwn(fm, "draft")) return;
171
-
172
- const err = new Error(`${draftRetiredMessage(file)}.`);
173
- // Where the field is, so the caller's diagnostic opens on the line that has
174
- // to be deleted. Read here rather than carried through every walk: this is
175
- // the failing path, and the build stops on it.
176
- const position = locateFrontmatterKey(absPath, "draft");
177
- if (position) err.position = position;
178
- throw err;
179
- }
180
-
181
- /**
182
- * What a note declaring a top-level `aliases:` is told, in one place.
183
- *
184
- * Shared by the compile-time refusal and the frontmatter lint, because an
185
- * author meets whichever of the two runs first and they should read the same.
186
- * It says what the field fed and what to write instead, rather than which value
187
- * to correct: no value makes declaring it right.
188
- *
189
- * **What it did.** It was the authored half of the alias index — the
190
- * namespace a bare `[[Alias]]` was looked up in. Across the three content trees
191
- * not one bare link resolved through it, while the collision rule that kept it
192
- * unambiguous folded in every note's `name.full` and so dictated what a note
193
- * could be named. The form is retired, so the list has no reader.
194
- *
195
- * **`name.aliases` is a different field.** It lists alternate names in the
196
- * infobox and is deliberately unmentioned by this message. See
197
- * {@link assertNoAliasesField}.
198
- *
199
- * @param {string} [file] - The note's path, named in the message. Omit it where
200
- * the caller emits through a diagnostic, whose locator already starts the
201
- * line — repeating it prints the path twice.
202
- * @returns {string} The message, unpunctuated at the end as a finding is.
203
- */
204
- export function aliasesRetiredMessage(file) {
205
- return (
206
- "`aliases:` is a retired frontmatter field — delete it" +
207
- (file ? ` — ${file}` : "") +
208
- ". It listed names the bare `[[Alias]]` form could cite, and that " +
209
- "form is retired: every wikilink is now an address, written " +
210
- "`[[type-shortcode|Text]]`. Nothing else ever read the list"
211
- );
212
- }
213
-
214
- /**
215
- * Refuse a note that declares a top-level `aliases:`.
216
- *
217
- * Presence is the whole test. `aliases: []` is as retired as a populated one —
218
- * it reads as "this note claims no other names", a statement about a namespace
219
- * that no longer exists.
220
- *
221
- * **The nested `name.aliases` is deliberately not refused.** It is the note's
222
- * ordered alternate names and appears in the note infobox. It does not create
223
- * addresses or change how authored links resolve.
224
- *
225
- * That distinction is the reason this checks `Object.hasOwn(fm, "aliases")`
226
- * rather than resolving a dotted key: the top-level field is the whole subject,
227
- * and reaching into `name` at all is the thing being avoided.
228
- *
229
- * @param {object|null|undefined} fm - Parsed frontmatter, or nothing when it
230
- * could not be parsed.
231
- * @param {object} [options] - Options.
232
- * @param {string} [options.file] - The note's path, named in the message. Omit
233
- * it where the caller emits through a diagnostic, which puts the locator at
234
- * the start of the line already — repeating it prints the path twice.
235
- * @param {string} [options.absPath] - The note's file on disk, read only on the
236
- * failing path to locate the offending line and column. The position rides on
237
- * the thrown error as `position`, for a caller that emits a diagnostic.
238
- * @returns {void}
239
- * @throws {Error} When the note declares a top-level `aliases`.
240
- */
241
- export function assertNoAliasesField(fm, { file, absPath } = {}) {
242
- if (!declaresRetiredAliasesField(fm)) return;
243
-
244
- const err = new Error(`${aliasesRetiredMessage(file)}.`);
245
- // Anchored at column 1: a permitted `name.aliases` writes the same key,
246
- // indented, and a finding about the retired top-level field must never
247
- // open on it.
248
- const position = locateFrontmatterKey(absPath, "aliases", undefined, { topLevel: true });
249
- if (position) err.position = position;
250
- throw err;
251
- }
252
-
253
- /**
254
- * Whether a note declares the retired top-level `aliases:`.
255
- *
256
- * A nested `name.aliases` is **not** this field and never answers true here —
257
- * see {@link assertNoAliasesField} for why the two part company.
258
- *
259
- * @param {object|null|undefined} fm - Parsed frontmatter.
260
- * @returns {boolean} Whether the retired field is declared.
261
- */
262
- export function declaresRetiredAliasesField(fm) {
263
- return Boolean(fm) && typeof fm === "object" && Object.hasOwn(fm, "aliases");
264
- }
265
-
266
- /**
267
- * What a note declaring `section:` is told, in one place.
268
- *
269
- * Shared by the compile-time refusal and the frontmatter lint, because an
270
- * author meets whichever of the two runs first and they should read the same.
271
- * It names what lands a section now rather than a value to correct: no value
272
- * makes declaring the field right.
273
- *
274
- * **What it did.** It named the section a `collection` note headed,
275
- * under the `collection` landing rule — the only reader it ever had, in the
276
- * second branch of `landingOf` (`engine/content-address.mjs`). That rule went
277
- * first, and the whole mechanism went with it: a section is a Hugo
278
- * directory the note format does not carry, so no note lands one and a page
279
- * that introduces a type is an ordinary note addressed `doc-<type>`. Nothing
280
- * else read the field, and no schema or vocabulary declared it, so left in
281
- * place it would be ignored in silence — the note saying one thing and the
282
- * build doing another.
283
- *
284
- * @param {string} [file] - The note's path, named in the message. Omit it where
285
- * the caller emits through a diagnostic, whose locator already starts the
286
- * line — repeating it prints the path twice.
287
- * @returns {string} The message, unpunctuated at the end as a finding is.
288
- */
289
- export function sectionRetiredMessage(file) {
290
- return (
291
- "`section:` is a retired frontmatter field — delete it" +
292
- (file ? ` — ${file}` : "") +
293
- ". It named the section a `collection` note headed, and both the rule " +
294
- "and the sections it routed to are retired: a page that introduces " +
295
- "the notes of a type is an ordinary note — `type: doc`, " +
296
- "`subType: reference`, `shortcode: <type>` — addressed `doc-<type>`. " +
297
- "Nothing else ever read the field"
298
- );
299
- }
300
-
301
- /**
302
- * Refuse a note that declares `section:` at all.
303
- *
304
- * Presence is the whole test, as it is for `draft:` and `aliases:`: an empty
305
- * value reads as "this note heads a section and names none", a statement about
306
- * a rule that no longer exists.
307
- *
308
- * @param {object|null|undefined} fm - Parsed frontmatter, or nothing when it
309
- * could not be parsed.
310
- * @param {object} [options] - Options.
311
- * @param {string} [options.file] - The note's path, named in the message. Omit
312
- * it where the caller emits through a diagnostic, which puts the locator at
313
- * the start of the line already — repeating it prints the path twice.
314
- * @param {string} [options.absPath] - The note's file on disk, read only on the
315
- * failing path to locate the offending line and column. The position rides on
316
- * the thrown error as `position`, for a caller that emits a diagnostic.
317
- * @returns {void}
318
- * @throws {Error} When the note declares the field.
319
- */
320
- export function assertNoSectionField(fm, { file, absPath } = {}) {
321
- if (!fm || typeof fm !== "object" || !Object.hasOwn(fm, "section")) return;
322
-
323
- const err = new Error(`${sectionRetiredMessage(file)}.`);
324
- // Anchored at column 1: a nested `section:` inside some other block is
325
- // not this field — a finding about the top-level one must not open on it.
326
- const position = locateFrontmatterKey(absPath, "section", undefined, { topLevel: true });
327
- if (position) err.position = position;
328
- throw err;
329
- }
330
-
331
- /**
332
- * What a note writing a top-level `traits:` block is told.
333
- *
334
- * The message states the **mapping**, not just the destination, because three
335
- * of the keys reshaped as well as moved: the block nested its measurements
336
- * where the format flattens them. A bare "write `data:` instead" would send an
337
- * author to author `data.height: {m: 1.78}`, which is a declared key holding an
338
- * undeclared shape.
339
- *
340
- * @param {string} [file] - The note's path, named in the message. Omit it where
341
- * the caller emits through a diagnostic, whose locator already starts the
342
- * line — repeating it prints the path twice.
343
- * @returns {string} The message, unpunctuated at the end as a finding is.
344
- */
345
- export function traitsRetiredMessage(file) {
346
- return (
347
- "`traits:` is a retired frontmatter block — move it into `data:`" +
348
- (file ? ` — ${file}` : "") +
349
- ". A being's own description belongs in the closed container the " +
350
- "content format declares, where `being` declares every one of its " +
351
- "keys. " +
352
- "`gender`, `species`, `age` and `appearance.*` move unchanged; " +
353
- "`traits.birthday` becomes `data.born`; three reshape — " +
354
- "`traits.height.m` becomes `data.height` " +
355
- "(metres), `traits.weight.kg` becomes `data.weight` (kilograms), and " +
356
- "`traits.build.frame` becomes `data.frame`"
357
- );
358
- }
359
-
360
- /**
361
- * Refuse a note that declares a top-level `traits:` block at all.
362
- *
363
- * Presence is the whole test, as it is for `draft:` and `aliases:`: an empty
364
- * `traits:` is still a note claiming a block that no longer exists.
365
- *
366
- * @param {object|null|undefined} fm - Parsed frontmatter, or nothing when it
367
- * could not be parsed.
368
- * @param {object} [options] - Options.
369
- * @param {string} [options.file] - The note's path, named in the message. Omit
370
- * it where the caller emits through a diagnostic, which puts the locator at
371
- * the start of the line already.
372
- * @param {string} [options.absPath] - The note's file on disk, read only on the
373
- * failing path to locate the offending line and column. The position rides on
374
- * the thrown error as `position`, for a caller that emits a diagnostic.
375
- * @returns {void}
376
- * @throws {Error} When the note declares the block.
377
- */
378
- export function assertNoTraitsField(fm, { file, absPath } = {}) {
379
- if (!fm || typeof fm !== "object" || !Object.hasOwn(fm, "traits")) return;
380
-
381
- const err = new Error(`${traitsRetiredMessage(file)}.`);
382
- // Anchored at column 1. `sohl.traits` is a *different field that shares the
383
- // name* — `projectilegear` declares one and the theme's gear sidebar reads
384
- // it — so a finding about the top-level block must never open on it.
385
- const position = locateFrontmatterKey(absPath, "traits", undefined, { topLevel: true });
386
- if (position) err.position = position;
387
- throw err;
388
- }
389
-
390
- /**
391
- * A frontmatter key's position in a note's file, or nothing.
392
- *
393
- * {@link positionInFrontmatter} answers the same question from the file's
394
- * *text*; this reads the file to ask it. Kept apart from either caller because
395
- * both refusals need it and a second copy is a second thing to keep correct.
396
- *
397
- * @param {string|undefined} absPath - The note's file.
398
- * @param {string} key - The frontmatter key.
399
- * @param {string} [value] - When given, prefer the occurrence whose line also
400
- * carries this text — so a finding about one entry of a block opens on that
401
- * entry rather than on the key that introduces it.
402
- * @param {object} [options] - Options, forwarded to
403
- * {@link positionInFrontmatter}.
404
- * @param {boolean} [options.topLevel=false] - Require the key at column 1, so
405
- * an identically named nested key cannot answer for it.
406
- * @returns {{line?: number, column?: number}|undefined} Spreadable position
407
- * fields, dropped rather than guessed when the file cannot be read or the key
408
- * cannot be found — as `formatDiagnostic` requires.
409
- */
410
- export function locateFrontmatterKey(absPath, key, value = undefined, { topLevel = false } = {}) {
411
- if (!absPath) return undefined;
412
- let raw;
413
- try {
414
- raw = fs.readFileSync(absPath, "utf8");
415
- } catch {
416
- return undefined;
417
- }
418
- const at = positionInFrontmatter(raw, key, value, { topLevel });
419
- return at.line === undefined ? undefined : at;
420
- }
421
-
422
- /* -------------------------------------------------------------------- */
423
- /* Retired *in favour of another field* */
424
- /* -------------------------------------------------------------------- */
425
-
426
- /**
427
- * The current field name a retired spelling was renamed to.
428
- *
429
- * Keyed by the **current** name, because that is what a type's schema declares
430
- * and what every reader asks for; the value is the spelling still honoured.
431
- * The table is therefore scoped by the schema without saying so twice: an alias
432
- * applies to a note only where that note's type declares the current field, so
433
- * `relation` is retired on an affiliation — which declares `relations` — and
434
- * remains an unknown key anywhere else.
435
- *
436
- * **`templatePriority`.** The number that decides which of several
437
- * competing templates the Create dialog offers was called `archetype`, and
438
- * `archetypes` — one letter apart — is a list of what *sort* a character is.
439
- * A priority and a taxonomy cannot be told apart by a plural `s`, so the
440
- * priority takes the name that says what it is. The specification already
441
- * called it `templatePriority` on two of its three sides.
442
- *
443
- * **`relations`.** The field holds a *map* of standings, one per
444
- * affiliation — its own description says so, and `resolveRelation` has always
445
- * read it that way. `relation` named the many as one, which every author had to
446
- * read past. Only `affiliation` declares the field, so the alias is reported
447
- * there and the old spelling stays an ordinary unknown key everywhere else.
448
- *
449
- * **`born`.** A birthday is a day that recurs; what a note records is the date
450
- * somebody was born, once. The two read alike while every value belonged to a
451
- * living person, and `born` is what the field has to be called beside `died`,
452
- * which a being now also states. It is the first alias whose field lives in
453
- * `data:` rather than in a system block, so the two readers below take
454
- * `inData`.
455
- *
456
- * @type {Readonly<Record<string, string>>}
457
- */
458
- export const RETIRED_FIELD_ALIASES = Object.freeze({
459
- templatePriority: "archetype",
460
- relations: "relation",
461
- born: "birthday",
462
- });
463
-
464
- /**
465
- * What a note writing a renamed field is told, in one place.
466
- *
467
- * Shared by the compile-time report and the frontmatter lint, because an author
468
- * meets whichever runs first and they should read the same. It names the key to
469
- * write rather than a value to correct — no value makes the retired spelling
470
- * right — and it says the note compiles either way, so a reader knows this is a
471
- * rename to schedule rather than a build to unbreak.
472
- *
473
- * @param {string} retired - The spelling the note used.
474
- * @param {string} current - What to write instead.
475
- * @param {string} [file] - The note's path, named in the message. Omit it where
476
- * the caller emits through a diagnostic, whose locator already starts the
477
- * line — repeating it prints the path twice.
478
- * @returns {string} The message, unpunctuated at the end as a finding is.
479
- */
480
- export function retiredAliasMessage(retired, current, file) {
481
- return (
482
- `\`${retired}:\` is a retired frontmatter field — write \`${current}:\` ` +
483
- `instead` +
484
- (file ? ` — ${file}` : "") +
485
- `. Both are read and \`${current}\` wins, so the note compiles ` +
486
- `identically either way; \`${retired}\` is removed in a later release`
487
- );
488
- }
489
-
490
- /**
491
- * What a note writing a field at its **legacy in-block position** is told.
492
- *
493
- * The same three-step retirement `retiredAliasMessage` announces, applied to a
494
- * *position* rather than a spelling. A field whose shared source moved
495
- * under `data:` keeps reading the key its block still carries, that read wins,
496
- * and it is reported here so a sweep has something to count down — without it,
497
- * moving a field into `data:` would be a flag day across every repository that
498
- * authors it.
499
- *
500
- * It names the block, because the key is `hm3.species` and not `species`: an
501
- * author told to move "`species`" has two regions to guess between.
502
- *
503
- * @param {string} block - The system block the key was written in.
504
- * @param {{name?: string, legacyKey?: string}} field - The declaration, which
505
- * names both positions.
506
- * @param {string} [file] - The note's path, named in the message. Omit it where
507
- * the caller emits through a diagnostic, whose locator already starts the
508
- * line — repeating it prints the path twice.
509
- * @returns {string} The message, unpunctuated at the end as a finding is.
510
- */
511
- export function legacyKeyMessage(block, field, file) {
512
- const legacy = `${block}.${field.legacyKey}`;
513
- return (
514
- `\`${legacy}:\` is the legacy position of the shared \`${field.name}:\` ` +
515
- `— write it under \`data:\` instead` +
516
- (file ? ` — ${file}` : "") +
517
- `. Both are read and \`${legacy}\` wins, so the note compiles ` +
518
- `identically either way; the in-block key is removed in a later release`
519
- );
520
- }
521
-
522
- /**
523
- * What a note writing a field at the **top-level key `data:` gathered it off**
524
- * is told.
525
- *
526
- * {@link legacyKeyMessage}'s counterpart for the other retiring position.
527
- * `portrait:` beside `img:` is the bare spelling of `data.portrait`, and both
528
- * are read for the same reason both in-block spellings are: a package moves its
529
- * corpus when it is ready, not on a flag day.
530
- *
531
- * It says nothing about which value is emitted, because that is not what an
532
- * author needs from it. The value is the same either way; what the finding
533
- * counts is one more note still on the old position.
534
- *
535
- * @param {{name?: string}} field - The declaration, which names the current
536
- * position; the retiring one is derived from it.
537
- * @param {string} [file] - The note's path, named in the message. Omit it where
538
- * the caller emits through a diagnostic, whose locator already starts the
539
- * line — repeating it prints the path twice.
540
- * @returns {string} The message, unpunctuated at the end as a finding is.
541
- */
542
- export function retiredTopLevelMessage(field, file) {
543
- const retiring = retiredTopLevelKey(field);
544
- return (
545
- `top-level \`${retiring}:\` is the pre-\`data:\` position of the shared ` +
546
- `\`${field.name}:\` — move it under \`data:\` instead` +
547
- (file ? ` — ${file}` : "") +
548
- `. Both are read and \`${field.name}\` wins, so the note compiles ` +
549
- `identically either way; the top-level key is removed in a later release`
550
- );
551
- }
552
-
553
- /**
554
- * Whether a note writes the retired spelling of a field, wherever it put it.
555
- *
556
- * Both regions are searched, because {@link sohlField} reads both: a note that
557
- * moved the key to the top level without renaming it has done half the
558
- * migration, and should be told so rather than passing in silence.
559
- *
560
- * @param {object|null|undefined} fm - Parsed frontmatter.
561
- * @param {string} current - The field's current name.
562
- * @param {object} [options] - Options.
563
- * @param {boolean} [options.inData=false] - Search the `data:` container
564
- * instead. A `data:` field has exactly one home, so its retired spelling has
565
- * exactly one too, and searching the block or the top level for it would
566
- * answer about a key the container check has already refused.
567
- * @returns {boolean} Whether the retired spelling is declared.
568
- */
569
- export function declaresRetiredAlias(fm, current, { inData = false } = {}) {
570
- const retired = RETIRED_FIELD_ALIASES[current];
571
- if (!retired || !fm || typeof fm !== "object") return false;
572
- if (inData) return Object.hasOwn(dataContainer(fm), retired);
573
- const block = fm.sohl;
574
- const inBlock =
575
- block &&
576
- typeof block === "object" &&
577
- !Array.isArray(block) &&
578
- Object.hasOwn(block, retired);
579
- return Boolean(inBlock) || Object.hasOwn(fm, retired);
580
- }
581
-
582
- /**
583
- * A note's `data:` container, or an empty one where it authored none.
584
- *
585
- * The property editor writes an emptied map as `[]`, which means the same thing
586
- * `{}` does — this note authors no entries — so both read as empty rather than
587
- * as something to search.
588
- *
589
- * @param {object} fm - Parsed frontmatter.
590
- * @returns {object} The container.
591
- */
592
- function dataContainer(fm) {
593
- const data = fm.data;
594
- return data && typeof data === "object" && !Array.isArray(data) ? data : {};
595
- }
596
-
597
- /**
598
- * Read a field that has a retired spelling, the current name winning.
599
- *
600
- * This is the whole of the retirement window's behaviour, in one function, so
601
- * the compiler and the linter cannot disagree about which value a note carries.
602
- * Resolution within each spelling is {@link sohlField}'s — the `sohl:` block
603
- * first, then the note's top level — so a renamed field keeps working wherever
604
- * it was already written while the canonical home is the top level.
605
- *
606
- * A blank value counts as absent: `relations:` cleared in an editor means the
607
- * note records no standings there, and falling through to the retired spelling
608
- * is what an author part-way through the rename means by it.
609
- *
610
- * @param {object|null|undefined} fm - Parsed frontmatter.
611
- * @param {string} current - The field's current name.
612
- * @param {object} [options] - Options.
613
- * @param {boolean} [options.inData=false] - Read the `data:` container instead,
614
- * which is where a `data:` field and its retired spelling are both written.
615
- * Dotted names resolve inside it, so `appearance.eye_color` reads the way it
616
- * is authored.
617
- * @returns {any} The value, or `undefined` when neither spelling carries one.
618
- */
619
- export function readAliasedField(fm, current, { inData = false } = {}) {
620
- const read =
621
- inData ?
622
- /** @param {string} key */ (key) =>
623
- fm && typeof fm === "object" ?
624
- getFrontmatter(dataContainer(fm), key, undefined)
625
- : undefined
626
- : /** @param {string} key */ (key) => sohlField(fm, key, undefined);
627
- const value = read(current);
628
- if (value !== undefined && value !== null && value !== "") return value;
629
- const retired = RETIRED_FIELD_ALIASES[current];
630
- return retired ? read(retired) : undefined;
631
- }