@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
@@ -0,0 +1,567 @@
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
+ * Running the `markdownlint` library over a repository: file discovery,
16
+ * configuration files, and `--fix`.
17
+ *
18
+ * **Files.** The walk is the one the Prettier check uses (`./walk-files.mjs`), so `.git` and
19
+ * `node_modules` are never entered. When `gitignore` is on, every `.gitignore`
20
+ * met on the way down applies to its own directory and below, and an ignored
21
+ * directory is not entered. The remaining files are selected by glob, relative
22
+ * to the repository root, with dot-files matched.
23
+ *
24
+ * **Configuration.** A repository carries zero or more of these, and the nearest
25
+ * one at or above a markdown file decides how that file is linted:
26
+ *
27
+ * - an _options file_ — `.markdownlint-cli2.jsonc`, `.yaml`, `.cjs` or `.mjs`
28
+ * (the first that exists), declaring any of the keys in {@link ROOT_OPTION_KEYS};
29
+ * - a _rule file_ — `.markdownlint.jsonc`, `.json`, `.yaml`, `.yml`, `.cjs` or
30
+ * `.mjs`, holding the `config` object directly.
31
+ *
32
+ * Options merge from the root down: a key a nearer file declares replaces the
33
+ * farther one, except `config`, which merges rule by rule. A rule file replaces
34
+ * the merged `config` for the directory it sits in. A key an options file cannot
35
+ * mean in its position — `globs` or `gitignore` below the root — is refused
36
+ * with a located diagnostic, as is any key outside the closed list.
37
+ *
38
+ * @module
39
+ */
40
+
41
+ import fs from "node:fs";
42
+ import path from "node:path";
43
+ import { pathToFileURL } from "node:url";
44
+
45
+ import ignore from "ignore";
46
+ import {
47
+ findNodeAtLocation,
48
+ parse as parseJsonc,
49
+ parseTree,
50
+ printParseErrorCode,
51
+ } from "jsonc-parser";
52
+ import markdownIt from "markdown-it";
53
+ import { applyFixes, resolveModule } from "markdownlint";
54
+ import { extendConfig, lint, readConfig } from "markdownlint/promise";
55
+ import { minimatch } from "minimatch";
56
+ import { LineCounter, parse as parseYaml, parseDocument } from "yaml";
57
+
58
+ import { walkFiles } from "./walk-files.mjs";
59
+
60
+ /** Option-file names, in the order the first existing one is taken. */
61
+ const OPTIONS_FILES = Object.freeze([
62
+ ".markdownlint-cli2.jsonc",
63
+ ".markdownlint-cli2.yaml",
64
+ ".markdownlint-cli2.cjs",
65
+ ".markdownlint-cli2.mjs",
66
+ ]);
67
+
68
+ /** Rule-file names, in the order the first existing one is taken. */
69
+ const RULE_FILES = Object.freeze([
70
+ ".markdownlint.jsonc",
71
+ ".markdownlint.json",
72
+ ".markdownlint.yaml",
73
+ ".markdownlint.yml",
74
+ ".markdownlint.cjs",
75
+ ".markdownlint.mjs",
76
+ ]);
77
+
78
+ /** Keys an options file may declare anywhere. */
79
+ const NESTED_OPTION_KEYS = Object.freeze([
80
+ "config",
81
+ "customRules",
82
+ "frontMatter",
83
+ "ignores",
84
+ "noInlineConfig",
85
+ ]);
86
+
87
+ /** Keys an options file may declare at the repository root. */
88
+ export const ROOT_OPTION_KEYS = Object.freeze([...NESTED_OPTION_KEYS, "globs", "gitignore"]);
89
+
90
+ /** The parsers `readConfig` and `extendConfig` try, in order. */
91
+ const CONFIG_PARSERS = Object.freeze([parseJsoncStrict, parseYaml]);
92
+
93
+ /** A configuration problem, located as far as the source allows. */
94
+ export class MarkdownConfigError extends Error {
95
+ /**
96
+ * @param {string} file - Absolute path of the configuration file.
97
+ * @param {string} message - What is wrong.
98
+ * @param {{line?: number, column?: number}} [where] - Position, when known.
99
+ */
100
+ constructor(file, message, where = {}) {
101
+ super(message);
102
+ this.name = "MarkdownConfigError";
103
+ this.file = file;
104
+ this.line = where.line;
105
+ this.column = where.column;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Parse JSONC, throwing on any syntax error.
111
+ *
112
+ * @param {string} text - JSONC text.
113
+ * @returns {any} The parsed value.
114
+ */
115
+ function parseJsoncStrict(text) {
116
+ const errors = [];
117
+ const value = parseJsonc(text, errors, { allowTrailingComma: true });
118
+ if (errors.length) {
119
+ const first = errors[0];
120
+ throw Object.assign(new Error(`invalid JSONC: ${printParseErrorCode(first.error)}`), {
121
+ offset: first.offset,
122
+ });
123
+ }
124
+ return value;
125
+ }
126
+
127
+ /**
128
+ * Line and column (1-based) of a character offset.
129
+ *
130
+ * @param {string} text - The file text.
131
+ * @param {number} offset - 0-based character offset.
132
+ * @returns {{line: number, column: number}} The position.
133
+ */
134
+ function positionOf(text, offset) {
135
+ const before = text.slice(0, offset).split("\n");
136
+ return { line: before.length, column: before[before.length - 1].length + 1 };
137
+ }
138
+
139
+ /**
140
+ * Where a top-level key sits in a JSONC or YAML options file.
141
+ *
142
+ * @param {string} file - Absolute path.
143
+ * @param {string} text - The file text.
144
+ * @param {string} key - The top-level key.
145
+ * @returns {{line?: number, column?: number}} The position, or nothing when the
146
+ * file is a module and has no source position for the key.
147
+ */
148
+ function locateKey(file, text, key) {
149
+ try {
150
+ if (file.endsWith(".jsonc")) {
151
+ const tree = parseTree(text, [], { allowTrailingComma: true });
152
+ const node = tree && findNodeAtLocation(tree, [key]);
153
+ const keyNode = node?.parent?.children?.[0];
154
+ return keyNode ? positionOf(text, keyNode.offset) : {};
155
+ }
156
+ if (file.endsWith(".yaml") || file.endsWith(".yml")) {
157
+ const lines = new LineCounter();
158
+ const doc = parseDocument(text, { lineCounter: lines });
159
+ const pair = doc.contents?.items?.find((item) => item.key?.value === key);
160
+ if (!pair?.key?.range) return {};
161
+ const { line, col } = lines.linePos(pair.key.range[0]);
162
+ return { line, column: col };
163
+ }
164
+ } catch {
165
+ // Fall through: an unlocatable key is reported against its file.
166
+ }
167
+ return {};
168
+ }
169
+
170
+ /**
171
+ * Read a JSONC or YAML file as a plain value, locating a syntax error.
172
+ *
173
+ * @param {string} file - Absolute path.
174
+ * @param {string} text - The file text.
175
+ * @returns {any} The parsed value.
176
+ */
177
+ function parseOptionsText(file, text) {
178
+ try {
179
+ return file.endsWith(".jsonc") ? parseJsoncStrict(text) : parseYaml(text);
180
+ } catch (err) {
181
+ const where = typeof err.offset === "number" ? positionOf(text, err.offset) : {};
182
+ const yamlAt = err.linePos?.[0];
183
+ throw new MarkdownConfigError(
184
+ file,
185
+ String(err.message).split("\n")[0],
186
+ yamlAt ? { line: yamlAt.line, column: yamlAt.col } : where,
187
+ );
188
+ }
189
+ }
190
+
191
+ /** @param {unknown} value - Anything. @returns {boolean} Whether it is a plain object. */
192
+ const isPlainObject = (value) =>
193
+ value !== null && typeof value === "object" && !Array.isArray(value);
194
+
195
+ /**
196
+ * Import a module's default export.
197
+ *
198
+ * @param {string} file - Absolute path.
199
+ * @returns {Promise<any>} The default export.
200
+ */
201
+ async function importDefault(file) {
202
+ try {
203
+ return (await import(pathToFileURL(file).href)).default;
204
+ } catch (err) {
205
+ throw new MarkdownConfigError(file, `cannot be loaded: ${err.message}`);
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Resolve custom rules named by module id, relative to a directory.
211
+ *
212
+ * @param {string} file - The options file, for diagnostics.
213
+ * @param {string} dir - Directory ids resolve from.
214
+ * @param {unknown[]} rules - Module ids and rule objects.
215
+ * @returns {Promise<object[]>} The rule objects.
216
+ */
217
+ async function resolveCustomRules(file, dir, rules) {
218
+ const out = [];
219
+ for (const rule of rules) {
220
+ if (typeof rule !== "string") {
221
+ out.push(rule);
222
+ continue;
223
+ }
224
+ let resolved;
225
+ try {
226
+ resolved = pathToFileURL(resolveModule(rule, [dir])).href;
227
+ } catch {
228
+ resolved = pathToFileURL(path.resolve(dir, rule)).href;
229
+ }
230
+ let loaded;
231
+ try {
232
+ loaded = (await import(resolved)).default;
233
+ } catch (err) {
234
+ throw new MarkdownConfigError(
235
+ file,
236
+ `customRules: cannot load "${rule}": ${err.message}`,
237
+ );
238
+ }
239
+ out.push(...[loaded].flat());
240
+ }
241
+ return out;
242
+ }
243
+
244
+ /**
245
+ * Validate an options object and resolve what needs resolving.
246
+ *
247
+ * @param {string} file - Absolute path of the options file.
248
+ * @param {string} text - Source text, or "" for a module.
249
+ * @param {any} raw - The parsed options.
250
+ * @param {boolean} isRoot - Whether the file sits at the repository root.
251
+ * @returns {Promise<object>} The validated options.
252
+ */
253
+ async function validateOptions(file, text, raw, isRoot) {
254
+ const refuse = (key, message) => {
255
+ throw new MarkdownConfigError(file, message, locateKey(file, text, key));
256
+ };
257
+ if (!isPlainObject(raw)) throw new MarkdownConfigError(file, "must declare an object");
258
+ const allowed = isRoot ? ROOT_OPTION_KEYS : NESTED_OPTION_KEYS;
259
+ for (const key of Object.keys(raw)) {
260
+ if (allowed.includes(key)) continue;
261
+ refuse(
262
+ key,
263
+ ROOT_OPTION_KEYS.includes(key) ?
264
+ `\`${key}\` can only be declared in the repository root's options file`
265
+ : `unknown option \`${key}\`; accepted: ${allowed.join(", ")}`,
266
+ );
267
+ }
268
+ const strings = (key) => {
269
+ if (
270
+ raw[key] !== undefined &&
271
+ !(Array.isArray(raw[key]) && raw[key].every((v) => typeof v === "string"))
272
+ ) {
273
+ refuse(key, `\`${key}\` must be an array of strings`);
274
+ }
275
+ };
276
+ strings("globs");
277
+ strings("ignores");
278
+ for (const key of ["gitignore", "noInlineConfig"]) {
279
+ if (raw[key] !== undefined && typeof raw[key] !== "boolean") {
280
+ refuse(key, `\`${key}\` must be true or false`);
281
+ }
282
+ }
283
+ if (raw.config !== undefined && !isPlainObject(raw.config)) {
284
+ refuse("config", "`config` must be an object of rule settings");
285
+ }
286
+ if (raw.frontMatter !== undefined) {
287
+ try {
288
+ new RegExp(raw.frontMatter, "u");
289
+ } catch {
290
+ refuse("frontMatter", "`frontMatter` must be a regular expression source string");
291
+ }
292
+ }
293
+ if (raw.customRules !== undefined && !Array.isArray(raw.customRules)) {
294
+ refuse("customRules", "`customRules` must be an array of module ids or rule objects");
295
+ }
296
+ const options = { ...raw };
297
+ if (raw.customRules) {
298
+ options.customRules = await resolveCustomRules(file, path.dirname(file), raw.customRules);
299
+ }
300
+ if (isPlainObject(raw.config) && raw.config.extends !== undefined) {
301
+ options.config = await extendRules(file, raw.config);
302
+ }
303
+ return options;
304
+ }
305
+
306
+ /**
307
+ * Apply a configuration's `extends` chain.
308
+ *
309
+ * @param {string} file - The file the configuration came from.
310
+ * @param {object} config - A configuration holding an `extends` key.
311
+ * @returns {Promise<object>} The configuration with the chain resolved.
312
+ */
313
+ async function extendRules(file, config) {
314
+ try {
315
+ return await extendConfig(config, file, [...CONFIG_PARSERS]);
316
+ } catch (err) {
317
+ throw new MarkdownConfigError(file, `cannot resolve \`extends\`: ${err.message}`);
318
+ }
319
+ }
320
+
321
+ /**
322
+ * The first of `names` that exists in `dir`.
323
+ *
324
+ * @param {string} dir - Directory to look in.
325
+ * @param {readonly string[]} names - Candidate file names.
326
+ * @returns {string | undefined} Absolute path of the match.
327
+ */
328
+ function firstExisting(dir, names) {
329
+ return names.map((name) => path.join(dir, name)).find((file) => fs.existsSync(file));
330
+ }
331
+
332
+ /**
333
+ * Load a directory's options file and rule file.
334
+ *
335
+ * @param {string} dir - Absolute directory.
336
+ * @param {boolean} isRoot - Whether it is the repository root.
337
+ * @returns {Promise<{dir: string, options?: object, ruleConfig?: object}>} What the
338
+ * directory declares.
339
+ */
340
+ async function loadDirectory(dir, isRoot) {
341
+ const layer = { dir };
342
+ const optionsFile = firstExisting(dir, OPTIONS_FILES);
343
+ if (optionsFile) {
344
+ const isModule = /\.[cm]js$/u.test(optionsFile);
345
+ const text = isModule ? "" : fs.readFileSync(optionsFile, "utf8");
346
+ const raw =
347
+ isModule ? await importDefault(optionsFile) : parseOptionsText(optionsFile, text);
348
+ layer.options = await validateOptions(optionsFile, text, raw, isRoot);
349
+ }
350
+ const ruleFile = firstExisting(dir, RULE_FILES);
351
+ if (ruleFile) {
352
+ try {
353
+ layer.ruleConfig =
354
+ /\.[cm]js$/u.test(ruleFile) ?
355
+ await extendRules(ruleFile, await importDefault(ruleFile))
356
+ : await readConfig(ruleFile, [...CONFIG_PARSERS]);
357
+ } catch (err) {
358
+ if (err instanceof MarkdownConfigError) throw err;
359
+ throw new MarkdownConfigError(ruleFile, `cannot be read: ${err.message}`);
360
+ }
361
+ }
362
+ return layer;
363
+ }
364
+
365
+ /**
366
+ * Merge options: later keys replace earlier ones, `config` merges rule by rule.
367
+ *
368
+ * @param {object} first - Farther options.
369
+ * @param {object | undefined} second - Nearer options.
370
+ * @returns {object} The merged options.
371
+ */
372
+ function mergeOptions(first, second) {
373
+ const merged = { ...first, ...second };
374
+ if (first.config || second?.config) merged.config = { ...first.config, ...second?.config };
375
+ return merged;
376
+ }
377
+
378
+ /** @param {string} p - A path. @returns {string} The path with POSIX separators. */
379
+ const posix = (p) => p.split(path.sep).join("/");
380
+
381
+ /**
382
+ * Normalise a command-line or configuration glob.
383
+ *
384
+ * `#pattern` negates like `!pattern`; a directory that exists selects its markdown;
385
+ * a leading `./` is dropped.
386
+ *
387
+ * @param {string} root - Repository root.
388
+ * @param {string} glob - The pattern.
389
+ * @returns {{negated: boolean, pattern: string}} The pattern and its sense.
390
+ */
391
+ function normaliseGlob(root, glob) {
392
+ const negated = glob.startsWith("!") || glob.startsWith("#");
393
+ let pattern = negated ? glob.slice(1) : glob;
394
+ if (path.isAbsolute(pattern)) pattern = posix(path.relative(root, pattern));
395
+ pattern = pattern.replace(/^\.\//u, "");
396
+ if (pattern === "." || pattern === "") pattern = "**/*.md";
397
+ else if (
398
+ fs.existsSync(path.join(root, pattern)) &&
399
+ fs.statSync(path.join(root, pattern)).isDirectory()
400
+ ) {
401
+ pattern = `${pattern.replace(/\/+$/u, "")}/**/*.md`;
402
+ }
403
+ return { negated, pattern };
404
+ }
405
+
406
+ /**
407
+ * Ignore-file handling: a path is ignored when any `.gitignore` at or above its
408
+ * directory (inside the root) says so.
409
+ *
410
+ * @param {string} root - Absolute repository root.
411
+ * @returns {{enter: (dir: string) => void, ignored: (abs: string, isDirectory: boolean) => boolean}}
412
+ * Hooks for {@link walkFiles}.
413
+ */
414
+ function gitignoreHooks(root) {
415
+ /** @type {Map<string, ReturnType<typeof ignore>>} */
416
+ const matchers = new Map();
417
+ return {
418
+ enter(dir) {
419
+ const file = path.join(dir, ".gitignore");
420
+ if (fs.existsSync(file)) matchers.set(dir, ignore().add(fs.readFileSync(file, "utf8")));
421
+ },
422
+ ignored(abs, isDirectory) {
423
+ let dir = path.dirname(abs);
424
+ for (;;) {
425
+ const matcher = matchers.get(dir);
426
+ if (matcher) {
427
+ const rel = posix(path.relative(dir, abs));
428
+ if (matcher.ignores(isDirectory ? `${rel}/` : rel)) return true;
429
+ }
430
+ if (dir === root) return false;
431
+ const parent = path.dirname(dir);
432
+ if (parent === dir) return false;
433
+ dir = parent;
434
+ }
435
+ },
436
+ };
437
+ }
438
+
439
+ /**
440
+ * Lint markdown under a root with the `markdownlint` library.
441
+ *
442
+ * @param {object} params
443
+ * @param {string} params.directory - Repository root.
444
+ * @param {readonly string[]} [params.globs] - Globs selecting the files; when
445
+ * absent, `defaults.globs` plus the root options file's `globs`.
446
+ * @param {object} params.defaults - Options the root options file overrides:
447
+ * `config`, `ignores`, `gitignore` and `globs`.
448
+ * @param {boolean} [params.fix=false] - Repair what markdownlint can, then
449
+ * report what remains.
450
+ * @returns {Promise<{results: object[]}>} markdownlint results, each with its
451
+ * `fileName` relative to `directory`, ordered by file, line and rule.
452
+ * @throws {MarkdownConfigError} A configuration file the contract refuses.
453
+ */
454
+ export async function runMarkdownlint(params) {
455
+ const { globs, defaults, fix = false } = params;
456
+ const root = path.resolve(params.directory);
457
+
458
+ /** @type {Map<string, object>} */
459
+ const layers = new Map();
460
+ const layerOf = async (dir) => {
461
+ if (!layers.has(dir)) layers.set(dir, await loadDirectory(dir, dir === root));
462
+ return layers.get(dir);
463
+ };
464
+
465
+ const rootLayer = await layerOf(root);
466
+ const rootOptions = mergeOptions(defaults, rootLayer.options);
467
+
468
+ // Discovery.
469
+ const git = rootOptions.gitignore ? gitignoreHooks(root) : undefined;
470
+ const candidates = walkFiles(root, {
471
+ enter: (dir) => git?.enter(dir),
472
+ skip: (abs, isDirectory) => Boolean(git?.ignored(abs, isDirectory)),
473
+ });
474
+
475
+ const selectors = (
476
+ globs?.length ?
477
+ [...globs]
478
+ : [...(defaults.globs ?? []), ...(rootLayer.options?.globs ?? [])]).map((glob) =>
479
+ normaliseGlob(root, glob),
480
+ );
481
+ const include = selectors.filter((s) => !s.negated).map((s) => s.pattern);
482
+ const exclude = [
483
+ ...selectors.filter((s) => s.negated).map((s) => s.pattern),
484
+ ...(rootOptions.ignores ?? []),
485
+ ];
486
+ const matches = (rel, patterns) => patterns.some((p) => minimatch(rel, p, { dot: true }));
487
+ const selected = candidates.filter((abs) => {
488
+ const rel = posix(path.relative(root, abs));
489
+ return matches(rel, include) && !matches(rel, exclude);
490
+ });
491
+
492
+ // Group files by the nearest directory that declares configuration.
493
+ /** @type {Map<string, string[]>} */
494
+ const groups = new Map();
495
+ const chainOf = async (dir) => {
496
+ const chain = [];
497
+ for (let d = dir; ; d = path.dirname(d)) {
498
+ chain.unshift(await layerOf(d));
499
+ if (d === root) break;
500
+ }
501
+ return chain;
502
+ };
503
+ for (const abs of selected) {
504
+ const chain = await chainOf(path.dirname(abs));
505
+ const nestedIgnored = chain.slice(1).some((layer) => {
506
+ const patterns = layer.options?.ignores ?? [];
507
+ return matches(posix(path.relative(layer.dir, abs)), patterns);
508
+ });
509
+ if (nestedIgnored) continue;
510
+ const owner =
511
+ [...chain].reverse().find((layer) => layer.options || layer.ruleConfig) ?? chain[0];
512
+ if (!groups.has(owner.dir)) groups.set(owner.dir, []);
513
+ groups.get(owner.dir).push(abs);
514
+ }
515
+
516
+ const lintOptions = async (dir, files) => {
517
+ const chain = await chainOf(dir);
518
+ let merged = defaults;
519
+ for (const layer of chain) merged = mergeOptions(merged, layer.options);
520
+ const owner = chain[chain.length - 1];
521
+ return {
522
+ files,
523
+ config: owner.ruleConfig ?? merged.config,
524
+ customRules: merged.customRules,
525
+ frontMatter: merged.frontMatter ? new RegExp(merged.frontMatter, "u") : undefined,
526
+ handleRuleFailures: true,
527
+ markdownItFactory: () => markdownIt({ html: true }),
528
+ noInlineConfig: Boolean(merged.noInlineConfig),
529
+ };
530
+ };
531
+
532
+ const resultsByFile = new Map();
533
+ for (const [dir, files] of groups) {
534
+ const options = await lintOptions(dir, files);
535
+ let results = await lint(options);
536
+ if (fix) {
537
+ const toFix = [];
538
+ for (const file of files) {
539
+ const fixable = (results[file] ?? []).filter((error) => error.fixInfo);
540
+ if (!fixable.length) continue;
541
+ const original = fs.readFileSync(file, "utf8");
542
+ fs.writeFileSync(file, applyFixes(original, fixable), "utf8");
543
+ toFix.push(file);
544
+ }
545
+ if (toFix.length)
546
+ results = { ...results, ...(await lint({ ...options, files: toFix })) };
547
+ }
548
+ for (const [file, errors] of Object.entries(results)) resultsByFile.set(file, errors);
549
+ }
550
+
551
+ // Order: file, line, rule id, then discovery order.
552
+ const flat = [];
553
+ for (const [file, errors] of resultsByFile) {
554
+ for (const error of errors) {
555
+ flat.push({ fileName: posix(path.relative(root, path.resolve(root, file))), ...error });
556
+ }
557
+ }
558
+ const order = new Map(flat.map((result, index) => [result, index]));
559
+ flat.sort(
560
+ (a, b) =>
561
+ a.fileName.localeCompare(b.fileName) ||
562
+ a.lineNumber - b.lineNumber ||
563
+ a.ruleNames[0].localeCompare(b.ruleNames[0]) ||
564
+ order.get(a) - order.get(b),
565
+ );
566
+ return { results: flat };
567
+ }
@@ -79,7 +79,9 @@ const STAMP = ".complete";
79
79
  * `conflicts` are declarations *about* other packages rather than dependencies
80
80
  * on them, so an address resolved through one would emit a link into a package
81
81
  * the consumer does not require — a defect in the citing note, not a lookup to
82
- * satisfy.
82
+ * satisfy. A relationship of either kind that declares `itemCatalog: true` is
83
+ * the exception: its items are embedded in this package's own documents, so
84
+ * its index is read whatever its kind (see {@link metadataRelationships}).
83
85
  *
84
86
  * @type {readonly string[]}
85
87
  */
@@ -96,6 +98,12 @@ export const METADATA_RELATIONSHIP_KINDS = Object.freeze(["systems", "requires"]
96
98
  * and needing no items is the mirror of it. Gating the index on the catalogue
97
99
  * flag would serve neither.
98
100
  *
101
+ * **Includes a relationship of any kind declaring `itemCatalog: true`.** The
102
+ * compile resolves an embedded item through that catalogue however the
103
+ * relationship is declared — a `recommends` entry keeps a module installable
104
+ * under any system and still supplies the items its beings embed — so the lint
105
+ * resolves the same addresses through the same package's index.
106
+ *
99
107
  * **Excludes a relationship declaring `contentIndex: false`.** That opts a
100
108
  * dependency out of both edges at once: it is a Foundry dependency only, cited
101
109
  * by neither a wikilink nor an item reference, so there is nothing here for
@@ -113,9 +121,19 @@ export const METADATA_RELATIONSHIP_KINDS = Object.freeze(["systems", "requires"]
113
121
  */
114
122
  export function metadataRelationships(config) {
115
123
  const out = [];
116
- for (const kind of METADATA_RELATIONSHIP_KINDS) {
124
+ const seen = new Set();
125
+ const declared = Object.keys(config?.relationships ?? {});
126
+ const kinds = [
127
+ ...METADATA_RELATIONSHIP_KINDS,
128
+ ...declared.filter((kind) => !METADATA_RELATIONSHIP_KINDS.includes(kind)),
129
+ ];
130
+ for (const kind of kinds) {
131
+ const dependency = METADATA_RELATIONSHIP_KINDS.includes(kind);
117
132
  for (const rel of config?.relationships?.[kind] ?? []) {
118
133
  if (rel.contentIndex === false) continue;
134
+ if (!dependency && rel.itemCatalog !== true) continue;
135
+ if (seen.has(rel.id)) continue;
136
+ seen.add(rel.id);
119
137
  out.push({
120
138
  id: rel.id,
121
139
  manifest: rel.manifest,
@@ -369,6 +387,8 @@ export function loadForeignIndexes(config, localPackages, bases = PACKAGE_BASE)
369
387
  *
370
388
  * @param {string} pkg - The package that published them.
371
389
  * @param {Array<Record<string, any>>} records - Its index records.
390
+ * @param {string} [sourceFile] - The cached file they were read from;
391
+ * absent for records the toolchain generates itself.
372
392
  * @returns {void}
373
393
  */
374
394
  const ingest = (pkg, records, sourceFile) => {
@@ -384,18 +404,24 @@ export function loadForeignIndexes(config, localPackages, bases = PACKAGE_BASE)
384
404
  const base = bases?.[pkg];
385
405
  const web = typeof base === "string" && base.length > 0;
386
406
 
407
+ // The file line of each record, so a decode finding lands on its own.
408
+ const recordLines = [];
409
+ if (sourceFile)
410
+ fs.readFileSync(sourceFile, "utf8")
411
+ .split("\n")
412
+ .forEach((text, at) => text.trim() && recordLines.push(at + 1));
413
+
387
414
  const pageAliases = [];
388
415
  for (const [recordIndex, record] of records.entries()) {
389
- try {
390
- decodeIndexAddresses(record, noteAddressContext(config));
391
- } catch (error) {
392
- error.file = sourceFile;
393
- const lines = sourceFile ? fs.readFileSync(sourceFile, "utf8").split("\n") : [];
394
- let nonempty = -1;
395
- const line = lines.findIndex((text) => text.trim() && ++nonempty === recordIndex);
396
- error.position = line >= 0 ? { line: line + 1 } : {};
397
- throw error;
398
- }
416
+ decodeIndexAddresses(
417
+ record,
418
+ noteAddressContext(config),
419
+ sourceFile && {
420
+ package: pkg,
421
+ file: sourceFile,
422
+ ...(recordLines[recordIndex] ? { line: recordLines[recordIndex] } : {}),
423
+ },
424
+ );
399
425
  let key = encodeAddresses(record?.address?.canonical);
400
426
  if (!key) continue;
401
427
  let parts = readCanonicalKey(key);
@@ -454,6 +480,10 @@ export function loadForeignIndexes(config, localPackages, bases = PACKAGE_BASE)
454
480
  borders: record.data?.borders ?? undefined,
455
481
  routes: record.data?.routes ?? undefined,
456
482
  parents: record.data?.parents ?? undefined,
483
+ // The culture the publishing package resolved, so a consumer's
484
+ // place or affiliation inherits from a parent here without
485
+ // walking that parent's own chain.
486
+ culture: record.data?.culture ?? undefined,
457
487
  population: record.data?.population ?? undefined,
458
488
  ...(Object.hasOwn(record.data ?? {}, "government") ?
459
489
  { government: record.data.government }