@heroiclands/package-build 25.6.3 → 25.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +19 -1
  3. package/bin/content-commands.mjs +14 -70
  4. package/bin/package-build.mjs +25 -10
  5. package/ci/wip-workflows.test.mjs +252 -0
  6. package/content-config.mjs +40 -12
  7. package/docs/api.md +237 -48
  8. package/docs/commands.md +73 -36
  9. package/docs/configuration.md +92 -48
  10. package/docs/guides/release-and-deploy.md +26 -14
  11. package/docs/guides/site.md +29 -6
  12. package/engine/cdn-publish.mjs +164 -6
  13. package/engine/content-index.mjs +71 -19
  14. package/engine/content-spans.mjs +18 -1
  15. package/engine/content-styles.mjs +85 -0
  16. package/engine/derived-sections.mjs +4 -3
  17. package/engine/event-views.mjs +6 -4
  18. package/engine/helpers.mjs +33 -9
  19. package/engine/index.mjs +1 -0
  20. package/engine/note-cache.mjs +1107 -0
  21. package/engine/note-page.mjs +424 -0
  22. package/engine/pack-config.mjs +47 -4
  23. package/engine/pathnames.mjs +22 -7
  24. package/engine/prepared-only.mjs +32 -0
  25. package/engine/preview-index.mjs +55 -13
  26. package/engine/site-build.mjs +34 -448
  27. package/engine/site-config.mjs +33 -4
  28. package/engine/site-maps.mjs +16 -24
  29. package/engine/site-origins.mjs +23 -0
  30. package/engine/site-page.mjs +648 -0
  31. package/engine/site-render.mjs +142 -10
  32. package/engine/site-serve.mjs +1 -1
  33. package/engine/sql-tables.mjs +12 -3
  34. package/package.json +6 -1
  35. package/site-theme/static/css/style.css +14 -0
  36. package/types/content-config.d.mts +24 -4
  37. package/types/engine/cdn-publish.d.mts +22 -0
  38. package/types/engine/content-index.d.mts +36 -2
  39. package/types/engine/content-styles.d.mts +20 -0
  40. package/types/engine/derived-sections.d.mts +3 -3
  41. package/types/engine/event-views.d.mts +4 -3
  42. package/types/engine/helpers.d.mts +18 -1
  43. package/types/engine/index.d.mts +1 -0
  44. package/types/engine/note-cache.d.mts +149 -0
  45. package/types/engine/note-page.d.mts +92 -0
  46. package/types/engine/pack-config.d.mts +30 -0
  47. package/types/engine/prepared-only.d.mts +14 -0
  48. package/types/engine/preview-index.d.mts +35 -0
  49. package/types/engine/site-build.d.mts +5 -100
  50. package/types/engine/site-config.d.mts +3 -2
  51. package/types/engine/site-maps.d.mts +7 -11
  52. package/types/engine/site-origins.d.mts +9 -0
  53. package/types/engine/site-page.d.mts +261 -0
  54. package/types/engine/site-render.d.mts +20 -1
  55. package/types/engine/sql-tables.d.mts +8 -4
