@heroiclands/package-build 20.0.0 → 20.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +495 -0
- package/CONTENT.md +185 -25
- package/README.md +43 -4
- package/bin/content-build.mjs +87 -2
- package/config.mjs +9 -1
- package/content-config.mjs +89 -18
- package/e2e.mjs +297 -3
- package/engine/content-charset.mjs +434 -0
- package/engine/content-icons.mjs +388 -0
- package/engine/foreign-catalog.mjs +109 -7
- package/engine/frontmatter-lint.mjs +191 -29
- package/engine/generate.mjs +5 -2
- package/engine/helpers.mjs +10 -1
- package/engine/index.mjs +6 -0
- package/engine/note-claims.mjs +208 -5
- package/engine/pack-config.mjs +102 -12
- package/engine/pack-router.mjs +0 -0
- package/engine/prose-config.mjs +42 -0
- package/engine/prose-lint.mjs +126 -0
- package/engine/schema-extract.mjs +13 -0
- package/package.json +1 -1
- package/types/config.d.mts +7 -0
- package/types/e2e.d.mts +130 -3
- package/types/engine/content-charset.d.mts +127 -0
- package/types/engine/content-icons.d.mts +151 -0
- package/types/engine/foreign-catalog.d.mts +38 -2
- package/types/engine/frontmatter-lint.d.mts +146 -28
- package/types/engine/helpers.d.mts +8 -0
- package/types/engine/index.d.mts +2 -0
- package/types/engine/note-claims.d.mts +67 -0
- package/types/engine/pack-config.d.mts +35 -0
- package/types/engine/prose-config.d.mts +41 -0
- package/types/engine/prose-lint.d.mts +36 -0
|
@@ -0,0 +1,388 @@
|
|
|
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
|
+
* Naming an interface icon in a note, without drawing it there (#378).
|
|
16
|
+
*
|
|
17
|
+
* The user guide describes Foundry's interface, and it did so by pasting
|
|
18
|
+
* Unicode lookalikes of the icons the sheets actually draw: `☆` for the improve
|
|
19
|
+
* flag, `✎` for the formula editor, `◆` in the success-value table, `★★★` for
|
|
20
|
+
* mastery. The system renders every one of those with **Font Awesome** — a
|
|
21
|
+
* `fa-regular fa-star`, a `fa-solid fa-pen-to-square` — so the note and the
|
|
22
|
+
* screen it describes were drawing different pictures, and drifting apart with
|
|
23
|
+
* every sheet change.
|
|
24
|
+
*
|
|
25
|
+
* They are also the worst characters in the corpus to typeset. Of the eight
|
|
26
|
+
* book faces probed for #377, **none** carries `✕ ✗ ✎ ☆ ⚗ ➕`; in a Libertinus
|
|
27
|
+
* setting `✕` resolves to macOS LastResort, which draws a tofu box.
|
|
28
|
+
*
|
|
29
|
+
* **Neither obvious fix works.** Keeping the dingbats pins the book to some
|
|
30
|
+
* icon-capable font forever, which is the coupling #377 exists to remove.
|
|
31
|
+
* Pasting Font Awesome's own codepoints is worse: they live in the Private Use
|
|
32
|
+
* Area, which is unassigned by definition, so they break search, copy-paste and
|
|
33
|
+
* screen readers, and no charset check can validate them.
|
|
34
|
+
*
|
|
35
|
+
* So a note **names** an icon and never contains one. `:icon-star-outline:` is
|
|
36
|
+
* ASCII, it is greppable, it survives a charset check, and it degrades to
|
|
37
|
+
* visible literal text on any surface that has not been taught to render it —
|
|
38
|
+
* which is the failure mode you want, because you can see it.
|
|
39
|
+
*
|
|
40
|
+
* **Why a registry rather than the Font Awesome classes.** Three surfaces need
|
|
41
|
+
* three different artefacts from one name: the journals and the website want
|
|
42
|
+
* `<i class="fa-solid fa-star">`, and the PDF wants a font file and a glyph.
|
|
43
|
+
* Only a mapping serves both. It also means a Font Awesome major version that
|
|
44
|
+
* renames an icon — `fa-trash-o` became `fa-trash-can` — costs one line here
|
|
45
|
+
* rather than a sweep of the corpus, and it lets an unknown name be *reported*
|
|
46
|
+
* instead of passing silently through as literal text.
|
|
47
|
+
*
|
|
48
|
+
* **The codepoint is deliberately not here.** A renderer that embeds Font
|
|
49
|
+
* Awesome has to read the font to subset it, and the font's own `cmap` is the
|
|
50
|
+
* only trustworthy source for which glyph a name resolves to. Writing the
|
|
51
|
+
* codepoints out by hand would be a second copy of that table, wrong the first
|
|
52
|
+
* time Font Awesome renumbers anything, and wrong silently. This module states
|
|
53
|
+
* the style and the name; the renderer resolves them against the file it ships.
|
|
54
|
+
*
|
|
55
|
+
* **Licence.** Font Awesome Free's icons are CC BY 4.0 and its fonts SIL OFL
|
|
56
|
+
* 1.1, so a distributed PDF may embed the subset it uses. Attribution belongs
|
|
57
|
+
* in the book's colophon, not in every note.
|
|
58
|
+
*
|
|
59
|
+
* @module
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
import fs from "node:fs";
|
|
63
|
+
import path from "node:path";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The Font Awesome styles a registry entry may name.
|
|
67
|
+
*
|
|
68
|
+
* Free ships these three and no others, so a `light` or `duotone` entry would
|
|
69
|
+
* name a glyph the shipped font does not contain — refused here rather than
|
|
70
|
+
* discovered as a blank space in a printed book.
|
|
71
|
+
*
|
|
72
|
+
* @type {readonly string[]}
|
|
73
|
+
*/
|
|
74
|
+
export const ICON_STYLES = Object.freeze(["solid", "regular", "brands"]);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The icons the user guide already depicts, under the names it should call them.
|
|
78
|
+
*
|
|
79
|
+
* Each entry was read off the interface it describes rather than invented: the
|
|
80
|
+
* `star`/`star-outline` pair is the filled and hollow star the mastery row and
|
|
81
|
+
* the improve flag draw, and `edit` is the pencil the formula editor opens
|
|
82
|
+
* from. The names are what a *writer* would reach for — `delete`, not
|
|
83
|
+
* `trash-can` — because the writer is the one typing them; the Font Awesome
|
|
84
|
+
* spelling is this table's business, not theirs.
|
|
85
|
+
*
|
|
86
|
+
* @type {Readonly<Record<string, {style: string, icon: string, label: string}>>}
|
|
87
|
+
*/
|
|
88
|
+
export const DEFAULT_ICONS = Object.freeze({
|
|
89
|
+
star: { style: "solid", icon: "star", label: "star" },
|
|
90
|
+
"star-outline": { style: "regular", icon: "star", label: "hollow star" },
|
|
91
|
+
diamond: { style: "solid", icon: "diamond", label: "diamond" },
|
|
92
|
+
edit: { style: "solid", icon: "pen-to-square", label: "edit" },
|
|
93
|
+
delete: { style: "solid", icon: "trash-can", label: "delete" },
|
|
94
|
+
add: { style: "solid", icon: "plus", label: "add" },
|
|
95
|
+
remove: { style: "solid", icon: "xmark", label: "remove" },
|
|
96
|
+
"not-applicable": { style: "solid", icon: "xmark", label: "not applicable" },
|
|
97
|
+
menu: { style: "solid", icon: "ellipsis-vertical", label: "actions menu" },
|
|
98
|
+
expand: { style: "solid", icon: "caret-right", label: "expand" },
|
|
99
|
+
shield: { style: "solid", icon: "shield-halved", label: "armour" },
|
|
100
|
+
compass: { style: "solid", icon: "compass", label: "guided tour" },
|
|
101
|
+
flask: { style: "solid", icon: "flask", label: "under construction" },
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The shape a note writes, and the one this module claims.
|
|
106
|
+
*
|
|
107
|
+
* The `icon-` prefix is what keeps it out of the way of an emoji shortcode: a
|
|
108
|
+
* surface that also renders `:smile:` can tell the two apart without a lookup,
|
|
109
|
+
* and a reader can tell what `:icon-star:` is without knowing this module
|
|
110
|
+
* exists. Names are lowercase, digits and hyphens — the charset an address
|
|
111
|
+
* segment already uses (#59), so nothing new has to be explained.
|
|
112
|
+
*
|
|
113
|
+
* Not `:name[content]`. That is remark-directive syntax, and this toolchain
|
|
114
|
+
* parses with markdown-it; a directive would render as its own literal text.
|
|
115
|
+
*
|
|
116
|
+
* @type {RegExp}
|
|
117
|
+
*/
|
|
118
|
+
export const ICON_PATTERN = /:icon-([a-z0-9]+(?:-[a-z0-9]+)*):/g;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Look one name up.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} name - The name written between the colons, without `icon-`.
|
|
124
|
+
* @param {Record<string, object>} [registry] - Defaults to {@link DEFAULT_ICONS}.
|
|
125
|
+
* @returns {{style: string, icon: string, label: string}|null} The entry, or
|
|
126
|
+
* `null` when the registry does not declare it.
|
|
127
|
+
*/
|
|
128
|
+
export function resolveIcon(name, registry = DEFAULT_ICONS) {
|
|
129
|
+
return Object.prototype.hasOwnProperty.call(registry, name) ? registry[name] : null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** HTML-escape a value going into an attribute. */
|
|
133
|
+
const attr = (value) =>
|
|
134
|
+
String(value).replace(/&/g, "&").replace(/</g, "<").replace(/"/g, """);
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The HTML the journals and the website emit — what the system already renders.
|
|
138
|
+
*
|
|
139
|
+
* Carries an accessible name rather than `aria-hidden`. The system's own
|
|
140
|
+
* templates hide their icons because a labelled parent element speaks for them;
|
|
141
|
+
* an icon dropped into a sentence has no such parent, and "the ☆ toggles it"
|
|
142
|
+
* read aloud as "the toggles it" is a sentence with a hole in it.
|
|
143
|
+
*
|
|
144
|
+
* @param {{style: string, icon: string, label: string}} entry - A registry entry.
|
|
145
|
+
* @returns {string} An `<i>` element.
|
|
146
|
+
*/
|
|
147
|
+
export function iconHtml(entry) {
|
|
148
|
+
return (
|
|
149
|
+
`<i class="fa-${attr(entry.style)} fa-${attr(entry.icon)}" ` +
|
|
150
|
+
`role="img" aria-label="${attr(entry.label)}"></i>`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Every icon a string names, in the order written.
|
|
156
|
+
*
|
|
157
|
+
* @param {string} text - Markdown source.
|
|
158
|
+
* @returns {Array<{name: string, index: number, raw: string}>} What it names.
|
|
159
|
+
*/
|
|
160
|
+
export function iconsIn(text) {
|
|
161
|
+
const out = [];
|
|
162
|
+
for (const m of text.matchAll(ICON_PATTERN)) {
|
|
163
|
+
out.push({ name: m[1], index: m.index ?? 0, raw: m[0] });
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Report every icon a tree names that its registry does not declare.
|
|
170
|
+
*
|
|
171
|
+
* The whole point of a registry is that a typo is answerable, so this is the
|
|
172
|
+
* half that makes `:icon-stra:` a finding rather than three words of literal
|
|
173
|
+
* text nobody notices in a rendered page.
|
|
174
|
+
*
|
|
175
|
+
* @param {string} text - The file's contents.
|
|
176
|
+
* @param {string} file - Path to report.
|
|
177
|
+
* @param {Record<string, object>} [registry] - Defaults to {@link DEFAULT_ICONS}.
|
|
178
|
+
* @returns {Array<{file: string, line: number, column: number,
|
|
179
|
+
* severity: "error", message: string}>} The unknown names.
|
|
180
|
+
*/
|
|
181
|
+
export function lintIcons(text, file, registry = DEFAULT_ICONS) {
|
|
182
|
+
const findings = [];
|
|
183
|
+
for (const { name, index, raw } of iconsIn(text)) {
|
|
184
|
+
if (resolveIcon(name, registry)) continue;
|
|
185
|
+
const before = text.slice(0, index);
|
|
186
|
+
const line = before.split("\n").length;
|
|
187
|
+
const column = index - (before.lastIndexOf("\n") + 1) + 1;
|
|
188
|
+
// Nearest declared name, when there is an obvious one: a typo is the
|
|
189
|
+
// common case and the registry is short enough to say what was meant.
|
|
190
|
+
const suggestion = nearestName(name, Object.keys(registry));
|
|
191
|
+
findings.push({
|
|
192
|
+
file,
|
|
193
|
+
line,
|
|
194
|
+
column,
|
|
195
|
+
severity: /** @type {const} */ ("error"),
|
|
196
|
+
message:
|
|
197
|
+
`\`${raw}\` names an icon the registry does not declare` +
|
|
198
|
+
(suggestion ? `; did you mean \`:icon-${suggestion}:\`?` : "") +
|
|
199
|
+
` — an undeclared name renders as its own literal text`,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
return findings;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The closest declared name within one edit, or nothing.
|
|
207
|
+
*
|
|
208
|
+
* Deliberately strict: a suggestion that is merely the alphabetically nearest
|
|
209
|
+
* string is worse than none, because it sends the author to look at an icon
|
|
210
|
+
* they never meant.
|
|
211
|
+
*
|
|
212
|
+
* @param {string} name - What was written.
|
|
213
|
+
* @param {readonly string[]} known - The declared names.
|
|
214
|
+
* @returns {string|undefined} The suggestion, when one is close enough.
|
|
215
|
+
*/
|
|
216
|
+
function nearestName(name, known) {
|
|
217
|
+
let best;
|
|
218
|
+
let bestScore = Infinity;
|
|
219
|
+
for (const candidate of known) {
|
|
220
|
+
const score = editDistance(name, candidate);
|
|
221
|
+
if (score < bestScore) {
|
|
222
|
+
bestScore = score;
|
|
223
|
+
best = candidate;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// Two edits on a short name is already a different word.
|
|
227
|
+
return bestScore <= Math.min(2, Math.floor(name.length / 3) + 1) ? best : undefined;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Levenshtein distance, on the two short strings a registry lookup compares. */
|
|
231
|
+
function editDistance(a, b) {
|
|
232
|
+
/** @type {number[]} */
|
|
233
|
+
let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
234
|
+
for (let i = 1; i <= a.length; i++) {
|
|
235
|
+
const current = [i];
|
|
236
|
+
for (let j = 1; j <= b.length; j++) {
|
|
237
|
+
current[j] = Math.min(
|
|
238
|
+
previous[j] + 1,
|
|
239
|
+
current[j - 1] + 1,
|
|
240
|
+
previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
previous = current;
|
|
244
|
+
}
|
|
245
|
+
return previous[b.length];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Refuse a registry that names a style Font Awesome Free does not ship.
|
|
250
|
+
*
|
|
251
|
+
* @param {Record<string, object>} registry - A package's icon table.
|
|
252
|
+
* @param {string} [where="icons"] - Where to say the fault is.
|
|
253
|
+
* @returns {Array<{severity: "error", message: string}>} What is wrong with it.
|
|
254
|
+
*/
|
|
255
|
+
export function checkIconRegistry(registry, where = "icons") {
|
|
256
|
+
const findings = [];
|
|
257
|
+
for (const [name, entry] of Object.entries(registry ?? {})) {
|
|
258
|
+
const at = `\`${where}.${name}\``;
|
|
259
|
+
if (!entry || typeof entry !== "object") {
|
|
260
|
+
findings.push({
|
|
261
|
+
severity: /** @type {const} */ ("error"),
|
|
262
|
+
message: `${at} is not an icon entry — it takes \`style\`, \`icon\` and \`label\``,
|
|
263
|
+
});
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (!ICON_STYLES.includes(entry.style)) {
|
|
267
|
+
findings.push({
|
|
268
|
+
severity: /** @type {const} */ ("error"),
|
|
269
|
+
message:
|
|
270
|
+
`${at} names style \`${entry.style}\`, and Font Awesome Free ships ` +
|
|
271
|
+
`only ${ICON_STYLES.join(", ")} — a glyph in any other style is ` +
|
|
272
|
+
`absent from the font a book would embed`,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
if (typeof entry.icon !== "string" || !entry.icon) {
|
|
276
|
+
findings.push({
|
|
277
|
+
severity: /** @type {const} */ ("error"),
|
|
278
|
+
message: `${at} declares no \`icon\`, so nothing names the glyph to draw`,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
if (typeof entry.label !== "string" || !entry.label) {
|
|
282
|
+
findings.push({
|
|
283
|
+
severity: /** @type {const} */ ("error"),
|
|
284
|
+
message:
|
|
285
|
+
`${at} declares no \`label\`, and an icon with no accessible name ` +
|
|
286
|
+
`is read aloud as a gap in the sentence`,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return findings;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Walk a content tree and report every icon name its registry does not declare.
|
|
295
|
+
*
|
|
296
|
+
* Its own walk rather than the charset check's, so both modules stay leaves
|
|
297
|
+
* with nothing imported between them. The cost is one extra pass over the tree,
|
|
298
|
+
* which is the cheaper half of a lint that already parses every note.
|
|
299
|
+
*
|
|
300
|
+
* @param {string} contentBase - Root of the content tree.
|
|
301
|
+
* @param {object} [opts]
|
|
302
|
+
* @param {readonly string[]} [opts.skipDirectories] - Directory names to ignore.
|
|
303
|
+
* @param {Record<string, object>} [opts.registry] - The package's icon table.
|
|
304
|
+
* @returns {{findings: Array<{file: string, line: number, column: number,
|
|
305
|
+
* severity: "error", message: string}>, files: number}} What it found.
|
|
306
|
+
*/
|
|
307
|
+
export function lintContentIcons(contentBase, { skipDirectories = [], registry } = {}) {
|
|
308
|
+
const skip = new Set(skipDirectories);
|
|
309
|
+
const findings = [];
|
|
310
|
+
let files = 0;
|
|
311
|
+
|
|
312
|
+
/** @param {string} dir - Directory to descend into. */
|
|
313
|
+
const walk = (dir) => {
|
|
314
|
+
/** @type {import("node:fs").Dirent[]} */
|
|
315
|
+
let entries;
|
|
316
|
+
try {
|
|
317
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
318
|
+
} catch {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
for (const entry of entries) {
|
|
322
|
+
if (entry.name.startsWith(".") || skip.has(entry.name)) continue;
|
|
323
|
+
const full = path.join(dir, entry.name);
|
|
324
|
+
if (entry.isDirectory()) {
|
|
325
|
+
walk(full);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (!/\.(md|markdown)$/i.test(entry.name)) continue;
|
|
329
|
+
let text;
|
|
330
|
+
try {
|
|
331
|
+
text = fs.readFileSync(full, "utf8");
|
|
332
|
+
} catch {
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
files += 1;
|
|
336
|
+
findings.push(...lintIcons(text, path.relative(contentBase, full), registry));
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
walk(contentBase);
|
|
341
|
+
return { findings, files };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* A markdown-it plugin rendering `:icon-name:` inline.
|
|
346
|
+
*
|
|
347
|
+
* An unknown name is left **exactly as written** rather than dropped. The name
|
|
348
|
+
* is reported by {@link lintIcons}, and a rendered page that still shows
|
|
349
|
+
* `:icon-stra:` is how the author finds it without reading a log.
|
|
350
|
+
*
|
|
351
|
+
* @param {Record<string, object>} [registry] - Defaults to {@link DEFAULT_ICONS}.
|
|
352
|
+
* @returns {(md: object) => void} A markdown-it plugin.
|
|
353
|
+
*/
|
|
354
|
+
export function iconPlugin(registry = DEFAULT_ICONS) {
|
|
355
|
+
return (md) => {
|
|
356
|
+
/** @type {any} */ (md).inline.ruler.before("emphasis", "heroiclands_icon", iconRule);
|
|
357
|
+
/** @type {any} */ (md).renderer.rules.heroiclands_icon = (tokens, idx) =>
|
|
358
|
+
iconHtml(tokens[idx].meta.entry);
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* @param {any} state - markdown-it inline state.
|
|
362
|
+
* @param {boolean} silent - Validation pass, which emits no token.
|
|
363
|
+
* @returns {boolean} Whether the rule consumed anything.
|
|
364
|
+
*/
|
|
365
|
+
function iconRule(state, silent) {
|
|
366
|
+
const start = state.pos;
|
|
367
|
+
if (state.src.charCodeAt(start) !== 0x3a /* : */) return false;
|
|
368
|
+
// Anchored at the cursor, so the scan is O(token) rather than a
|
|
369
|
+
// search of the remaining source at every colon in the paragraph.
|
|
370
|
+
const re = /^:icon-([a-z0-9]+(?:-[a-z0-9]+)*):/;
|
|
371
|
+
const m = re.exec(state.src.slice(start));
|
|
372
|
+
if (!m) return false;
|
|
373
|
+
|
|
374
|
+
const entry = resolveIcon(m[1], registry);
|
|
375
|
+
// Not ours to consume: leaving the source untouched is what makes an
|
|
376
|
+
// unrecognised name visible on the page instead of vanishing.
|
|
377
|
+
if (!entry) return false;
|
|
378
|
+
|
|
379
|
+
if (!silent) {
|
|
380
|
+
const token = state.push("heroiclands_icon", "", 0);
|
|
381
|
+
token.meta = { name: m[1], entry };
|
|
382
|
+
token.markup = m[0];
|
|
383
|
+
}
|
|
384
|
+
state.pos += m[0].length;
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
}
|
|
@@ -1,3 +1,16 @@
|
|
|
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
|
+
|
|
1
14
|
/**
|
|
2
15
|
* @file The item catalogue of a package this repository depends on but does not
|
|
3
16
|
* contain.
|
|
@@ -151,13 +164,73 @@ function cacheSchemaArtifact(root, dir) {
|
|
|
151
164
|
return true;
|
|
152
165
|
}
|
|
153
166
|
|
|
167
|
+
/**
|
|
168
|
+
* What each extracted pack is, written beside the items rather than inferred
|
|
169
|
+
* from the directory it landed in (#58).
|
|
170
|
+
*
|
|
171
|
+
* A dependency may ship a pack per system — `items-sohl` and `items-hm3` — and
|
|
172
|
+
* the two hold documents of the *same* `(type, shortcode)` addresses with
|
|
173
|
+
* different data models: `skill:awar` exists in both vocabularies and means two
|
|
174
|
+
* different documents. So a consumer compiling an `hm3` pack has to read the
|
|
175
|
+
* `hm3` half of the catalogue and no other, and the only place that says which
|
|
176
|
+
* half a directory is, is the dependency's own manifest at fetch time.
|
|
177
|
+
*
|
|
178
|
+
* Held as a manifest at the cache root rather than a marker inside each pack
|
|
179
|
+
* directory, because those directories are walked as JSON trees: a file dropped
|
|
180
|
+
* in one would be loaded as though it were a document.
|
|
181
|
+
*
|
|
182
|
+
* @type {string}
|
|
183
|
+
*/
|
|
184
|
+
const ITEM_PACKS = "item-packs.json";
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* What to record about the packs being extracted.
|
|
188
|
+
*
|
|
189
|
+
* Exported so the pair is one fact: {@link foreignItemCatalogDirs} reads what
|
|
190
|
+
* this writes, and a test that hand-wrote the file would prove the reader
|
|
191
|
+
* against a transcription of the format rather than against the format.
|
|
192
|
+
*
|
|
193
|
+
* A pack declaring no `system` records `null` — Foundry requires the field on
|
|
194
|
+
* an Item pack, so this is the shape of a manifest that is wrong rather than a
|
|
195
|
+
* case with a meaning, and `null` reads as "neutral", which is the safe way to
|
|
196
|
+
* be wrong: a neutral pack is read by every system rather than by none.
|
|
197
|
+
*
|
|
198
|
+
* @param {readonly object[]} itemPacks - The manifest's Item pack entries.
|
|
199
|
+
* @returns {Array<{name: string, system: string|null}>} What each one is.
|
|
200
|
+
*/
|
|
201
|
+
export function itemPackManifest(itemPacks) {
|
|
202
|
+
return itemPacks.map((pack) => ({ name: pack.name, system: pack.system ?? null }));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The system each extracted pack was published for.
|
|
207
|
+
*
|
|
208
|
+
* @param {string} dir - The dependency's cache directory.
|
|
209
|
+
* @returns {Map<string, string|null>} Pack name → its declared system, `null`
|
|
210
|
+
* for a pack that declares none.
|
|
211
|
+
*/
|
|
212
|
+
function cachedItemPacks(dir) {
|
|
213
|
+
const file = path.join(dir, ITEM_PACKS);
|
|
214
|
+
const declared = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
215
|
+
return new Map(declared.map((pack) => [pack.name, pack.system ?? null]));
|
|
216
|
+
}
|
|
217
|
+
|
|
154
218
|
/**
|
|
155
219
|
* Whether a dependency's cache is present and complete.
|
|
156
220
|
*
|
|
221
|
+
* **A cache without its pack manifest is incomplete**, not merely unlabelled.
|
|
222
|
+
* One written before #58 holds the items and not what they are, and the two
|
|
223
|
+
* ways of proceeding without it are both wrong: reading every pack resolves an
|
|
224
|
+
* `hm3` reference against `sohl` documents — the silent-wrong-output failure
|
|
225
|
+
* this scoping exists to remove — and reading none fails a build that was
|
|
226
|
+
* working. Treating it as incomplete makes `content-build deps fetch` refill
|
|
227
|
+
* it, which is a command the cold-cache path already tells anyone to run.
|
|
228
|
+
*
|
|
157
229
|
* @param {string} dir - The dependency's cache directory.
|
|
158
230
|
* @returns {boolean} True when it was fetched to completion.
|
|
159
231
|
*/
|
|
160
|
-
const isComplete = (dir) =>
|
|
232
|
+
const isComplete = (dir) =>
|
|
233
|
+
fs.existsSync(path.join(dir, STAMP)) && fs.existsSync(path.join(dir, ITEM_PACKS));
|
|
161
234
|
|
|
162
235
|
/**
|
|
163
236
|
* Read a dependency's manifest.
|
|
@@ -230,8 +303,18 @@ async function extractItemPacks(id, version, manifest, root, dir) {
|
|
|
230
303
|
const out = path.join(dir, "items", pack.name);
|
|
231
304
|
fs.mkdirSync(out, { recursive: true });
|
|
232
305
|
await extractPack(src, out, { log: false });
|
|
233
|
-
log.info(
|
|
306
|
+
log.info(
|
|
307
|
+
`${id}@${version}: extracted pack "${pack.name}"` +
|
|
308
|
+
(pack.system ? ` (system: ${pack.system})` : ""),
|
|
309
|
+
);
|
|
234
310
|
}
|
|
311
|
+
// What each pack is, from the only place that knows: the manifest that
|
|
312
|
+
// declared it (#58). Written before the stamp, so the stamp continues to
|
|
313
|
+
// mean the cache is whole.
|
|
314
|
+
fs.writeFileSync(
|
|
315
|
+
path.join(dir, ITEM_PACKS),
|
|
316
|
+
`${JSON.stringify(itemPackManifest(itemPacks), null, 4)}\n`,
|
|
317
|
+
);
|
|
235
318
|
// Last, so a fetch that died partway is never mistaken for a complete one.
|
|
236
319
|
fs.writeFileSync(path.join(dir, STAMP), `${version}\n`);
|
|
237
320
|
}
|
|
@@ -637,10 +720,24 @@ export async function fetchAllCatalogs(config) {
|
|
|
637
720
|
* Reads the cache only. A cold cache is an error naming the command that fills
|
|
638
721
|
* it, rather than a download nobody asked for.
|
|
639
722
|
*
|
|
723
|
+
* **Scoped to one system when the caller compiles for one (#58)**, exactly as
|
|
724
|
+
* {@link module:engine/generate.itemPackJsonDirs} scopes the local half. The
|
|
725
|
+
* two halves answer the same lookup — `loadItemsMap` merges them into one
|
|
726
|
+
* address space keyed by `subType:shortcode` — so scoping only the local one
|
|
727
|
+
* leaves the collision it was meant to remove: `skill:awar` is a real address
|
|
728
|
+
* in both vocabularies, and a `harn-ensemble` actor compiled for `hm3` would
|
|
729
|
+
* resolve three quarters of its references against whichever document the
|
|
730
|
+
* dependency's `sohl` pack happened to supply. A pack that declares no system
|
|
731
|
+
* is neutral and always read; asking for no system reads every pack, which is
|
|
732
|
+
* every single-system build.
|
|
733
|
+
*
|
|
640
734
|
* @param {object} config - The resolved build configuration.
|
|
641
|
-
* @
|
|
735
|
+
* @param {string|null} [system] - The system the caller is compiling for.
|
|
736
|
+
* Omitted or `null`, every cached pack is read.
|
|
737
|
+
* @returns {Array<{dir: string, package: string}>} Every cached dependency's
|
|
738
|
+
* item directories, each with the package that published it.
|
|
642
739
|
*/
|
|
643
|
-
export function foreignItemCatalogDirs(config) {
|
|
740
|
+
export function foreignItemCatalogDirs(config, system = null) {
|
|
644
741
|
const dirs = [];
|
|
645
742
|
for (const rel of itemCatalogRelationships(config)) {
|
|
646
743
|
const root = config.paths.foreignCache;
|
|
@@ -664,12 +761,17 @@ export function foreignItemCatalogDirs(config) {
|
|
|
664
761
|
// rewritten: a plain string sort would put `0.8.10` before `0.8.2` and
|
|
665
762
|
// silently resolve every embedded item against the older catalogue
|
|
666
763
|
// (#272).
|
|
667
|
-
const
|
|
668
|
-
|
|
764
|
+
const newest = newestVersionDir(cached);
|
|
765
|
+
const packSystems = cachedItemPacks(newest);
|
|
766
|
+
const items = itemsDir(newest);
|
|
767
|
+
for (const entry of fs.readdirSync(items, { withFileTypes: true })) {
|
|
768
|
+
if (!entry.isDirectory()) continue;
|
|
769
|
+
const packSystem = packSystems.get(entry.name) ?? null;
|
|
770
|
+
if (system != null && packSystem != null && packSystem !== system) continue;
|
|
669
771
|
// The dependency's own id travels with its directory (#334): a
|
|
670
772
|
// being's `model:` names the package its template comes from, and
|
|
671
773
|
// the address cannot be built from the path.
|
|
672
|
-
dirs.push({ dir: path.join(items, name), package: rel.id });
|
|
774
|
+
dirs.push({ dir: path.join(items, entry.name), package: rel.id });
|
|
673
775
|
}
|
|
674
776
|
}
|
|
675
777
|
return dirs;
|