main
1import fs from "node:fs/promises";
2import path from "node:path";
3import { fileURLToPath } from "node:url";
4import { z } from "astro/zod";
5import { defineIntegration } from "astro-integration-kit";
6import * as cheerio from "cheerio";
7import { getBase64ImageInfo } from "../utils/image.js";
8import { joinUrl, url } from "../utils/url.js";
9import type { AstroConfig } from "astro";
10
11// TODO: After Typst 0.15 published, use jsx and path to auto optimize images in typst
12// See https://github.com/typst/typst/pull/7555
13// It's not possible now because image path is string in Typst 0.14,
14// so we can't resolve images' actual path to import them by Astro's `<Image />` component.
15export const extractImage = defineIntegration({
16 name: "tybios-extract-image",
17 optionsSchema: z.object({
18 dir: z.string().optional().default("assets/img/typ"),
19 }),
20 setup({ options }) {
21 let _astroConfig: AstroConfig;
22 return {
23 hooks: {
24 "astro:config:done": async ({ config }) => {
25 _astroConfig = config;
26 },
27 "astro:build:done": async ({ dir, logger }) => {
28 const rootDir = fileURLToPath(dir);
29 const outDir = path.join(rootDir, options.dir);
30 await fs.mkdir(outDir, { recursive: true });
31
32 const seenImages = new Set<string>();
33
34 async function processDirectory(currentDir: string) {
35 const entries = await fs.readdir(currentDir, {
36 withFileTypes: true,
37 });
38 for (const entry of entries) {
39 const fullPath = path.join(currentDir, entry.name);
40 if (entry.isDirectory()) {
41 await processDirectory(fullPath);
42 } else if (entry.isFile() && entry.name.endsWith(".html")) {
43 const html = await fs.readFile(fullPath, "utf-8");
44 const $ = cheerio.load(html);
45 let htmlChanged = false;
46
47 const articleImages = $("article img");
48
49 for (let i = 0; i < articleImages.length; i++) {
50 const el = articleImages[i];
51 const src = $(el).attr("src") || "";
52
53 const imageInfo = getBase64ImageInfo(src);
54 if (!imageInfo) {
55 continue;
56 }
57
58 const { filename, base64Data } = imageInfo;
59
60 if (!seenImages.has(filename)) {
61 seenImages.add(filename);
62 const imageFilePath = path.join(outDir, filename);
63
64 if (filename.endsWith(".svg")) {
65 const svgStr = Buffer.from(base64Data, "base64").toString(
66 "utf-8",
67 );
68 const minifiedSvg = svgStr
69 .replace(/>\s+</g, "><")
70 .replace(/\s{2,}/g, " ")
71 .trim();
72 await fs.writeFile(imageFilePath, minifiedSvg, "utf-8");
73 } else {
74 const imageBuffer = Buffer.from(base64Data, "base64");
75 await fs.writeFile(imageFilePath, imageBuffer);
76 }
77 }
78
79 $(el).attr(
80 "src",
81 joinUrl(_astroConfig.base, `/${options.dir}/${filename}`),
82 );
83 htmlChanged = true;
84 }
85
86 if (htmlChanged) {
87 await fs.writeFile(fullPath, $.html());
88 }
89 }
90 }
91 }
92
93 await processDirectory(rootDir);
94 logger.info(
95 `Extracted ${seenImages.size} base64 images to ${options.dir}`,
96 );
97 },
98 },
99 api: {
100 get dir() {
101 return options.dir;
102 },
103 },
104 };
105 },
106});