@@ -0,0 +1,424 @@
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
+ * **One note, rendered as its site page's body.** {@link renderNoteFile}
16
+ * reads one note of a repository and returns the body HTML
17
+ * `package-build site build` writes for it, through the same page render the
18
+ * site build runs, against the same records: the content tree's index
19
+ * records, the dependencies' fetched indexes, and the note's own query
20
+ * answers. The records and answers are kept under `build/` and reused while
21
+ * every input they were derived from is unchanged; see
22
+ * {@link module:engine/note-cache.noteTree}.
23
+ *
24
+ * @module
25
+ */
26
+
27
+ import fs from "node:fs";
28
+ import os from "node:os";
29
+ import path from "node:path";
30
+
31
+ import { expandContentTables } from "./content-tables.mjs";
32
+ import { scanFigures } from "./content-figures.mjs";
33
+ import { embedRole } from "./content-embeds.mjs";
34
+ import { isNoteRecord, noteFile } from "./index-records.mjs";
35
+ import { figureIndexKeys } from "./site-index.mjs";
36
+ import { drawSiteMaps } from "./site-maps.mjs";
37
+ import { NoteInputsChanged, READ_ATTEMPTS, noteTree } from "./note-cache.mjs";
38
+ import { findConfigFile, readPackConfig, withPackConfig } from "./pack-config.mjs";
39
+ import { recordSiteContext } from "./preview-index.mjs";
40
+ import {
41
+ addressDiagnostic,
42
+ contentPage,
43
+ frontmatterLinkDiagnostic,
44
+ pageDiagnostics,
45
+ parseNote,
46
+ renderSitePage,
47
+ resolveSitePass,
48
+ siteBodyRenderer,
49
+ sitePageDecorator,
50
+ } from "./site-page.mjs";
51
+ import { groupDictionaryLists } from "./dictionary-html.mjs";
52
+ import { LEXICAL_LINK_CLASS } from "./dictionary-layout.mjs";
53
+ import { walkMarkdownFiles } from "./helpers.mjs";
54
+ import { pruneGenerated, splitContent, unlinkEntries } from "./site-reach.mjs";
55
+ import { noteBodyStylesheet } from "./content-styles.mjs";
56
+ import { isHomepage } from "./homepage.mjs";
57
+ import { normalizeCurrentDate } from "./current-date.mjs";
58
+ import { reckoningContext } from "./reckoning-markers.mjs";
59
+ import { siteBase } from "../content-config.mjs";
60
+
61
+ /**
62
+ * A rendered note.
63
+ *
64
+ * @typedef {object} NotePage
65
+ * @property {string} html - The page body, as the site theme receives it,
66
+ * with every page it links counted as reachable from the homepage.
67
+ * @property {string} styles - The CSS the theme applies to a page body; see
68
+ * {@link module:engine/content-styles.noteBodyStylesheet}.
69
+ * @property {Array<{file?: string, line?: number, column?: number,
70
+ * severity: string, message: string}>} findings - What the render found,
71
+ * each printing as `file:line:column: severity: message` through
72
+ * `formatDiagnostic`.
73
+ * @property {{tree: "hit"|"miss"|"off", notes: number, tables: "hit"|"miss"|"off"}} cache -
74
+ * What the cache answered: `tree` whether the content tree's records were
75
+ * reused whole, `notes` how many notes derived their own records again, and
76
+ * `tables` whether the query answers of the note and of the notes it links
77
+ * were all reused.
78
+ */
79
+
80
+ /**
81
+ * Render one note of a repository to the body HTML of its site page.
82
+ *
83
+ * Loads the repository's `package-build.config.yaml`, its dependencies'
84
+ * fetched indexes, the content tree's records and the note's own query
85
+ * answers, and renders the note against them as `package-build site build`
86
+ * renders its page. The records and answers come from
87
+ * `build/cache/note-page/` while every input they were derived from is
88
+ * unchanged; see {@link module:engine/note-cache.noteTree}. The note is read
89
+ * from disk, so what is rendered is what is saved.
90
+ *
91
+ * @param {string} file - The note, relative to `root` or absolute.
92
+ * @param {object} [options]
93
+ * @param {string} [options.root] - The repository, or a directory inside it;
94
+ * the working directory by default.
95
+ * @param {boolean} [options.cache=true] - Whether to read and write the cache;
96
+ * `false` derives everything and writes nothing.
97
+ * @param {typeof import("./sql-tables.mjs").prepareTreeSqlTables} [options.prepareTables] -
98
+ * Prepares the query answers;
99
+ * {@link module:engine/sql-tables.prepareTreeSqlTables} by default. A host
100
+ * may wrap it, to time or observe the preparation.
101
+ * @returns {Promise<NotePage>} The body, its styles, the findings and what the
102
+ * cache did.
103
+ * @throws {Error} When there is no configuration; when the file is not a
104
+ * note of the content tree; when a dependency's index has not been fetched
105
+ * or cannot be read, naming `package-build deps fetch`; when a note of the
106
+ * tree cannot be indexed, as the content-index build reports it; and when
107
+ * the configuration, fonts or dependency indexes keep changing while they
108
+ * are read.
109
+ */
110
+ export async function renderNoteFile(
111
+ file,
112
+ { root = process.cwd(), cache = true, prepareTables } = {},
113
+ ) {
114
+ for (let attempt = 1; ; attempt++) {
115
+ try {
116
+ // Everything is resolved afresh on each attempt: the
117
+ // configuration, the ambient setup and the note's place in the
118
+ // tree, so a retry answers as a fresh call would.
119
+ const configPath = findConfigFile(root);
120
+ if (!configPath) throw new Error(`no package-build configuration at or above ${root}`);
121
+ const config = readPackConfig(configPath);
122
+ // What the engine reads of the configuration where no caller
123
+ // passes it is answered from this repository's, wherever the
124
+ // caller stands.
125
+ return await withPackConfig(
126
+ config,
127
+ () => renderInRepository(file, root, config, { cache, prepareTables }),
128
+ configPath,
129
+ );
130
+ } catch (error) {
131
+ if (!(error instanceof NoteInputsChanged) || attempt >= READ_ATTEMPTS) throw error;
132
+ }
133
+ }
134
+ }
135
+
136
+ /**
137
+ * {@link renderNoteFile}, with `config` as the ambient configuration.
138
+ *
139
+ * @param {string} file - The note, relative to `root` or absolute.
140
+ * @param {string} root - The directory `file` is relative to.
141
+ * @param {object} config - The repository's resolved configuration.
142
+ * @param {{cache: boolean, prepareTables?: Function}} options - Whether to
143
+ * read and write the cache, and what prepares the answers.
144
+ * @returns {Promise<NotePage>} The rendered note.
145
+ * @throws {NoteInputsChanged} When the inputs moved while they were read.
146
+ */
147
+ async function renderInRepository(file, root, config, options) {
148
+ const absolute = path.resolve(root, file);
149
+ const contentBase = path.resolve(config.paths.content);
150
+ const inTree = [...walkMarkdownFiles(contentBase, { skipDirectories: config.skipDirectories })];
151
+ if (!inTree.includes(absolute))
152
+ throw new Error(
153
+ `${absolute}: not a note of the content tree at ${contentBase}, so it has no site page`,
154
+ );
155
+ return renderOnce(absolute, contentBase, config, options);
156
+ }
157
+
158
+ /**
159
+ * One reading and render of {@link renderInRepository}.
160
+ *
161
+ * @param {string} absolute - The note.
162
+ * @param {string} contentBase - The content tree.
163
+ * @param {object} config - The repository's resolved configuration.
164
+ * @param {{cache: boolean, prepareTables?: Function}} options - Whether to
165
+ * read and write the cache, and what prepares the answers.
166
+ * @returns {Promise<NotePage>} The rendered note.
167
+ * @throws {NoteInputsChanged} When the inputs moved under the answers.
168
+ */
169
+ async function renderOnce(absolute, contentBase, config, { cache, prepareTables }) {
170
+ const tree = await noteTree(config, { cache, prepareTables });
171
+ const text = tree.textOf(absolute);
172
+ const context = {
173
+ file: absolute,
174
+ config,
175
+ records: tree.records,
176
+ foreign: tree.foreign,
177
+ contentBase,
178
+ textOf: tree.textOf,
179
+ };
180
+ // The page resolves against the notes it links, as their own pages emit
181
+ // them: so it is rendered once to learn which notes those are, and again
182
+ // with their query answers and generated sections in hand.
183
+ const own = await tree.answers([absolute], absolute);
184
+ const first = renderNote(text, { ...context, sqlTables: own.sqlTables });
185
+ const linked = await tree.answers([absolute, ...first.consulted], absolute);
186
+ const page = renderNote(text, {
187
+ ...context,
188
+ sqlTables: linked.sqlTables,
189
+ linked: new Set([absolute, ...first.consulted]),
190
+ });
191
+ const { consulted: _, ...result } = page;
192
+ return {
193
+ ...result,
194
+ cache: {
195
+ ...tree.cache,
196
+ tables:
197
+ own.tables === "off" ? "off"
198
+ : own.tables === "hit" && linked.tables === "hit" ? "hit"
199
+ : "miss",
200
+ },
201
+ };
202
+ }
203
+
204
+ /**
205
+ * Each linked note's figures, by the addresses a cross-note `ref` writes, as
206
+ * the site build numbers them: from the note's body with its query answers
207
+ * and generated sections expanded.
208
+ *
209
+ * @param {Iterable<object>} targets - The linked notes' records.
210
+ * @param {object} options
211
+ * @param {string} options.contentBase - The content tree.
212
+ * @param {(file: string) => string} options.textOf - A note's text.
213
+ * @param {Map<string, object[]>} options.sqlTables - The prepared answers.
214
+ * @param {object} options.config - The resolved configuration.
215
+ * @param {object} options.assets - The asset address space.
216
+ * @returns {Map<string, Map<string, object>>} The figures.
217
+ */
218
+ function linkedFigures(targets, { contentBase, textOf, sqlTables, config, assets }) {
219
+ const figures = new Map();
220
+ for (const record of targets) {
221
+ const file = noteFile(contentBase, record);
222
+ const note = parseNote(textOf(file), file, { config });
223
+ const shortcode = note?.fm?.shortcode;
224
+ if (typeof shortcode !== "string" || !shortcode) continue;
225
+ const { markdown } = expandContentTables(note.body, {
226
+ source: record.file?.path,
227
+ sqlTables: sqlTables.get(file),
228
+ pageLists: sqlTables.pageLists?.get(file),
229
+ });
230
+ const byId = new Map(
231
+ scanFigures(markdown, { resolveRole: (address) => embedRole(assets, address) })
232
+ .figures.filter((figure) => figure.id)
233
+ .map((figure) => [
234
+ figure.id,
235
+ { label: figure.label, caption: figure.caption, hasCaption: figure.hasCaption },
236
+ ]),
237
+ );
238
+ const pkg = record.package ?? config.contentPackage;
239
+ const type = String(note.fm.type ?? "").toLowerCase();
240
+ for (const key of figureIndexKeys(pkg, type, shortcode)) figures.set(key, byId);
241
+ }
242
+ return figures;
243
+ }
244
+
245
+ /**
246
+ * The drawing a generated **From here** section names, drawn for this note
247
+ * alone as the site build draws it, in a scratch directory that is removed
248
+ * once the drawing is read.
249
+ *
250
+ * @param {object} options
251
+ * @param {object} options.config - The resolved configuration.
252
+ * @param {object[]} options.records - The tree's records.
253
+ * @param {object} options.foreign - The dependencies' indexes.
254
+ * @param {string} options.shortcode - The note's shortcode.
255
+ * @returns {(name: string) => string|undefined} The drawing's SVG text by the
256
+ * file name the section names.
257
+ */
258
+ function noteDrawing({ config, records, foreign, shortcode }) {
259
+ let drawn;
260
+ return (name) => {
261
+ if (!drawn) {
262
+ const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "note-page-map-"));
263
+ try {
264
+ const { maps } = drawSiteMaps({
265
+ records,
266
+ foreignIndex: foreign.index,
267
+ config,
268
+ base: siteBase(config),
269
+ only: [shortcode],
270
+ outDir,
271
+ });
272
+ drawn = new Map(
273
+ [...maps].map(([key, map]) => [key, fs.readFileSync(map.file, "utf8")]),
274
+ );
275
+ } finally {
276
+ fs.rmSync(outDir, { recursive: true, force: true });
277
+ }
278
+ }
279
+ return drawn.get(name);
280
+ };
281
+ }
282
+
283
+ /** Split a path on either separator. */
284
+ const segments = (file) => String(file).split(/[\\/]/).filter(Boolean);
285
+
286
+ /**
287
+ * Render one note to the body HTML of its site page, with `config` as the
288
+ * ambient configuration.
289
+ *
290
+ * @param {string} text - The note, frontmatter and body.
291
+ * @param {object} options
292
+ * @param {string} options.file - The note's absolute path, which findings
293
+ * name and `sqlTables` is keyed by.
294
+ * @param {object} options.config - The package's resolved configuration.
295
+ * @param {object[]} options.records - The content tree's index records.
296
+ * @param {object} options.foreign - The dependencies' indexes, as
297
+ * {@link module:engine/metadata-index.loadForeignIndexes} returns them.
298
+ * @param {Map<string, object[]>} [options.sqlTables] - The prepared answers to
299
+ * the note's queries and its generated sections.
300
+ * @returns {Omit<NotePage, "cache">} The body, its styles and the findings.
301
+ */
302
+ function renderNote(
303
+ text,
304
+ { file, config, records, foreign, sqlTables, contentBase, textOf, linked },
305
+ ) {
306
+ const findings = [];
307
+ const styles = noteBodyStylesheet();
308
+ const consulted = new Set();
309
+ const done = (html = "") => ({ html, styles, findings, consulted });
310
+
311
+ let note;
312
+ try {
313
+ note = parseNote(String(text ?? ""), file, { config });
314
+ } catch (error) {
315
+ findings.push({ file, ...error.position, severity: "error", message: error.message });
316
+ return done();
317
+ }
318
+ if (!note) {
319
+ findings.push({ file, severity: "error", message: "not a note: no frontmatter" });
320
+ return done();
321
+ }
322
+
323
+ // A linked note's anchors are those its own page emits, generated
324
+ // sections included, wherever its answers are in hand.
325
+ const { site, assets, events, runs, pages } = recordSiteContext({
326
+ config,
327
+ records,
328
+ foreign,
329
+ anchorsOf: (record) => sqlTables?.get(noteFile(contentBase, record))?.anchors,
330
+ });
331
+ // Which of the package's pages the render looks up, so their answers can
332
+ // be prepared for the render that is kept.
333
+ const lookups = site.index;
334
+ const recording = new Map(lookups);
335
+ recording.get = (key) => {
336
+ const entry = Map.prototype.get.call(recording, key);
337
+ const record = entry?.url ? pages.get(entry.url) : undefined;
338
+ if (record) consulted.add(noteFile(contentBase, record));
339
+ return entry;
340
+ };
341
+ site.index = recording;
342
+ const renderBody = siteBodyRenderer(config);
343
+
344
+ // A homepage is published as authored: no expansion, no link resolution.
345
+ if (isHomepage(note.fm)) return done(renderBody(note.body, { runs }));
346
+
347
+ const contentRoot = segments(config.paths?.content ?? "");
348
+ const parts = segments(file);
349
+ const below =
350
+ contentRoot.length > 0 &&
351
+ parts.length > contentRoot.length &&
352
+ contentRoot.every((part, at) => parts[at] === part);
353
+ const made = contentPage(note, {
354
+ file,
355
+ fileBase: parts.at(-1) ?? "",
356
+ relPath: (below ? parts.slice(contentRoot.length) : parts).join("/"),
357
+ folder: parts.at(-2) ?? contentRoot.at(-1) ?? "",
358
+ base: siteBase(config),
359
+ packages: new Set(
360
+ config.site?.packages?.length ? config.site.packages : [config.contentPackage],
361
+ ),
362
+ contentPackage: config.contentPackage,
363
+ sectionPages: new Map(
364
+ Object.entries(config.site?.sections ?? {}).map(([name, address]) => [address, name]),
365
+ ),
366
+ present: normalizeCurrentDate(config.currentDate),
367
+ dates: reckoningContext({ notes: records }),
368
+ });
369
+ if (!made) {
370
+ findings.push({
371
+ file,
372
+ severity: "error",
373
+ message: "publishes no page: a stub, a GM note, or a note with no type",
374
+ });
375
+ return done();
376
+ }
377
+ findings.push(...made.fmLinks.map(frontmatterLinkDiagnostic));
378
+ if (made.addressFinding) {
379
+ findings.push(addressDiagnostic(made.addressFinding));
380
+ return done();
381
+ }
382
+
383
+ const result = renderSitePage(made.page, {
384
+ index: site,
385
+ foreign,
386
+ pass: resolveSitePass(config.site?.pass, {
387
+ ...config.site?.passOptions,
388
+ repoRoot: config.rootDir,
389
+ config,
390
+ }),
391
+ decorate: sitePageDecorator(config, site),
392
+ sqlTables,
393
+ config,
394
+ artIndex: assets,
395
+ events,
396
+ runs,
397
+ ...(linked ?
398
+ {
399
+ figuresByAddress: linkedFigures(
400
+ records.filter(
401
+ (record) =>
402
+ isNoteRecord(record) && linked.has(noteFile(contentBase, record)),
403
+ ),
404
+ { contentBase, textOf, sqlTables, config, assets },
405
+ ),
406
+ drawing: noteDrawing({ config, records, foreign, shortcode: note.fm.shortcode }),
407
+ }
408
+ : {}),
409
+ });
410
+ findings.push(...pageDiagnostics(result, (at) => (at === file ? String(text) : "")));
411
+ // The body as the site writer hands it to the theme, with every page it
412
+ // links counted as reachable: which pages the homepage reaches is a fact
413
+ // about the whole site.
414
+ const { authored, generated } = splitContent(
415
+ renderBody(result.markdown, { runs, figures: result.figures }),
416
+ );
417
+ const outside = () => false;
418
+ return done(
419
+ groupDictionaryLists(
420
+ unlinkEntries(authored, outside, LEXICAL_LINK_CLASS) +
421
+ pruneGenerated(generated, outside),
422
+ ),
423
+ );
424
+ }
@@ -99,6 +99,7 @@
99
99
  * @module
