@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,648 @@
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 content page, from its note to its body HTML.
16
+ *
17
+ * The site build renders every page through these functions, and
18
+ * {@link module:engine/note-page.renderNoteFile} renders a single note
19
+ * through the same ones. Nothing here reads or writes a file: what a page
20
+ * resolves against arrives as arguments.
21
+ *
22
+ * @module
23
+ */
24
+
25
+ import { createRequire } from "node:module";
26
+
27
+ import { readMatter } from "./note-matter.mjs";
28
+ import { positionOfLiteral, positionOfYamlPath } from "./diagnostics.mjs";
29
+ import { decodeNoteAddresses, noteAddressContext } from "./note-addresses.mjs";
30
+ import { addressSlug } from "./content-address.mjs";
31
+ import { expandContent } from "./content-expand.mjs";
32
+ import { scanPageBreaks } from "./page-breaks.mjs";
33
+ import { scanAlerts } from "./content-alerts.mjs";
34
+ import { scanSpans } from "./content-spans.mjs";
35
+ import { runContext, scanRuns } from "./content-scripts.mjs";
36
+ import { scanBlocks } from "./content-blocks.mjs";
37
+ import { scanHeadingAttributes, withheldSections } from "./heading-attributes.mjs";
38
+ import { footnoteFindings } from "./content-footnotes.mjs";
39
+ import { markupAnchorFindings } from "./anchors.mjs";
40
+ import { IMAGE_PATTERN, checkImages, renderImageFigures } from "./content-images.mjs";
41
+ import { isAddressTuple } from "./address.mjs";
42
+ import { assetPathnameKey, pathnameProblem, resolvePathname } from "./pathnames.mjs";
43
+ import { resolveInfoboxRef } from "./site-index.mjs";
44
+ import { frontmatterWikilinks } from "./wikilinks.mjs";
45
+ import { linkFindingMessage } from "./wikilink-syntax.mjs";
46
+ import { noteInfoboxes } from "./infobox-registry.mjs";
47
+ import { routerFor } from "./pack-router.mjs";
48
+ import { applyComputedBeingAge } from "./being-age.mjs";
49
+ import { resolvedDateFields } from "./note-dates.mjs";
50
+ import { isStubNote } from "./note-state.mjs";
51
+ import { isGmNote } from "./note-vocabulary.mjs";
52
+ import { ART_SLOTS, artPathname } from "./art-fields.mjs";
53
+ import { embedRole } from "./content-embeds.mjs";
54
+ import { isHomepage } from "./homepage.mjs";
55
+ import { GENERATED_MARK } from "./site-reach.mjs";
56
+ import { generatedDrawing } from "./generated-sections.mjs";
57
+ import { createRenderer } from "./content-render.mjs";
58
+
59
+ /**
60
+ * A note's frontmatter and body, from its source text.
61
+ *
62
+ * @param {string} source - The note as authored.
63
+ * @param {string} file - Where it is, for a finding.
64
+ * @param {object} ctx - `{ config, contentPackage }`, which decode its
65
+ * addresses.
66
+ * @returns {{fm: object, body: string, bodyLine: number}|null} `null` for text
67
+ * that is not front-mattered markdown.
68
+ * @throws {Error} When an address in its frontmatter cannot be decoded; the
69
+ * error carries `file` and `position`.
70
+ */
71
+ export function parseNote(source, file, ctx) {
72
+ let parsed;
73
+ try {
74
+ parsed = readMatter(source);
75
+ } catch {
76
+ return null;
77
+ }
78
+ const context = {
79
+ ...noteAddressContext(ctx.config ?? {}),
80
+ package: ctx.contentPackage ?? ctx.config?.contentPackage,
81
+ };
82
+ try {
83
+ return {
84
+ fm: decodeNoteAddresses(parsed.data, context),
85
+ body: parsed.content,
86
+ bodyLine: source.slice(0, source.length - parsed.content.length).split("\n").length,
87
+ };
88
+ } catch (error) {
89
+ const position = positionOfYamlPath(parsed.matter, error.keyPath ?? [], {
90
+ key: error.addressKey,
91
+ });
92
+ if (position.line) position.line++;
93
+ error.file = file;
94
+ error.position = position;
95
+ throw error;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * The page a note publishes as, or why it publishes none.
101
+ *
102
+ * @param {{fm: object, body: string, bodyLine: number}} note - From {@link parseNote}.
103
+ * @param {object} ctx - `{ file, fileBase, relPath, folder, base, packages,
104
+ * contentPackage, sectionPages, present, dates }`: the note's path, its
105
+ * file name, its path below the content root and its folder's name; the
106
+ * package's site address; the packages the site publishes and the one the
107
+ * note belongs to; the pages `site.sections` names; and the present and
108
+ * reckoning a being's age is computed in.
109
+ * @returns {{page?: object, fmLinks: object[], addressFinding?: object}|null}
110
+ * `null` for a note with no page: a homepage, a stub, a GM note, or one
111
+ * outside the published packages. Otherwise the page, or the finding that
112
+ * says why it has no address, with any wikilink authored in frontmatter.
113
+ */
114
+ export function contentPage(note, ctx) {
115
+ const { file } = ctx;
116
+ applyComputedBeingAge(note.fm, ctx.present, ctx.dates);
117
+ const { fm, body, bodyLine } = note;
118
+ // The configuration's, never a note's: a note declares no `package:`, so every
119
+ // note in the tree belongs to the package this repository compiles.
120
+ const pkg = ctx.contentPackage;
121
+ if (!ctx.packages.has(pkg) || !fm.type) return null;
122
+ // A homepage is addressed like any other note, but it is
123
+ // gathered by `collectHomepages` rather than here: it is the
124
+ // whole of a homepage-only build, which never walks the tree for
125
+ // content pages at all.
126
+ if (isHomepage(fm)) return null;
127
+ // A stub has no body, so it has no page. Asked of the note's own text
128
+ // rather than of the absent address the index derived, so the site and
129
+ // the index apply one rule from one module and a note that cannot be
130
+ // addressed for some *other* reason still reaches the finding below
131
+ // that says so.
132
+ if (isStubNote(fm, body) || isGmNote(fm)) return null;
133
+
134
+ const fmLinks = frontmatterWikilinks(fm).map((hit) => ({ file, ...hit }));
135
+
136
+ const fileBase = ctx.fileBase;
137
+ const name =
138
+ fm.name?.full ??
139
+ (fileBase.endsWith(".md") && fileBase !== ".md" ? fileBase.slice(0, -3) : fileBase);
140
+ // The page's address, and therefore its URL. `name` is the
141
+ // display string and nothing else: it titles the page and labels an
142
+ // inbound link, and moving it moves no address.
143
+ let slug;
144
+ try {
145
+ slug = addressSlug(fm);
146
+ } catch (err) {
147
+ return { fmLinks, addressFinding: { file, reason: err.message } };
148
+ }
149
+
150
+ const page = {
151
+ kind: "content",
152
+ fm,
153
+ // The note's own path on disk. Carried so a link finding can be
154
+ // reported as `file:line:column:` against the source an author
155
+ // edits, rather than against the page this build emits.
156
+ file,
157
+ // The page's package, recorded once here so every consumer — the
158
+ // index's canonical keys, the table universe, the local-package set
159
+ // — reads one configured value and never frontmatter.
160
+ pkg,
161
+ body,
162
+ bodyLine,
163
+ name,
164
+ slug,
165
+ base: fileBase,
166
+ // Top-level content directory ("Rules", "Skills", …). Wikilinks do
167
+ // not use it: a note is addressed as `type/shortcode` wherever it
168
+ // is filed.
169
+ tld: ctx.relPath.split("/")[0],
170
+ // Location below the content root, POSIX-separated.
171
+ relPath: ctx.relPath,
172
+ // The immediate source subfolder — the page's `kbfolder`.
173
+ folder: ctx.folder,
174
+ // Every page is addressed by `(type, shortcode)` at the package
175
+ // root, which takes no content mount.
176
+ //
177
+ // A note `site.sections` names is the page of its section, and is
178
+ // published at the section rather than at its own address.
179
+ url:
180
+ ctx.sectionPages?.has(slug) ?
181
+ `${ctx.base}${ctx.sectionPages.get(slug)}/`
182
+ : `${ctx.base}${slug}/`,
183
+ };
184
+ return { page, fmLinks };
185
+ }
186
+
187
+ /**
188
+ * The figure the website sets a drawing in: the SVG inline, so its place names
189
+ * stay links, with no blank line inside it — a blank line would end the HTML
190
+ * block and hand the rest of the drawing to the Markdown renderer.
191
+ *
192
+ * @param {string} svg - The drawing, as {@link module:engine/site-maps.inlineSvg} wrote it.
193
+ * @param {string} alt - What the drawing shows, for a screen reader.
194
+ * @returns {string} One HTML block.
195
+ */
196
+ export function inlineDrawingFigure(svg, alt) {
197
+ const label = String(alt ?? "").replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
198
+ const body = String(svg)
199
+ .split("\n")
200
+ .filter((line) => line.trim() !== "")
201
+ .join("\n");
202
+ return (
203
+ `<figure class="note-image note-image-full-width" role="img" ` +
204
+ `aria-label="${label}">\n${body}\n</figure>`
205
+ );
206
+ }
207
+
208
+ /**
209
+ * The frontmatter a page publishes with.
210
+ *
211
+ * A content page carries the package the build **derived**. No note
212
+ * declares one, so the note's frontmatter alone would publish a page that does
213
+ * not say which package it belongs to. Writing the derived value keeps a page
214
+ * self-describing and makes sweeping the field out of a content tree
215
+ * output-preserving for a site as it already is for the packs.
216
+ *
217
+ * **`related` is derived, never authored.** What links to a page is a fact
218
+ * about every other page, known only once the whole tree has resolved — see
219
+ * {@link module:engine/related-pages} — so an authored value is dropped, and
220
+ * {@link module:engine/site-build.renderPages} sets the derived block once it holds the graph.
221
+ *
222
+ * @param {object} page - The page.
223
+ * @param {object} options
224
+ * @param {(data: object, page: object) => void} [options.decorate] - Called
225
+ * with each page's frontmatter, for whatever a consumer's own pass adds.
226
+ * @param {(value: unknown, type: string, accepts?: Iterable<string>) => string|null} [options.artSrc] -
227
+ * Translates an art address into the pathname the website resolver takes, or
228
+ * `null` where nothing answers it.
229
+ * @param {(src: string) => string} [options.webSrc] - Translates an authored
230
+ * pathname into the address the website serves. Every artwork field goes
231
+ * through it, so a page's `img:` and its body images name the same file the
232
+ * same way.
233
+ * @returns {object} The frontmatter.
234
+ */
235
+ export function pageFrontmatter(page, { decorate, webSrc, artSrc }) {
236
+ const { fm, slug } = page;
237
+ const data = {
238
+ ...fm,
239
+ ...(isPlainObject(fm.data) ? { data: { ...fm.data } } : {}),
240
+ // Spread after the note's own frontmatter. Guarded because
241
+ // `package: undefined` is not a value YAML can carry.
242
+ ...(page.pkg ? { package: page.pkg } : {}),
243
+ // The address, stated site-root relative; `slug` is its last segment.
244
+ slug,
245
+ url: `/${slug}/`,
246
+ title: pageTitle(page),
247
+ kbfolder: page.folder,
248
+ };
249
+ if (decorate) decorate(data, page);
250
+ delete data.related;
251
+ if (webSrc && artSrc) resolveArtFields(data, webSrc, artSrc);
252
+ return data;
253
+ }
254
+
255
+ /**
256
+ * The title a content page publishes under — the note's name.
257
+ *
258
+ * One rule, read by the page's own front matter and by every `related` entry
259
+ * that names the page, so a card lists a page by exactly the title its heading
260
+ * shows.
261
+ *
262
+ * @param {object} page - The page, from {@link module:engine/site-build.collectContentPages}.
263
+ * @returns {string} The title.
264
+ */
265
+ export function pageTitle(page) {
266
+ return page.name;
267
+ }
268
+
269
+ /**
270
+ * Rewrite a page's artwork addresses into the URLs the website serves.
271
+ *
272
+ * The slots are read from {@link module:engine/art-fields.ART_SLOTS}, so a slot
273
+ * added to the vocabulary reaches the page without anyone remembering this
274
+ * function exists. Only an authored **string** is touched: `null` is a note
275
+ * naming no art and `""` is one naming none on purpose, and neither is an
276
+ * address to resolve. A slot's declining word (`banner: none`) is emitted as
277
+ * a blank, the value a theme reads as "draw no image".
278
+ *
279
+ * **An address nothing answers is dropped**, not emitted as written. The theme
280
+ * renders nothing where a value is absent — its silent-disappear convention —
281
+ * and a raw address left in place would reach the reader as a broken image
282
+ * source with nothing saying why.
283
+ *
284
+ * @param {object} data - The frontmatter being emitted, rewritten in place.
285
+ * @param {(src: string) => string} webSrc - The website's pathname resolver.
286
+ * @param {(value: unknown, type: string, accepts?: Iterable<string>) => string|null} artSrc -
287
+ * The address resolver, yielding the pathname `webSrc` takes.
288
+ * @returns {void}
289
+ */
290
+ export function resolveArtFields(data, webSrc, artSrc) {
291
+ for (const { key, type, accepts, declines } of ART_SLOTS) {
292
+ const holder = isPlainObject(data.data) ? data.data : null;
293
+ if (!holder) continue;
294
+ const value = holder[key];
295
+ if (declines !== undefined && value === declines) {
296
+ holder[key] = "";
297
+ continue;
298
+ }
299
+ if ((typeof value !== "string" && !isAddressTuple(value)) || value === "") continue;
300
+ const pathname = artSrc(value, type, accepts);
301
+ if (pathname === null) delete holder[key];
302
+ else holder[key] = webSrc(pathname);
303
+ }
304
+ }
305
+
306
+ /**
307
+ * @param {unknown} value - Anything.
308
+ * @returns {boolean} Whether it is a mapping a field may be read out of.
309
+ */
310
+ export function isPlainObject(value) {
311
+ return typeof value === "object" && value !== null && !Array.isArray(value);
312
+ }
313
+
314
+ /**
315
+ * Transform one content page.
316
+ *
317
+ * `page` is from {@link contentPage}; the options are what it resolves
318
+ * against: the site `index`, the `foreign` indexes, the consumer's `pass`, the
319
+ * frontmatter `decorate`, the prepared `sqlTables`, the `config`, the asset
320
+ * `artIndex`, other pages' `figuresByAddress`, the `events` index, `drawing`
321
+ * and the marked-text `runs`. The result holds the expanded `markdown`, its
322
+ * `figures`, the page's frontmatter as `data`, the links it `resolved`, and
323
+ * each kind of finding.
324
+ *
325
+ * Reads no file. A drawing a generated **From here** section names is asked
326
+ * of `drawing`, by its file name, for its SVG text, and set inline when it
327
+ * answers.
328
+ */
329
+ export function renderSitePage(
330
+ page,
331
+ {
332
+ index,
333
+ foreign,
334
+ pass = {},
335
+ decorate,
336
+ sqlTables,
337
+ config,
338
+ artIndex,
339
+ figuresByAddress = new Map(),
340
+ events,
341
+ drawing = () => undefined,
342
+ runs = runContext(config, undefined, {
343
+ referenceName: (target) => resolveInfoboxRef(index, target)?.name,
344
+ }),
345
+ },
346
+ ) {
347
+ const secretErrors = [];
348
+ const captionErrors = [];
349
+ const headingErrors = [];
350
+ const footnoteErrors = [];
351
+ const imageErrors = [];
352
+ const embedErrors = [];
353
+ const src = page.relPath ?? page.base;
354
+
355
+ const seenImages = new Map();
356
+ const webSrc = (src) => {
357
+ const occurrence = (seenImages.get(src) ?? 0) + 1;
358
+ seenImages.set(src, occurrence);
359
+ const problem = pathnameProblem(src);
360
+ if (problem) {
361
+ imageErrors.push({ file: page.file, src, occurrence, message: problem });
362
+ return src;
363
+ }
364
+ const forms = resolvePathname(src, config);
365
+ if (!forms || forms.web !== null) return forms?.web ?? src;
366
+ imageErrors.push({
367
+ file: page.file,
368
+ src,
369
+ occurrence,
370
+ message:
371
+ `\`${src}\` names a file the \`${forms.package}\` package ships, and ` +
372
+ "no asset host is configured to serve it from — set `site.assets` in " +
373
+ "package-build.config.yaml. Emitted as authored, the address resolves " +
374
+ "against the page's own URL, which is nowhere",
375
+ });
376
+ return src;
377
+ };
378
+ const artSrc = (value, type, accepts) => artPathname(artIndex, value, type, accepts).pathname;
379
+ // The role lookup a captioned item's `map` counter reaches through — see
380
+ // `engine/content-figures.mjs`'s `resolveRole`. It numbers the page's
381
+ // figures once, in expansion, and the render below reads that numbering.
382
+ const resolveRole = (address) => embedRole(artIndex, address);
383
+ // The picture's role and pixel size, by the address it resolved to — read
384
+ // before `webSrc` translates it to the page's own host. A body image is
385
+ // free to write the bare, own-package form, so the address is normalized
386
+ // to the pathname the asset index keys by first.
387
+ const lookupAsset = (src) => {
388
+ const key = assetPathnameKey(src, config);
389
+ return key ? artIndex?.byPath?.get(key) : undefined;
390
+ };
391
+ // A generated **From here** section names the drawing the build made, which
392
+ // the asset host does not serve: it is set inline, so its place names stay
393
+ // links. A drawing this build did not make is left for the image pass,
394
+ // which reports it.
395
+ const inlineDrawings = (text) =>
396
+ text.replace(new RegExp(IMAGE_PATTERN.source, "g"), (whole, alt, src) => {
397
+ const name = generatedDrawing(src);
398
+ const svg = name ? drawing(name) : undefined;
399
+ if (svg === undefined) return whole;
400
+ return inlineDrawingFigure(svg, alt);
401
+ });
402
+
403
+ // The frontmatter's art resolves before the body's pictures, so a picture
404
+ // named in both is counted in that order.
405
+ const data = pageFrontmatter(page, { decorate, webSrc, artSrc });
406
+ const expanded = expandContent(page.body, {
407
+ target: "web",
408
+ index,
409
+ note: { fm: page.fm, file: page.file, source: src, bodyLine: page.bodyLine },
410
+ prepared: {
411
+ sqlTables: sqlTables?.get(page.file),
412
+ pageLists: sqlTables?.pageLists?.get(page.file),
413
+ inline: sqlTables?.inline?.get(page.file),
414
+ },
415
+ events,
416
+ resolveRole,
417
+ figuresByAddress,
418
+ links: {
419
+ foreign: foreign.index,
420
+ assets: artIndex,
421
+ beforeLinks: pass.beforeLinks ? (text) => pass.beforeLinks(text, page) : undefined,
422
+ afterLinks: (text) => renderImageFigures(inlineDrawings(text), webSrc, lookupAsset),
423
+ },
424
+ });
425
+ const { lineMap } = expanded;
426
+ const tableErrors = expanded.findings.tables;
427
+ const expressionErrors = expanded.findings.expressions;
428
+ const wikiErrors = expanded.findings.links;
429
+ // The author's text and the sections the build generated after it are
430
+ // told apart by a comment, so the site writer can leave the generated
431
+ // links out of what makes a page reachable.
432
+ const resolvedBody =
433
+ expanded.generated ?
434
+ `${expanded.authored}\n\n${GENERATED_MARK}\n\n${expanded.generated}`
435
+ : expanded.markdown;
436
+ for (const error of expanded.findings.captions)
437
+ captionErrors.push({
438
+ file: page.file,
439
+ line: (page.bodyLine ?? 1) + (lineMap[error.line - 1]?.line ?? error.line - 1),
440
+ column: error.column,
441
+ message: error.message,
442
+ });
443
+ for (const error of [
444
+ ...scanBlocks(page.body).errors,
445
+ ...scanAlerts(page.body).errors,
446
+ ...scanPageBreaks(page.body).errors,
447
+ ...scanSpans(page.body).errors,
448
+ ...scanRuns(page.body, runs).errors,
449
+ ...markupAnchorFindings(page.body),
450
+ ])
451
+ secretErrors.push({
452
+ file: error.file ?? page.file,
453
+ line: error.file ? error.line : (page.bodyLine ?? 1) + error.line - 1,
454
+ column: error.column,
455
+ message: error.message,
456
+ });
457
+ for (const error of scanHeadingAttributes(page.body, page.bodyLine ?? 1).errors)
458
+ headingErrors.push({ file: page.file, ...error });
459
+ for (const error of withheldSections(page.body, page.bodyLine ?? 1).errors)
460
+ headingErrors.push({ file: page.file, ...error });
461
+ for (const error of footnoteFindings(page.body))
462
+ footnoteErrors.push({
463
+ file: error.file ?? page.file,
464
+ line: error.file ? error.line : (page.bodyLine ?? 1) + error.line - 1,
465
+ column: error.column,
466
+ message: error.message,
467
+ });
468
+ // An image sharing its paragraph with other text, or an address or title
469
+ // the lint already refuses — asked here too, so a site build run on its
470
+ // own fails on the same input `renderImageFigures` otherwise renders as
471
+ // though the directive were absent.
472
+ for (const error of checkImages(page.body, page.file, { bodyLine: page.bodyLine ?? 1 }))
473
+ embedErrors.push(error);
474
+ return {
475
+ page,
476
+ // The expanded body and its numbered figures, for the renderer that
477
+ // writes the whole page.
478
+ markdown: resolvedBody,
479
+ figures: expanded.figures.figures,
480
+ data,
481
+ // The links a generated section makes join no page's Related card:
482
+ // that card is what the authors link, and the sections already list
483
+ // what they derive.
484
+ resolved: expanded.resolved.slice(0, expanded.authoredLinks),
485
+ tableErrors,
486
+ expressionErrors,
487
+ secretErrors,
488
+ captionErrors,
489
+ headingErrors,
490
+ footnoteErrors,
491
+ wikiErrors,
492
+ imageErrors,
493
+ embedErrors,
494
+ };
495
+ }
496
+
497
+ /**
498
+ * The named pass bundles a configuration may select.
499
+ *
500
+ * `site.pass` is the one part of the site contract that is *code* — a
501
+ * repository's own body rewrites — so a configuration names a bundle instead of
502
+ * supplying one, exactly as `itemBuilders` names an item registry. Required
503
+ * lazily, so importing this module does not drag a package-specific half of the
504
+ * toolchain in behind it.
505
+ *
506
+ * @type {Readonly<Record<string, () => Function>>}
507
+ */
508
+ const SITE_PASSES = Object.freeze({
509
+ sohlKb: () => createRequire(import.meta.url)("../sohl/kb-passes.mjs").sohlKbPass,
510
+ });
511
+
512
+ /**
513
+ * Resolves `site.pass` to its bundle.
514
+ *
515
+ * @param {string|undefined} name - The configured name.
516
+ * @param {object} options - The configured options, plus `repoRoot`.
517
+ * @returns {{beforeLinks?: Function}} The bundle.
518
+ */
519
+ export function resolveSitePass(name, options) {
520
+ if (!name) return {};
521
+ const factory = SITE_PASSES[name];
522
+ if (!factory) {
523
+ throw new Error(
524
+ `unknown site pass ${JSON.stringify(name)} — expected one of ` +
525
+ `${Object.keys(SITE_PASSES).join(", ")}`,
526
+ );
527
+ }
528
+ return factory()(options);
529
+ }
530
+
531
+ /** The site frontmatter decoration shared by the site writer and page preview. */
532
+ export function sitePageDecorator(config, index) {
533
+ const router = routerFor(config);
534
+ return (data, page) => {
535
+ // A being's dates reach the page; a note's events reach no page.
536
+ const { events: _events, ...resolvedDates } = resolvedDateFields(
537
+ page.fm,
538
+ index.dateContext,
539
+ );
540
+ if (Object.keys(resolvedDates).length) data.resolvedDates = resolvedDates;
541
+ data.infoboxes = noteInfoboxes(page.fm, {
542
+ resolve: (ref, hint) => resolveInfoboxRef(index, ref, hint),
543
+ router,
544
+ dates: index.dateContext,
545
+ contentPackage: index.contentPackage,
546
+ });
547
+ };
548
+ }
549
+
550
+ /**
551
+ * How a site renders a page body from the markdown its build expanded.
552
+ *
553
+ * @param {object} config - The resolved configuration.
554
+ * @returns {(markdown: string, options?: {runs?: object, figures?: object[]}) => string}
555
+ * The body HTML as the site writer receives it.
556
+ */
557
+ export function siteBodyRenderer(config) {
558
+ const renderer = createRenderer("web", { icons: config.icons });
559
+ const resolveSrc = (src) => resolvePathname(src, config)?.web ?? null;
560
+ return (markdown, { runs, figures } = {}) =>
561
+ renderer.render(markdown, { runs, figures, resolveSrc });
562
+ }
563
+
564
+ /**
565
+ * Each finding of a page render as a diagnostic, kind by kind.
566
+ *
567
+ * @param {object} result - From {@link renderSitePage}, or the site build's
568
+ * gathered result, which carries the same lists.
569
+ * @param {(file: string) => string} rawOf - A note's source text, which
570
+ * locates a link or an image by the literal it was handed.
571
+ * @returns {Array<{file?: string, line?: number, column?: number,
572
+ * severity: string, message: string}>} The diagnostics.
573
+ */
574
+ export function pageDiagnostics(result, rawOf) {
575
+ const out = [];
576
+ for (const e of result.tableErrors)
577
+ out.push({
578
+ file: e.source,
579
+ line: typeof e.line === "number" ? e.line + 1 : undefined,
580
+ column: e.column,
581
+ severity: "error",
582
+ message: e.reason,
583
+ });
584
+ for (const e of result.expressionErrors) out.push(e);
585
+ for (const list of [
586
+ result.secretErrors,
587
+ result.captionErrors,
588
+ result.headingErrors,
589
+ result.footnoteErrors,
590
+ result.embedErrors,
591
+ ])
592
+ for (const e of list) out.push({ ...e, severity: "error" });
593
+ // Worded by the shared table the pack build words the same finding
594
+ // with, and located by searching the note for the link. An embed's
595
+ // directive speaks the image vocabulary rather than the link one, and
596
+ // carries its own sentence.
597
+ for (const e of result.wikiErrors)
598
+ out.push({
599
+ file: e.file,
600
+ ...positionOfLiteral(rawOf(e.file), e.link, e.occurrence),
601
+ severity: "error",
602
+ message: e.message ?? linkFindingMessage(e),
603
+ });
604
+ // An image whose pathname the site cannot resolve, located the way a
605
+ // wikilink finding is: by searching the note for the literal the resolver
606
+ // was handed.
607
+ for (const e of result.imageErrors)
608
+ out.push({
609
+ file: e.file,
610
+ ...positionOfLiteral(rawOf(e.file), e.src, e.occurrence),
611
+ severity: "error",
612
+ message: e.message,
613
+ });
614
+ return out;
615
+ }
616
+
617
+ /**
618
+ * A wikilink authored in a note's frontmatter, as a diagnostic.
619
+ *
620
+ * @param {{file: string, link: string, path: string}} finding - From
621
+ * {@link contentPage}'s `fmLinks`.
622
+ * @returns {{file: string, severity: "error", message: string}} The diagnostic.
623
+ */
624
+ export function frontmatterLinkDiagnostic(finding) {
625
+ return {
626
+ file: finding.file,
627
+ severity: "error",
628
+ message:
629
+ `wikilink ${finding.link} authored in frontmatter at ` +
630
+ `${finding.path} — frontmatter is data, is copied to the ` +
631
+ `page verbatim, and reaches the reader as brackets`,
632
+ };
633
+ }
634
+
635
+ /**
636
+ * A note whose page has no address, as a diagnostic.
637
+ *
638
+ * @param {{file: string, reason: string}} finding - From {@link contentPage}'s
639
+ * `addressFinding`.
640
+ * @returns {{file: string, severity: "error", message: string}} The diagnostic.
641
+ */
642
+ export function addressDiagnostic(finding) {
643
+ return {
644
+ file: finding.file,
645
+ severity: "error",
646
+ message: `cannot derive a URL: ${finding.reason}`,
647
+ };
648
+ }