@heroiclands/package-build 25.6.2 → 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 +23 -0
- package/README.md +19 -1
- package/bin/content-commands.mjs +64 -79
- package/bin/package-build.mjs +25 -10
- package/ci/wip-workflows.test.mjs +252 -0
- package/content-config.mjs +118 -8
- package/docs/api.md +237 -47
- package/docs/commands.md +99 -35
- package/docs/configuration.md +93 -44
- package/docs/guides/release-and-deploy.md +26 -14
- package/docs/guides/site.md +53 -0
- 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 +34 -0
- 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,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
|
+
}
|