@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.
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
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) => fs.existsSync(path.join(dir, STAMP));
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(`${id}@${version}: extracted pack "${pack.name}"`);
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
- * @returns {string[]} Every cached dependency's item directories.
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 items = itemsDir(newestVersionDir(cached));
668
- for (const name of fs.readdirSync(items)) {
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;