@stati/core 1.27.2 → 1.29.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/README.md +6 -1
- package/dist/config/errors.d.ts +10 -0
- package/dist/config/errors.d.ts.map +1 -0
- package/dist/config/errors.js +10 -0
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +15 -2
- package/dist/constants.d.ts +26 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +23 -0
- package/dist/core/build.d.ts.map +1 -1
- package/dist/core/build.js +201 -41
- package/dist/core/collections.d.ts +139 -0
- package/dist/core/collections.d.ts.map +1 -0
- package/dist/core/collections.js +342 -0
- package/dist/core/content.d.ts.map +1 -1
- package/dist/core/content.js +2 -1
- package/dist/core/dev.d.ts.map +1 -1
- package/dist/core/dev.js +11 -62
- package/dist/core/expand.d.ts +86 -0
- package/dist/core/expand.d.ts.map +1 -0
- package/dist/core/expand.js +484 -0
- package/dist/core/isg/builder.d.ts +27 -2
- package/dist/core/isg/builder.d.ts.map +1 -1
- package/dist/core/isg/builder.js +83 -11
- package/dist/core/isg/deps.d.ts +9 -0
- package/dist/core/isg/deps.d.ts.map +1 -1
- package/dist/core/isg/deps.js +30 -1
- package/dist/core/isg/hash.d.ts +14 -2
- package/dist/core/isg/hash.d.ts.map +1 -1
- package/dist/core/isg/hash.js +30 -2
- package/dist/core/isg/index.d.ts +4 -2
- package/dist/core/isg/index.d.ts.map +1 -1
- package/dist/core/isg/index.js +4 -2
- package/dist/core/isg/manifest.js +14 -0
- package/dist/core/isg/prune.d.ts +32 -0
- package/dist/core/isg/prune.d.ts.map +1 -0
- package/dist/core/isg/prune.js +113 -0
- package/dist/core/markdown.d.ts +10 -1
- package/dist/core/markdown.d.ts.map +1 -1
- package/dist/core/markdown.js +13 -3
- package/dist/core/navigation.d.ts +4 -2
- package/dist/core/navigation.d.ts.map +1 -1
- package/dist/core/navigation.js +17 -2
- package/dist/core/templates.d.ts +15 -1
- package/dist/core/templates.d.ts.map +1 -1
- package/dist/core/templates.js +121 -126
- package/dist/core/utils/fs.utils.d.ts +29 -1
- package/dist/core/utils/fs.utils.d.ts.map +1 -1
- package/dist/core/utils/fs.utils.js +74 -1
- package/dist/core/utils/index.d.ts +3 -3
- package/dist/core/utils/index.d.ts.map +1 -1
- package/dist/core/utils/index.js +3 -3
- package/dist/core/utils/paths.utils.d.ts +15 -0
- package/dist/core/utils/paths.utils.d.ts.map +1 -1
- package/dist/core/utils/paths.utils.js +20 -0
- package/dist/core/utils/template-discovery.utils.d.ts +25 -1
- package/dist/core/utils/template-discovery.utils.d.ts.map +1 -1
- package/dist/core/utils/template-discovery.utils.js +61 -11
- package/dist/images/config.d.ts +52 -0
- package/dist/images/config.d.ts.map +1 -0
- package/dist/images/config.js +200 -0
- package/dist/images/index.d.ts +15 -0
- package/dist/images/index.d.ts.map +1 -0
- package/dist/images/index.js +11 -0
- package/dist/images/manifest.d.ts +36 -0
- package/dist/images/manifest.d.ts.map +1 -0
- package/dist/images/manifest.js +69 -0
- package/dist/images/markdown-rule.d.ts +26 -0
- package/dist/images/markdown-rule.d.ts.map +1 -0
- package/dist/images/markdown-rule.js +60 -0
- package/dist/images/markup.d.ts +39 -0
- package/dist/images/markup.d.ts.map +1 -0
- package/dist/images/markup.js +72 -0
- package/dist/images/og.d.ts +52 -0
- package/dist/images/og.d.ts.map +1 -0
- package/dist/images/og.js +165 -0
- package/dist/images/plan.d.ts +101 -0
- package/dist/images/plan.d.ts.map +1 -0
- package/dist/images/plan.js +128 -0
- package/dist/images/provider.d.ts +35 -0
- package/dist/images/provider.d.ts.map +1 -0
- package/dist/images/provider.js +70 -0
- package/dist/images/resolve.d.ts +45 -0
- package/dist/images/resolve.d.ts.map +1 -0
- package/dist/images/resolve.js +51 -0
- package/dist/images/runtime.d.ts +258 -0
- package/dist/images/runtime.d.ts.map +1 -0
- package/dist/images/runtime.js +659 -0
- package/dist/images/sources.d.ts +47 -0
- package/dist/images/sources.d.ts.map +1 -0
- package/dist/images/sources.js +97 -0
- package/dist/images/utils/image-probe.utils.d.ts +30 -0
- package/dist/images/utils/image-probe.utils.d.ts.map +1 -0
- package/dist/images/utils/image-probe.utils.js +307 -0
- package/dist/images/utils/index.d.ts +7 -0
- package/dist/images/utils/index.d.ts.map +1 -0
- package/dist/images/utils/index.js +5 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/metrics/noop.d.ts.map +1 -1
- package/dist/metrics/noop.js +3 -0
- package/dist/metrics/recorder.d.ts.map +1 -1
- package/dist/metrics/recorder.js +3 -0
- package/dist/metrics/types.d.ts +8 -0
- package/dist/metrics/types.d.ts.map +1 -1
- package/dist/rss/generator.d.ts.map +1 -1
- package/dist/rss/generator.js +5 -2
- package/dist/search/generator.d.ts +9 -0
- package/dist/search/generator.d.ts.map +1 -1
- package/dist/search/generator.js +20 -0
- package/dist/search/index.d.ts +1 -1
- package/dist/search/index.d.ts.map +1 -1
- package/dist/search/index.js +1 -1
- package/dist/seo/auto-inject.d.ts +3 -0
- package/dist/seo/auto-inject.d.ts.map +1 -1
- package/dist/seo/auto-inject.js +1 -0
- package/dist/seo/generator.d.ts +24 -0
- package/dist/seo/generator.d.ts.map +1 -1
- package/dist/seo/generator.js +65 -1
- package/dist/seo/index.d.ts +1 -1
- package/dist/seo/index.d.ts.map +1 -1
- package/dist/seo/index.js +1 -1
- package/dist/types/config.d.ts +60 -0
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/content.d.ts +150 -3
- package/dist/types/content.d.ts.map +1 -1
- package/dist/types/images.d.ts +185 -0
- package/dist/types/images.d.ts.map +1 -0
- package/dist/types/images.js +8 -0
- package/dist/types/index.d.ts +3 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/isg.d.ts +19 -0
- package/dist/types/isg.d.ts.map +1 -1
- package/dist/types/seo.d.ts +9 -0
- package/dist/types/seo.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-build image pipeline: renders image markup while pages render, runs the jobs the
|
|
3
|
+
* written pages need, recovers from failed jobs and prunes outputs no page uses.
|
|
4
|
+
* @module images/runtime
|
|
5
|
+
*/
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { dirname, join, posix, relative, resolve } from 'node:path';
|
|
8
|
+
import { URL } from 'node:url';
|
|
9
|
+
import { copyFile, createTemplateError, ensureDir, isPathWithinDirectory, listFilesRecursiveSync, pathExists, readFile, readFileBuffer, remove, writeFile, } from '../core/utils/index.js';
|
|
10
|
+
import { resolveAbsoluteUrl } from '../seo/utils/index.js';
|
|
11
|
+
import { generatedOgImageTags } from '../seo/generator.js';
|
|
12
|
+
import { DEFAULT_SRC_DIR, DEFAULT_STATIC_DIR, IMAGES_OUTPUT_DIR, OG_IMAGES_SUBDIR, } from '../constants.js';
|
|
13
|
+
import { resolveImagesConfig, validateImagesConfig, } from './config.js';
|
|
14
|
+
import { loadImageProvider } from './provider.js';
|
|
15
|
+
import { loadImagesManifest, saveImagesManifest } from './manifest.js';
|
|
16
|
+
import { getSourceCacheEntries, inspectSourceSync, seedSourceCache, } from './sources.js';
|
|
17
|
+
import { resolveImageSource } from './resolve.js';
|
|
18
|
+
import { planCopy, planVariants } from './plan.js';
|
|
19
|
+
import { renderImg, renderResponsiveImage } from './markup.js';
|
|
20
|
+
import { displayTemplatePath, findOgTemplatePath, inlineLocalImages, isOgImageExcluded, ogImageSlug, } from './og.js';
|
|
21
|
+
/**
|
|
22
|
+
* Bumped when the generated markup changes, so pages rendered by an older pipeline rebuild.
|
|
23
|
+
*/
|
|
24
|
+
const IMAGE_PIPELINE_VERSION = 1;
|
|
25
|
+
/** Host names that cannot appear in an absolute og:image URL. */
|
|
26
|
+
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '[::1]']);
|
|
27
|
+
function toPosix(path) {
|
|
28
|
+
return path.replace(/\\/g, '/');
|
|
29
|
+
}
|
|
30
|
+
function sha256(input) {
|
|
31
|
+
return createHash('sha256').update(input).digest('hex');
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Checks that og:image URLs will be absolute and public.
|
|
35
|
+
* @throws {Error} When `site.baseUrl` is not an absolute http(s) URL on a public host
|
|
36
|
+
*/
|
|
37
|
+
export function validateOgBaseUrl(baseUrl) {
|
|
38
|
+
let url;
|
|
39
|
+
try {
|
|
40
|
+
url = new URL(baseUrl);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
url = undefined;
|
|
44
|
+
}
|
|
45
|
+
if (!url ||
|
|
46
|
+
(url.protocol !== 'http:' && url.protocol !== 'https:') ||
|
|
47
|
+
LOCAL_HOSTS.has(url.hostname)) {
|
|
48
|
+
throw new Error(`images.og needs site.baseUrl set to your site's public address, such as https://example.com, so og:image is an absolute URL (got ${JSON.stringify(baseUrl)}).`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async function loadOgState(provider, userFonts) {
|
|
52
|
+
const defaults = provider.loadOgDefaults ? await provider.loadOgDefaults() : undefined;
|
|
53
|
+
const fonts = [];
|
|
54
|
+
for (const font of userFonts) {
|
|
55
|
+
const data = await readFileBuffer(resolve(process.cwd(), font.path));
|
|
56
|
+
if (!data) {
|
|
57
|
+
throw new Error(`images.og.fonts: font file not found: ${font.path}`);
|
|
58
|
+
}
|
|
59
|
+
if (data.subarray(0, 4).toString('latin1') === 'wOF2') {
|
|
60
|
+
throw new Error(`images.og.fonts: ${font.path} is WOFF2, which the image renderer can't read. Use a TTF, OTF or WOFF file.`);
|
|
61
|
+
}
|
|
62
|
+
fonts.push({
|
|
63
|
+
name: font.name,
|
|
64
|
+
data,
|
|
65
|
+
...(font.weight !== undefined && { weight: font.weight }),
|
|
66
|
+
...(font.style !== undefined && { style: font.style }),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
fonts.push(...(defaults?.fonts ?? []));
|
|
70
|
+
if (fonts.length === 0) {
|
|
71
|
+
throw new Error('images.og needs at least one font. Add one to images.og.fonts.');
|
|
72
|
+
}
|
|
73
|
+
const fontsHash = sha256(fonts
|
|
74
|
+
.map((font) => `${font.name}|${font.weight ?? ''}|${font.style ?? ''}|${sha256(font.data)}`)
|
|
75
|
+
.join('\n'));
|
|
76
|
+
return { fonts, fontsHash, defaultTemplate: defaults?.template };
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Image state for one page while it renders.
|
|
80
|
+
*/
|
|
81
|
+
export class PageImages {
|
|
82
|
+
runtime;
|
|
83
|
+
page;
|
|
84
|
+
/** Source files whose content affects the page */
|
|
85
|
+
assetDeps = new Set();
|
|
86
|
+
/** Processed images and cards, filtered against the written HTML when the page commits */
|
|
87
|
+
uses = [];
|
|
88
|
+
/** The Markdown image that `eagerFirst` loads eagerly, chosen by `renderMarkdown()` */
|
|
89
|
+
eagerToken;
|
|
90
|
+
context;
|
|
91
|
+
constructor(runtime, page) {
|
|
92
|
+
this.runtime = runtime;
|
|
93
|
+
this.page = page;
|
|
94
|
+
this.context = {
|
|
95
|
+
pageSourcePath: page.sourcePath,
|
|
96
|
+
srcDir: runtime.srcDir,
|
|
97
|
+
staticDir: runtime.staticDir,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Renders a Markdown image token.
|
|
102
|
+
*
|
|
103
|
+
* @returns The markup, or the `src` to give markdown-it's default renderer when the image
|
|
104
|
+
* is left as authored
|
|
105
|
+
*/
|
|
106
|
+
renderMarkdownImage(token, alt) {
|
|
107
|
+
const attributes = (token.attrs ?? []).filter(([name]) => !['src', 'alt', 'title', 'width', 'height'].includes(name.toLowerCase()));
|
|
108
|
+
return this.renderImage(token.attrGet('src') ?? '', {
|
|
109
|
+
alt,
|
|
110
|
+
title: token.attrGet('title') ?? undefined,
|
|
111
|
+
attributes,
|
|
112
|
+
width: token.attrGet('width') ?? undefined,
|
|
113
|
+
height: token.attrGet('height') ?? undefined,
|
|
114
|
+
eager: this.runtime.settings.eagerFirst && token === this.eagerToken,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Implements `stati.image(src, options)` for templates. Synchronous, because templates
|
|
119
|
+
* print its result directly.
|
|
120
|
+
*/
|
|
121
|
+
renderHelperImage(src, options = {}) {
|
|
122
|
+
if (typeof src !== 'string' || !src.trim()) {
|
|
123
|
+
return '';
|
|
124
|
+
}
|
|
125
|
+
if (options.alt === undefined) {
|
|
126
|
+
this.runtime.warnOnce(`alt:${this.page.url}:${src}`, `stati.image('${src}') on ${this.page.url} has no alt text. Pass { alt: '' } for decorative images.`);
|
|
127
|
+
}
|
|
128
|
+
const markupOptions = {
|
|
129
|
+
alt: options.alt ?? '',
|
|
130
|
+
title: options.title,
|
|
131
|
+
attributes: options.class ? [['class', options.class]] : undefined,
|
|
132
|
+
eager: options.priority === true,
|
|
133
|
+
sizes: options.sizes,
|
|
134
|
+
};
|
|
135
|
+
const result = this.renderImage(src, markupOptions);
|
|
136
|
+
return 'html' in result ? result.html : renderImg(result.src, markupOptions);
|
|
137
|
+
}
|
|
138
|
+
renderImage(rawSrc, options) {
|
|
139
|
+
const resolved = resolveImageSource(rawSrc, this.context);
|
|
140
|
+
if (resolved.kind === 'skip') {
|
|
141
|
+
return { src: resolved.src };
|
|
142
|
+
}
|
|
143
|
+
const filePath = resolved.filePath;
|
|
144
|
+
this.assetDeps.add(toPosix(filePath));
|
|
145
|
+
const info = this.runtime.inspect(filePath);
|
|
146
|
+
const shownPath = toPosix(relative(process.cwd(), filePath));
|
|
147
|
+
if (!info) {
|
|
148
|
+
this.runtime.warnOnce(`missing:${filePath}`, `Image not found: ${shownPath} (used by ${this.page.url}). It is left as written.`);
|
|
149
|
+
return { src: resolved.src };
|
|
150
|
+
}
|
|
151
|
+
if (!info.probe) {
|
|
152
|
+
this.runtime.warnOnce(`unreadable:${filePath}`, `Could not read the dimensions of ${shownPath}. It is left as written.`);
|
|
153
|
+
return { src: resolved.src };
|
|
154
|
+
}
|
|
155
|
+
const probe = info.probe;
|
|
156
|
+
const base = {
|
|
157
|
+
...options,
|
|
158
|
+
width: options.width ?? probe.width,
|
|
159
|
+
height: options.height ?? probe.height,
|
|
160
|
+
};
|
|
161
|
+
let tier1Src = resolved.src;
|
|
162
|
+
let copy;
|
|
163
|
+
if (resolved.kind === 'colocated') {
|
|
164
|
+
copy = planCopy(filePath, info.hash, probe.format);
|
|
165
|
+
tier1Src = copy.url;
|
|
166
|
+
}
|
|
167
|
+
const tier1Markup = renderImg(tier1Src, base);
|
|
168
|
+
const responsive = this.runtime.responsive;
|
|
169
|
+
const provider = this.runtime.provider;
|
|
170
|
+
if (responsive && provider && !probe.animated) {
|
|
171
|
+
const plan = planVariants({
|
|
172
|
+
filePath,
|
|
173
|
+
sourceHash: info.hash,
|
|
174
|
+
format: probe.format,
|
|
175
|
+
width: probe.width,
|
|
176
|
+
responsive,
|
|
177
|
+
providerVersion: provider.version,
|
|
178
|
+
});
|
|
179
|
+
const markup = renderResponsiveImage(plan, base, options.sizes ?? responsive.sizes);
|
|
180
|
+
this.runtime.registerJob(this.runtime.variantJob(plan, filePath));
|
|
181
|
+
this.uses.push({
|
|
182
|
+
jobKey: plan.key,
|
|
183
|
+
outputs: plan.outputs.map((output) => output.relPath),
|
|
184
|
+
marker: plan.fallback.src,
|
|
185
|
+
markup,
|
|
186
|
+
fallbackMarkup: tier1Markup,
|
|
187
|
+
...(copy && { fallbackCopy: this.runtime.copyJob(copy, filePath) }),
|
|
188
|
+
});
|
|
189
|
+
return { html: markup };
|
|
190
|
+
}
|
|
191
|
+
if (copy) {
|
|
192
|
+
const job = this.runtime.copyJob(copy, filePath);
|
|
193
|
+
this.runtime.registerJob(job);
|
|
194
|
+
this.uses.push({ jobKey: job.key, outputs: [copy.relPath], marker: copy.url });
|
|
195
|
+
}
|
|
196
|
+
return { html: tier1Markup };
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Whether this page gets a generated Open Graph image.
|
|
200
|
+
*/
|
|
201
|
+
wantsOg() {
|
|
202
|
+
const { og, ogState, provider } = this.runtime;
|
|
203
|
+
return Boolean(og && ogState && provider?.renderOgImage && !isOgImageExcluded(this.page, og.exclude));
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Size of the page's Open Graph image, exposed to the template as `stati.og`.
|
|
207
|
+
*/
|
|
208
|
+
ogSize() {
|
|
209
|
+
const og = this.runtime.og;
|
|
210
|
+
return og ? { width: og.width, height: og.height } : undefined;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Renders this page's Open Graph template and registers the card.
|
|
214
|
+
*
|
|
215
|
+
* @param context - Template context: the layout's, with an empty body and `stati.og`
|
|
216
|
+
* @param eta - The template engine
|
|
217
|
+
* @returns The generated image, or null when the page gets none
|
|
218
|
+
*/
|
|
219
|
+
async renderOg(context, eta) {
|
|
220
|
+
const { og, ogState, provider } = this.runtime;
|
|
221
|
+
if (!og || !ogState || !provider?.renderOgImage)
|
|
222
|
+
return null;
|
|
223
|
+
if (isOgImageExcluded(this.page, og.exclude))
|
|
224
|
+
return null;
|
|
225
|
+
const srcDir = this.runtime.srcDir;
|
|
226
|
+
const templatePath = await findOgTemplatePath(this.page.sourcePath, srcDir, og.template);
|
|
227
|
+
if (!templatePath && !ogState.defaultTemplate)
|
|
228
|
+
return null;
|
|
229
|
+
const templateLabel = templatePath
|
|
230
|
+
? displayTemplatePath(templatePath)
|
|
231
|
+
: 'built-in Open Graph template';
|
|
232
|
+
let html;
|
|
233
|
+
try {
|
|
234
|
+
html = templatePath
|
|
235
|
+
? await eta.renderAsync(toPosix(relative(srcDir, templatePath)), context)
|
|
236
|
+
: await eta.renderStringAsync(ogState.defaultTemplate, context);
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
const failure = error instanceof Error ? error : new Error(String(error));
|
|
240
|
+
if (this.runtime.development) {
|
|
241
|
+
throw createTemplateError(failure, templatePath ?? undefined);
|
|
242
|
+
}
|
|
243
|
+
this.runtime.logger.error(`Failed to render ${templateLabel} for ${this.page.url}: ${failure.message}`);
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
const inlined = await inlineLocalImages(html, {
|
|
247
|
+
staticDir: this.runtime.staticDir,
|
|
248
|
+
baseDir: templatePath ? dirname(templatePath) : srcDir,
|
|
249
|
+
srcDir,
|
|
250
|
+
}, (path) => this.runtime.generatedSourceFor(path));
|
|
251
|
+
for (const file of inlined.files)
|
|
252
|
+
this.assetDeps.add(file);
|
|
253
|
+
const key = sha256(JSON.stringify({
|
|
254
|
+
html: inlined.html,
|
|
255
|
+
width: og.width,
|
|
256
|
+
height: og.height,
|
|
257
|
+
format: og.format,
|
|
258
|
+
fonts: ogState.fontsHash,
|
|
259
|
+
provider: provider.version,
|
|
260
|
+
}));
|
|
261
|
+
const extension = og.format === 'jpeg' ? 'jpg' : 'png';
|
|
262
|
+
const slug = ogImageSlug(this.page.url);
|
|
263
|
+
const relPath = posix.join(IMAGES_OUTPUT_DIR, OG_IMAGES_SUBDIR, `${slug}-${key.slice(0, 8)}.${extension}`);
|
|
264
|
+
const path = `/${relPath}`;
|
|
265
|
+
const title = this.page.frontMatter.title;
|
|
266
|
+
const image = {
|
|
267
|
+
url: resolveAbsoluteUrl(path, this.runtime.baseUrl),
|
|
268
|
+
path,
|
|
269
|
+
width: og.width,
|
|
270
|
+
height: og.height,
|
|
271
|
+
...(typeof title === 'string' && title && { alt: title }),
|
|
272
|
+
};
|
|
273
|
+
// The slug is part of the key: pages that draw the same card each get their own file
|
|
274
|
+
const jobKey = `og:${slug}:${key}`;
|
|
275
|
+
const filePath = join(this.runtime.outDir, relPath);
|
|
276
|
+
const render = provider.renderOgImage.bind(provider);
|
|
277
|
+
this.runtime.registerJob({
|
|
278
|
+
key: jobKey,
|
|
279
|
+
outputs: [relPath],
|
|
280
|
+
label: `${templateLabel} (${this.page.url})`,
|
|
281
|
+
run: () => render({
|
|
282
|
+
html: inlined.html,
|
|
283
|
+
width: og.width,
|
|
284
|
+
height: og.height,
|
|
285
|
+
format: og.format,
|
|
286
|
+
fonts: ogState.fonts,
|
|
287
|
+
filePath,
|
|
288
|
+
templatePath: templateLabel,
|
|
289
|
+
}),
|
|
290
|
+
});
|
|
291
|
+
this.uses.push({ jobKey, outputs: [relPath], marker: path, ogImage: image });
|
|
292
|
+
return image;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* The image pipeline for one build.
|
|
297
|
+
*/
|
|
298
|
+
export class ImageRuntime {
|
|
299
|
+
settings;
|
|
300
|
+
provider;
|
|
301
|
+
ogState;
|
|
302
|
+
manifest;
|
|
303
|
+
existing;
|
|
304
|
+
srcDir;
|
|
305
|
+
staticDir;
|
|
306
|
+
outDir;
|
|
307
|
+
cacheDir;
|
|
308
|
+
baseUrl;
|
|
309
|
+
logger;
|
|
310
|
+
development;
|
|
311
|
+
/** Responsive options in effect for this build; null when off or skipped in dev */
|
|
312
|
+
responsive;
|
|
313
|
+
/** Open Graph options in effect for this build; null when off or skipped in dev */
|
|
314
|
+
og;
|
|
315
|
+
/** Counters for the build summary and metrics */
|
|
316
|
+
stats = { generated: 0, cached: 0, failed: 0 };
|
|
317
|
+
specs = new Map();
|
|
318
|
+
/** Source file of each generated image registered this build, by URL path */
|
|
319
|
+
generatedSources = new Map();
|
|
320
|
+
jobs = new Map();
|
|
321
|
+
failures = new Map();
|
|
322
|
+
records = [];
|
|
323
|
+
touched = new Set();
|
|
324
|
+
warned = new Set();
|
|
325
|
+
/** Whether the images manifest changed and needs saving */
|
|
326
|
+
dirty = false;
|
|
327
|
+
constructor(settings, provider, ogState, manifest, existing, srcDir, staticDir, outDir, cacheDir, baseUrl, logger, development, generate) {
|
|
328
|
+
this.settings = settings;
|
|
329
|
+
this.provider = provider;
|
|
330
|
+
this.ogState = ogState;
|
|
331
|
+
this.manifest = manifest;
|
|
332
|
+
this.existing = existing;
|
|
333
|
+
this.srcDir = srcDir;
|
|
334
|
+
this.staticDir = staticDir;
|
|
335
|
+
this.outDir = outDir;
|
|
336
|
+
this.cacheDir = cacheDir;
|
|
337
|
+
this.baseUrl = baseUrl;
|
|
338
|
+
this.logger = logger;
|
|
339
|
+
this.development = development;
|
|
340
|
+
this.responsive = generate ? settings.responsive : null;
|
|
341
|
+
this.og = generate && ogState ? settings.og : null;
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Creates the pipeline for a build.
|
|
345
|
+
*
|
|
346
|
+
* @returns The runtime, or null when images are turned off with `images: false`
|
|
347
|
+
* @throws {ConfigValidationError} When the `images` block is invalid
|
|
348
|
+
* @throws {Error} When a feature needs `@stati/images` and it is missing or incompatible,
|
|
349
|
+
* or when an Open Graph font or `site.baseUrl` is unusable
|
|
350
|
+
*/
|
|
351
|
+
static async create(config, options) {
|
|
352
|
+
validateImagesConfig(config.images);
|
|
353
|
+
const settings = resolveImagesConfig(config.images);
|
|
354
|
+
if (!settings)
|
|
355
|
+
return null;
|
|
356
|
+
const generate = !options.development || settings.dev === 'full';
|
|
357
|
+
const features = [];
|
|
358
|
+
if (settings.responsive)
|
|
359
|
+
features.push('responsive');
|
|
360
|
+
if (settings.og)
|
|
361
|
+
features.push('og');
|
|
362
|
+
// The package must be installed whenever a feature is on, even when dev skips it
|
|
363
|
+
const provider = features.length > 0 ? await loadImageProvider({ features, load: generate }) : null;
|
|
364
|
+
let ogState = null;
|
|
365
|
+
if (generate && settings.og && provider) {
|
|
366
|
+
if (!options.development) {
|
|
367
|
+
validateOgBaseUrl(config.site.baseUrl);
|
|
368
|
+
}
|
|
369
|
+
ogState = await loadOgState(provider, settings.og.fonts);
|
|
370
|
+
}
|
|
371
|
+
const manifest = await loadImagesManifest(options.cacheDir);
|
|
372
|
+
seedSourceCache(manifest.sources);
|
|
373
|
+
const existing = new Set(listFilesRecursiveSync(join(options.outDir, IMAGES_OUTPUT_DIR)).map((file) => `${IMAGES_OUTPUT_DIR}/${file}`));
|
|
374
|
+
const srcDir = join(process.cwd(), config.srcDir || DEFAULT_SRC_DIR);
|
|
375
|
+
const staticDir = join(process.cwd(), config.staticDir || DEFAULT_STATIC_DIR);
|
|
376
|
+
if (await pathExists(join(staticDir, IMAGES_OUTPUT_DIR))) {
|
|
377
|
+
options.logger.warning(`${toPosix(relative(process.cwd(), join(staticDir, IMAGES_OUTPUT_DIR)))} shares its URL with generated images (/${IMAGES_OUTPUT_DIR}/). Move those files to another folder.`);
|
|
378
|
+
}
|
|
379
|
+
return new ImageRuntime(settings, provider, ogState, manifest, existing, srcDir, staticDir, options.outDir, options.cacheDir, config.site.baseUrl, options.logger, options.development, generate);
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Settings that change generated HTML, added to every page's render-inputs hash.
|
|
383
|
+
*/
|
|
384
|
+
fingerprint() {
|
|
385
|
+
const hasFeatures = this.settings.responsive !== null || this.settings.og !== null;
|
|
386
|
+
return {
|
|
387
|
+
version: IMAGE_PIPELINE_VERSION,
|
|
388
|
+
eagerFirst: this.settings.eagerFirst,
|
|
389
|
+
...(hasFeatures && { generate: this.responsive !== null || this.og !== null }),
|
|
390
|
+
...(this.responsive && { responsive: this.responsive }),
|
|
391
|
+
...(this.og &&
|
|
392
|
+
this.ogState && {
|
|
393
|
+
og: {
|
|
394
|
+
template: this.og.template,
|
|
395
|
+
width: this.og.width,
|
|
396
|
+
height: this.og.height,
|
|
397
|
+
format: this.og.format,
|
|
398
|
+
exclude: this.og.exclude,
|
|
399
|
+
fonts: this.ogState.fontsHash,
|
|
400
|
+
},
|
|
401
|
+
}),
|
|
402
|
+
...(this.provider && { provider: this.provider.version }),
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Creates the image state for a page about to render.
|
|
407
|
+
*/
|
|
408
|
+
createPageContext(page) {
|
|
409
|
+
return new PageImages(this, page);
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Whether every generated file a cached page references still exists.
|
|
413
|
+
*/
|
|
414
|
+
outputsExist(images) {
|
|
415
|
+
return (images ?? []).every((image) => this.existing.has(image));
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Records a written page and starts the jobs for the images its HTML actually contains.
|
|
419
|
+
*
|
|
420
|
+
* @param pageImages - The page's image state
|
|
421
|
+
* @param html - The final HTML of the page
|
|
422
|
+
* @param info - Output path and cache key of the page
|
|
423
|
+
* @returns The files to record on the page's cache entry
|
|
424
|
+
*/
|
|
425
|
+
commitPage(pageImages, html, info) {
|
|
426
|
+
// Partials are pre-rendered for every page, so keep only images the page really shows
|
|
427
|
+
const used = pageImages.uses.filter((use) => html.includes(use.marker));
|
|
428
|
+
for (const use of used) {
|
|
429
|
+
this.startJob(use.jobKey);
|
|
430
|
+
}
|
|
431
|
+
if (used.length > 0) {
|
|
432
|
+
this.records.push({ ...info, uses: used });
|
|
433
|
+
}
|
|
434
|
+
return {
|
|
435
|
+
assetDeps: [...pageImages.assetDeps].sort(),
|
|
436
|
+
images: [...new Set(used.flatMap((use) => use.outputs))].sort(),
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Waits for every job, then rewrites pages whose images failed so they show plain images,
|
|
441
|
+
* and marks those pages stale so the next build retries.
|
|
442
|
+
*
|
|
443
|
+
* @param cacheManifest - The ISG manifest, updated in place
|
|
444
|
+
*/
|
|
445
|
+
async settle(cacheManifest) {
|
|
446
|
+
await Promise.all(this.jobs.values());
|
|
447
|
+
const failures = [];
|
|
448
|
+
if (this.failures.size === 0) {
|
|
449
|
+
return { patchedPages: 0, failures };
|
|
450
|
+
}
|
|
451
|
+
for (const [key, error] of this.failures) {
|
|
452
|
+
const spec = this.specs.get(key);
|
|
453
|
+
failures.push({
|
|
454
|
+
label: spec?.label ?? key,
|
|
455
|
+
message: error.message,
|
|
456
|
+
og: key.startsWith('og:'),
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
let patchedPages = 0;
|
|
460
|
+
for (const record of this.records) {
|
|
461
|
+
const failed = record.uses.filter((use) => this.failures.has(use.jobKey));
|
|
462
|
+
if (failed.length === 0)
|
|
463
|
+
continue;
|
|
464
|
+
const html = await readFile(record.outputPath);
|
|
465
|
+
if (html === null)
|
|
466
|
+
continue;
|
|
467
|
+
let patched = html;
|
|
468
|
+
const removed = new Set();
|
|
469
|
+
const added = new Set();
|
|
470
|
+
for (const use of failed) {
|
|
471
|
+
use.outputs.forEach((output) => removed.add(output));
|
|
472
|
+
if (use.fallbackCopy) {
|
|
473
|
+
this.registerJob(use.fallbackCopy);
|
|
474
|
+
this.startJob(use.fallbackCopy.key);
|
|
475
|
+
await this.jobs.get(use.fallbackCopy.key);
|
|
476
|
+
if (!this.failures.has(use.fallbackCopy.key)) {
|
|
477
|
+
use.fallbackCopy.outputs.forEach((output) => added.add(output));
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (use.markup !== undefined && use.fallbackMarkup !== undefined) {
|
|
481
|
+
patched = patched.split(use.markup).join(use.fallbackMarkup);
|
|
482
|
+
}
|
|
483
|
+
if (use.ogImage) {
|
|
484
|
+
const tags = generatedOgImageTags(use.ogImage);
|
|
485
|
+
for (const tag of [...tags.openGraph, ...tags.twitter]) {
|
|
486
|
+
patched = patched.split(tag).join('');
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
if (patched !== html) {
|
|
491
|
+
await writeFile(record.outputPath, patched, 'utf-8');
|
|
492
|
+
}
|
|
493
|
+
const entry = cacheManifest.entries[record.cacheKey];
|
|
494
|
+
if (entry) {
|
|
495
|
+
const { images: previous, ...rest } = entry;
|
|
496
|
+
const images = [
|
|
497
|
+
...new Set([...(previous ?? []).filter((image) => !removed.has(image)), ...added]),
|
|
498
|
+
].sort();
|
|
499
|
+
cacheManifest.entries[record.cacheKey] = {
|
|
500
|
+
...rest,
|
|
501
|
+
inputsHash: 'STALE',
|
|
502
|
+
...(images.length > 0 && { images }),
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
patchedPages++;
|
|
506
|
+
}
|
|
507
|
+
for (const failure of failures) {
|
|
508
|
+
this.logger.warning(`Image generation failed for ${failure.label}: ${failure.message}. ${failure.og ? 'The page has no generated og:image' : 'Pages show the original image'} until the next build.`);
|
|
509
|
+
}
|
|
510
|
+
return { patchedPages, failures };
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Saves the images manifest.
|
|
514
|
+
*/
|
|
515
|
+
async save() {
|
|
516
|
+
if (!this.dirty && this.touched.size === 0)
|
|
517
|
+
return;
|
|
518
|
+
this.manifest.sources = getSourceCacheEntries(new Set([...Object.keys(this.manifest.sources), ...this.touched]));
|
|
519
|
+
await saveImagesManifest(this.cacheDir, this.manifest);
|
|
520
|
+
this.dirty = false;
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Deletes generated files no page of this build references. Only files recorded in the
|
|
524
|
+
* images manifest are ever deleted.
|
|
525
|
+
*
|
|
526
|
+
* @param liveImages - Generated files referenced by this build's pages
|
|
527
|
+
* @param liveSources - Source files referenced by this build's pages
|
|
528
|
+
* @returns Number of files deleted
|
|
529
|
+
*/
|
|
530
|
+
async prune(liveImages, liveSources) {
|
|
531
|
+
const imagesDir = join(this.outDir, IMAGES_OUTPUT_DIR);
|
|
532
|
+
let removedFiles = 0;
|
|
533
|
+
let changed = false;
|
|
534
|
+
for (const [key, record] of Object.entries(this.manifest.outputs)) {
|
|
535
|
+
if (record.files.some((file) => liveImages.has(file)))
|
|
536
|
+
continue;
|
|
537
|
+
for (const file of record.files) {
|
|
538
|
+
const filePath = join(this.outDir, file);
|
|
539
|
+
if (isPathWithinDirectory(imagesDir, filePath) && (await pathExists(filePath))) {
|
|
540
|
+
await remove(filePath);
|
|
541
|
+
removedFiles++;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
delete this.manifest.outputs[key];
|
|
545
|
+
changed = true;
|
|
546
|
+
}
|
|
547
|
+
for (const path of Object.keys(this.manifest.sources)) {
|
|
548
|
+
if (!liveSources.has(path)) {
|
|
549
|
+
delete this.manifest.sources[path];
|
|
550
|
+
changed = true;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
if (changed) {
|
|
554
|
+
await saveImagesManifest(this.cacheDir, this.manifest);
|
|
555
|
+
}
|
|
556
|
+
return removedFiles;
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Inspects a source image and remembers it for the manifest.
|
|
560
|
+
*/
|
|
561
|
+
inspect(filePath) {
|
|
562
|
+
this.touched.add(toPosix(filePath));
|
|
563
|
+
return inspectSourceSync(filePath);
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Logs a warning once per build.
|
|
567
|
+
*/
|
|
568
|
+
warnOnce(key, message) {
|
|
569
|
+
if (this.warned.has(key))
|
|
570
|
+
return;
|
|
571
|
+
this.warned.add(key);
|
|
572
|
+
this.logger.warning(message);
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Registers a job. It only runs once a written page references its outputs.
|
|
576
|
+
*/
|
|
577
|
+
registerJob(spec) {
|
|
578
|
+
if (!this.specs.has(spec.key)) {
|
|
579
|
+
this.specs.set(spec.key, spec);
|
|
580
|
+
}
|
|
581
|
+
if (spec.sourcePath) {
|
|
582
|
+
for (const output of spec.outputs)
|
|
583
|
+
this.generatedSources.set(`/${output}`, spec.sourcePath);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* The source file of an image this build generates, by its URL path, such as
|
|
588
|
+
* `/_img/hero-1a2b3c4d-960.webp`.
|
|
589
|
+
*/
|
|
590
|
+
generatedSourceFor(path) {
|
|
591
|
+
return this.generatedSources.get(path);
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Job that copies a co-located image to its hashed URL.
|
|
595
|
+
*/
|
|
596
|
+
copyJob(plan, sourcePath) {
|
|
597
|
+
const target = join(this.outDir, plan.relPath);
|
|
598
|
+
return {
|
|
599
|
+
key: plan.key,
|
|
600
|
+
outputs: [plan.relPath],
|
|
601
|
+
label: toPosix(relative(process.cwd(), sourcePath)),
|
|
602
|
+
sourcePath,
|
|
603
|
+
run: () => copyFile(sourcePath, target),
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Job that encodes the responsive variants of an image.
|
|
608
|
+
*/
|
|
609
|
+
variantJob(plan, sourcePath) {
|
|
610
|
+
const provider = this.provider;
|
|
611
|
+
return {
|
|
612
|
+
key: plan.key,
|
|
613
|
+
outputs: plan.outputs.map((output) => output.relPath),
|
|
614
|
+
label: toPosix(relative(process.cwd(), sourcePath)),
|
|
615
|
+
sourcePath,
|
|
616
|
+
run: () => provider.createVariants({
|
|
617
|
+
sourcePath,
|
|
618
|
+
outputs: plan.outputs.map((output) => ({
|
|
619
|
+
filePath: join(this.outDir, output.relPath),
|
|
620
|
+
width: output.width,
|
|
621
|
+
format: output.format,
|
|
622
|
+
...(output.quality !== undefined && { quality: output.quality }),
|
|
623
|
+
})),
|
|
624
|
+
}),
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
startJob(key) {
|
|
628
|
+
if (this.jobs.has(key))
|
|
629
|
+
return;
|
|
630
|
+
const spec = this.specs.get(key);
|
|
631
|
+
if (!spec)
|
|
632
|
+
return;
|
|
633
|
+
if (this.manifest.outputs[key] && spec.outputs.every((output) => this.existing.has(output))) {
|
|
634
|
+
this.stats.cached++;
|
|
635
|
+
this.jobs.set(key, Promise.resolve());
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
const promise = (async () => {
|
|
639
|
+
for (const dir of new Set(spec.outputs.map((output) => dirname(join(this.outDir, output))))) {
|
|
640
|
+
await ensureDir(dir);
|
|
641
|
+
}
|
|
642
|
+
await spec.run();
|
|
643
|
+
this.manifest.outputs[key] = { files: [...spec.outputs] };
|
|
644
|
+
this.dirty = true;
|
|
645
|
+
for (const output of spec.outputs)
|
|
646
|
+
this.existing.add(output);
|
|
647
|
+
this.stats.generated++;
|
|
648
|
+
})().catch(async (error) => {
|
|
649
|
+
this.failures.set(key, error instanceof Error ? error : new Error(String(error)));
|
|
650
|
+
this.stats.failed++;
|
|
651
|
+
// Outputs this run wrote before failing are recorded nowhere, so pruning would never
|
|
652
|
+
// remove them. Files that existed when the build started stay: cached pages may show them.
|
|
653
|
+
await Promise.all(spec.outputs
|
|
654
|
+
.filter((output) => !this.existing.has(output))
|
|
655
|
+
.map((output) => remove(join(this.outDir, output)).catch(() => undefined)));
|
|
656
|
+
});
|
|
657
|
+
this.jobs.set(key, promise);
|
|
658
|
+
}
|
|
659
|
+
}
|