import type { AstroBuildDoneHookInput, FilterFunction, IntegrationOptions, Page, PathFilterFunction, RenderFunction, } from "./types.ts"; import * as fs from "node:fs/promises"; import type { AstroIntegrationLogger } from "astro"; import { extract, sanitizeHtml } from "./extract.ts"; import { getFilePath } from "./util.ts"; import { fileURLToPath } from "node:url"; import * as jsdom from "jsdom"; import path from "node:path"; export async function buildDoneHook({ logger, pages, options, dir, render, filter, pathFilter, }: AstroBuildDoneHookInput & { options: IntegrationOptions; render: RenderFunction; filter: FilterFunction | undefined; pathFilter: PathFilterFunction | undefined; }) { logger.info("Generating Open Graph images"); const promises = pages.map((page) => handlePage({ page, options, render, dir, logger, filter, pathFilter }), ); await Promise.all(promises); } type HandlePageInput = { page: Page; options: IntegrationOptions; render: RenderFunction; dir: URL; logger: AstroIntegrationLogger; filter: FilterFunction | undefined; pathFilter: PathFilterFunction | undefined; }; async function handlePage({ page, options, render, dir, logger, filter, pathFilter, }: HandlePageInput) { if (pathFilter) { const shouldRender = await pathFilter(page); if (!shouldRender) { if (options.verbose) { logger.info(`Skipping page ${page.pathname}.`); } return; } } // gets the absolute path to the HTML file. E.g. /home/user/project/dist/blog/index.html // fileURLToPath() converts the URL to a file path. Without it, the path would start with a leading slash on Windows // systems, resulting in an invalid path. const htmlFile = await getFilePath({ dir: fileURLToPath(dir), page: page.pathname, }); // read the HTML file and parse it with jsdom const htmlBuffer = await fs.readFile(htmlFile); const html = htmlBuffer.toString(); const document = new jsdom.JSDOM(sanitizeHtml(html)).window.document; // extract the OpenGraph properties from the HTML file const pageDetails = extract(document); const renderInput = { ...page, ...pageDetails, dir, document }; if (filter) { const shouldRender = await filter(renderInput); if (!shouldRender) { if (options.verbose) { logger.info(`Skipping page ${page.pathname}.`); } return; } } // render the image using Satori and Resvg const reactNode = await render(renderInput); const [{ Resvg }, { default: satori }] = await Promise.all([ import("@resvg/resvg-js"), import("satori"), ]); const svg = await satori(reactNode, options); const resvg = new Resvg(svg, { font: { loadSystemFonts: false, }, fitTo: { mode: "width", value: options.width, }, }); // save the image as a PNG file. The file name is the same as the HTML file, but with a .png extension. const pngFile = htmlFile.replace(/\.html$/, ".png"); await fs.writeFile(pngFile, resvg.render().asPng()); // get the relative filesystem path to the PNG file from the output directory. E.g. blog/index.png // path.relative() returns the relative path from the first argument to the second argument. const relativePngFile = path .relative(fileURLToPath(dir), pngFile) .replaceAll("\\", "/"); // convert the image path to a URL, decode URL-encoded characters, and remove the leading slash const imageUrl = decodeURIComponent( new URL(pageDetails.image).pathname.slice(1), ); // check that the og:image property matches the sitePath if (imageUrl !== relativePngFile) { throw new Error( `The og:image property in ${htmlFile} (${imageUrl}) does not match the generated image (${relativePngFile}).`, ); } if (options.verbose) { logger.info(`Generated ${relativePngFile} for ${htmlFile}.`); } }