main
1import crypto from "node:crypto";
2import fs from "node:fs/promises";
3import path from "node:path";
4import { fileURLToPath } from "node:url";
5
6import type { AstroIntegration, AstroIntegrationLogger } from "astro";
7
8import { joinUrl } from "../utils/url.ts";
9
10export const ASSET_DIR = "_typst";
11
12// Typst's HTML export inlines images (including generated SVGs) as data URLs.
13// After `astro build`, pull them out into files so pages don't carry megabytes
14// of base64.
15
16const EXT_BY_MIME: Record<string, string> = {
17 png: "png",
18 jpeg: "jpg",
19 jpg: "jpg",
20 gif: "gif",
21 webp: "webp",
22 "svg+xml": "svg",
23 avif: "avif",
24 tiff: "tiff",
25 "x-icon": "ico",
26};
27
28const IMG_TAG = /<img\b[^>]*>/g;
29const IMG_SRC = /src=(["'])(data:image\/[^"']+)\1/;
30
31function imageInfo(src: string): { bytes: Buffer; filename: string } | null {
32 const match = src.match(/^data:image\/([^;]+);base64,([A-Za-z0-9+/=]+)$/);
33 if (!match) return null;
34 const ext = EXT_BY_MIME[match[1]];
35 if (!ext) return null;
36 const bytes = Buffer.from(match[2], "base64");
37 const hash = crypto
38 .createHash("sha256")
39 .update(bytes)
40 .digest("hex")
41 .slice(0, 12);
42 return { bytes, filename: `${hash}.${ext}` };
43}
44
45// Shared with the feed pipeline so inlined images resolve to the same files.
46export function dataImageFilename(src: string): string | null {
47 return imageInfo(src)?.filename ?? null;
48}
49
50async function collectImages(
51 rootDir: string,
52 onHtml: (filePath: string) => Promise<void>,
53): Promise<void> {
54 for (const entry of await fs.readdir(rootDir, { withFileTypes: true })) {
55 const fullPath = path.join(rootDir, entry.name);
56 if (entry.isDirectory()) {
57 await collectImages(fullPath, onHtml);
58 } else if (entry.isFile() && entry.name.endsWith(".html")) {
59 await onHtml(fullPath);
60 }
61 }
62}
63
64export function extractImages(): AstroIntegration {
65 let base = "/";
66
67 return {
68 name: "extract-images",
69 hooks: {
70 "astro:config:done": ({ config }) => {
71 base = config.base;
72 },
73 "astro:build:done": async ({ dir, logger }) => {
74 const outDir = path.join(fileURLToPath(dir), ASSET_DIR);
75 const written = new Map<string, Buffer>();
76
77 const rewrite = (
78 html: string,
79 filePath: string,
80 logger: AstroIntegrationLogger,
81 ) =>
82 html.replace(IMG_TAG, (tag) => {
83 const srcMatch = tag.match(IMG_SRC);
84 if (!srcMatch) return tag;
85 const info = imageInfo(srcMatch[2]);
86 if (!info) {
87 logger.warn(
88 `[${filePath}] keeping data:image as-is (non-base64 or unknown mime)`,
89 );
90 return tag;
91 }
92 written.set(info.filename, info.bytes);
93 let out = tag.replace(
94 IMG_SRC,
95 `src=${srcMatch[1]}${joinUrl(base, ASSET_DIR, info.filename)}${
96 srcMatch[1]
97 }`,
98 );
99 if (!/\sloading=/.test(out)) {
100 out = out.replace(
101 /^<img\b/,
102 '<img loading="lazy" decoding="async"',
103 );
104 }
105 return out;
106 });
107
108 await collectImages(fileURLToPath(dir), async (filePath) => {
109 const html = await fs.readFile(filePath, "utf-8");
110 const rewritten = rewrite(html, filePath, logger);
111 if (rewritten !== html) {
112 await fs.writeFile(filePath, rewritten);
113 }
114 });
115
116 if (written.size > 0) {
117 await fs.mkdir(outDir, { recursive: true });
118 await Promise.all(
119 [...written].map(([filename, bytes]) =>
120 fs.writeFile(path.join(outDir, filename), bytes),
121 ),
122 );
123 logger.info(
124 `Extracted ${written.size} inlined images to /${ASSET_DIR}`,
125 );
126 }
127 },
128 },
129 };
130}