100
100
  */
101
101
 
102
+ import { AsyncLocalStorage } from "node:async_hooks";
102
103
  import fs from "node:fs";
103
104
  import path from "node:path";
104
105
  import { createRequire } from "node:module";
@@ -677,6 +678,9 @@ let loaded;
677
678
  /** The file {@link loadPackConfig} read, alongside the memoised result. */
678
679
  let loadedFrom;
679
680
 
681
+ /** The configuration {@link withPackConfig} gives the work it runs, and its file. */
682
+ const scoped = new AsyncLocalStorage();
683
+
680
684
  /**
681
685
  * The consuming repository's resolved, frozen configuration.
682
686
  *
@@ -691,6 +695,8 @@ let loadedFrom;
691
695
  * compilers know neither what to compile nor where to put it.
692
696
  */
693
697
  export function loadPackConfig() {
698
+ const scope = scoped.getStore();
699
+ if (scope) return scope.config;
694
700
  if (loaded) return loaded;
695
701
 
696
702
  const explicit = process.env.PACKAGE_BUILD_CONFIG;
@@ -739,14 +745,49 @@ export function loadPackConfig() {
739
745
  });
740
746
  }
741
747
 
742
- loaded =
743
- configPath.endsWith(".mjs") ?
744
- loadCodeConfig(configPath)
745
- : configFromData(YAML.parse(fs.readFileSync(configPath, "utf8")), configPath);
748
+ loaded = readPackConfig(configPath);
746
749
  loadedFrom = configPath;
