@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.
- package/CHANGELOG.md +17 -0
- package/README.md +19 -1
- package/bin/content-commands.mjs +14 -70
- package/bin/package-build.mjs +25 -10
- package/ci/wip-workflows.test.mjs +252 -0
- package/content-config.mjs +40 -12
- package/docs/api.md +237 -48
- package/docs/commands.md +73 -36
- package/docs/configuration.md +92 -48
- package/docs/guides/release-and-deploy.md +26 -14
- package/docs/guides/site.md +29 -6
- package/engine/cdn-publish.mjs +164 -6
- package/engine/content-index.mjs +71 -19
- package/engine/content-spans.mjs +18 -1
- package/engine/content-styles.mjs +85 -0
- package/engine/derived-sections.mjs +4 -3
- package/engine/event-views.mjs +6 -4
- package/engine/helpers.mjs +33 -9
- package/engine/index.mjs +1 -0
- package/engine/note-cache.mjs +1107 -0
- package/engine/note-page.mjs +424 -0
- package/engine/pack-config.mjs +47 -4
- package/engine/pathnames.mjs +22 -7
- package/engine/prepared-only.mjs +32 -0
- package/engine/preview-index.mjs +55 -13
- package/engine/site-build.mjs +34 -448
- package/engine/site-config.mjs +33 -4
- package/engine/site-maps.mjs +16 -24
- package/engine/site-origins.mjs +23 -0
- package/engine/site-page.mjs +648 -0
- package/engine/site-render.mjs +142 -10
- package/engine/site-serve.mjs +1 -1
- package/engine/sql-tables.mjs +12 -3
- package/package.json +6 -1
- package/site-theme/static/css/style.css +14 -0
- package/types/content-config.d.mts +24 -4
- package/types/engine/cdn-publish.d.mts +22 -0
- package/types/engine/content-index.d.mts +36 -2
- package/types/engine/content-styles.d.mts +20 -0
- package/types/engine/derived-sections.d.mts +3 -3
- package/types/engine/event-views.d.mts +4 -3
- package/types/engine/helpers.d.mts +18 -1
- package/types/engine/index.d.mts +1 -0
- package/types/engine/note-cache.d.mts +149 -0
- package/types/engine/note-page.d.mts +92 -0
- package/types/engine/pack-config.d.mts +30 -0
- package/types/engine/prepared-only.d.mts +14 -0
- package/types/engine/preview-index.d.mts +35 -0
- package/types/engine/site-build.d.mts +5 -100
- package/types/engine/site-config.d.mts +3 -2
- package/types/engine/site-maps.d.mts +7 -11
- package/types/engine/site-origins.d.mts +9 -0
- package/types/engine/site-page.d.mts +261 -0
- package/types/engine/site-render.d.mts +20 -1
- 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
|
+
}
|
package/engine/pack-config.mjs
CHANGED
|
@@ -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
|
}
|
package/engine/pathnames.mjs
CHANGED
|
@@ -67,11 +67,15 @@
|
|
|
67
67
|
*
|
|
68
68
|
* ## What is not a package pathname
|
|
69
69
|
*
|
|
70
|
-
* **An off-install address passes through
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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
|
|
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
|
|
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
|
+
}
|