UNPKG

@nolebase/vitepress-plugin-og-image

Version:

A vitepress plugin to generate Open Graph Protocol previewing images (a.k.a. social media cards) for your site.

597 lines (579 loc) 21.3 kB
import { resolve, dirname, relative, join, sep, basename } from 'node:path'; import { sep as sep$1 } from 'node:path/posix'; import fs from 'fs-extra'; import GrayMatter from 'gray-matter'; import RehypeMeta from 'rehype-meta'; import RehypeParse from 'rehype-parse'; import RehypeStringify from 'rehype-stringify'; import { cyan, gray, yellow, red, green } from 'colorette'; import { defu } from 'defu'; import { glob } from 'tinyglobby'; import { unified } from 'unified'; import { visit } from 'unist-util-visit'; import { fileURLToPath } from 'node:url'; import { Buffer } from 'node:buffer'; import { readFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { initWasm, Resvg } from '@resvg/resvg-wasm'; import regexCreator from 'emoji-regex'; import ora from 'ora'; const logModulePrefix = `${cyan(`@nolebase/vitepress-plugin-og-image`)}${gray(":")}`; async function tryToLocateTemplateSVGFile(siteConfig, configTemplateSvgPath) { if (configTemplateSvgPath != null) return resolve(siteConfig.srcDir, configTemplateSvgPath); const templateSvgPathUnderPublicDir = resolve(siteConfig.srcDir, "public", "og-template.svg"); if (await fs.pathExists(templateSvgPathUnderPublicDir)) return templateSvgPathUnderPublicDir; const __dirname = dirname(fileURLToPath(import.meta.url)); const templateSvgPathUnderRootDir = resolve(__dirname, "assets", "og-template.svg"); if (await fs.pathExists(templateSvgPathUnderRootDir)) return templateSvgPathUnderRootDir; } async function tryToLocateFontFile(siteConfig) { const fontPathUnderPublicDir = resolve(siteConfig.srcDir, "public", "SourceHanSansSC.otf"); if (await fs.pathExists(fontPathUnderPublicDir)) return fontPathUnderPublicDir; const __dirname = dirname(fileURLToPath(import.meta.url)); const fontPathUnderRootDir = resolve(__dirname, "assets", "SourceHanSansSC.otf"); if (await fs.pathExists(fontPathUnderRootDir)) return fontPathUnderRootDir; } async function applyCategoryText(pageItem, categoryOptions) { if (typeof categoryOptions?.byCustomGetter !== "undefined") { const gotTextMaybePromise = categoryOptions.byCustomGetter({ ...pageItem }); if (typeof gotTextMaybePromise !== "undefined") { if (gotTextMaybePromise instanceof Promise) return await gotTextMaybePromise; if (gotTextMaybePromise) return gotTextMaybePromise; } } if (typeof categoryOptions?.byPathPrefix !== "undefined") { for (const { prefix, text } of categoryOptions.byPathPrefix) { if (pageItem.normalizedSourceFilePath.startsWith(prefix)) { if (!text) { console.warn( `${logModulePrefix} ${yellow("[WARN]")} empty text for prefix ${prefix} when processing ${pageItem.sourceFilePath} with categoryOptions.byPathPrefix, will ignore...` ); return; } return text; } if (pageItem.normalizedSourceFilePath.startsWith(`/${prefix}`)) { if (!text) { console.warn( `${logModulePrefix} ${yellow("[WARN]")} empty text for prefix ${prefix} when processing ${pageItem.sourceFilePath} with categoryOptions.byPathPrefix, will ignore...` ); return; } return text; } } console.warn( `${logModulePrefix} ${yellow("[WARN]")} no path prefix matched for ${pageItem.sourceFilePath} with categoryOptions.byPathPrefix, will ignore...` ); return; } if (typeof categoryOptions?.byLevel !== "undefined") { const level = Number.parseInt(String(categoryOptions?.byLevel ?? 0)); if (Number.isNaN(level)) { console.warn( `${logModulePrefix} ${yellow("[ERROR]")} byLevel must be a number, but got ${categoryOptions.byLevel} instead when processing ${pageItem.sourceFilePath} with categoryOptions.byLevel, will ignore...` ); return; } const dirs = pageItem.sourceFilePath.split("/"); if (dirs.length > level) return dirs[level]; console.warn(`${logModulePrefix} ${red(`[ERROR] byLevel is out of range for ${pageItem.sourceFilePath} with categoryOptions.byLevel.`)} will ignore...`); } } async function applyCategoryTextWithFallback(pageItem, categoryOptions) { const customText = await applyCategoryText(pageItem, categoryOptions); if (customText) return customText; const fallbackWithFrontmatter = typeof categoryOptions?.fallbackWithFrontmatter === "undefined" ? true : categoryOptions.fallbackWithFrontmatter; if (fallbackWithFrontmatter && "category" in pageItem.frontmatter && pageItem.frontmatter.category && typeof pageItem.frontmatter.category === "string") { return pageItem.frontmatter.category ?? ""; } console.warn(`${logModulePrefix} ${yellow("[WARN]")} no category text found for ${pageItem.sourceFilePath} with categoryOptions ${JSON.stringify(categoryOptions)}.}`); return "Un-categorized"; } const emojiRegex = regexCreator(); function removeEmoji(str) { return str.replace(emojiRegex, ""); } const escapeMap = { "<": "&lt;", ">": "&gt;", "'": "&apos;", '"': "&quot;", "&": "&amp;" }; function escape(content, ignore) { ignore = (ignore || "").replace(/[^&"<>']/g, ""); const pattern = `([&"<>'])`.replace(new RegExp(`[${ignore}]`, "g"), ""); return content.replace(new RegExp(pattern, "g"), (_, item) => { return escapeMap[item]; }); } const imageBuffers = /* @__PURE__ */ new Map(); function templateSVG(siteName, siteDescription, title, category, ogTemplate, maxCharactersPerLine) { maxCharactersPerLine ??= 17; const lines = removeEmoji(title).trim().replaceAll("\r\n", "\n").split("\n").map((line) => line.trim()); for (let i = 0; i < lines.length; i++) { const val = lines[i].trim(); if (val.length > maxCharactersPerLine) { let breakPoint = val.lastIndexOf(" ", maxCharactersPerLine); if (breakPoint < 0) { for (let j = Math.min(val.length - 1, maxCharactersPerLine); j > 0; j--) { if (val[j] === val[j].toUpperCase()) { breakPoint = j; break; } } } if (breakPoint < 0) breakPoint = maxCharactersPerLine; lines[i] = val.slice(0, breakPoint); lines[i + 1] = `${val.slice(lines[i].length)}${lines[i + 1] || ""}`; } lines[i] = lines[i].trim(); } const categoryStr = category ? removeEmoji(category).trim() : ""; const data = { siteName, siteDescription, category: categoryStr, line1: lines[0] || "", line2: lines[1] || "", line3: `${lines[2] || ""}${lines[3] ? "..." : ""}` }; return ogTemplate.replace(/\{\{([^}]+)\}\}/g, (_, name) => { if (!name || typeof name !== "string" || !(name in data)) return ""; const nameKeyOf = name; return escape(data[nameKeyOf]); }); } let resvgInit = false; async function initSVGRenderer() { try { if (!resvgInit) { const wasm = readFile(createRequire(import.meta.url).resolve("@resvg/resvg-wasm/index_bg.wasm")); await initWasm(wasm); resvgInit = true; } } catch (err) { throw new Error(`Failed to init resvg wasm due to ${err}`); } } let fontBuffer; async function initFontBuffer(options) { if (!options?.fontPath) return; if (fontBuffer) return fontBuffer; try { fontBuffer = await readFile(options.fontPath); } catch (err) { throw new Error(`Failed to read font file due to ${err}`); } return fontBuffer; } async function renderSVG(svgContent, fontBuffer2, imageUrlResolver, additionalFontBuffers, resultImageWidth) { try { const resvg = new Resvg( svgContent, { fitTo: { mode: "width", value: resultImageWidth ?? 1200 }, font: { fontBuffers: fontBuffer2 ? [fontBuffer2, ...additionalFontBuffers ?? []] : additionalFontBuffers ?? [], // Load system fonts might cost more time loadSystemFonts: false } } ); try { const resolvedImages = await Promise.all( resvg.imagesToResolve().map(async (url) => { return { url, buffer: await resolveImageUrlWithCache(url, imageUrlResolver) }; }) ); for (const { url, buffer } of resolvedImages) resvg.resolveImage(url, buffer); const res = resvg.render(); return { png: res.asPng(), width: res.width, height: res.height }; } catch (err) { throw new Error(`Failed to render open graph images on path due to ${err}`); } } catch (err) { throw new Error(`Failed to initiate Resvg instance to render open graph images due to ${err}`); } } function resolveImageUrlWithCache(url, imageUrlResolver) { if (imageBuffers.has(url)) return imageBuffers.get(url); const result = resolveImageUrl(url, imageUrlResolver); imageBuffers.set(url, result); return result; } async function resolveImageUrl(url, imageUrlResolver) { if (imageUrlResolver != null) { const res2 = await imageUrlResolver(url); if (res2 != null) return res2; } const res = await fetch(url); const buffer = await res.arrayBuffer(); return Buffer.from(buffer); } const okMark = green("\u2713"); const failMark = red("\u2716"); async function task(taskName, task2) { const startsAt = Date.now(); const moduleNamePrefix = cyan("@nolebase/vitepress-plugin-og-image"); const grayPrefix = gray(":"); const spinnerPrefix = `${moduleNamePrefix}${grayPrefix}`; const spinner = ora({ discardStdin: false }); spinner.start(`${spinnerPrefix} ${taskName}...`); let result; try { result = await task2(); } catch (e) { spinner.stopAndPersist({ symbol: failMark }); throw e; } const elapsed = Date.now() - startsAt; const suffixText = `${gray(`(${elapsed}ms)`)} ${result || ""}`; spinner.stopAndPersist({ symbol: okMark, suffixText }); } function renderTaskResultsSummary(results, siteConfig) { const successCount = results.filter((item) => item.status === "success"); const skippedCount = results.filter((item) => item.status === "skipped"); const erroredCount = results.filter((item) => item.status === "errored"); const stats = `${green(`${successCount.length} generated`)}, ${yellow(`${skippedCount.length} skipped`)}, ${red(`${erroredCount.length} errored`)}`; const skippedList = ` - ${yellow("Following files were skipped")}: ${skippedCount.map((item) => { return gray(` - ${relative(siteConfig.root, item.filePath)}: ${item.reason}`); }).join("\n")}`; const erroredList = ` - ${red("Following files encountered errors")} ${erroredCount.map((item) => { return gray(` - ${relative(siteConfig.root, item.filePath)}: ${item.reason}`); }).join("\n")}`; const overallResults = [stats]; if (skippedCount.length > 0) overallResults.push(skippedList); if (erroredCount.length > 0) overallResults.push(erroredList); return overallResults.join("\n\n"); } function getLocales(siteData) { const locales = []; locales.push(siteData.lang ?? "root"); if (Object.keys(siteData.locales).length === 0) return locales; for (const locale in siteData.locales) { if (locale !== siteData.lang) locales.push(locale); } return locales; } function getTitleWithLocales(siteData, locale) { if (Object.keys(siteData.locales).length > 0) { const title = siteData.locales[locale]?.title; if (title) return title; if (siteData.locales.root.title) return siteData.locales.root.title; return siteData.title; } return siteData.title; } function getDescriptionWithLocales(siteData, locale) { if (Object.keys(siteData.locales).length > 0) { const description = siteData.locales[locale]?.description; if (description) return description; if (siteData.locales.root.description) return siteData.locales.root.description; return siteData.description; } return siteData.description; } function getSidebar(siteData, themeConfig) { const locales = getLocales(siteData); if (locales.length === 0) { return { defaultLocale: siteData.lang, locales: locales || [], sidebar: { [siteData.lang]: flattenThemeConfigSidebar(themeConfig.sidebar) || [] } }; } const sidebar = { defaultLocale: siteData.lang, locales, sidebar: {} }; for (const locale of locales) { let themeConfigSidebar = []; if (typeof siteData.locales[locale]?.themeConfig?.sidebar !== "undefined") { if (Array.isArray(siteData.locales[locale]?.themeConfig?.sidebar)) { themeConfigSidebar = siteData.locales[locale]?.themeConfig?.sidebar || []; } else { themeConfigSidebar = siteData.locales[locale]?.themeConfig?.sidebar; } } else if (typeof siteData.themeConfig?.sidebar !== "undefined") { themeConfigSidebar = siteData.themeConfig?.sidebar || []; } else if (typeof themeConfig.sidebar !== "undefined") { themeConfigSidebar = themeConfig.sidebar; } else { themeConfigSidebar = []; } sidebar.sidebar[locale] = flattenThemeConfigSidebar(themeConfigSidebar) || []; } return sidebar; } function flattenThemeConfigSidebar(sidebar) { if (!sidebar) return []; if (Array.isArray(sidebar)) return sidebar; return Object.keys(sidebar).reduce((prev, curr) => { const items = sidebar[curr]; return prev.concat(items); }, []); } function flattenSidebar(sidebar, base) { return sidebar.reduce((prev, curr) => { if (curr.items) { return prev.concat( flattenSidebar( curr.items.map((item) => addBaseToItem(item, curr.base ?? base)), curr.base ?? base ).concat( curr.link == null ? [] : [{ ...curr, items: void 0, link: curr.link != null ? (curr.base ?? "") + curr.link : curr.link }] ) ); } return prev.concat(curr); }, []); } function addBaseToItem(item, base) { if (base == null || base === "") return item; return { ...item, link: item.link != null ? base + item.link : item.link }; } async function renderSVGAndRewriteHTML(siteConfig, siteTitle, siteDescription, page, file, ogImageTemplateSvg, ogImageTemplateSvgPath, domain, imageUrlResolver, additionalFontBuffers, resultImageWidth, maxCharactersPerLine, overrideExistingMetaTags) { const fileName = basename(file, ".html"); const ogImageFilePathBaseName = `og-${fileName}.png`; const ogImageFilePathFullName = `${dirname(file)}/${ogImageFilePathBaseName}`; const html = await fs.readFile(file, "utf-8"); const parsedHtml = unified().use(RehypeParse, { fragment: true }).parse(html); let hasOgImage = false; visit(parsedHtml, "element", (node) => { if (node.tagName === "meta" && (node.properties?.name === "og:image" || node.properties?.name === "twitter:image")) hasOgImage = node.properties.name; else return true; }); if (hasOgImage && !overrideExistingMetaTags) { return { filePath: file, status: "skipped", reason: `already has ${hasOgImage} meta tag` }; } const templatedOgImageSvg = templateSVG( siteTitle, siteDescription, page.title, page.category ?? "", ogImageTemplateSvg, maxCharactersPerLine ); let width; let height; try { const res = await renderSVGAndSavePNG( templatedOgImageSvg, ogImageFilePathFullName, ogImageTemplateSvgPath, relative(siteConfig.srcDir, file), { fontPath: await tryToLocateFontFile(siteConfig), imageUrlResolver, additionalFontBuffers, resultImageWidth } ); width = res.width; height = res.height; } catch (err) { return { filePath: file, status: "errored", reason: String(err) }; } const result = await unified().use(RehypeParse).use(RehypeMeta, { og: true, twitter: true, image: { url: `${domain}/${relative(siteConfig.outDir, ogImageFilePathFullName).split(sep).map((item) => encodeURIComponent(item)).join("/")}`, width, height } }).use(RehypeStringify).process(html); try { await fs.writeFile(file, String(result), "utf-8"); } catch (err) { console.error( `${logModulePrefix} `, `${red("[ERROR] \u2717")} failed to write transformed HTML on path [${relative(siteConfig.srcDir, file)}] due to ${err}`, ` ${red(err.message)} ${gray(String(err.stack))}` ); return { filePath: file, status: "errored", reason: String(err) }; } return { filePath: file, status: "success" }; } async function renderSVGAndSavePNG(svgContent, saveAs, forSvgSource, forFile, options) { try { const { png: pngBuffer, width, height } = await renderSVG(svgContent, await initFontBuffer(options), options.imageUrlResolver, options.additionalFontBuffers, options.resultImageWidth); try { await fs.writeFile(saveAs, pngBuffer, "binary"); } catch (err) { console.error( `${logModulePrefix} `, `${red("[ERROR] \u2717")} open graph image rendered successfully, but failed to write generated open graph image on path [${saveAs}] due to ${err}`, ` ${red(err.message)} ${gray(String(err.stack))}` ); throw err; } return { width, height }; } catch (err) { console.error( `${logModulePrefix} `, `${red("[ERROR] \u2717")} failed to generate open graph image as ${green(`[${saveAs}]`)} with ${green(`[${forSvgSource}]`)} due to ${red(String(err))}`, `skipped open graph image generation for ${green(`[${forFile}]`)}`, ` SVG Content: ${svgContent}`, ` Detailed stack information bellow: ${red(err.message)} ${gray(String(err.stack))}` ); throw err; } } function buildEndGenerateOpenGraphImages(options) { options = defu(options, { resultImageWidth: 1200, maxCharactersPerLine: 17, overrideExistingMetaTags: true }); return async (siteConfig) => { await initSVGRenderer(); const ogImageTemplateSvgPath = await tryToLocateTemplateSVGFile(siteConfig, options.templateSvgPath); await task("rendering open graph images", async () => { const themeConfig = siteConfig.site.themeConfig; const sidebar = getSidebar(siteConfig.site, themeConfig); let pages = []; for (const locale of sidebar.locales) { const flattenedSidebar = flattenSidebar(sidebar.sidebar[locale]); const items = []; for (const item of flattenedSidebar) { const relativeLink = item.link ?? ""; const sourceFilePath = relativeLink.endsWith("/") ? `${relativeLink}index.md` : relativeLink.endsWith(".md") ? relativeLink : `${relativeLink}.md`; const sourceFileContent = fs.readFileSync(`${join(siteConfig.srcDir, sourceFilePath)}`, "utf-8"); const { data } = GrayMatter(sourceFileContent); const res = { ...item, title: item.text ?? item.title ?? "Untitled", category: "", locale, frontmatter: data, sourceFilePath, normalizedSourceFilePath: sourceFilePath.split(sep).join(sep$1) }; res.category = await applyCategoryTextWithFallback(res, options.category); items.push(res); } pages = pages.concat(items); } const files = await glob(`${siteConfig.outDir}/**/*.html`, { onlyFiles: true }); if (!ogImageTemplateSvgPath) { return `${green(`${0} generated`)}, ${yellow(`${files.length} (all) skipped`)}, ${red(`${0} errored`)}. - ${red("Failed to locate")} og-template.svg ${red("under public or plugin directory")}, did you forget to put it? will skip open graph image generation.`; } const ogImageTemplateSvg = fs.readFileSync(ogImageTemplateSvgPath, "utf-8"); const generatedForFiles = await Promise.all(files.map(async (file) => { const relativePath = relative(siteConfig.outDir, file); const link = `/${relativePath.slice(0, relativePath.lastIndexOf(".")).replaceAll(sep, "/")}`.split("/index")[0]; const page = pages.find((item) => { let itemLink = item.link; if (itemLink?.endsWith(".md")) itemLink = itemLink.slice(0, -".md".length); if (itemLink === link) return true; if (itemLink === `${link}/`) return true; return false; }); if (!page) { return { filePath: file, status: "skipped", reason: "correspond Markdown page not found in sidebar" }; } const siteTitle = getTitleWithLocales(siteConfig.site, page.locale); const siteDescription = getDescriptionWithLocales(siteConfig.site, page.locale); return await renderSVGAndRewriteHTML( siteConfig, siteTitle, siteDescription, page, file, ogImageTemplateSvg, ogImageTemplateSvgPath, options.baseUrl, options.svgImageUrlResolver, options.svgFontBuffers, options.resultImageWidth, options.maxCharactersPerLine, options.overrideExistingMetaTags ); })); return renderTaskResultsSummary(generatedForFiles, siteConfig); }); }; } export { buildEndGenerateOpenGraphImages }; //# sourceMappingURL=index.mjs.map