@heroiclands/package-build 0.6.1 → 3.0.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 (127) hide show
  1. package/CHANGELOG-content-build.md +965 -0
  2. package/CHANGELOG.md +68 -0
  3. package/CONTENT.md +824 -0
  4. package/MIGRATING.md +127 -0
  5. package/README.md +29 -17
  6. package/bin/content-build.mjs +1082 -0
  7. package/bin/package-build.mjs +2 -2
  8. package/bin/report.mjs +2 -2
  9. package/config.mjs +21 -18
  10. package/content-config.mjs +1424 -0
  11. package/coverage.mjs +1 -1
  12. package/e2e.mjs +1 -1
  13. package/engine/abbreviations.mjs +0 -0
  14. package/engine/base-compiler.mjs +608 -0
  15. package/engine/code-fences.mjs +311 -0
  16. package/engine/compendiums.mjs +353 -0
  17. package/engine/content-address.mjs +141 -0
  18. package/engine/content-links.mjs +438 -0
  19. package/engine/content-lint.mjs +204 -0
  20. package/engine/content-package.mjs +67 -0
  21. package/engine/content-slug.mjs +169 -0
  22. package/engine/content-tables.mjs +1216 -0
  23. package/engine/content-tree.mjs +56 -0
  24. package/engine/diagnostics.mjs +270 -0
  25. package/engine/field-reference.mjs +242 -0
  26. package/engine/field-spec.mjs +218 -0
  27. package/engine/foreign-catalog.mjs +450 -0
  28. package/engine/foreign-manifests.mjs +129 -0
  29. package/engine/frontmatter-lint.mjs +343 -0
  30. package/engine/frontmatter.mjs +258 -0
  31. package/engine/generate.mjs +305 -0
  32. package/engine/helpers.mjs +809 -0
  33. package/engine/ids.mjs +192 -0
  34. package/engine/index.mjs +142 -0
  35. package/engine/item-docs.mjs +153 -0
  36. package/engine/item-registry.mjs +129 -0
  37. package/engine/journals.mjs +374 -0
  38. package/engine/kb-manifest.mjs +473 -0
  39. package/engine/macros.mjs +333 -0
  40. package/engine/manifest-emit.mjs +339 -0
  41. package/engine/map-notes.mjs +1302 -0
  42. package/engine/pack-config.mjs +479 -0
  43. package/engine/pack-router.mjs +254 -0
  44. package/engine/prose-config.mjs +238 -0
  45. package/engine/prose-lint.mjs +294 -0
  46. package/engine/region-events.mjs +79 -0
  47. package/engine/scene-levels.mjs +420 -0
  48. package/engine/scenes.mjs +528 -0
  49. package/engine/site-build.mjs +778 -0
  50. package/engine/site-index.mjs +332 -0
  51. package/engine/web-wikilinks.mjs +330 -0
  52. package/engine/wikilink-syntax.mjs +105 -0
  53. package/engine/wikilinks.mjs +645 -0
  54. package/index.mjs +42 -25
  55. package/lang.mjs +1 -1
  56. package/manifest.mjs +79 -5
  57. package/markdownlint-config.mjs +45 -0
  58. package/package.json +78 -35
  59. package/prettier-config.mjs +34 -0
  60. package/sohl/actors.mjs +581 -0
  61. package/sohl/affiliation-standings.mjs +44 -0
  62. package/sohl/being-info.mjs +173 -0
  63. package/sohl/default-item-art.mjs +82 -0
  64. package/sohl/index.mjs +55 -0
  65. package/sohl/item-builders.mjs +114 -0
  66. package/sohl/item-fields.mjs +867 -0
  67. package/sohl/items.mjs +197 -0
  68. package/sohl/kb-passes.mjs +207 -0
  69. package/sohl/note-schemas.mjs +322 -0
  70. package/templates.mjs +1 -1
  71. package/types/config.d.mts +1 -1
  72. package/types/content-config.d.mts +571 -0
  73. package/types/engine/abbreviations.d.mts +44 -0
  74. package/types/engine/base-compiler.d.mts +320 -0
  75. package/types/engine/code-fences.d.mts +102 -0
  76. package/types/engine/compendiums.d.mts +77 -0
  77. package/types/engine/content-address.d.mts +61 -0
  78. package/types/engine/content-links.d.mts +87 -0
  79. package/types/engine/content-lint.d.mts +47 -0
  80. package/types/engine/content-package.d.mts +36 -0
  81. package/types/engine/content-slug.d.mts +59 -0
  82. package/types/engine/content-tables.d.mts +131 -0
  83. package/types/engine/content-tree.d.mts +12 -0
  84. package/types/engine/diagnostics.d.mts +152 -0
  85. package/types/engine/field-reference.d.mts +20 -0
  86. package/types/engine/field-spec.d.mts +181 -0
  87. package/types/engine/foreign-catalog.d.mts +103 -0
  88. package/types/engine/foreign-manifests.d.mts +43 -0
  89. package/types/engine/frontmatter-lint.d.mts +74 -0
  90. package/types/engine/frontmatter.d.mts +99 -0
  91. package/types/engine/generate.d.mts +56 -0
  92. package/types/engine/helpers.d.mts +348 -0
  93. package/types/engine/ids.d.mts +126 -0
  94. package/types/engine/index.d.mts +33 -0
  95. package/types/engine/item-docs.d.mts +73 -0
  96. package/types/engine/item-registry.d.mts +57 -0
  97. package/types/engine/journals.d.mts +121 -0
  98. package/types/engine/kb-manifest.d.mts +214 -0
  99. package/types/engine/macros.d.mts +201 -0
  100. package/types/engine/manifest-emit.d.mts +137 -0
  101. package/types/engine/map-notes.d.mts +283 -0
  102. package/types/engine/pack-config.d.mts +77 -0
  103. package/types/engine/pack-router.d.mts +53 -0
  104. package/types/engine/prose-config.d.mts +121 -0
  105. package/types/engine/prose-lint.d.mts +59 -0
  106. package/types/engine/region-events.d.mts +43 -0
  107. package/types/engine/scene-levels.d.mts +36 -0
  108. package/types/engine/scenes.d.mts +48 -0
  109. package/types/engine/site-build.d.mts +214 -0
  110. package/types/engine/site-index.d.mts +141 -0
  111. package/types/engine/web-wikilinks.d.mts +67 -0
  112. package/types/engine/wikilink-syntax.d.mts +101 -0
  113. package/types/engine/wikilinks.d.mts +172 -0
  114. package/types/index.d.mts +3 -0
  115. package/types/manifest.d.mts +52 -4
  116. package/types/markdownlint-config.d.mts +8 -0
  117. package/types/prettier-config.d.mts +1 -0
  118. package/types/sohl/actors.d.mts +65 -0
  119. package/types/sohl/affiliation-standings.d.mts +22 -0
  120. package/types/sohl/being-info.d.mts +78 -0
  121. package/types/sohl/default-item-art.d.mts +28 -0
  122. package/types/sohl/index.d.mts +7 -0
  123. package/types/sohl/item-builders.d.mts +11 -0
  124. package/types/sohl/item-fields.d.mts +20 -0
  125. package/types/sohl/items.d.mts +19 -0
  126. package/types/sohl/kb-passes.d.mts +71 -0
  127. package/types/sohl/note-schemas.d.mts +7 -0