747
750
  return loaded;
748
751
  }
749
752
 
753
+ /**
754
+ * The resolved, frozen configuration one file declares.
755
+ *
756
+ * {@link loadPackConfig} reads the file the working directory resolves to and
757
+ * keeps it; this reads the file named, every time, for a caller working on a
758
+ * repository other than the one it stands in.
759
+ *
760
+ * @param {string} configPath - Absolute path of the configuration file.
761
+ * @returns {import("../content-config.mjs").ContentBuildConfig} The frozen configuration.
762
+ * @throws {Error} As {@link loadPackConfig}, when the file cannot be loaded.
763
+ */
764
+ export function readPackConfig(configPath) {
765
+ return configPath.endsWith(".mjs") ?
766
+ loadCodeConfig(configPath)
767
+ : configFromData(YAML.parse(fs.readFileSync(configPath, "utf8")), configPath);
768
+ }
769
+
770
+ /**
771
+ * Run `fn` with `config` as the configuration {@link loadPackConfig} answers.
772
+ *
773
+ * The engine reads configured values through {@link loadPackConfig} wherever
774
+ * a caller passes no configuration. A caller working on a repository other
775
+ * than the one it stands in makes those reads answer with that repository's
776
+ * configuration for the whole of `fn`, its asynchronous continuations
777
+ * included, and for nothing else running beside it.
778
+ *
779
+ * @template T
780
+ * @param {import("../content-config.mjs").ContentBuildConfig} config - The
781
+ * resolved configuration.
782
+ * @param {() => T} fn - The work, synchronous or asynchronous.
783
+ * @param {string} [file] - The file `config` was read from, which
784
+ * {@link packConfigPath} answers within `fn`.
785
+ * @returns {T} What `fn` returned.
786
+ */
787
+ export function withPackConfig(config, fn, file) {
788
+ return scoped.run({ config, file }, fn);
789
+ }
790
+
750
791
  /**
751
792
  * The file {@link loadPackConfig} resolved the configuration from.
752
793
  *
@@ -760,6 +801,8 @@ export function loadPackConfig() {
760
801
  * @throws {Error} As {@link loadPackConfig}, when there is no configuration.
761
802
  */
