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}