spark-html-image 0.1.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 +63 -0
- package/package.json +44 -0
- package/src/index.d.ts +28 -0
- package/src/index.js +207 -0
package/README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# ⚡ spark-html-image
|
|
2
|
+
|
|
3
|
+
Build-time image optimization for [spark-html](https://www.npmjs.com/package/spark-html)
|
|
4
|
+
sites — a Vite plugin that converts your `<img>` references to **webp/avif**
|
|
5
|
+
with a responsive `srcset`, **zero config**. No more hand-written
|
|
6
|
+
`scripts/optimize-images.js` wired into the build command.
|
|
7
|
+
|
|
8
|
+
```js
|
|
9
|
+
// vite.config.js
|
|
10
|
+
import spark from 'spark-html/vite';
|
|
11
|
+
import prerender from 'spark-prerender/vite';
|
|
12
|
+
import image from 'spark-html-image';
|
|
13
|
+
|
|
14
|
+
export default {
|
|
15
|
+
plugins: [spark(), prerender(), image()],
|
|
16
|
+
};
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
That's it. After the build, every local `.png`/`.jpg` referenced by an `<img>`
|
|
20
|
+
in the output — pages **and** component `.html` fragments — is:
|
|
21
|
+
|
|
22
|
+
- converted to webp at several widths (never upscaled past the original),
|
|
23
|
+
- rewritten with `srcset` + `sizes` (the original file stays as the `src`
|
|
24
|
+
fallback),
|
|
25
|
+
- given `width`/`height` (no layout shift) and `loading="lazy"` +
|
|
26
|
+
`decoding="async"` when absent.
|
|
27
|
+
|
|
28
|
+
```html
|
|
29
|
+
<img src="/img/hero.png" alt="hero">
|
|
30
|
+
<!-- becomes -->
|
|
31
|
+
<img src="/img/hero.png" alt="hero"
|
|
32
|
+
srcset="/img/hero-640.webp 640w, /img/hero-960.webp 960w, /img/hero.webp 1600w"
|
|
33
|
+
sizes="100vw" width="1600" height="900" loading="lazy" decoding="async">
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
External URLs, SVGs, and any `<img>` that already has a `srcset` (or sits in a
|
|
37
|
+
`<picture>`) are left alone — the author knows best.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npm install -D spark-html-image
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Options
|
|
46
|
+
|
|
47
|
+
| Option | Default | Meaning |
|
|
48
|
+
|--------|---------|---------|
|
|
49
|
+
| `widths` | `[640, 960, 1280, 1920]` | srcset widths, capped at each image's intrinsic width. |
|
|
50
|
+
| `formats` | `['webp']` | `'webp'` and/or `'avif'`; order = `<source>` order in picture mode. |
|
|
51
|
+
| `quality` | `80` | Encoder quality. |
|
|
52
|
+
| `sizes` | `'100vw'` | Written alongside `srcset` when the img has no `sizes`. |
|
|
53
|
+
| `picture` | `false` | Wrap in `<picture>` with one `<source>` per format (use with avif). |
|
|
54
|
+
| `lazy` | `true` | Add `loading="lazy"` + `decoding="async"` when absent. |
|
|
55
|
+
|
|
56
|
+
```js
|
|
57
|
+
image({ formats: ['avif', 'webp'], picture: true, quality: 75 })
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
It runs in `closeBundle` (order `post`), after
|
|
61
|
+
[`spark-prerender`](https://www.npmjs.com/package/spark-prerender) has written
|
|
62
|
+
its per-route HTML — so prerendered pages are optimized too. Conversion uses
|
|
63
|
+
[sharp](https://sharp.pixelplumbing.com/).
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "spark-html-image",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Build-time image optimization for spark-html sites — a Vite plugin that converts <img> references to webp/avif with responsive srcset, zero config.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"types": "./src/index.d.ts",
|
|
8
|
+
"homepage": "https://wilkinnovo.github.io/spark",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./src/index.d.ts",
|
|
12
|
+
"default": "./src/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./vite": {
|
|
15
|
+
"types": "./src/index.d.ts",
|
|
16
|
+
"default": "./src/index.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node test/image.js"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"src"
|
|
24
|
+
],
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/wilkinnovo/spark.git",
|
|
28
|
+
"directory": "packages/spark-html-image"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"linkedom": "^0.18.12",
|
|
32
|
+
"sharp": "^0.34.0"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"spark-html",
|
|
36
|
+
"vite-plugin",
|
|
37
|
+
"images",
|
|
38
|
+
"webp",
|
|
39
|
+
"avif",
|
|
40
|
+
"srcset",
|
|
41
|
+
"optimization"
|
|
42
|
+
],
|
|
43
|
+
"license": "MIT"
|
|
44
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* spark-html-image — build-time image optimization (Vite plugin).
|
|
3
|
+
* Converts local raster <img> references in the built HTML (pages and
|
|
4
|
+
* component fragments) to webp/avif variants with a responsive srcset.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface SparkImageOptions {
|
|
8
|
+
/** srcset widths, capped at each image's intrinsic width. Default [640, 960, 1280, 1920]. */
|
|
9
|
+
widths?: number[];
|
|
10
|
+
/** Output formats, in <source> order when picture=true. Default ['webp']. */
|
|
11
|
+
formats?: Array<'webp' | 'avif'>;
|
|
12
|
+
/** Encoder quality for every format. Default 80. */
|
|
13
|
+
quality?: number;
|
|
14
|
+
/** The sizes attribute written alongside srcset (when the img has none). Default '100vw'. */
|
|
15
|
+
sizes?: string;
|
|
16
|
+
/** Wrap in <picture> with one <source> per format instead of img srcset. Default false. */
|
|
17
|
+
picture?: boolean;
|
|
18
|
+
/** Add loading="lazy" + decoding="async" when absent. Default true. */
|
|
19
|
+
lazy?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Vite plugin: optimize <img> references in the build output. */
|
|
23
|
+
export default function sparkImage(options?: SparkImageOptions): {
|
|
24
|
+
name: string;
|
|
25
|
+
apply: 'build';
|
|
26
|
+
configResolved(config: unknown): void;
|
|
27
|
+
closeBundle: { order: 'post'; handler(): Promise<void> };
|
|
28
|
+
};
|
package/src/index.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* spark-html-image — build-time image optimization for spark-html sites.
|
|
3
|
+
*
|
|
4
|
+
* A Vite plugin: after the build (and after spark-prerender has written its
|
|
5
|
+
* per-route HTML), it scans every *.html in the out dir — pages AND
|
|
6
|
+
* components — for local raster <img> references, converts each to
|
|
7
|
+
* webp/avif at several widths with sharp, and rewrites the tag:
|
|
8
|
+
*
|
|
9
|
+
* <img src="/img/hero.png">
|
|
10
|
+
* →
|
|
11
|
+
* <img src="/img/hero.png" srcset="/img/hero-640.webp 640w, …"
|
|
12
|
+
* sizes="100vw" width="1600" height="900"
|
|
13
|
+
* loading="lazy" decoding="async">
|
|
14
|
+
*
|
|
15
|
+
* The original file stays in place as the src fallback, so nothing breaks in
|
|
16
|
+
* a browser (or crawler) that ignores srcset. Zero config:
|
|
17
|
+
*
|
|
18
|
+
* // vite.config.js
|
|
19
|
+
* import image from 'spark-html-image';
|
|
20
|
+
* export default { plugins: [spark(), prerender(), image()] };
|
|
21
|
+
*
|
|
22
|
+
* With `picture: true` it instead wraps the img in <picture> with one
|
|
23
|
+
* <source> per format (useful when you enable avif).
|
|
24
|
+
*
|
|
25
|
+
* No core involvement — this is the same pattern as spark-prerender:
|
|
26
|
+
* build-time only, optional, nothing added to the runtime.
|
|
27
|
+
*/
|
|
28
|
+
import { join, dirname, extname, posix } from 'node:path';
|
|
29
|
+
import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
|
|
30
|
+
import { existsSync } from 'node:fs';
|
|
31
|
+
import { resolve } from 'node:path';
|
|
32
|
+
import { parseHTML } from 'linkedom';
|
|
33
|
+
|
|
34
|
+
const RASTER = new Set(['.png', '.jpg', '.jpeg']);
|
|
35
|
+
const MIME = { webp: 'image/webp', avif: 'image/avif' };
|
|
36
|
+
|
|
37
|
+
// Recursively list *.html under dir.
|
|
38
|
+
async function htmlFiles(dir) {
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const name of await readdir(dir)) {
|
|
41
|
+
const full = join(dir, name);
|
|
42
|
+
const s = await stat(full);
|
|
43
|
+
if (s.isDirectory()) out.push(...await htmlFiles(full));
|
|
44
|
+
else if (name.endsWith('.html')) out.push(full);
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Resolve an <img src> to a file inside the out dir, or null when it isn't a
|
|
50
|
+
// local raster image we should touch (external URL, data:, svg, missing file).
|
|
51
|
+
function localImagePath(src, htmlFile, outRoot) {
|
|
52
|
+
if (!src || /^[a-z]+:/i.test(src) || src.startsWith('//')) return null; // external / data:
|
|
53
|
+
const clean = src.split(/[?#]/)[0];
|
|
54
|
+
if (!RASTER.has(extname(clean).toLowerCase())) return null;
|
|
55
|
+
const file = clean.startsWith('/')
|
|
56
|
+
? join(outRoot, clean.slice(1))
|
|
57
|
+
: join(dirname(htmlFile), clean);
|
|
58
|
+
return existsSync(file) ? file : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// "/img/hero.png" + 640 + webp → "/img/hero-640.webp"; no width → "/img/hero.webp"
|
|
62
|
+
function variantUrl(src, width, format) {
|
|
63
|
+
const clean = src.split(/[?#]/)[0];
|
|
64
|
+
const q = src.slice(clean.length);
|
|
65
|
+
const ext = posix.extname(clean);
|
|
66
|
+
const stem = clean.slice(0, -ext.length);
|
|
67
|
+
return `${stem}${width ? `-${width}` : ''}.${format}${q}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {object} [options]
|
|
72
|
+
* @param {number[]} [options.widths=[640, 960, 1280, 1920]] srcset widths (capped at the image's intrinsic width).
|
|
73
|
+
* @param {('webp'|'avif')[]} [options.formats=['webp']] Output formats, in <source> order when picture=true.
|
|
74
|
+
* @param {number} [options.quality=80] Encoder quality for every format.
|
|
75
|
+
* @param {string} [options.sizes='100vw'] The sizes attribute written alongside srcset (when the img has none).
|
|
76
|
+
* @param {boolean} [options.picture=false] Wrap in <picture> with one <source> per format instead of img srcset.
|
|
77
|
+
* @param {boolean} [options.lazy=true] Add loading="lazy" + decoding="async" when absent.
|
|
78
|
+
*/
|
|
79
|
+
export default function sparkImage(options = {}) {
|
|
80
|
+
const widths = options.widths || [640, 960, 1280, 1920];
|
|
81
|
+
const formats = options.formats || ['webp'];
|
|
82
|
+
const quality = options.quality ?? 80;
|
|
83
|
+
const sizes = options.sizes || '100vw';
|
|
84
|
+
const picture = options.picture === true;
|
|
85
|
+
const lazy = options.lazy !== false;
|
|
86
|
+
|
|
87
|
+
let outDir = 'dist';
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
name: 'spark-html-image',
|
|
91
|
+
apply: 'build',
|
|
92
|
+
configResolved(config) {
|
|
93
|
+
if (config && config.build && config.build.outDir) outDir = config.build.outDir;
|
|
94
|
+
},
|
|
95
|
+
// order:'post' → runs after spark-prerender's closeBundle has written the
|
|
96
|
+
// per-route HTML, so prerendered pages are rewritten too.
|
|
97
|
+
closeBundle: {
|
|
98
|
+
order: 'post',
|
|
99
|
+
async handler() {
|
|
100
|
+
// sharp is imported lazily so merely having the plugin installed
|
|
101
|
+
// never loads the native module until a build actually runs.
|
|
102
|
+
let sharp;
|
|
103
|
+
try {
|
|
104
|
+
sharp = (await import('sharp')).default;
|
|
105
|
+
} catch (e) {
|
|
106
|
+
console.warn(`[spark-html-image] sharp unavailable — skipped (${e.message})`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const root = resolve(outDir);
|
|
111
|
+
if (!existsSync(root)) return;
|
|
112
|
+
|
|
113
|
+
// One conversion per (file, width, format) even when many pages
|
|
114
|
+
// reference the same image; metadata is read once per file.
|
|
115
|
+
const meta = new Map(); // file → { width, height }
|
|
116
|
+
const converted = new Map(); // file|width|format → url written
|
|
117
|
+
let images = 0;
|
|
118
|
+
let files = 0;
|
|
119
|
+
|
|
120
|
+
const variantsFor = async (file, src) => {
|
|
121
|
+
let m = meta.get(file);
|
|
122
|
+
if (!m) {
|
|
123
|
+
const info = await sharp(file).metadata();
|
|
124
|
+
m = { width: info.width || 0, height: info.height || 0 };
|
|
125
|
+
meta.set(file, m);
|
|
126
|
+
}
|
|
127
|
+
// Widths strictly below the intrinsic width, plus the intrinsic
|
|
128
|
+
// itself (as the un-suffixed variant) — never upscale.
|
|
129
|
+
const targets = widths.filter((w) => w < m.width).concat(m.width ? [null] : []);
|
|
130
|
+
const out = {};
|
|
131
|
+
for (const format of formats) {
|
|
132
|
+
const entries = [];
|
|
133
|
+
for (const w of targets) {
|
|
134
|
+
const url = variantUrl(src, w, format);
|
|
135
|
+
const key = `${file}|${w || ''}|${format}`;
|
|
136
|
+
if (!converted.has(key)) {
|
|
137
|
+
const destUrl = url.split(/[?#]/)[0];
|
|
138
|
+
const dest = destUrl.startsWith('/')
|
|
139
|
+
? join(root, destUrl.slice(1))
|
|
140
|
+
: join(dirname(file), posix.basename(destUrl));
|
|
141
|
+
let img = sharp(file);
|
|
142
|
+
if (w) img = img.resize({ width: w });
|
|
143
|
+
await img[format]({ quality }).toFile(dest);
|
|
144
|
+
converted.set(key, url);
|
|
145
|
+
}
|
|
146
|
+
entries.push(`${url} ${w || m.width}w`);
|
|
147
|
+
}
|
|
148
|
+
out[format] = entries.join(', ');
|
|
149
|
+
}
|
|
150
|
+
return { srcsets: out, ...m };
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
for (const htmlFile of await htmlFiles(root)) {
|
|
154
|
+
const source = await readFile(htmlFile, 'utf8');
|
|
155
|
+
if (!/<img\s/i.test(source)) continue;
|
|
156
|
+
const { document } = parseHTML(source);
|
|
157
|
+
let changed = false;
|
|
158
|
+
|
|
159
|
+
for (const img of [...document.querySelectorAll('img[src]')]) {
|
|
160
|
+
const src = img.getAttribute('src');
|
|
161
|
+
const file = localImagePath(src, htmlFile, root);
|
|
162
|
+
if (!file) continue;
|
|
163
|
+
if (img.hasAttribute('srcset') || img.closest('picture')) continue; // author knows best
|
|
164
|
+
try {
|
|
165
|
+
const v = await variantsFor(file, src);
|
|
166
|
+
if (!v.width) continue; // unreadable metadata — leave the tag alone
|
|
167
|
+
if (picture) {
|
|
168
|
+
const pic = document.createElement('picture');
|
|
169
|
+
img.replaceWith(pic);
|
|
170
|
+
for (const format of formats) {
|
|
171
|
+
const s = document.createElement('source');
|
|
172
|
+
s.setAttribute('type', MIME[format]);
|
|
173
|
+
s.setAttribute('srcset', v.srcsets[format]);
|
|
174
|
+
s.setAttribute('sizes', img.getAttribute('sizes') || sizes);
|
|
175
|
+
pic.appendChild(s);
|
|
176
|
+
}
|
|
177
|
+
pic.appendChild(img);
|
|
178
|
+
} else {
|
|
179
|
+
// srcset straight on the img — every srcset-capable browser
|
|
180
|
+
// also decodes webp; `src` stays the original as the fallback.
|
|
181
|
+
img.setAttribute('srcset', v.srcsets[formats[0]]);
|
|
182
|
+
if (!img.hasAttribute('sizes')) img.setAttribute('sizes', sizes);
|
|
183
|
+
}
|
|
184
|
+
if (!img.hasAttribute('width')) img.setAttribute('width', String(v.width));
|
|
185
|
+
if (!img.hasAttribute('height')) img.setAttribute('height', String(v.height));
|
|
186
|
+
if (lazy && !img.hasAttribute('loading')) img.setAttribute('loading', 'lazy');
|
|
187
|
+
if (lazy && !img.hasAttribute('decoding')) img.setAttribute('decoding', 'async');
|
|
188
|
+
changed = true;
|
|
189
|
+
images++;
|
|
190
|
+
} catch (e) {
|
|
191
|
+
console.warn(`[spark-html-image] skipped ${src} — ${e.message}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (changed) {
|
|
196
|
+
await writeFile(htmlFile, document.toString(), 'utf8');
|
|
197
|
+
files++;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (images) {
|
|
202
|
+
console.log(`[spark-html-image] optimized ${images} image reference(s) across ${files} file(s) (${formats.join('+')})`);
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|