762
803
  export function packConfigPath() {
804
+ const scope = scoped.getStore();
805
+ if (scope) return scope.file;
763
806
  loadPackConfig();
764
807
  return /** @type {string} */ (loadedFrom);
765
808
  }
@@ -67,11 +67,15 @@
67
67
  *
68
68
  * ## What is not a package pathname
69
69
  *
70
- * **An off-install address passes through on every surface**: a URL, a
71
- * protocol-relative `//host/…`, or a `/`-rooted path, which Foundry serves from
72
- * the data root and which names no package at all. That is how a note addresses
73
- * core Foundry art (`/icons/svg/mystery-man.svg`) or a package this build knows
74
- * nothing about (`/systems/dnd5e/icons/spell.webp`).
70
+ * **An off-install address passes through**: a URL, a protocol-relative
71
+ * `//host/…`, or a `/`-rooted path, which Foundry serves from the data root and
72
+ * which names no package at all. This covers core Foundry art
73
+ * (`/icons/svg/mystery-man.svg`) and packages outside this build
74
+ * (`/systems/dnd5e/icons/spell.webp`).
75
+ *
76
+ * The web form of an absolute canonical Heroic Lands CDN asset uses the
77
+ * effective `site.assets` host instead, preserving its path, query and fragment.
78
+ * This affects asset resolution only; authored links and text stay unchanged.
75
79
  *
