sitegen/src/q+a/image.tsx
2025-08-02 20:56:36 -04:00

81 lines
2.3 KiB
TypeScript

const width = 768;
const cacheImageDir = path.resolve(".clover/question_images");
// Cached browser session
const getBrowser = RefCountedExpirable(
() =>
puppeteer.launch({
args: ["--no-sandbox", "--disable-setuid-sandbox"],
}),
(b) => b.close(),
);
export async function renderQuestionImage(question: Question) {
const html = await renderViewToString("q+a/image-embed", { question });
// this browser session will be reused if multiple images are generated
// either at the same time or within a 5-minute time span. the dispose
// symbol
using sharedBrowser = await getBrowser();
const b = sharedBrowser.value;
const p = await b.newPage();
await p.setViewport({ width, height: 400 });
await p.setContent(html);
try {
await p.waitForNetworkIdle({ idleTime: 100, timeout: 500 });
} catch (e) {}
const height = await p.evaluate(() => {
const e = document.querySelector("main")!;
return e.getBoundingClientRect().height;
});
const buf = await p.screenshot({
path: "screenshot.png",
type: "png",
captureBeyondViewport: true,
clip: { x: 0, width, y: 0, height: height, scale: 1.5 },
});
await p.close();
return Buffer.from(buf);
}
export async function getQuestionImage(
question: Question,
headOnly: boolean,
): Promise<Response> {
const hash = crypto.createHash("sha1")
.update(question.qmid + question.type + question.text)
.digest("hex");
const headers = {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=31536000",
"ETag": `"${hash}"`,
"Last-Modified": question.date.toUTCString(),
};
if (headOnly) {
return new Response(null, { headers });
}
const cachedFilePath = path.join(cacheImageDir, `/${hash}.png`);
let buf: Buffer;
try {
buf = await fs.readFile(cachedFilePath);
} catch (e: any) {
if (e.code !== "ENOENT") throw e;
buf = await renderQuestionImage(question);
fs.writeMkdir(cachedFilePath, buf).catch(() => {});
}
return new Response(buf, { headers });
}
import * as crypto from "node:crypto";
import * as fs from "#sitegen/fs";
import * as path from "node:path";
import * as puppeteer from "puppeteer";
import { Question } from "@/q+a/models/Question.ts";
import { RefCountedExpirable } from "#sitegen/async";
import { renderViewToString } from "#sitegen/view";