Skip to content

Commit 9f0f304

Browse files
committed
Fix and improve image optimization
1 parent bbab3aa commit 9f0f304

4 files changed

Lines changed: 88 additions & 72 deletions

File tree

astro.config.ts

Lines changed: 69 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type { PluginOption } from "vite";
1010
import cp from "node:child_process";
1111
import fs from "node:fs/promises";
1212
import path from "node:path";
13-
import nodeUrl from "node:url";
13+
import nodeURL from "node:url";
1414
import util from "node:util";
1515

1616
import { rehypeHeadingIds } from "@astrojs/markdown-remark";
@@ -20,14 +20,21 @@ import remarkA11yEmoji from "@fec/remark-a11y-emoji";
2020
import { addExtension, createFilter, dataToEsm } from "@rollup/pluginutils";
2121
import { transformerNotationDiff } from "@shikijs/transformers";
2222
import compress from "astro-compress";
23-
import { walk } from "estree-walker";
23+
import { walk as walkJS } from "estree-walker";
2424
import findCacheDirectory from "find-cache-dir";
2525
import gifsicle from "gifsicle";
2626
import { customAlphabet } from "nanoid";
2727
import rehypeAutolinkHeadings from "rehype-autolink-headings";
2828
import rehypeClassNames from "rehype-class-names";
2929
import rehypeExternalLinks from "rehype-external-links";
3030
import sharp from "sharp";
31+
import {
32+
ELEMENT_NODE,
33+
h as createElementNode,
34+
parse as parseHTML,
35+
render as renderHTML,
36+
walk as walkHTML
37+
} from "ultrahtml";
3138
sharp.cache(false);
3239

