@stati/core 1.27.2 → 1.28.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/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 +96 -24
- package/dist/core/isg/builder.d.ts +14 -2
- package/dist/core/isg/builder.d.ts.map +1 -1
- package/dist/core/isg/builder.js +41 -7
- 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 +10 -0
- package/dist/core/isg/hash.d.ts.map +1 -1
- package/dist/core/isg/hash.js +14 -0
- package/dist/core/isg/index.d.ts +2 -1
- package/dist/core/isg/index.d.ts.map +1 -1
- package/dist/core/isg/index.js +1 -1
- package/dist/core/isg/manifest.js +8 -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/templates.d.ts +12 -1
- package/dist/core/templates.d.ts.map +1 -1
- package/dist/core/templates.js +61 -6
- package/dist/core/utils/fs.utils.d.ts +21 -1
- package/dist/core/utils/fs.utils.d.ts.map +1 -1
- package/dist/core/utils/fs.utils.js +61 -0
- package/dist/core/utils/index.d.ts +1 -1
- package/dist/core/utils/index.d.ts.map +1 -1
- package/dist/core/utils/index.js +1 -1
- 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 +1 -0
- 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/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 +5 -0
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/content.d.ts +21 -2
- 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 +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/isg.d.ts +7 -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,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for Open Graph image generation: template lookup, exclusions and inlining.
|
|
3
|
+
* @module images/og
|
|
4
|
+
*/
|
|
5
|
+
import { dirname, extname, join, relative, resolve } from 'node:path';
|
|
6
|
+
import { isPathWithinDirectory, pathExists, readFileBuffer } from '../core/utils/index.js';
|
|
7
|
+
import { matchesGlob } from '../core/utils/glob-patterns.utils.js';
|
|
8
|
+
/** Cache of template lookups per directory, cleared at the start of each build. */
|
|
9
|
+
const templateCache = new Map();
|
|
10
|
+
/**
|
|
11
|
+
* Clears the Open Graph template lookup cache.
|
|
12
|
+
*/
|
|
13
|
+
export function clearOgTemplateCache() {
|
|
14
|
+
templateCache.clear();
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Finds the Open Graph template for a page: the nearest file named `templateName` in the
|
|
18
|
+
* page's directory or one of its parents, up to the source directory.
|
|
19
|
+
*
|
|
20
|
+
* @param pageSourcePath - Absolute path of the page's Markdown file
|
|
21
|
+
* @param srcDir - Absolute source directory
|
|
22
|
+
* @param templateName - Template file name, such as `og.eta`
|
|
23
|
+
* @returns Absolute path of the template, or null when there is none
|
|
24
|
+
*/
|
|
25
|
+
export async function findOgTemplatePath(pageSourcePath, srcDir, templateName) {
|
|
26
|
+
const root = resolve(srcDir);
|
|
27
|
+
let dir = dirname(resolve(pageSourcePath));
|
|
28
|
+
const visited = [];
|
|
29
|
+
while (isPathWithinDirectory(root, dir)) {
|
|
30
|
+
const cacheKey = `${dir}|${templateName}`;
|
|
31
|
+
if (templateCache.has(cacheKey)) {
|
|
32
|
+
const cached = templateCache.get(cacheKey);
|
|
33
|
+
for (const key of visited)
|
|
34
|
+
templateCache.set(key, cached);
|
|
35
|
+
return cached;
|
|
36
|
+
}
|
|
37
|
+
visited.push(cacheKey);
|
|
38
|
+
const candidate = join(dir, templateName);
|
|
39
|
+
if (await pathExists(candidate)) {
|
|
40
|
+
for (const key of visited)
|
|
41
|
+
templateCache.set(key, candidate);
|
|
42
|
+
return candidate;
|
|
43
|
+
}
|
|
44
|
+
if (dir === root)
|
|
45
|
+
break;
|
|
46
|
+
dir = dirname(dir);
|
|
47
|
+
}
|
|
48
|
+
for (const key of visited)
|
|
49
|
+
templateCache.set(key, null);
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Whether a page gets no generated Open Graph image: it sets its own image in front matter
|
|
54
|
+
* (`seo.openGraph.image`, or `false` to have none) or matches an `exclude` pattern.
|
|
55
|
+
*/
|
|
56
|
+
export function isOgImageExcluded(page, exclude) {
|
|
57
|
+
const seo = page.frontMatter.seo;
|
|
58
|
+
const image = seo?.openGraph?.image;
|
|
59
|
+
if (image !== undefined && image !== null && image !== '') {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
return exclude.some((pattern) => matchesGlob(page.url, pattern));
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* File name stem for a page's Open Graph image, from its URL.
|
|
66
|
+
* The root page is `index`; `/blog/hello/` becomes `blog-hello`.
|
|
67
|
+
*/
|
|
68
|
+
export function ogImageSlug(url) {
|
|
69
|
+
const slug = url
|
|
70
|
+
.split('/')
|
|
71
|
+
.filter(Boolean)
|
|
72
|
+
.join('-')
|
|
73
|
+
.replace(/[^a-zA-Z0-9-_]+/g, '-')
|
|
74
|
+
.replace(/-+/g, '-')
|
|
75
|
+
.replace(/^-|-$/g, '')
|
|
76
|
+
.toLowerCase();
|
|
77
|
+
return slug || 'index';
|
|
78
|
+
}
|
|
79
|
+
const MIME_BY_EXTENSION = {
|
|
80
|
+
'.png': 'image/png',
|
|
81
|
+
'.jpg': 'image/jpeg',
|
|
82
|
+
'.jpeg': 'image/jpeg',
|
|
83
|
+
'.gif': 'image/gif',
|
|
84
|
+
'.webp': 'image/webp',
|
|
85
|
+
'.avif': 'image/avif',
|
|
86
|
+
'.svg': 'image/svg+xml',
|
|
87
|
+
};
|
|
88
|
+
const NAMED_ENTITIES = {
|
|
89
|
+
amp: '&',
|
|
90
|
+
lt: '<',
|
|
91
|
+
gt: '>',
|
|
92
|
+
quot: '"',
|
|
93
|
+
apos: "'",
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* Decodes the character references in an attribute value: the ones Eta's escaping writes,
|
|
97
|
+
* and numeric ones.
|
|
98
|
+
*/
|
|
99
|
+
function decodeHtmlEntities(text) {
|
|
100
|
+
return text.replace(/&(#[xX][0-9a-fA-F]+|#\d+|amp|lt|gt|quot|apos);/g, (match, entity) => {
|
|
101
|
+
if (!entity.startsWith('#'))
|
|
102
|
+
return NAMED_ENTITIES[entity];
|
|
103
|
+
const code = entity[1] === 'x' || entity[1] === 'X'
|
|
104
|
+
? parseInt(entity.slice(2), 16)
|
|
105
|
+
: parseInt(entity.slice(1), 10);
|
|
106
|
+
return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Replaces local `<img src>` values in Open Graph HTML with data URIs, because the renderer
|
|
111
|
+
* does not fetch files. Root-relative paths resolve from the static directory, relative ones
|
|
112
|
+
* from `baseDir`. Remote URLs and data URIs are left alone.
|
|
113
|
+
*
|
|
114
|
+
* @param generatedSource - Finds the source of an image Stati generates (`/_img/...`), such as
|
|
115
|
+
* a responsive variant from `stati.image()`. Cards inline the source, because generated
|
|
116
|
+
* files may not exist yet.
|
|
117
|
+
* @returns The HTML and the absolute paths of the files that were inlined
|
|
118
|
+
*/
|
|
119
|
+
export async function inlineLocalImages(html, dirs, generatedSource) {
|
|
120
|
+
const files = [];
|
|
121
|
+
// `[^<>]` keeps each match inside one tag, so unclosed tags can't make the search quadratic
|
|
122
|
+
const pattern = /(<img\b[^<>]*?\bsrc\s*=\s*)("([^"]*)"|'([^']*)')/gi;
|
|
123
|
+
const replacements = [];
|
|
124
|
+
for (const match of html.matchAll(pattern)) {
|
|
125
|
+
// The value is HTML-escaped: `a&b.png` names the file `a&b.png`
|
|
126
|
+
const src = decodeHtmlEntities(match[3] ?? match[4] ?? '').trim();
|
|
127
|
+
if (!src || /^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(src))
|
|
128
|
+
continue;
|
|
129
|
+
let path;
|
|
130
|
+
try {
|
|
131
|
+
path = decodeURIComponent(src.replace(/[?#].*$/, ''));
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
let filePath = generatedSource?.(path);
|
|
137
|
+
if (!filePath) {
|
|
138
|
+
filePath = path.startsWith('/') ? join(dirs.staticDir, path) : resolve(dirs.baseDir, path);
|
|
139
|
+
const root = path.startsWith('/') ? dirs.staticDir : dirs.srcDir;
|
|
140
|
+
if (!isPathWithinDirectory(root, filePath))
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const mime = MIME_BY_EXTENSION[extname(filePath).toLowerCase()];
|
|
144
|
+
if (!mime)
|
|
145
|
+
continue;
|
|
146
|
+
files.push(filePath.replace(/\\/g, '/'));
|
|
147
|
+
const bytes = await readFileBuffer(filePath);
|
|
148
|
+
if (!bytes)
|
|
149
|
+
continue;
|
|
150
|
+
const dataUri = `data:${mime};base64,${bytes.toString('base64')}`;
|
|
151
|
+
replacements.push({ match: match[0], replacement: `${match[1]}"${dataUri}"` });
|
|
152
|
+
}
|
|
153
|
+
let result = html;
|
|
154
|
+
for (const { match, replacement } of replacements) {
|
|
155
|
+
// A function, so `$&` and the like in the tag's other attributes stay literal
|
|
156
|
+
result = result.replace(match, () => replacement);
|
|
157
|
+
}
|
|
158
|
+
return { html: result, files };
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* The template path to show in messages, relative to the project.
|
|
162
|
+
*/
|
|
163
|
+
export function displayTemplatePath(templatePath) {
|
|
164
|
+
return relative(process.cwd(), templatePath).replace(/\\/g, '/') || templatePath;
|
|
165
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decides which files to generate for an image and what to call them.
|
|
3
|
+
* Names come from inputs, not from encoded bytes, so page HTML can be written before
|
|
4
|
+
* encoding finishes.
|
|
5
|
+
* @module images/plan
|
|
6
|
+
*/
|
|
7
|
+
import type { ImageOutputFormat, ImageSourceFormat } from '../types/index.js';
|
|
8
|
+
import type { ResolvedResponsiveConfig } from './config.js';
|
|
9
|
+
/**
|
|
10
|
+
* Picks the widths to generate for an image.
|
|
11
|
+
* Configured widths up to the image's own width are kept, and the image's own width is
|
|
12
|
+
* added when it is below the largest configured width. Images are never enlarged.
|
|
13
|
+
*
|
|
14
|
+
* @param intrinsicWidth - The image's display width
|
|
15
|
+
* @param configured - Candidate widths, ascending
|
|
16
|
+
* @returns Widths to generate, ascending
|
|
17
|
+
*/
|
|
18
|
+
export declare function planWidths(intrinsicWidth: number, configured: readonly number[]): number[];
|
|
19
|
+
/**
|
|
20
|
+
* The format of the `<img>` fallback for a source format: the source's own format, except
|
|
21
|
+
* GIF, which becomes PNG.
|
|
22
|
+
*/
|
|
23
|
+
export declare function fallbackFormatFor(format: ImageSourceFormat): ImageOutputFormat;
|
|
24
|
+
/**
|
|
25
|
+
* File extension for a format.
|
|
26
|
+
*/
|
|
27
|
+
export declare function extensionFor(format: ImageSourceFormat | ImageOutputFormat): string;
|
|
28
|
+
/**
|
|
29
|
+
* MIME type for a generated format.
|
|
30
|
+
*/
|
|
31
|
+
export declare function mimeTypeFor(format: ImageOutputFormat): string;
|
|
32
|
+
/**
|
|
33
|
+
* A URL-safe base name for generated files, from the source file name.
|
|
34
|
+
*/
|
|
35
|
+
export declare function imageBaseName(filePath: string): string;
|
|
36
|
+
/** One file to generate, with its path relative to the output directory and its URL. */
|
|
37
|
+
export interface PlannedOutput {
|
|
38
|
+
/** Path relative to the output directory, with forward slashes */
|
|
39
|
+
relPath: string;
|
|
40
|
+
/** Root-relative URL */
|
|
41
|
+
url: string;
|
|
42
|
+
width: number;
|
|
43
|
+
format: ImageOutputFormat;
|
|
44
|
+
quality?: number;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* A copy of a co-located source image under its content-hashed name.
|
|
48
|
+
*/
|
|
49
|
+
export interface CopyPlan {
|
|
50
|
+
key: string;
|
|
51
|
+
/** Path relative to the output directory, with forward slashes */
|
|
52
|
+
relPath: string;
|
|
53
|
+
/** Root-relative URL */
|
|
54
|
+
url: string;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Plans the copy of a co-located image: `/_img/{name}-{hash}.{ext}`.
|
|
58
|
+
*
|
|
59
|
+
* @param filePath - Absolute source path
|
|
60
|
+
* @param sourceHash - SHA-256 of the source bytes
|
|
61
|
+
* @param format - Source format
|
|
62
|
+
*/
|
|
63
|
+
export declare function planCopy(filePath: string, sourceHash: string, format: ImageSourceFormat): CopyPlan;
|
|
64
|
+
/**
|
|
65
|
+
* Generated files for one responsive image, grouped for markup.
|
|
66
|
+
*/
|
|
67
|
+
export interface VariantPlan {
|
|
68
|
+
/** Cache key covering the output name, source bytes, options and encoder */
|
|
69
|
+
key: string;
|
|
70
|
+
/** Every file to generate */
|
|
71
|
+
outputs: PlannedOutput[];
|
|
72
|
+
/** `<source>` elements, one per modern format, in order of preference */
|
|
73
|
+
sources: Array<{
|
|
74
|
+
type: string;
|
|
75
|
+
srcset: string;
|
|
76
|
+
}>;
|
|
77
|
+
/** The `<img>` fallback in the source's own format */
|
|
78
|
+
fallback: {
|
|
79
|
+
src: string;
|
|
80
|
+
srcset: string;
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Inputs for {@link planVariants}.
|
|
85
|
+
*/
|
|
86
|
+
export interface VariantPlanInput {
|
|
87
|
+
filePath: string;
|
|
88
|
+
sourceHash: string;
|
|
89
|
+
format: ImageSourceFormat;
|
|
90
|
+
/** Display width */
|
|
91
|
+
width: number;
|
|
92
|
+
responsive: ResolvedResponsiveConfig;
|
|
93
|
+
/** Encoder identity, so encoder upgrades produce new URLs */
|
|
94
|
+
providerVersion: string;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Plans the responsive variants of one image: `/_img/{name}-{hash}-{width}.{ext}` for every
|
|
98
|
+
* width in each modern format, plus the fallback format.
|
|
99
|
+
*/
|
|
100
|
+
export declare function planVariants(input: VariantPlanInput): VariantPlan;
|
|
101
|
+
//# sourceMappingURL=plan.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plan.d.ts","sourceRoot":"","sources":["../../src/images/plan.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAMH,OAAO,KAAK,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAC9E,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,aAAa,CAAC;AAa5D;;;;;;;;GAQG;AACH,wBAAgB,UAAU,CAAC,cAAc,EAAE,MAAM,EAAE,UAAU,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,EAAE,CAU1F;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,iBAAiB,GAAG,iBAAiB,CAE9E;AAED;;GAEG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,iBAAiB,GAAG,iBAAiB,GAAG,MAAM,CAElF;AAED;;GAEG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,iBAAiB,GAAG,MAAM,CAE7D;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEtD;AAED,wFAAwF;AACxF,MAAM,WAAW,aAAa;IAC5B,kEAAkE;IAClE,OAAO,EAAE,MAAM,CAAC;IAChB,wBAAwB;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,iBAAiB,CAAC;IAC1B,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;GAEG;AACH,MAAM,WAAW,QAAQ;IACvB,GAAG,EAAE,MAAM,CAAC;IACZ,kEAAkE;IAClE,OAAO,EAAE,MAAM,CAAC;IAChB,wBAAwB;IACxB,GAAG,EAAE,MAAM,CAAC;CACb;AAED;;;;;;GAMG;AACH,wBAAgB,QAAQ,CACtB,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,iBAAiB,GACxB,QAAQ,CAMV;AAED;;GAEG;AACH,MAAM,WAAW,WAAW;IAC1B,4EAA4E;IAC5E,GAAG,EAAE,MAAM,CAAC;IACZ,6BAA6B;IAC7B,OAAO,EAAE,aAAa,EAAE,CAAC;IACzB,yEAAyE;IACzE,OAAO,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACjD,sDAAsD;IACtD,QAAQ,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;CAC3C;AAED;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,iBAAiB,CAAC;IAC1B,oBAAoB;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,wBAAwB,CAAC;IACrC,6DAA6D;IAC7D,eAAe,EAAE,MAAM,CAAC;CACzB;AAaD;;;GAGG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,gBAAgB,GAAG,WAAW,CAiDjE"}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decides which files to generate for an image and what to call them.
|
|
3
|
+
* Names come from inputs, not from encoded bytes, so page HTML can be written before
|
|
4
|
+
* encoding finishes.
|
|
5
|
+
* @module images/plan
|
|
6
|
+
*/
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import { basename, extname, posix } from 'node:path';
|
|
9
|
+
import { slugify } from '../core/utils/index.js';
|
|
10
|
+
import { IMAGES_OUTPUT_DIR } from '../constants.js';
|
|
11
|
+
/** Length of the hash in generated file names. */
|
|
12
|
+
const NAME_HASH_LENGTH = 8;
|
|
13
|
+
/** MIME types for `<source type>`. */
|
|
14
|
+
const MIME_TYPES = {
|
|
15
|
+
avif: 'image/avif',
|
|
16
|
+
webp: 'image/webp',
|
|
17
|
+
jpeg: 'image/jpeg',
|
|
18
|
+
png: 'image/png',
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Picks the widths to generate for an image.
|
|
22
|
+
* Configured widths up to the image's own width are kept, and the image's own width is
|
|
23
|
+
* added when it is below the largest configured width. Images are never enlarged.
|
|
24
|
+
*
|
|
25
|
+
* @param intrinsicWidth - The image's display width
|
|
26
|
+
* @param configured - Candidate widths, ascending
|
|
27
|
+
* @returns Widths to generate, ascending
|
|
28
|
+
*/
|
|
29
|
+
export function planWidths(intrinsicWidth, configured) {
|
|
30
|
+
const widths = configured.filter((width) => width <= intrinsicWidth);
|
|
31
|
+
const largest = configured.length > 0 ? Math.max(...configured) : intrinsicWidth;
|
|
32
|
+
if (intrinsicWidth < largest && !widths.includes(intrinsicWidth)) {
|
|
33
|
+
widths.push(intrinsicWidth);
|
|
34
|
+
}
|
|
35
|
+
if (widths.length === 0) {
|
|
36
|
+
widths.push(intrinsicWidth);
|
|
37
|
+
}
|
|
38
|
+
return [...new Set(widths)].sort((a, b) => a - b);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The format of the `<img>` fallback for a source format: the source's own format, except
|
|
42
|
+
* GIF, which becomes PNG.
|
|
43
|
+
*/
|
|
44
|
+
export function fallbackFormatFor(format) {
|
|
45
|
+
return format === 'gif' ? 'png' : format;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* File extension for a format.
|
|
49
|
+
*/
|
|
50
|
+
export function extensionFor(format) {
|
|
51
|
+
return format === 'jpeg' ? 'jpg' : format;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* MIME type for a generated format.
|
|
55
|
+
*/
|
|
56
|
+
export function mimeTypeFor(format) {
|
|
57
|
+
return MIME_TYPES[format];
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* A URL-safe base name for generated files, from the source file name.
|
|
61
|
+
*/
|
|
62
|
+
export function imageBaseName(filePath) {
|
|
63
|
+
return slugify(basename(filePath, extname(filePath))) || 'image';
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Plans the copy of a co-located image: `/_img/{name}-{hash}.{ext}`.
|
|
67
|
+
*
|
|
68
|
+
* @param filePath - Absolute source path
|
|
69
|
+
* @param sourceHash - SHA-256 of the source bytes
|
|
70
|
+
* @param format - Source format
|
|
71
|
+
*/
|
|
72
|
+
export function planCopy(filePath, sourceHash, format) {
|
|
73
|
+
const relPath = posix.join(IMAGES_OUTPUT_DIR, `${imageBaseName(filePath)}-${sourceHash.slice(0, NAME_HASH_LENGTH)}.${extensionFor(format)}`);
|
|
74
|
+
return { key: `copy:${relPath}`, relPath, url: `/${relPath}` };
|
|
75
|
+
}
|
|
76
|
+
function qualityFor(format, responsive) {
|
|
77
|
+
return responsive.quality[format];
|
|
78
|
+
}
|
|
79
|
+
function srcsetOf(outputs) {
|
|
80
|
+
return outputs.map((output) => `${output.url} ${output.width}w`).join(', ');
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Plans the responsive variants of one image: `/_img/{name}-{hash}-{width}.{ext}` for every
|
|
84
|
+
* width in each modern format, plus the fallback format.
|
|
85
|
+
*/
|
|
86
|
+
export function planVariants(input) {
|
|
87
|
+
const widths = planWidths(input.width, input.responsive.widths);
|
|
88
|
+
const fallbackFormat = fallbackFormatFor(input.format);
|
|
89
|
+
const formats = input.responsive.formats.filter((format) => format !== fallbackFormat);
|
|
90
|
+
const hash = createHash('sha256')
|
|
91
|
+
.update(JSON.stringify({
|
|
92
|
+
source: input.sourceHash,
|
|
93
|
+
widths,
|
|
94
|
+
formats,
|
|
95
|
+
fallbackFormat,
|
|
96
|
+
quality: [...formats, fallbackFormat].map((f) => qualityFor(f, input.responsive) ?? null),
|
|
97
|
+
provider: input.providerVersion,
|
|
98
|
+
}))
|
|
99
|
+
.digest('hex');
|
|
100
|
+
const baseName = imageBaseName(input.filePath);
|
|
101
|
+
const name = `${baseName}-${hash.slice(0, NAME_HASH_LENGTH)}`;
|
|
102
|
+
const outputsFor = (format) => widths.map((width) => {
|
|
103
|
+
const relPath = posix.join(IMAGES_OUTPUT_DIR, `${name}-${width}.${extensionFor(format)}`);
|
|
104
|
+
const quality = qualityFor(format, input.responsive);
|
|
105
|
+
return {
|
|
106
|
+
relPath,
|
|
107
|
+
url: `/${relPath}`,
|
|
108
|
+
width,
|
|
109
|
+
format,
|
|
110
|
+
...(quality !== undefined && { quality }),
|
|
111
|
+
};
|
|
112
|
+
});
|
|
113
|
+
const sourceOutputs = formats.map((format) => ({ format, outputs: outputsFor(format) }));
|
|
114
|
+
const fallbackOutputs = outputsFor(fallbackFormat);
|
|
115
|
+
return {
|
|
116
|
+
// The name is part of the key: identical files under different names get their own outputs
|
|
117
|
+
key: `variants:${baseName}:${hash}`,
|
|
118
|
+
outputs: [...sourceOutputs.flatMap((group) => group.outputs), ...fallbackOutputs],
|
|
119
|
+
sources: sourceOutputs.map((group) => ({
|
|
120
|
+
type: mimeTypeFor(group.format),
|
|
121
|
+
srcset: srcsetOf(group.outputs),
|
|
122
|
+
})),
|
|
123
|
+
fallback: {
|
|
124
|
+
src: fallbackOutputs[fallbackOutputs.length - 1].url,
|
|
125
|
+
srcset: srcsetOf(fallbackOutputs),
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finds and loads the optional `@stati/images` package.
|
|
3
|
+
* @module images/provider
|
|
4
|
+
*/
|
|
5
|
+
import type { ImageProvider } from '../types/index.js';
|
|
6
|
+
/** Provider contract version this core supports. */
|
|
7
|
+
export declare const SUPPORTED_PROVIDER_API_VERSION = 1;
|
|
8
|
+
/** Image features that need the package. */
|
|
9
|
+
export type ImageFeature = 'responsive' | 'og';
|
|
10
|
+
/**
|
|
11
|
+
* Resolves the package from the project, as markdown-it plugins are, so it is found under
|
|
12
|
+
* strict layouts such as pnpm's where core cannot see the project's dependencies.
|
|
13
|
+
* Resolution walks up from the project, which also covers workspaces.
|
|
14
|
+
*
|
|
15
|
+
* @returns The resolved entry file, or null when the package is not installed
|
|
16
|
+
*/
|
|
17
|
+
export declare function resolveImagesPackage(cwd?: string): string | null;
|
|
18
|
+
/**
|
|
19
|
+
* The error shown when a feature is on but the package is missing.
|
|
20
|
+
*/
|
|
21
|
+
export declare function missingPackageMessage(features: ImageFeature[]): string;
|
|
22
|
+
/**
|
|
23
|
+
* Checks that the package is installed and, when `load` is set, creates its provider.
|
|
24
|
+
*
|
|
25
|
+
* @param options.features - The features that are on; they decide the error message
|
|
26
|
+
* @param options.load - Whether to import the package; false only checks it is installed
|
|
27
|
+
* @returns The provider, or null when `load` is false
|
|
28
|
+
* @throws {Error} When the package is missing, fails to load, or is incompatible
|
|
29
|
+
*/
|
|
30
|
+
export declare function loadImageProvider(options: {
|
|
31
|
+
features: ImageFeature[];
|
|
32
|
+
load: boolean;
|
|
33
|
+
cwd?: string;
|
|
34
|
+
}): Promise<ImageProvider | null>;
|
|
35
|
+
//# sourceMappingURL=provider.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../../src/images/provider.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAKH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAIvD,oDAAoD;AACpD,eAAO,MAAM,8BAA8B,IAAI,CAAC;AAEhD,4CAA4C;AAC5C,MAAM,MAAM,YAAY,GAAG,YAAY,GAAG,IAAI,CAAC;AAE/C;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,GAAE,MAAsB,GAAG,MAAM,GAAG,IAAI,CAO/E;AAED;;GAEG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,YAAY,EAAE,GAAG,MAAM,CAItE;AAED;;;;;;;GAOG;AACH,wBAAsB,iBAAiB,CAAC,OAAO,EAAE;IAC/C,QAAQ,EAAE,YAAY,EAAE,CAAC;IACzB,IAAI,EAAE,OAAO,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAuChC"}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finds and loads the optional `@stati/images` package.
|
|
3
|
+
* @module images/provider
|
|
4
|
+
*/
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import { resolve as resolvePath } from 'node:path';
|
|
7
|
+
import { pathToFileURL } from 'node:url';
|
|
8
|
+
import { IMAGES_PACKAGE_NAME } from '../constants.js';
|
|
9
|
+
import { getStatiVersion } from '../core/utils/index.js';
|
|
10
|
+
/** Provider contract version this core supports. */
|
|
11
|
+
export const SUPPORTED_PROVIDER_API_VERSION = 1;
|
|
12
|
+
/**
|
|
13
|
+
* Resolves the package from the project, as markdown-it plugins are, so it is found under
|
|
14
|
+
* strict layouts such as pnpm's where core cannot see the project's dependencies.
|
|
15
|
+
* Resolution walks up from the project, which also covers workspaces.
|
|
16
|
+
*
|
|
17
|
+
* @returns The resolved entry file, or null when the package is not installed
|
|
18
|
+
*/
|
|
19
|
+
export function resolveImagesPackage(cwd = process.cwd()) {
|
|
20
|
+
try {
|
|
21
|
+
const require = createRequire(pathToFileURL(resolvePath(cwd, 'package.json')));
|
|
22
|
+
return require.resolve(IMAGES_PACKAGE_NAME);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The error shown when a feature is on but the package is missing.
|
|
30
|
+
*/
|
|
31
|
+
export function missingPackageMessage(features) {
|
|
32
|
+
const names = features.map((feature) => `images.${feature}`).join(' and ');
|
|
33
|
+
const verb = features.length > 1 ? 'require' : 'requires';
|
|
34
|
+
return `${names} ${verb} ${IMAGES_PACKAGE_NAME}. Run: npm i -D ${IMAGES_PACKAGE_NAME}`;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Checks that the package is installed and, when `load` is set, creates its provider.
|
|
38
|
+
*
|
|
39
|
+
* @param options.features - The features that are on; they decide the error message
|
|
40
|
+
* @param options.load - Whether to import the package; false only checks it is installed
|
|
41
|
+
* @returns The provider, or null when `load` is false
|
|
42
|
+
* @throws {Error} When the package is missing, fails to load, or is incompatible
|
|
43
|
+
*/
|
|
44
|
+
export async function loadImageProvider(options) {
|
|
45
|
+
const entry = resolveImagesPackage(options.cwd);
|
|
46
|
+
if (!entry) {
|
|
47
|
+
throw new Error(missingPackageMessage(options.features));
|
|
48
|
+
}
|
|
49
|
+
if (!options.load) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
let provider;
|
|
53
|
+
try {
|
|
54
|
+
const module = (await import(pathToFileURL(entry).href));
|
|
55
|
+
if (typeof module.createImageProvider !== 'function') {
|
|
56
|
+
throw new Error(`${IMAGES_PACKAGE_NAME} does not export createImageProvider()`);
|
|
57
|
+
}
|
|
58
|
+
provider = await module.createImageProvider();
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
throw new Error(`Failed to load ${IMAGES_PACKAGE_NAME}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
62
|
+
}
|
|
63
|
+
if (provider?.apiVersion !== SUPPORTED_PROVIDER_API_VERSION) {
|
|
64
|
+
throw new Error(`${IMAGES_PACKAGE_NAME} ${provider?.version ?? ''} is not compatible with @stati/core ${getStatiVersion()}. Update both packages.`.replace(/\s+/g, ' '));
|
|
65
|
+
}
|
|
66
|
+
if (options.features.includes('og') && typeof provider.renderOgImage !== 'function') {
|
|
67
|
+
throw new Error(`images.og needs a newer ${IMAGES_PACKAGE_NAME} (${provider.version}). Update the package.`);
|
|
68
|
+
}
|
|
69
|
+
return provider;
|
|
70
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps an image `src` as written in Markdown or a template to a file on disk.
|
|
3
|
+
* @module images/resolve
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Where an image's `src` points.
|
|
7
|
+
* - `skip`: leave the image alone (remote, SVG, unsupported, opted out or outside the site);
|
|
8
|
+
* `src` is the value to emit, with any `?raw` suffix removed.
|
|
9
|
+
* - `static`: a file under the static directory, served at its own URL.
|
|
10
|
+
* - `colocated`: a file under the source directory, next to the page that uses it.
|
|
11
|
+
*/
|
|
12
|
+
export type ResolvedImageSource = {
|
|
13
|
+
kind: 'skip';
|
|
14
|
+
src: string;
|
|
15
|
+
} | {
|
|
16
|
+
kind: 'static';
|
|
17
|
+
src: string;
|
|
18
|
+
filePath: string;
|
|
19
|
+
} | {
|
|
20
|
+
kind: 'colocated';
|
|
21
|
+
src: string;
|
|
22
|
+
filePath: string;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Paths needed to resolve an image for one page.
|
|
26
|
+
*/
|
|
27
|
+
export interface ImageResolveContext {
|
|
28
|
+
/** Absolute path of the Markdown file being rendered */
|
|
29
|
+
pageSourcePath: string;
|
|
30
|
+
/** Absolute source directory */
|
|
31
|
+
srcDir: string;
|
|
32
|
+
/** Absolute static assets directory */
|
|
33
|
+
staticDir: string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Resolves an image `src`.
|
|
37
|
+
* Root-relative paths (`/images/hero.jpg`) resolve from the static directory; relative
|
|
38
|
+
* paths (`./hero.jpg`) resolve next to the page's Markdown file.
|
|
39
|
+
*
|
|
40
|
+
* @param rawSrc - The `src` as authored
|
|
41
|
+
* @param context - The page and site directories
|
|
42
|
+
* @returns Where the image lives, or `skip` when it should be left as authored
|
|
43
|
+
*/
|
|
44
|
+
export declare function resolveImageSource(rawSrc: string, context: ImageResolveContext): ResolvedImageSource;
|
|
45
|
+
//# sourceMappingURL=resolve.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolve.d.ts","sourceRoot":"","sources":["../../src/images/resolve.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAWH;;;;;;GAMG;AACH,MAAM,MAAM,mBAAmB,GAC3B;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAC7B;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GACjD;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC;AAEzD;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC,wDAAwD;IACxD,cAAc,EAAE,MAAM,CAAC;IACvB,gCAAgC;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,uCAAuC;IACvC,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,mBAAmB,GAC3B,mBAAmB,CAmCrB"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps an image `src` as written in Markdown or a template to a file on disk.
|
|
3
|
+
* @module images/resolve
|
|
4
|
+
*/
|
|
5
|
+
import { dirname, extname, join, resolve } from 'node:path';
|
|
6
|
+
import { isPathWithinDirectory } from '../core/utils/index.js';
|
|
7
|
+
/** Extensions Stati reads dimensions from. SVG and anything else are left as authored. */
|
|
8
|
+
const SUPPORTED_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif']);
|
|
9
|
+
/** Suffix that opts a single image out of processing. */
|
|
10
|
+
const RAW_SUFFIX = '?raw';
|
|
11
|
+
/**
|
|
12
|
+
* Resolves an image `src`.
|
|
13
|
+
* Root-relative paths (`/images/hero.jpg`) resolve from the static directory; relative
|
|
14
|
+
* paths (`./hero.jpg`) resolve next to the page's Markdown file.
|
|
15
|
+
*
|
|
16
|
+
* @param rawSrc - The `src` as authored
|
|
17
|
+
* @param context - The page and site directories
|
|
18
|
+
* @returns Where the image lives, or `skip` when it should be left as authored
|
|
19
|
+
*/
|
|
20
|
+
export function resolveImageSource(rawSrc, context) {
|
|
21
|
+
const src = rawSrc.trim();
|
|
22
|
+
if (!src)
|
|
23
|
+
return { kind: 'skip', src: rawSrc };
|
|
24
|
+
if (src.endsWith(RAW_SUFFIX)) {
|
|
25
|
+
return { kind: 'skip', src: src.slice(0, -RAW_SUFFIX.length) };
|
|
26
|
+
}
|
|
27
|
+
// Remote URLs, protocol-relative URLs and data URIs
|
|
28
|
+
if (/^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(src)) {
|
|
29
|
+
return { kind: 'skip', src };
|
|
30
|
+
}
|
|
31
|
+
let path;
|
|
32
|
+
try {
|
|
33
|
+
path = decodeURIComponent(src.replace(/[?#].*$/, ''));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return { kind: 'skip', src };
|
|
37
|
+
}
|
|
38
|
+
if (!SUPPORTED_EXTENSIONS.has(extname(path).toLowerCase())) {
|
|
39
|
+
return { kind: 'skip', src };
|
|
40
|
+
}
|
|
41
|
+
if (path.startsWith('/')) {
|
|
42
|
+
const filePath = join(context.staticDir, path);
|
|
43
|
+
return isPathWithinDirectory(context.staticDir, filePath)
|
|
44
|
+
? { kind: 'static', src, filePath }
|
|
45
|
+
: { kind: 'skip', src };
|
|
46
|
+
}
|
|
47
|
+
const filePath = resolve(dirname(context.pageSourcePath), path);
|
|
48
|
+
return isPathWithinDirectory(context.srcDir, filePath)
|
|
49
|
+
? { kind: 'colocated', src, filePath }
|
|
50
|
+
: { kind: 'skip', src };
|
|
51
|
+
}
|