@heroiclands/package-build 0.6.0 → 3.0.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.
- package/CHANGELOG-content-build.md +965 -0
- package/CHANGELOG.md +95 -0
- package/CONTENT.md +824 -0
- package/MIGRATING.md +127 -0
- package/README.md +29 -17
- package/bin/content-build.mjs +1082 -0
- package/bin/package-build.mjs +2 -2
- package/bin/report.mjs +2 -2
- package/config.mjs +21 -18
- package/content-config.mjs +1424 -0
- package/coverage.mjs +1 -1
- package/e2e.mjs +1 -1
- package/engine/abbreviations.mjs +0 -0
- package/engine/base-compiler.mjs +608 -0
- package/engine/code-fences.mjs +311 -0
- package/engine/compendiums.mjs +353 -0
- package/engine/content-address.mjs +141 -0
- package/engine/content-links.mjs +438 -0
- package/engine/content-lint.mjs +204 -0
- package/engine/content-package.mjs +67 -0
- package/engine/content-slug.mjs +169 -0
- package/engine/content-tables.mjs +1216 -0
- package/engine/content-tree.mjs +56 -0
- package/engine/diagnostics.mjs +270 -0
- package/engine/field-reference.mjs +242 -0
- package/engine/field-spec.mjs +218 -0
- package/engine/foreign-catalog.mjs +450 -0
- package/engine/foreign-manifests.mjs +129 -0
- package/engine/frontmatter-lint.mjs +343 -0
- package/engine/frontmatter.mjs +258 -0
- package/engine/generate.mjs +305 -0
- package/engine/helpers.mjs +809 -0
- package/engine/ids.mjs +192 -0
- package/engine/index.mjs +142 -0
- package/engine/item-docs.mjs +153 -0
- package/engine/item-registry.mjs +129 -0
- package/engine/journals.mjs +374 -0
- package/engine/kb-manifest.mjs +473 -0
- package/engine/macros.mjs +333 -0
- package/engine/manifest-emit.mjs +339 -0
- package/engine/map-notes.mjs +1302 -0
- package/engine/pack-config.mjs +479 -0
- package/engine/pack-router.mjs +254 -0
- package/engine/prose-config.mjs +238 -0
- package/engine/prose-lint.mjs +294 -0
- package/engine/region-events.mjs +79 -0
- package/engine/scene-levels.mjs +420 -0
- package/engine/scenes.mjs +528 -0
- package/engine/site-build.mjs +778 -0
- package/engine/site-index.mjs +332 -0
- package/engine/web-wikilinks.mjs +330 -0
- package/engine/wikilink-syntax.mjs +105 -0
- package/engine/wikilinks.mjs +645 -0
- package/index.mjs +42 -25
- package/lang.mjs +1 -1
- package/manifest.mjs +79 -5
- package/markdownlint-config.mjs +45 -0
- package/package.json +78 -34
- package/prettier-config.mjs +34 -0
- package/sohl/actors.mjs +581 -0
- package/sohl/affiliation-standings.mjs +44 -0
- package/sohl/being-info.mjs +173 -0
- package/sohl/default-item-art.mjs +82 -0
- package/sohl/index.mjs +55 -0
- package/sohl/item-builders.mjs +114 -0
- package/sohl/item-fields.mjs +867 -0
- package/sohl/items.mjs +197 -0
- package/sohl/kb-passes.mjs +207 -0
- package/sohl/note-schemas.mjs +322 -0
- package/templates.mjs +1 -1
- package/types/config.d.mts +1 -1
- package/types/content-config.d.mts +571 -0
- package/types/engine/abbreviations.d.mts +44 -0
- package/types/engine/base-compiler.d.mts +320 -0
- package/types/engine/code-fences.d.mts +102 -0
- package/types/engine/compendiums.d.mts +77 -0
- package/types/engine/content-address.d.mts +61 -0
- package/types/engine/content-links.d.mts +87 -0
- package/types/engine/content-lint.d.mts +47 -0
- package/types/engine/content-package.d.mts +36 -0
- package/types/engine/content-slug.d.mts +59 -0
- package/types/engine/content-tables.d.mts +131 -0
- package/types/engine/content-tree.d.mts +12 -0
- package/types/engine/diagnostics.d.mts +152 -0
- package/types/engine/field-reference.d.mts +20 -0
- package/types/engine/field-spec.d.mts +181 -0
- package/types/engine/foreign-catalog.d.mts +103 -0
- package/types/engine/foreign-manifests.d.mts +43 -0
- package/types/engine/frontmatter-lint.d.mts +74 -0
- package/types/engine/frontmatter.d.mts +99 -0
- package/types/engine/generate.d.mts +56 -0
- package/types/engine/helpers.d.mts +348 -0
- package/types/engine/ids.d.mts +126 -0
- package/types/engine/index.d.mts +33 -0
- package/types/engine/item-docs.d.mts +73 -0
- package/types/engine/item-registry.d.mts +57 -0
- package/types/engine/journals.d.mts +121 -0
- package/types/engine/kb-manifest.d.mts +214 -0
- package/types/engine/macros.d.mts +201 -0
- package/types/engine/manifest-emit.d.mts +137 -0
- package/types/engine/map-notes.d.mts +283 -0
- package/types/engine/pack-config.d.mts +77 -0
- package/types/engine/pack-router.d.mts +53 -0
- package/types/engine/prose-config.d.mts +121 -0
- package/types/engine/prose-lint.d.mts +59 -0
- package/types/engine/region-events.d.mts +43 -0
- package/types/engine/scene-levels.d.mts +36 -0
- package/types/engine/scenes.d.mts +48 -0
- package/types/engine/site-build.d.mts +214 -0
- package/types/engine/site-index.d.mts +141 -0
- package/types/engine/web-wikilinks.d.mts +67 -0
- package/types/engine/wikilink-syntax.d.mts +101 -0
- package/types/engine/wikilinks.d.mts +172 -0
- package/types/index.d.mts +3 -0
- package/types/manifest.d.mts +52 -4
- package/types/markdownlint-config.d.mts +8 -0
- package/types/prettier-config.d.mts +1 -0
- package/types/sohl/actors.d.mts +65 -0
- package/types/sohl/affiliation-standings.d.mts +22 -0
- package/types/sohl/being-info.d.mts +78 -0
- package/types/sohl/default-item-art.d.mts +28 -0
- package/types/sohl/index.d.mts +7 -0
- package/types/sohl/item-builders.d.mts +11 -0
- package/types/sohl/item-fields.d.mts +20 -0
- package/types/sohl/items.d.mts +19 -0
- package/types/sohl/kb-passes.d.mts +71 -0
- package/types/sohl/note-schemas.d.mts +7 -0
|
@@ -0,0 +1,311 @@
|
|
|
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
|
+
* Where **code** lives in a markdown body, so a rewriter can leave it alone.
|
|
16
|
+
*
|
|
17
|
+
* A code block is verbatim: its contents are shown to the reader exactly as
|
|
18
|
+
* written. Every build-time rewriter that pattern-matches a body therefore has
|
|
19
|
+
* to know where code is — otherwise a source listing that happens to contain
|
|
20
|
+
* the rewriter's syntax is silently corrupted. Wikilink conversion met this as
|
|
21
|
+
* `[[0]]` inside a fence being turned into a link (#1505), and it depended on
|
|
22
|
+
* the surrounding literal's shape (`[[1,2],[3,4]]` survived), so the corruption
|
|
23
|
+
* looked arbitrary.
|
|
24
|
+
*
|
|
25
|
+
* Three forms are recognised, which is every form the content tree uses:
|
|
26
|
+
*
|
|
27
|
+
* - **Fenced blocks** — three or more backticks or tildes, closed by the same
|
|
28
|
+
* character at least as long, or by the end of the document. A longer fence
|
|
29
|
+
* contains a shorter one, so a markdown sample can quote a code sample. The
|
|
30
|
+
* opening line, info string and all, is part of the block.
|
|
31
|
+
* - **Indented blocks** — four columns past the enclosing context, following a
|
|
32
|
+
* blank line (an indent cannot interrupt a paragraph).
|
|
33
|
+
* - **Inline spans** — a run of backticks closed by a run of equal length,
|
|
34
|
+
* within one paragraph.
|
|
35
|
+
*
|
|
36
|
+
* The fence syntax is the one `expandContentTables` already reads, and
|
|
37
|
+
* this module owns it now so the two can never disagree.
|
|
38
|
+
*
|
|
39
|
+
* **Known limits.** This is a scanner, not a markdown parser, and it errs
|
|
40
|
+
* towards treating something as code — a false positive leaves an author's text
|
|
41
|
+
* as written, a false negative rewrites it. List nesting is tracked only to the
|
|
42
|
+
* innermost open marker, so an indented block inside a deeply nested list may be
|
|
43
|
+
* read as list prose; a backslash-escaped backtick still counts as a span
|
|
44
|
+
* delimiter; and HTML blocks are not code (they are not verbatim in markdown
|
|
45
|
+
* either). None of these can turn code into a link — the failure this exists to
|
|
46
|
+
* prevent.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/** A fence line, capturing its indent, its marker, and its info string. */
|
|
50
|
+
export const FENCE_LINE = /^([ \t]*)(`{3,}|~{3,})[ \t]*([^\r\n]*)$/;
|
|
51
|
+
|
|
52
|
+
/** A list item's opening line, capturing the indent and the marker itself. */
|
|
53
|
+
const LIST_MARKER = /^([ \t]*)(?:[-*+]|\d{1,9}[.)])(?:[ \t]+|$)/;
|
|
54
|
+
|
|
55
|
+
/** A line's leading whitespace. */
|
|
56
|
+
const LEADING = /^[ \t]*/;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The column a run of leading whitespace reaches, tabs expanded to four.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} space - Leading whitespace only.
|
|
62
|
+
* @returns {number} The column the first non-space character sits at.
|
|
63
|
+
*/
|
|
64
|
+
function column(space) {
|
|
65
|
+
let col = 0;
|
|
66
|
+
for (const ch of space) col = ch === "\t" ? col + 4 - (col % 4) : col + 1;
|
|
67
|
+
return col;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Every code region in a markdown body, as character offsets into it.
|
|
72
|
+
*
|
|
73
|
+
* @param {string | null | undefined} markdown - The body (frontmatter already
|
|
74
|
+
* stripped). An absent body has no code in it.
|
|
75
|
+
* @param {object} [options]
|
|
76
|
+
* @param {boolean} [options.spans=true] - Include inline code spans. Set false
|
|
77
|
+
* to consider only block-level code.
|
|
78
|
+
* @returns {Array<{start: number, end: number}>} Non-overlapping regions, in
|
|
79
|
+
* source order. `end` is exclusive.
|
|
80
|
+
*/
|
|
81
|
+
export function codeRegions(markdown, { spans = true } = {}) {
|
|
82
|
+
const src = String(markdown ?? "");
|
|
83
|
+
if (!src) return [];
|
|
84
|
+
const lines = src.split("\n");
|
|
85
|
+
|
|
86
|
+
// Where each line begins, so a region can be reported in the caller's own
|
|
87
|
+
// coordinates rather than in lines.
|
|
88
|
+
const at = [];
|
|
89
|
+
let offset = 0;
|
|
90
|
+
for (const line of lines) {
|
|
91
|
+
at.push(offset);
|
|
92
|
+
offset += line.length + 1;
|
|
93
|
+
}
|
|
94
|
+
const through = (first, last) => ({
|
|
95
|
+
start: at[first],
|
|
96
|
+
end: at[last] + lines[last].length,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const regions = [];
|
|
100
|
+
// The content column of the innermost open list item: an indented block
|
|
101
|
+
// has to clear *that*, not column zero, or every list continuation would
|
|
102
|
+
// read as code.
|
|
103
|
+
let listColumn = null;
|
|
104
|
+
// The start of the document counts as a blank line, so a body that opens
|
|
105
|
+
// with an indented sample is still code.
|
|
106
|
+
let afterBlank = true;
|
|
107
|
+
|
|
108
|
+
for (let i = 0; i < lines.length; i++) {
|
|
109
|
+
const line = lines[i];
|
|
110
|
+
|
|
111
|
+
const fence = FENCE_LINE.exec(line);
|
|
112
|
+
if (fence) {
|
|
113
|
+
const marker = fence[2];
|
|
114
|
+
const closer = new RegExp(
|
|
115
|
+
`^[ \\t]*${marker[0]}{${marker.length},}[ \\t]*$`,
|
|
116
|
+
);
|
|
117
|
+
let close = i + 1;
|
|
118
|
+
while (close < lines.length && !closer.test(lines[close])) close++;
|
|
119
|
+
// An unclosed fence runs to the end of the document.
|
|
120
|
+
regions.push(through(i, Math.min(close, lines.length - 1)));
|
|
121
|
+
i = close;
|
|
122
|
+
afterBlank = false;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (line.trim() === "") {
|
|
127
|
+
afterBlank = true;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const indent = column(LEADING.exec(line)[0]);
|
|
132
|
+
const codeColumn = (listColumn ?? 0) + 4;
|
|
133
|
+
if (afterBlank && indent >= codeColumn) {
|
|
134
|
+
// The block runs to the last line still indented that far; blank
|
|
135
|
+
// lines inside it belong to it, trailing ones do not.
|
|
136
|
+
let last = i;
|
|
137
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
138
|
+
if (lines[j].trim() === "") continue;
|
|
139
|
+
if (column(LEADING.exec(lines[j])[0]) < codeColumn) break;
|
|
140
|
+
last = j;
|
|
141
|
+
}
|
|
142
|
+
regions.push(through(i, last));
|
|
143
|
+
i = last;
|
|
144
|
+
afterBlank = false;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const marker = LIST_MARKER.exec(line);
|
|
149
|
+
if (marker) {
|
|
150
|
+
listColumn = column(marker[0]);
|
|
151
|
+
} else if (listColumn !== null && indent < listColumn) {
|
|
152
|
+
// Prose back at the outer margin closes the list.
|
|
153
|
+
listColumn = null;
|
|
154
|
+
}
|
|
155
|
+
afterBlank = false;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (spans) regions.push(...codeSpans(src, regions));
|
|
159
|
+
regions.sort((a, b) => a.start - b.start);
|
|
160
|
+
return regions;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The inline code spans outside the block-level regions already found.
|
|
165
|
+
*
|
|
166
|
+
* A run of _n_ backticks opens a span that the next run of exactly _n_ closes.
|
|
167
|
+
* A run with no partner is a literal backtick, and a span cannot cross a blank
|
|
168
|
+
* line — both are CommonMark's rules, and both are what an author expects when
|
|
169
|
+
* a stray backtick appears in prose.
|
|
170
|
+
*
|
|
171
|
+
* @param {string} src - The body.
|
|
172
|
+
* @param {Array<{start: number, end: number}>} blocks - Block-level regions.
|
|
173
|
+
* @returns {Array<{start: number, end: number}>} The spans, in source order.
|
|
174
|
+
*/
|
|
175
|
+
function codeSpans(src, blocks) {
|
|
176
|
+
const runs = [];
|
|
177
|
+
const backticks = /`+/g;
|
|
178
|
+
let run;
|
|
179
|
+
while ((run = backticks.exec(src)) !== null) {
|
|
180
|
+
if (!inside(blocks, run.index)) {
|
|
181
|
+
runs.push({ start: run.index, length: run[0].length });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const spans = [];
|
|
186
|
+
for (let i = 0; i < runs.length; i++) {
|
|
187
|
+
const open = runs[i];
|
|
188
|
+
let close = i + 1;
|
|
189
|
+
while (close < runs.length && runs[close].length !== open.length) {
|
|
190
|
+
close++;
|
|
191
|
+
}
|
|
192
|
+
if (close >= runs.length) continue; // no partner: a literal backtick
|
|
193
|
+
const end = runs[close].start + runs[close].length;
|
|
194
|
+
if (/\n[ \t]*\n/.test(src.slice(open.start, end))) continue;
|
|
195
|
+
spans.push({ start: open.start, end });
|
|
196
|
+
i = close;
|
|
197
|
+
}
|
|
198
|
+
return spans;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Is this offset inside one of the regions?
|
|
203
|
+
*
|
|
204
|
+
* @param {Array<{start: number, end: number}>} regions - Sorted regions.
|
|
205
|
+
* @param {number} offset - A character offset.
|
|
206
|
+
* @returns {boolean}
|
|
207
|
+
*/
|
|
208
|
+
function inside(regions, offset) {
|
|
209
|
+
for (const region of regions) {
|
|
210
|
+
if (offset < region.start) return false;
|
|
211
|
+
if (offset < region.end) return true;
|
|
212
|
+
}
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The offset argument of a `String.prototype.replace` callback.
|
|
218
|
+
*
|
|
219
|
+
* The callback is handed `(match, ...groups, offset, source)`, plus a groups
|
|
220
|
+
* object when the pattern has named groups — so the offset is found from the
|
|
221
|
+
* end, not by counting captures the caller might change.
|
|
222
|
+
*
|
|
223
|
+
* @param {Array<unknown>} args - The callback's own arguments.
|
|
224
|
+
* @returns {number} The match's offset into the source.
|
|
225
|
+
*/
|
|
226
|
+
function offsetOf(args) {
|
|
227
|
+
const last = args[args.length - 1];
|
|
228
|
+
return typeof last === "string" ?
|
|
229
|
+
args[args.length - 2]
|
|
230
|
+
: args[args.length - 3];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* `String.prototype.replace`, skipping anything inside code.
|
|
235
|
+
*
|
|
236
|
+
* A match inside a code region is returned as it was written, so the block
|
|
237
|
+
* stays verbatim; the replacer is not called for it at all, which matters when
|
|
238
|
+
* it records a side effect (an unresolved link, say).
|
|
239
|
+
*
|
|
240
|
+
* @param {string} markdown - The body.
|
|
241
|
+
* @param {RegExp} pattern - A **global** pattern to rewrite.
|
|
242
|
+
* @param {(...args: Array<any>) => string} replacer - As `replace` takes.
|
|
243
|
+
* @param {object} [options] - Passed to {@link codeRegions}.
|
|
244
|
+
* @returns {string} The rewritten body.
|
|
245
|
+
*/
|
|
246
|
+
export function replaceOutsideCode(markdown, pattern, replacer, options) {
|
|
247
|
+
const src = String(markdown ?? "");
|
|
248
|
+
const regions = codeRegions(src, options);
|
|
249
|
+
if (regions.length === 0) return src.replace(pattern, replacer);
|
|
250
|
+
return src.replace(pattern, (...args) =>
|
|
251
|
+
inside(regions, offsetOf(args)) ? args[0] : replacer(...args),
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* `String.prototype.matchAll`, skipping anything inside code.
|
|
257
|
+
*
|
|
258
|
+
* @param {string} markdown - The body.
|
|
259
|
+
* @param {RegExp} pattern - A **global** pattern to search for.
|
|
260
|
+
* @param {object} [options] - Passed to {@link codeRegions}.
|
|
261
|
+
* @returns {Array<RegExpMatchArray>} The matches outside code, in source order.
|
|
262
|
+
*/
|
|
263
|
+
export function matchAllOutsideCode(markdown, pattern, options) {
|
|
264
|
+
const src = String(markdown ?? "");
|
|
265
|
+
const regions = codeRegions(src, options);
|
|
266
|
+
return [...src.matchAll(pattern)].filter(
|
|
267
|
+
(match) => !inside(regions, match.index),
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Run `transform` over a whole Markdown body while leaving code untouched.
|
|
273
|
+
*
|
|
274
|
+
* {@link replaceOutsideCode} is the right tool when the caller has a pattern.
|
|
275
|
+
* This is for the other shape: a transform that rewrites the **whole** body — a
|
|
276
|
+
* link rewriter, a path fixer — and must simply never see code. Each code run is
|
|
277
|
+
* stashed and replaced with a `\u0000<index>\u0000` sentinel; a NUL never occurs
|
|
278
|
+
* in Markdown source, so the sentinel cannot collide with prose and survives the
|
|
279
|
+
* transform unchanged before being restored.
|
|
280
|
+
*
|
|
281
|
+
* **Which runs count as code is {@link codeRegions}' rule, not a second copy of
|
|
282
|
+
* it.** The knowledgebase build carried its own regex once, and it was weaker in
|
|
283
|
+
* two ways that both corrupted the one page whose subject *is* link syntax — so
|
|
284
|
+
* its examples were exactly the input a looser rule mangles (SoHL#1665). A
|
|
285
|
+
* single-backtick span was allowed to cross newlines, so one odd backtick paired
|
|
286
|
+
* with another paragraphs away and every span after it paired wrongly: prose was
|
|
287
|
+
* masked as code while real spans were left exposed. And only three-backtick
|
|
288
|
+
* fences were recognised, so a four-backtick example holding a three-backtick
|
|
289
|
+
* block — the documented "fences of any length" case (#1505) — leaked its
|
|
290
|
+
* contents.
|
|
291
|
+
*
|
|
292
|
+
* @param {string} body - The markdown body.
|
|
293
|
+
* @param {(masked: string) => string} transform - Applied to the masked body.
|
|
294
|
+
* @returns {string} The transformed body, with every code run restored verbatim.
|
|
295
|
+
*/
|
|
296
|
+
export function protectCode(body, transform) {
|
|
297
|
+
const src = String(body ?? "");
|
|
298
|
+
const stash = [];
|
|
299
|
+
let masked = "";
|
|
300
|
+
let last = 0;
|
|
301
|
+
for (const region of codeRegions(src)) {
|
|
302
|
+
const index = stash.push(src.slice(region.start, region.end)) - 1;
|
|
303
|
+
masked += src.slice(last, region.start) + `\u0000${index}\u0000`;
|
|
304
|
+
last = region.end;
|
|
305
|
+
}
|
|
306
|
+
masked += src.slice(last);
|
|
307
|
+
return transform(masked).replace(
|
|
308
|
+
/\u0000(\d+)\u0000/g,
|
|
309
|
+
(_m, i) => stash[Number(i)],
|
|
310
|
+
);
|
|
311
|
+
}
|
|
@@ -0,0 +1,353 @@
|
|
|
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
|
+
* Compendium pack library — compile / unpack / clean LevelDB packs.
|
|
16
|
+
*
|
|
17
|
+
* Wraps `@foundryvtt/foundryvtt-cli` over the packs a consuming repository
|
|
18
|
+
* declares:
|
|
19
|
+
* - {@link compilePacks}: generates each pack's per-entry JSON from the
|
|
20
|
+
* `assets/content/` Markdown into `build/packs-json/<name>/` (via
|
|
21
|
+
* generate.mjs), then builds LevelDB from it; no committed JSON, no vault.
|
|
22
|
+
* - {@link unpackPacks}: extracts a compiled pack back to per-entry JSON,
|
|
23
|
+
* rebuilding folder paths.
|
|
24
|
+
* - {@link cleanPacks}: normalizes/strips extracted JSON.
|
|
25
|
+
*
|
|
26
|
+
* **This module has no import-time side effects.** It creates no directories,
|
|
27
|
+
* reads no manifest, configures no logger, and parses no argv — every path and
|
|
28
|
+
* pack list is a parameter, defaulted from the resolved build configuration
|
|
29
|
+
* (#1508), which a caller may replace wholesale to compile another package's
|
|
30
|
+
* tree. Those side effects belong to the command
|
|
31
|
+
* line that drives it (`bin/build-compendiums.mjs`), so the library can be
|
|
32
|
+
* imported by another repository's build, or by a test, without a stray
|
|
33
|
+
* `build/` tree appearing or the shared `loglevel` singleton being
|
|
34
|
+
* reconfigured (#1507). In particular, a *module* repository ships
|
|
35
|
+
* `module.json` rather than `system.template.json`, so importing must not
|
|
36
|
+
* depend on the latter existing.
|
|
37
|
+
*
|
|
38
|
+
* @module
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import fs from "fs";
|
|
42
|
+
import { readdir, readFile, writeFile } from "node:fs/promises";
|
|
43
|
+
import log from "loglevel";
|
|
44
|
+
import path from "path";
|
|
45
|
+
import { compilePack, extractPack } from "@foundryvtt/foundryvtt-cli";
|
|
46
|
+
import { generatePacksJson, packJsonDir } from "./generate.mjs";
|
|
47
|
+
// The one slug rule — see `./content-slug.mjs`. This module carried a copy
|
|
48
|
+
// that stripped only the *first* straight apostrophe (a string argument, not a
|
|
49
|
+
// pattern), never a curly one, and dropped non-ASCII letters rather than
|
|
50
|
+
// transliterating them: `Kûrbúl Helm` filed itself as `k-rb-l-helm`.
|
|
51
|
+
import { slugify } from "./content-slug.mjs";
|
|
52
|
+
import { verifyPackSceneLevels } from "./scene-levels.mjs";
|
|
53
|
+
import { loadPackConfig } from "./pack-config.mjs";
|
|
54
|
+
|
|
55
|
+
/* ----------------------------------------- */
|
|
56
|
+
/* Compile Packs */
|
|
57
|
+
/* ----------------------------------------- */
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Generates each pack's per-entry JSON from `assets/content/` into
|
|
61
|
+
* `build/packs-json/<name>/`, then builds the LevelDB output from it. No
|
|
62
|
+
* committed JSON and no vault access. Destination: `<stageDest>/<name>/`.
|
|
63
|
+
*
|
|
64
|
+
* @param {object} opts
|
|
65
|
+
* @param {object} [opts.config] The resolved build configuration, which
|
|
66
|
+
* the two path arguments below default from. Supplying one is how a caller
|
|
67
|
+
* compiles a package other than this repository's (#1508).
|
|
68
|
+
* @param {string[]} [opts.sourcePacks] Every pack compiled from the content
|
|
69
|
+
* tree, in compile order. Defaults to the configured pack directories.
|
|
70
|
+
* @param {string} [opts.stageDest] Directory the LevelDB packs are built
|
|
71
|
+
* into, one subdirectory per pack. Defaults to the configured stage.
|
|
72
|
+
* @param {string} [opts.packName] Restrict the run to a single pack.
|
|
73
|
+
* @throws {Error} If pack JSON generation reported any error. Packs compiled
|
|
74
|
+
* from incomplete or empty JSON ship blank or short compendiums, and the
|
|
75
|
+
* omission is invisible until a player looks for content that is not there
|
|
76
|
+
* (#1502) — so this is fatal, not a warning, and the caller is expected to
|
|
77
|
+
* turn it into a failing exit code.
|
|
78
|
+
* @throws {Error} If a compiled pack ships a Scene that has lost its embedded
|
|
79
|
+
* Level (#1538). Fatal for the same reason: Foundry reads a missing Level
|
|
80
|
+
* record as "no levels" and persists that on the next world launch, so the
|
|
81
|
+
* map image is gone before anyone notices it was ever at risk. See
|
|
82
|
+
* {@link verifyPackSceneLevels}.
|
|
83
|
+
*/
|
|
84
|
+
export async function compilePacks({
|
|
85
|
+
config = loadPackConfig(),
|
|
86
|
+
sourcePacks = config.packDirectories,
|
|
87
|
+
stageDest = config.paths.stage,
|
|
88
|
+
packName,
|
|
89
|
+
} = {}) {
|
|
90
|
+
const packNames = sourcePacks.filter(
|
|
91
|
+
(name) => !packName || name === packName,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// Generate the per-entry JSON from the content tree into the build-only
|
|
95
|
+
// JSON intermediate. The package-id guard runs first, inside this call,
|
|
96
|
+
// before any pack is written.
|
|
97
|
+
const errors = await generatePacksJson({ only: packName, config });
|
|
98
|
+
if (errors > 0) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Pack JSON generation reported ${errors} error(s); refusing to ` +
|
|
101
|
+
`compile packs from incomplete output.`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (const name of packNames) {
|
|
106
|
+
const source = packJsonDir(name, config);
|
|
107
|
+
if (!fs.existsSync(source)) {
|
|
108
|
+
log.error(`Pack ${name}: generated JSON not found at ${source}.`);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const stage = path.join(stageDest, name);
|
|
113
|
+
log.info(`Pack ${name}: compiling to LevelDB at ${stage}`);
|
|
114
|
+
await compilePack(source, stage, {
|
|
115
|
+
recursive: true,
|
|
116
|
+
log: false,
|
|
117
|
+
transformEntry: cleanPackEntry,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// A Scene's map image lives on an embedded Level, stored under its own
|
|
121
|
+
// LevelDB key. Nothing in Foundry ties the two together on read: a
|
|
122
|
+
// missing Level record only warns, and the next world launch persists
|
|
123
|
+
// the emptied `levels` array — so the map is lost for good and the
|
|
124
|
+
// only symptom is a blank battlemap (#1538). Assert it on the bytes
|
|
125
|
+
// just written, which is the one place the compendium CLI's write path
|
|
126
|
+
// is observable.
|
|
127
|
+
const problems = await verifyPackSceneLevels(stage);
|
|
128
|
+
if (problems.length) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`Pack ${name}: ${problems.length} Scene/Level integrity ` +
|
|
131
|
+
`problem(s) in the compiled pack:\n ` +
|
|
132
|
+
problems.join("\n "),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
log.info("Pack compilation complete.");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* ----------------------------------------- */
|
|
140
|
+
/* Clean Packs */
|
|
141
|
+
/* ----------------------------------------- */
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Removes unwanted flags, permissions, and other data from entries before extracting or compiling.
|
|
145
|
+
* @param {object} data Data for a single entry to clean.
|
|
146
|
+
* @param {object} [options={}]
|
|
147
|
+
* @param {boolean} [options.clearSourceId=true] Should the core sourceId flag be deleted.
|
|
148
|
+
* @param {number} [options.ownership=0] Value to reset default ownership to.
|
|
149
|
+
* @param {string} [options.lastModifiedBy] The stamped author id. Defaults to
|
|
150
|
+
* the configured one — the same value `buildStats` stamps, so a compiled
|
|
151
|
+
* entry and a re-cleaned one never disagree (#1508).
|
|
152
|
+
*/
|
|
153
|
+
function cleanPackEntry(
|
|
154
|
+
data,
|
|
155
|
+
{
|
|
156
|
+
clearSourceId = true,
|
|
157
|
+
ownership = 0,
|
|
158
|
+
lastModifiedBy = loadPackConfig().stats.lastModifiedBy,
|
|
159
|
+
} = {},
|
|
160
|
+
) {
|
|
161
|
+
if (data.ownership) data.ownership = { default: ownership };
|
|
162
|
+
if (clearSourceId) {
|
|
163
|
+
delete data._stats?.compendiumSource;
|
|
164
|
+
delete data.flags?.core?.sourceId;
|
|
165
|
+
}
|
|
166
|
+
delete data.flags?.importSource;
|
|
167
|
+
delete data.flags?.exportSource;
|
|
168
|
+
if (data._stats?.lastModifiedBy)
|
|
169
|
+
data._stats.lastModifiedBy = lastModifiedBy;
|
|
170
|
+
|
|
171
|
+
// Remove empty entries in flags
|
|
172
|
+
if (!data.flags) data.flags = {};
|
|
173
|
+
Object.entries(data.flags).forEach(([key, contents]) => {
|
|
174
|
+
if (Object.keys(contents).length === 0) delete data.flags[key];
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
if (data.effects)
|
|
178
|
+
data.effects.forEach((i) =>
|
|
179
|
+
cleanPackEntry(i, { clearSourceId: false }),
|
|
180
|
+
);
|
|
181
|
+
if (data.items)
|
|
182
|
+
data.items.forEach((i) => cleanPackEntry(i, { clearSourceId: false }));
|
|
183
|
+
if (data.pages)
|
|
184
|
+
data.pages.forEach((i) => cleanPackEntry(i, { ownership: -1 }));
|
|
185
|
+
if (data.system?.description)
|
|
186
|
+
data.system.description = cleanString(data.system.description);
|
|
187
|
+
if (data.system?.biography)
|
|
188
|
+
data.system.biography = cleanString(data.system.biography);
|
|
189
|
+
if (data.system?.textReference)
|
|
190
|
+
data.system.textReference = cleanString(data.system.textReference);
|
|
191
|
+
if (data.system?.notes) data.system.notes = cleanString(data.system.notes);
|
|
192
|
+
if (data.label) data.label = cleanString(data.label);
|
|
193
|
+
if (data.name) data.name = cleanString(data.name);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Removes invisible whitespace characters and normalizes single- and double-quotes.
|
|
198
|
+
* @param {string} str The string to be cleaned.
|
|
199
|
+
* @returns {string} The cleaned string.
|
|
200
|
+
*/
|
|
201
|
+
function cleanString(str) {
|
|
202
|
+
return str
|
|
203
|
+
.replace(/\u2060/gu, "")
|
|
204
|
+
.replace(/[‘’]/gu, "'")
|
|
205
|
+
.replace(/[“”]/gu, '"');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Cleans and formats source JSON files, removing unnecessary permissions and flags and adding the proper spacing.
|
|
210
|
+
* @param {object} opts
|
|
211
|
+
* @param {object} [opts.config] The resolved build configuration, which
|
|
212
|
+
* `packDest` defaults from.
|
|
213
|
+
* @param {string} [opts.packDest] Directory holding the extracted per-entry
|
|
214
|
+
* JSON, one subdirectory per pack.
|
|
215
|
+
* @param {string} [opts.packName] Name of pack to clean. If none provided, all packs will be cleaned.
|
|
216
|
+
* @param {string} [opts.entryName] Name of a specific entry to clean.
|
|
217
|
+
*
|
|
218
|
+
* - `npm run build:clean` - Clean all source JSON files.
|
|
219
|
+
* - `npm run build:clean -- classes` - Only clean the source files for the specified compendium.
|
|
220
|
+
* - `npm run build:clean -- classes Barbarian` - Only clean a single item from the specified compendium.
|
|
221
|
+
*/
|
|
222
|
+
export async function cleanPacks({
|
|
223
|
+
config = loadPackConfig(),
|
|
224
|
+
packDest = config.paths.unpack,
|
|
225
|
+
packName,
|
|
226
|
+
entryName,
|
|
227
|
+
} = {}) {
|
|
228
|
+
entryName = entryName?.toLowerCase();
|
|
229
|
+
|
|
230
|
+
const folders = fs
|
|
231
|
+
.readdirSync(packDest, { withFileTypes: true })
|
|
232
|
+
.filter(
|
|
233
|
+
(file) =>
|
|
234
|
+
file.isDirectory() && (!packName || packName === file.name),
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Walk through directories to find JSON files.
|
|
239
|
+
* @param {string} directoryPath
|
|
240
|
+
* @yields {string}
|
|
241
|
+
*/
|
|
242
|
+
async function* _walkDir(directoryPath) {
|
|
243
|
+
const directory = await readdir(directoryPath, { withFileTypes: true });
|
|
244
|
+
for (const entry of directory) {
|
|
245
|
+
const entryPath = path.join(directoryPath, entry.name);
|
|
246
|
+
if (path.extname(entry.name) === ".json") yield entryPath;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
for (const folder of folders) {
|
|
251
|
+
log.info(`Cleaning pack ${folder.name}`);
|
|
252
|
+
for await (const src of _walkDir(path.join(packDest, folder.name))) {
|
|
253
|
+
const json = JSON.parse(await readFile(src, { encoding: "utf8" }));
|
|
254
|
+
if (entryName && entryName !== json.name.toLowerCase()) continue;
|
|
255
|
+
if (!json._id || !json._key) {
|
|
256
|
+
log.info(
|
|
257
|
+
`Failed to clean \x1b[31m${src}\x1b[0m, must have _id and _key.`,
|
|
258
|
+
);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
cleanPackEntry(json);
|
|
262
|
+
fs.rmSync(src, { force: true });
|
|
263
|
+
writeFile(src, `${JSON.stringify(json, null, 2)}\n`, {
|
|
264
|
+
mode: 0o664,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/* ----------------------------------------- */
|
|
271
|
+
/* Unpack Packs */
|
|
272
|
+
/* ----------------------------------------- */
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Extracts compiled LevelDB packs back to per-entry JSON, rebuilding the folder
|
|
276
|
+
* hierarchy as directories.
|
|
277
|
+
*
|
|
278
|
+
* @param {object} opts
|
|
279
|
+
* @param {Array<{name: string}>} opts.packs The packs the shipped Foundry
|
|
280
|
+
* package declares — the manifest's `packs` array.
|
|
281
|
+
* @param {object} [opts.config] The resolved build configuration, which
|
|
282
|
+
* the two directories below default from.
|
|
283
|
+
* @param {string} [opts.stageDest] Directory holding the compiled LevelDB
|
|
284
|
+
* packs, one subdirectory per pack.
|
|
285
|
+
* @param {string} [opts.packDest] Directory the extracted JSON is written
|
|
286
|
+
* to, one subdirectory per pack.
|
|
287
|
+
* @param {string} [opts.packName] Restrict the run to a single pack.
|
|
288
|
+
* @param {string} [opts.entryName] Restrict the run to a single entry.
|
|
289
|
+
*/
|
|
290
|
+
export async function unpackPacks({
|
|
291
|
+
packs,
|
|
292
|
+
config = loadPackConfig(),
|
|
293
|
+
stageDest = config.paths.stage,
|
|
294
|
+
packDest = config.paths.unpack,
|
|
295
|
+
packName,
|
|
296
|
+
entryName,
|
|
297
|
+
}) {
|
|
298
|
+
entryName = entryName?.toLowerCase();
|
|
299
|
+
|
|
300
|
+
// Determine which source packs to process.
|
|
301
|
+
const selected = packs.filter((p) => !packName || p.name === packName);
|
|
302
|
+
|
|
303
|
+
for (const packInfo of selected) {
|
|
304
|
+
const src = path.join(stageDest, packInfo.name);
|
|
305
|
+
const dest = path.join(packDest, packInfo.name);
|
|
306
|
+
log.info(`Extracting pack ${packInfo.name}`);
|
|
307
|
+
|
|
308
|
+
const folders = {};
|
|
309
|
+
const containers = {};
|
|
310
|
+
await extractPack(src, dest, {
|
|
311
|
+
log: false,
|
|
312
|
+
transformEntry: (e) => {
|
|
313
|
+
if (e._key.startsWith("!folders"))
|
|
314
|
+
folders[e._id] = {
|
|
315
|
+
name: slugify(e.name),
|
|
316
|
+
folder: e.folder,
|
|
317
|
+
};
|
|
318
|
+
return false;
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
const buildPath = (collection, entry, parentKey) => {
|
|
322
|
+
let parent = collection[entry[parentKey]];
|
|
323
|
+
entry.path = entry.name;
|
|
324
|
+
while (parent) {
|
|
325
|
+
entry.path = path.join(parent.name, entry.path);
|
|
326
|
+
parent = collection[parent[parentKey]];
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
Object.values(folders).forEach((f) => buildPath(folders, f, "folder"));
|
|
330
|
+
|
|
331
|
+
await extractPack(src, dest, {
|
|
332
|
+
log: true,
|
|
333
|
+
transformEntry: (entry) => {
|
|
334
|
+
if (entryName && entryName !== entry.name.toLowerCase())
|
|
335
|
+
return false;
|
|
336
|
+
cleanPackEntry(entry);
|
|
337
|
+
},
|
|
338
|
+
transformName: (entry) => {
|
|
339
|
+
if (entry._id in folders)
|
|
340
|
+
return path.join(
|
|
341
|
+
"folder_",
|
|
342
|
+
folders[entry._id].path,
|
|
343
|
+
".json",
|
|
344
|
+
);
|
|
345
|
+
const outputName = slugify(entry.name);
|
|
346
|
+
const parent =
|
|
347
|
+
containers[entry.system?.container] ??
|
|
348
|
+
folders[entry.folder];
|
|
349
|
+
return path.join(parent?.path ?? "", `${outputName}.json`);
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|