76
80
  * **A package this build has never heard of keeps its ownership.** The website
77
81
  * and the book need only the package's name and the suffix, so both resolve;
@@ -98,6 +102,7 @@
98
102
  * @module
99
103
  */
100
104
 
105
+ import { CANONICAL_CDN_ORIGIN } from "./site-origins.mjs";
101
106
  import { PACKAGEBUILD_PACKAGE } from "./packages.mjs";
102
107
  import { deployedSuffix, isDeployedSuffix } from "./asset-types.mjs";
103
108
 
@@ -173,7 +178,7 @@ export const PATHNAME_SURFACES = Object.freeze(["foundry", "local", "web", "pdf"
173
178
  * which Foundry serves from the data root rather than from any package.
174
179
  *
175
180
  * @param {string} s - A non-empty authored pathname.
176
- * @returns {boolean} Whether every surface emits it unchanged.
181
+ * @returns {boolean} Whether it is outside the package pathname space.
177
182
  */
178
183
  function isExternal(s) {
179
184
  // Checked before the single-slash case, which would otherwise claim it.
@@ -389,6 +394,16 @@ export function resolvePathname(raw, config) {
389
394
  if (raw == null) return null;
390
395
  const authored = String(raw);
391
396
  if (isExternal(authored)) {
397
+ let web = authored;
398
+ const host = String(config.site?.assets ?? "").replace(/\/+$/, "");
399
+ if (host && URL.canParse(authored)) {
400
+ const url = new URL(authored);
401
+ // Only this exact owned origin changes; external image hosts and
402
+ // all non-web forms retain their authored addresses.
403
+ if (url.origin === CANONICAL_CDN_ORIGIN) {
404
+ web = `${host}${url.pathname}${url.search}${url.hash}`;
405
+ }
406
+ }
392
407
  return {
393
408
  authored,
394
409
  state: "external",
@@ -397,7 +412,7 @@ export function resolvePathname(raw, config) {
397
412
  own: false,
398
413
  foundry: authored,
399
414
  local: authored,
400
- web: authored,
415
+ web,
401
416
  pdf: authored,
402
417
  };
403
418
  }
@@ -0,0 +1,32 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * The `only` a query preparation takes: one note file, several, or none.
16
+ *
17
+ * @module
18
+ */
19
+
20
+ /**
21
+ * Whether a note file falls outside a preparation's `only`.
22
+ *
23
+ * @param {string|Iterable<string>|undefined} only - One note file, several,
24
+ * or none, which keeps every note.
25
+ * @param {string} file - The note file.
26
+ * @returns {boolean} Whether `only` is given and leaves `file` out.
27
+ */
28
+ export function outsideOnly(only, file) {
29
+ if (only === undefined) return false;
30
+ if (typeof only === "string") return file !== only;
31
+ return !(only instanceof Set ? only : new Set(only)).has(file);
32
+ }