@@ -0,0 +1,294 @@
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 shared prose conventions over a repository — Prettier for
16
+ * formatting, markdownlint for the structure Prettier is indifferent to (#69).
17
+ *
18
+ * Both are thin: the rules live in `./prose-config.mjs` and the tools are
19
+ * Prettier and markdownlint themselves. What this module adds is the two things
20
+ * a consumer would otherwise have to get right on its own — supplying the
21
+ * shared configuration *as a default a local one overrides*, and reporting
22
+ * findings in the one parseable form every check in this package emits (#17).
23
+ *
24
+ * **Neither tool's own file discovery is reimplemented.** Prettier decides what
25
+ * it formats and what an ignore file excludes, through `getFileInfo`;
26
+ * markdownlint expands its own globs and honours `.gitignore` itself. A second
27
+ * implementation of either would drift from the tool it stands in for, and the
28
+ * whole point of the command is that `content-build format --check` and a bare
29
+ * `prettier --check .` report the same thing.
30
+ *
31
+ * @module
32
+ */
33
+
34
+ import fs from "node:fs";
35
+ import path from "node:path";
36
+
37
+ import {
38
+ MARKDOWNLINT_CONFIG,
39
+ MARKDOWN_GLOBS,
40
+ MARKDOWN_IGNORES,
41
+ sharedPrettierOptionsFor,
42
+ } from "./prose-config.mjs";
43
+
44
+ /**
45
+ * Directories never walked, whatever the ignore files say.
46
+ *
47
+ * `node_modules` is in every consumer's `.gitignore` and would be excluded
48
+ * anyway; skipping it in the walk rather than per file is the difference
49
+ * between a check that takes a second and one that stats a hundred thousand
50
+ * files to be told each is ignored. `.git` is in nobody's `.gitignore`, so it
51
+ * has to be named.
52
+ */
53
+ const NEVER_WALK = Object.freeze(new Set([".git", "node_modules"]));
54
+
55
+ /**
56
+ * The ignore files Prettier consults, in its own default order.
57
+ *
58
+ * Stated explicitly because passing `ignorePath` at all replaces the default,
59
+ * and the default is both of these — dropping `.gitignore` would start
60
+ * reporting on build output.
61
+ */
62
+ const IGNORE_FILES = Object.freeze([".gitignore", ".prettierignore"]);
63
+
64
+ /**
65
+ * Every file under a root, minus the directories nothing should walk.
66
+ *
67
+ * @param {string} root - Absolute path to walk.
68
+ * @returns {string[]} Absolute file paths, in directory order.
69
+ */
70
+ function walkFiles(root) {
71
+ const out = [];
72
+ /** @param {string} dir - Directory to descend into. */
73
+ const descend = (dir) => {
74
+ let entries;
75
+ try {
76
+ entries = fs.readdirSync(dir, { withFileTypes: true });
77
+ } catch {
78
+ // A directory that vanished or cannot be read is not a finding
79
+ // about prose; leave it to whatever owns it.
80
+ return;
81
+ }
82
+ for (const entry of entries) {
83
+ const abs = path.join(dir, entry.name);
84
+ if (entry.isDirectory()) {
85
+ if (!NEVER_WALK.has(entry.name)) descend(abs);
86
+ } else if (entry.isFile()) {
87
+ out.push(abs);
88
+ }
89
+ }
90
+ };
91
+ descend(root);
92
+ return out;
93
+ }
94
+
95
+ /**
96
+ * Check — or rewrite — every file Prettier claims, under one root.
97
+ *
98
+ * The configuration resolution is the part that matters: a consumer's own
99
+ * Prettier config, found by Prettier walking up from each file, always wins.
100
+ * {@link sharedPrettierOptionsFor} is what a file gets when that search finds
101
+ * nothing, which is the case in a repository that has deliberately declared
102
+ * none.
103
+ *
104
+ * @param {string} root - Repository (or subtree) to check.
105
+ * @param {object} [opts]
106
+ * @param {readonly string[]} [opts.paths] - Files or directories to check
107
+ * instead of the whole root.
108
+ * @param {boolean} [opts.write=false] - Rewrite unformatted files in place
109
+ * rather than reporting them.
110
+ * @param {object} [opts.prettier] - The Prettier module, for tests.
111
+ * @returns {Promise<{findings: Array<{file: string, severity: string,
112
+ * message: string}>, checked: number, written: string[]}>} The findings, how
113
+ * many files were considered, and what was rewritten.
114
+ */
115
+ export async function checkFormatting(root, opts = {}) {
116
+ const { paths, write = false } = opts;
117
+ const prettier = opts.prettier ?? (await import("prettier"));
118
+ const base = path.resolve(root);
119
+ const ignorePath = IGNORE_FILES.map((name) => path.join(base, name)).filter(
120
+ (file) => fs.existsSync(file),
121
+ );
122
+
123
+ const roots =
124
+ paths?.length ?
125
+ paths.map((entry) => path.resolve(base, entry))
126
+ : [base];
127
+ const candidates = roots.flatMap((entry) =>
128
+ fs.existsSync(entry) && fs.statSync(entry).isDirectory() ?
129
+ walkFiles(entry)
130
+ : [entry],
131
+ );
132
+
133
+ const findings = [];
134
+ const written = [];
135
+ let checked = 0;
136
+
137
+ for (const file of candidates) {
138
+ const info = await prettier.getFileInfo(file, { ignorePath });
139
+ // `ignored` is the ignore files' answer; a null parser means Prettier
140
+ // has no opinion about this kind of file at all (an image, a lockfile
141
+ // it was told to skip). Neither is a finding.
142
+ if (info.ignored || !info.inferredParser) continue;
143
+ checked += 1;
144
+
145
+ const local = await prettier.resolveConfig(file, {
146
+ editorconfig: false,
147
+ });
148
+ // `resolveConfig` has already applied any `overrides` the consumer's
149
+ // own config declares. The shared fallback has to apply its own, since
150
+ // Prettier ignores an `overrides` block passed inline (#76).
151
+ const options = {
152
+ ...(local ?? sharedPrettierOptionsFor(file)),
153
+ filepath: file,
154
+ };
155
+ const source = fs.readFileSync(file, "utf8");
156
+
157
+ // A file Prettier cannot parse is a finding, not a crash. Its own CLI
158
+ // reports the syntax error and carries on to the next file, and a
159
+ // single unparseable file in a large tree must not cost the report on
160
+ // every other one.
161
+ try {
162
+ if (write) {
163
+ const formatted = await prettier.format(source, options);
164
+ if (formatted !== source) {
165
+ fs.writeFileSync(file, formatted);
166
+ written.push(file);
167
+ }
168
+ continue;
169
+ }
170
+
171
+ if (!(await prettier.check(source, options))) {
172
+ findings.push({
173
+ file,
174
+ severity: "error",
175
+ // No line or column: Prettier's answer is about the whole
176
+ // file, and #17's rule is to drop a field rather than
177
+ // invent one.
178
+ message:
179
+ "is not formatted; run `content-build format --write` to fix it",
180
+ });
181
+ }
182
+ } catch (err) {
183
+ // Prettier hangs a `loc` off a syntax error, shaped `{start:
184
+ // {line, column}}` — a range, not a point. Reading `loc.line`
185
+ // directly finds `undefined` and yields a locator-less finding for
186
+ // the one class of finding that has an exact position. The wrapped
187
+ // parser error underneath carries a flat, 0-based `loc`; the outer
188
+ // one is 1-based and agrees with the message, so it wins.
189
+ const loc = err?.loc?.start ?? err?.loc;
190
+ findings.push({
191
+ file,
192
+ ...(Number.isFinite(loc?.line) ?
193
+ { line: loc.line, column: loc.column }
194
+ : {}),
195
+ severity: "error",
196
+ message: `cannot be parsed: ${String(err?.message ?? err).split("\n")[0]}`,
197
+ });
198
+ }
199
+ }
200
+
201
+ return { findings, checked, written };
202
+ }
203
+
204
+ /**
205
+ * One markdownlint result as a diagnostic.
206
+ *
207
+ * @param {string} directory - The root the result's `fileName` is relative to.
208
+ * @param {object} result - A markdownlint `LintResult`.
209
+ * @returns {{file: string, line: number, column?: number, severity: string,
210
+ * message: string}} The diagnostic.
211
+ */
212
+ function toDiagnostic(directory, result) {
213
+ const {
214
+ fileName,
215
+ lineNumber,
216
+ ruleNames = [],
217
+ ruleDescription,
218
+ errorDetail,
219
+ errorContext,
220
+ errorRange,
221
+ } = result;
222
+ // `ruleNames` is [id, name, ...aliases]; the pair is what markdownlint's own
223
+ // output shows and what a reader searches the rule set for.
224
+ const rule = ruleNames.slice(0, 2).join("/");
225
+ const detail = errorDetail ? ` [${errorDetail}]` : "";
226
+ const context = errorContext ? ` [Context: "${errorContext}"]` : "";
227
+ return {
228
+ file: path.resolve(directory, fileName),
229
+ line: lineNumber,
230
+ // `errorRange` is `[column, length]`, 1-based, and absent for a finding
231
+ // about a whole line.
232
+ ...(Array.isArray(errorRange) ? { column: errorRange[0] } : {}),
233
+ severity: "error",
234
+ message: `${rule} ${ruleDescription}${detail}${context}`,
235
+ };
236
+ }
237
+
238
+ /**
239
+ * Lint a repository's markdown against the shared rule set.
240
+ *
241
+ * {@link MARKDOWNLINT_CONFIG} is passed as markdownlint's `optionsDefault`,
242
+ * which is precisely the "shipped default, consumer overrides" behaviour the
243
+ * command promises: a `.markdownlint-cli2.jsonc` found in the tree replaces it,
244
+ * and a repository with none gets these rules.
245
+ *
246
+ * @param {string} root - Repository to lint.
247
+ * @param {object} [opts]
248
+ * @param {readonly string[]} [opts.paths] - Globs to lint instead of every
249
+ * markdown file.
250
+ * @param {boolean} [opts.fix=false] - Apply the fixes markdownlint can make.
251
+ * @param {(params: object) => Promise<number>} [opts.run] - The
252
+ * markdownlint-cli2 entry point, for tests.
253
+ * @returns {Promise<{findings: object[], exitCode: number}>} The findings, and
254
+ * markdownlint's own exit code.
255
+ */
256
+ export async function lintMarkdown(root, opts = {}) {
257
+ const { paths, fix = false } = opts;
258
+ const run = opts.run ?? (await import("markdownlint-cli2")).main;
259
+ const directory = path.resolve(root);
260
+
261
+ /** @type {object[]} */
262
+ const collected = [];
263
+ // A formatter is normally a module id resolved from disk, but
264
+ // markdownlint-cli2 passes a non-string through untouched — so the results
265
+ // can be collected in process rather than parsed back out of its output.
266
+ const collect = ({ results }) => {
267
+ collected.push(...results);
268
+ };
269
+
270
+ const argv = [...(paths?.length ? paths : MARKDOWN_GLOBS)];
271
+ if (fix) argv.push("--fix");
272
+
273
+ const exitCode = await run({
274
+ directory,
275
+ argv,
276
+ optionsDefault: {
277
+ config: MARKDOWNLINT_CONFIG,
278
+ // Generated markdown reports on its generator, not its author, and
279
+ // every consumer already lists its generated trees here.
280
+ gitignore: true,
281
+ ignores: [...MARKDOWN_IGNORES],
282
+ noProgress: true,
283
+ noBanner: true,
284
+ },
285
+ optionsOverride: { outputFormatters: [[collect]] },
286
+ logMessage: () => {},
287
+ logError: () => {},
288
+ });
289
+
290
+ return {
291
+ findings: collected.map((result) => toDiagnostic(directory, result)),
292
+ exitCode,
293
+ };
294
+ }
@@ -0,0 +1,79 @@
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 curated Foundry region-event vocabulary, as plain data.
16
+ *
17
+ * Deliberately **plain ESM** — no TypeScript, no `@src` aliases, no Foundry —
18
+ * for the same reason `../sohl/default-item-art.mjs` is: the map-note pack
19
+ * compiler runs under bare `node`, outside the bundler that resolves `@src` and
20
+ * strips types, and it must reject an authored region event that the runtime
21
+ * would silently drop. One list here is what keeps the build-time lint and the
22
+ * runtime bridge from drifting apart.
23
+ *
24
+ * It sits in this package rather than in the system's `src/` tree because the
25
+ * map-note compiler that reads it is installed as a dependency (#1501), and a
26
+ * relative path out of the package would resolve to garbage from
27
+ * `node_modules`. The runtime reaches it back through the package's
28
+ * `./engine/region-events` entry point (#1510). It is engine-side, not
29
+ * SoHL-side, because any content module that authors a scene region — an
30
+ * adventure module included — needs this vocabulary.
31
+ *
32
+ * The prose explaining *why* each event is curated or excluded lives with the
33
+ * typed re-exports in `src/entity/event/region-triggers.ts`, which is the
34
+ * documented surface.
35
+ */
36
+
37
+ /**
38
+ * The curated Foundry region-event → SoHL trigger-name map. The keys are the
39
+ * `CONST.REGION_EVENTS` string values SoHL forwards.
40
+ *
41
+ * @type {Readonly<Record<string, string>>}
42
+ */
43
+ export const REGION_EVENT_TO_TRIGGER = Object.freeze({
44
+ tokenEnter: "regionTokenEnter",
45
+ tokenExit: "regionTokenExit",
46
+ tokenTurnStart: "regionTokenTurnStart",
47
+ tokenTurnEnd: "regionTokenTurnEnd",
48
+ tokenRoundStart: "regionTokenRoundStart",
49
+ tokenRoundEnd: "regionTokenRoundEnd",
50
+ });
51
+
52
+ /**
53
+ * The Foundry region-event names SoHL forwards (the keys of the map).
54
+ *
55
+ * @type {readonly string[]}
56
+ */
57
+ export const CURATED_REGION_EVENTS = Object.freeze(
58
+ Object.keys(REGION_EVENT_TO_TRIGGER),
59
+ );
60
+
61
+ /**
62
+ * Region events SoHL deliberately does **not** forward: the continuous
63
+ * (`tokenMove*`), view-dependent (`tokenAnimate*`) and lifecycle
64
+ * (`behavior*`, `regionBoundary`) streams.
65
+ *
66
+ * @type {readonly string[]}
67
+ */
68
+ export const EXCLUDED_REGION_EVENTS = Object.freeze([
69
+ "tokenMoveIn",
70
+ "tokenMoveOut",
71
+ "tokenMoveWithin",
72
+ "tokenAnimateIn",
73
+ "tokenAnimateOut",
74
+ "regionBoundary",
75
+ "behaviorActivated",
76
+ "behaviorDeactivated",
77
+ "behaviorViewed",
78
+ "behaviorUnviewed",
79
+ ]);