3340
//==================================================
@@ -130,70 +137,82 @@ const externalLinksPlugin: [RehypePlugin, ExternalLinksOptions] = [
130137
//==================================================
131138

132139
const execFile = util.promisify(cp.execFile);
133-
const optimizeImagesIntegration: AstroIntegration = {
134-
name: "optimize-images",
140+
const optimizeMediaIntegration: AstroIntegration = {
141+
name: "optimize-media",
135142
hooks: {
136143
async "astro:build:done"({ dir, pages }) {
137-
type Resize = "" | "up" | "down";
138-
type Images = { original: string; webp: string[]; resize: Resize }[];
139-
type MatchGroups = {
140-
resize: Resize;
141-
preSrc: string;
142-
src: string;
143-
postSrc: string;
144-
};
145-
146-
const distDir = nodeUrl.fileURLToPath(dir);
147-
const images: Images = [];
148-
149-
const resizeSuffixes = ["a", "b", "c", "d", "e"];
150-
const resizeUpMultipliers = [1, 1.5, 2, 3, 4];
151-
const resizeDownMultipliers = [0.25, 0.375, 0.5, 0.75, 1];
144+
const resizeValues = ["", "up", "down"] as const;
145+
const resizeSuffixes = ["a", "b", "c", "d", "e"] as const;
146+
const resizeUpMultipliers = [1, 1.5, 2, 3, 4] as const;
147+
const resizeDownMultipliers = [0.25, 0.375, 0.5, 0.75, 1] as const;
148+
149+
const distDir = nodeURL.fileURLToPath(dir);
150+
const images: {
151+
original: string;
152+
webp: string[];
153+
resize: (typeof resizeValues)[number];
154+
}[] = [];
152155

153156
const htmlPaths = pages.map(({ pathname }) =>
154157
path.join(distDir, pathname, "index.html")
155158
);
156159
for (const htmlPath of htmlPaths) {
157-
let html = await fs.readFile(htmlPath, "utf-8");
158-
const matches = html.matchAll(
159-
/<img optimize-image resize="(?<resize>[a-z-]*)"(?<preSrc>.+)src="(?<src>[^"]+)"(?<postSrc>.+)>/g
160-
);
161-
162-
for (const match of matches) {
163-
const { resize, preSrc, src, postSrc } = match.groups as MatchGroups;
164-
const extensionlessPath = src.slice(0, src.lastIndexOf("."));
165-
let webp;
166-
167-
if (resize === "") {
168-
webp = [`${extensionlessPath}.webp`];
169-
} else {
170-
webp = resizeSuffixes.map(
171-
(suffix) => `${extensionlessPath}${suffix}.webp`
172-
);
173-
}
160+
const htmlAST = parseHTML(await fs.readFile(htmlPath, "utf-8"));
161+
await walkHTML(htmlAST, async (node) => {
162+
if (
163+
node.type === ELEMENT_NODE &&
164+
node.name === "picture" &&
165+
Object.keys(node.attributes).includes("optimize-media")
166+
) {
167+
const img = node.children.find((child) => child.name === "img");
168+
if (!img || img.type !== ELEMENT_NODE) {
169+
throw new Error("'picture' must have an 'img' child.");
170+
}
174171

175-
images.push({ original: src, webp, resize });
172+
const resize = node.attributes[
173+
"optimize-media-resize"
174+
] as (typeof resizeValues)[number];
175+
if (!resizeValues.includes(resize)) {
176+
throw new Error("'resize' attribute must be set.");
177+
}
178+
179+
const { src } = img.attributes;
180+
if (typeof src !== "string") {
181+
throw new Error("'src' attribute must be set.");
182+
}
176183

177-
html = html.replace(
178-
match[0],
179-
`
180-
<source srcset="${
184+
const extensionlessPath = src.slice(0, src.lastIndexOf("."));
185+
let webp;
186+
if (resize === "") {
187+
webp = [`${extensionlessPath}.webp`];
188+
} else {
189+
webp = resizeSuffixes.map(
190+
(suffix) => `${extensionlessPath}${suffix}.webp`
191+
);
192+
}
193+
194+
images.push({ original: src, webp, resize });
195+
196+
const source = createElementNode("source", {
197+
type: "image/webp",
198+
srcset:
181199
webp.length === 1
182200
? webp[0]
183201
: webp.map(
184202
(filePath, i) => `${filePath} ${resizeUpMultipliers[i]}x`
185203
)
186-
}" type="image/webp">
187-
<img ${preSrc} src="${src}" ${postSrc}>
188-
`
189-
);
190-
}
204+
});
191205

192-
await fs.writeFile(htmlPath, html, "utf-8");
206+
node.children = [source, img];
207+
delete node.attributes["optimize-media"];
208+
delete node.attributes["optimize-media-resize"];
209+
}
210+
});
211+
await fs.writeFile(htmlPath, await renderHTML(htmlAST), "utf-8");
193212
}
194213

195214
const cacheDir = findCacheDirectory({
196-
name: "astro-optimize-images",
215+
name: "astro-optimize-media",
197216
create: true
198217
}) as string;
199218
const sharpOptions = { limitInputPixels: false, unlimited: true };
@@ -390,7 +409,7 @@ const generateIdsPlugin: PluginOption = {
390409

391410
const data = {};
392411

393-
walk(this.parse(code), {
412+
walkJS(this.parse(code), {
394413
enter(node, parent) {
395414
if (
396415
node.type === "VariableDeclaration" &&
@@ -447,6 +466,6 @@ export default <AstroUserConfig>{
447466
externalLinksPlugin
448467
]
449468
},
450-
integrations: [mdx(), optimizeImagesIntegration, compress({ Image: false })],
469+
integrations: [mdx(), optimizeMediaIntegration, compress({ Image: false })],
451470
vite: { css: { preprocessorOptions: { scss } }, plugins: [generateIdsPlugin] }
452471
};

package-lock.json

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"private": true,
3+
"type": "module",
34
"scripts": {
45
"start": "astro dev",
56
"build": "astro build",
@@ -22,6 +23,7 @@
2223
"rehype-class-names": "^2.0.0",
2324
"rehype-external-links": "^3.0.0",
2425
"sass-embedded": "^1.80.7",
25-
"sharp": "^0.34.3"
26+
"sharp": "^0.34.3",
27+
"ultrahtml": "^1.6.0"
2628
}
2729
}

src/components/picture.astro

Lines changed: 14 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,39 @@
11
---
2+
import type { ImageMetadata } from "astro";
3+
24
type Props = {
3-
src: string;
4-
width: number;
5-
height: number;
65
alt: string;
76
class?: string | { picture: string; img: string };
87
resize?: "up" | "down";
9-
format?: string;
10-
};
8+
} & ImageMetadata;
119
1210
let pictureClass;
1311
let imgClass;
14-
1512
if (typeof Astro.props.class === "string") {
1613
pictureClass = Astro.props.class;
1714
}
18-
1915
if (typeof Astro.props.class === "object" && Astro.props.class !== null) {
2016
pictureClass = Astro.props.class.picture;
2117
imgClass = Astro.props.class.img;
2218
}
2319
2420
const { resize = "" } = Astro.props;
25-
2621
if (resize === "down") {
2722
Astro.props.width = Astro.props.width * 0.25;
2823
Astro.props.height = Astro.props.height * 0.25;
2924
}
3025
31-
delete Astro.props.class;
32-
delete Astro.props.resize;
33-
delete Astro.props.format;
26+
const { alt, src, width, height } = Astro.props;
3427
---
3528

36-
{/* Formatting needs to be maintained for the integration to work properly. */}
37-
<!-- prettier-ignore -->
38-
<picture class={pictureClass}>
39-
{/*
40-
For some reason TypeScript sees a type error in the 'class' property when
41-
there should be none.
42-
*/}
43-
<!-- @ts-expect-error -->
44-
<img optimize-image resize=`${resize}` class={imgClass} decoding="async" loading="lazy" {...Astro.props} />
29+
<picture optimize-media optimize-media-resize={resize} class={pictureClass}>
30+
<img
31+
{src}
32+
{alt}
33+
{width}
34+
{height}
35+
class={imgClass}
36+
decoding="async"
37+
loading="lazy"
38+
/>
4539
</picture>

0 commit comments

Comments
 (0)