remix-og-image
Version:
Build-time in-browser Open Graph image generation plugin for Remix.
646 lines (638 loc) • 24.3 kB
JavaScript
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/plugin.ts
var plugin_exports = {};
__export(plugin_exports, {
openGraphImage: () => openGraphImage
});
module.exports = __toCommonJS(plugin_exports);
// node_modules/.pnpm/tsup@8.3.5_postcss@8.5.1_typescript@5.7.3/node_modules/tsup/assets/cjs_shims.js
var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${__filename}`).href : document.currentScript && document.currentScript.src || new URL("main.js", document.baseURI).href;
var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
// src/plugin.ts
var import_node_fs = __toESM(require("fs"), 1);
var import_node_path = __toESM(require("path"), 1);
var import_node_stream = require("stream");
var import_promises = require("stream/promises");
var import_vite = require("vite");
var import_es_module_lexer = require("es-module-lexer");
var import_turbo_stream = require("turbo-stream");
var import_sharp = __toESM(require("sharp"), 1);
var import_deferred_promise = require("@open-draft/deferred-promise");
var import_babel_dead_code_elimination = require("babel-dead-code-elimination");
var import_path_to_regexp = require("path-to-regexp");
var import_puppeteer = require("puppeteer");
// src/performance.ts
var import_node_perf_hooks = require("perf_hooks");
var { LOG_PERFORMANCE } = process.env;
if (LOG_PERFORMANCE) {
const observer = new import_node_perf_hooks.PerformanceObserver((items) => {
items.getEntries().forEach((entry) => {
console.log(entry);
});
});
observer.observe({ entryTypes: ["measure"], buffered: true });
}
var performance = LOG_PERFORMANCE ? import_node_perf_hooks.performance : {
mark() {
},
measure() {
}
};
// src/babel.ts
var import_parser = require("@babel/parser");
var t = __toESM(require("@babel/types"), 1);
var import_node_module = require("module");
var require2 = (0, import_node_module.createRequire)(importMetaUrl);
var _traverse = require2("@babel/traverse");
var traverse = _traverse.default;
var _generate = require2("@babel/generator");
var generate = _generate.default;
// src/index.ts
var OPEN_GRAPH_USER_AGENT_HEADER = "remix-og-image";
// src/plugin.ts
var PLUGIN_NAME = "remix-og-image-plugin";
var EXPORT_NAME = "openGraphImage";
var CACHE_DIR = import_node_path.default.resolve("node_modules/.cache/remix-og-image");
var CACHE_MANIFEST = import_node_path.default.resolve(CACHE_DIR, "manifest.json");
var CACHE_RESULTS_DIR = import_node_path.default.resolve(CACHE_DIR, "output");
function openGraphImage(options) {
if (import_node_path.default.isAbsolute(options.outputDirectory)) {
throw new Error(
`Failed to initialize plugin: expected "outputDirectory" to be a relative path but got "${options.outputDirectory}". Please make sure it starts with "./".`
);
}
const format = options.format || "jpeg";
const cache = new Cache();
const browserPromise = new import_deferred_promise.DeferredPromise();
const vitePreviewPromise = new import_deferred_promise.DeferredPromise();
const viteConfigPromise = new import_deferred_promise.DeferredPromise();
const remixContextPromise = new import_deferred_promise.DeferredPromise();
const routesWithImages = /* @__PURE__ */ new Set();
async function fromRemixApp(...paths) {
const remixContext = await remixContextPromise;
return import_node_path.default.resolve(remixContext.remixConfig.appDirectory, ...paths);
}
async function fromViteBuild(...paths) {
const remixContext = await remixContextPromise;
return import_node_path.default.resolve(
remixContext.remixConfig.buildDirectory,
"client",
...paths
);
}
async function fromOutputDirectory(...paths) {
return fromViteBuild(options.outputDirectory, ...paths);
}
async function generateOpenGraphImages(route, browser2, appUrl) {
if (!route.path) {
return [];
}
performance.mark(`generate-image-${route.file}-start`);
const cacheEntry = cache.get(route.file);
const routeStats = await import_node_fs.default.promises.stat(await fromRemixApp(route.file)).catch((error) => {
console.log('Failed to read stats for route "%s"', route.file, error);
throw error;
});
const routeLastModifiedAt = routeStats.mtimeMs;
if (cacheEntry) {
const hasRouteChanged = routeLastModifiedAt > cacheEntry.routeLastModifiedAt;
if (!hasRouteChanged) {
await Promise.all(
cacheEntry.images.map((cachedImage) => {
const cachedImagePath = import_node_path.default.resolve(
CACHE_RESULTS_DIR,
cachedImage.name
);
if (import_node_fs.default.existsSync(cachedImagePath)) {
return writeImage({
name: cachedImage.name,
path: cachedImage.outputPath,
stream: import_node_fs.default.createReadStream(cachedImagePath)
});
}
})
);
return [];
}
}
const remixContext = await remixContextPromise;
const createRoutePath = (0, import_path_to_regexp.compile)(route.path);
performance.mark(`generate-image-${route.id}-loader-start`);
const loaderData = await getLoaderData(
route,
appUrl,
remixContext.useSingleFetch
);
performance.mark(`generate-image-${route.id}-loader-end`);
performance.measure(
`generate-image-${route.id}:loader`,
`generate-image-${route.id}-loader-start`,
`generate-image-${route.id}-loader-end`
);
const images = [];
await Promise.all(
loaderData.map(async (data) => {
performance.mark(`generate-image-${route.id}-${data.name}-start`);
performance.mark(
`generate-image-${route.id}-${data.name}-new-page-start`
);
const page = await browser2.newPage();
const mediaFeatures = options.browser?.mediaFeatures;
if (mediaFeatures) {
await page.emulateMediaFeatures(
Object.entries(mediaFeatures).map(([name, value]) => {
return { name, value };
})
);
}
performance.mark(`generate-image-${route.id}-${data.name}-new-page-end`);
performance.measure(
`generate-image-${route.id}-${data.name}:new-page`,
`generate-image-${route.id}-${data.name}-new-page-start`,
`generate-image-${route.id}-${data.name}-new-page-end`
);
try {
const pageUrl = new URL(createRoutePath(data.params), appUrl).href;
performance.mark(
`generate-image-${route.id}-${data.name}-pageload-start`
);
await Promise.all([
page.goto(pageUrl, { waitUntil: "domcontentloaded" }),
// Set viewport to a 5K device equivalent.
// This is more than enough to ensure that the OG image is visible.
page.setViewport({
width: 5120,
height: 2880,
// Use a larger scale factor to get a crisp image.
deviceScaleFactor: 2
})
]);
performance.mark(
`generate-image-${route.id}-${data.name}-pageload-end`
);
performance.measure(
`generate-image-${route.id}-${data.name}:pageload`,
`generate-image-${route.id}-${data.name}-pageload-start`,
`generate-image-${route.id}-${data.name}-pageload-end`
);
const ogImageBoundingBox = await page.$(options.elementSelector).then(async (element) => {
if (!element) {
return;
}
await element.scrollIntoView();
return element.boundingBox();
});
if (!ogImageBoundingBox) {
return [];
}
performance.mark(
`generate-image-${route.id}-${data.name}-screenshot-start`
);
const imageBuffer = await page.screenshot({
type: format,
quality: 100,
encoding: "binary",
// Set an explicit `clip` boundary for the screenshot
// to capture only the image and ignore any otherwise
// present UI, like the layout.
clip: ogImageBoundingBox,
optimizeForSpeed: true
});
performance.mark(
`generate-image-${route.id}-${data.name}-screenshot-end`
);
performance.measure(
`generate-image-${route.id}-${data.name}:screenshot`,
`generate-image-${route.id}-${data.name}-screenshot-start`,
`generate-image-${route.id}-${data.name}-screenshot-end`
);
let imageStream = (0, import_sharp.default)(imageBuffer);
switch (format) {
case "jpeg": {
imageStream = imageStream.jpeg({
quality: 100,
progressive: true
});
break;
}
case "png": {
imageStream = imageStream.png({
compressionLevel: 9,
adaptiveFiltering: true
});
break;
}
case "webp": {
imageStream = imageStream.webp({
lossless: true,
smartSubsample: true,
quality: 100,
preset: "picture"
});
break;
}
}
const imageName = `${data.name}.${format}`;
images.push({
name: imageName,
path: await fromOutputDirectory(imageName),
stream: imageStream
});
} finally {
await page.close({ runBeforeUnload: false });
performance.mark(`generate-image-${route.id}-${data.name}-end`);
performance.measure(
`generate-image-${route.id}-${data.name}`,
`generate-image-${route.id}-${data.name}-start`,
`generate-image-${route.id}-${data.name}-end`
);
}
})
);
performance.mark(`generate-image-${route.id}-end`);
performance.measure(
`generate-image-${route.id}`,
`generate-image-${route.id}-start`,
`generate-image-${route.id}-end`
);
cache.set(route.file, {
routeLastModifiedAt,
images: images.map((image) => ({
name: image.name,
outputPath: image.path
}))
});
return images;
}
async function writeImage(image) {
if (options.writeImage) {
return await options.writeImage({
stream: image.stream
});
}
const directoryName = import_node_path.default.dirname(image.path);
await Promise.all([
ensureDirectory(CACHE_RESULTS_DIR),
ensureDirectory(directoryName)
]);
await ensureDirectory(directoryName);
const passthrough = new import_node_stream.PassThrough();
const destWriteStream = import_node_fs.default.createWriteStream(image.path);
const cacheWriteStream = import_node_fs.default.createWriteStream(
import_node_path.default.resolve(CACHE_RESULTS_DIR, image.name)
);
image.stream.pipe(passthrough);
passthrough.pipe(destWriteStream);
passthrough.pipe(cacheWriteStream);
await Promise.all([(0, import_promises.finished)(destWriteStream), (0, import_promises.finished)(cacheWriteStream)]);
console.log(`Generated OG image at "${image.path}".`);
}
performance.mark("plugin-start");
return {
name: PLUGIN_NAME,
apply: "build",
async buildStart() {
const viteConfig = await viteConfigPromise;
await cache.open(import_node_path.default.resolve(viteConfig.root, CACHE_MANIFEST));
},
configResolved(config) {
viteConfigPromise.resolve(config);
const reactRouterContext = Reflect.get(
config,
"__reactRouterPluginContext"
);
if (reactRouterContext) {
remixContextPromise.resolve({
remixConfig: reactRouterContext.reactRouterConfig,
useSingleFetch: true
});
return;
}
const remixContext = Reflect.get(config, "__remixPluginContext");
if (typeof remixContext === "undefined") {
throw new Error(
`Failed to apply "remix-og-image" plugin: no Remix context found. Did you forget to use the Remix plugin in your Vite configuration?`
);
}
remixContextPromise.resolve({
remixConfig: remixContext.remixConfig,
useSingleFetch: !!Reflect.get(
remixContext.remixConfig.future,
"unstable_singleFetch"
) || !!Reflect.get(remixContext.remixConfig.future, "v3_singleFetch")
});
},
async transform(code, id, options2 = {}) {
const remixContext = await remixContextPromise;
if (!remixContext) {
return;
}
const routePath = (0, import_vite.normalizePath)(
import_node_path.default.relative(remixContext.remixConfig.appDirectory, id)
);
const route = Object.values(remixContext.remixConfig.routes).find(
(route2) => {
return (0, import_vite.normalizePath)(route2.file) === routePath;
}
);
if (!route) {
return;
}
const [, routeExports] = (0, import_es_module_lexer.parse)(code);
const hasSpecialExport = routeExports.findIndex((e) => e.n === EXPORT_NAME) !== -1;
if (!hasSpecialExport) {
return;
}
if (!options2.ssr) {
const ast = (0, import_parser.parse)(code, { sourceType: "module" });
const refs = (0, import_babel_dead_code_elimination.findReferencedIdentifiers)(ast);
traverse(ast, {
ExportNamedDeclaration(path2) {
if (t.isFunctionDeclaration(path2.node.declaration) && t.isIdentifier(path2.node.declaration.id) && path2.node.declaration.id.name === EXPORT_NAME) {
path2.remove();
}
}
});
(0, import_babel_dead_code_elimination.deadCodeElimination)(ast, refs);
return generate(ast, { sourceMaps: true, sourceFileName: id }, code);
}
if (routesWithImages.size === 0) {
browserPromise.resolve(getBrowserInstance());
vitePreviewPromise.resolve(
runVitePreviewServer(await viteConfigPromise)
);
}
routesWithImages.add(route);
},
// Use `writeBundle` and not `closeBundle` so the image generation
// time is counted toward the total build time.
writeBundle: {
order: "post",
async handler() {
const viteConfig = await viteConfigPromise;
const isBuild = viteConfig.command === "build";
const isServerBuild = viteConfig.build.rollupOptions.input === "virtual:remix/server-build" || // react-router
viteConfig.build.rollupOptions.input === "virtual:react-router/server-build";
if (isBuild && /**
* @fixme This is a hacky way of knowing the build end.
* The problem is that `closeBundle` will trigger MULTIPLE times,
* as there are multiple bundles Remix builds (client, server, etc).
* This plugin has to run after the LASTMOST bundle.
*/
isServerBuild) {
console.log(
`Generating OG images for ${routesWithImages.size} route(s):
${Array.from(routesWithImages).map((route) => ` - ${route.id}`).join("\n")}
`
);
const [browser2, server] = await Promise.all([
browserPromise,
/**
* @fixme Vite preview server someties throws:
* "Error: The server is being restarted or closed. Request is outdated."
* when trying to navigate to it. It requires a refresh to work.
*/
vitePreviewPromise
]);
const appUrl = new URL(server.resolvedUrls?.local?.[0]);
const pendingScreenshots = [];
for (const route of routesWithImages) {
pendingScreenshots.push(
generateOpenGraphImages(route, browser2, appUrl).then((images) => Promise.all(images.map(writeImage))).catch((error) => {
throw new Error(
`Failed to generate OG image for route "${route.id}".`,
{ cause: error }
);
})
);
}
const results = await Promise.allSettled(pendingScreenshots);
await Promise.all([server.close(), browser2.close(), cache.close()]);
let hasErrors = false;
results.forEach((result) => {
if (result.status === "rejected") {
hasErrors = true;
console.error(result.reason);
}
});
if (hasErrors) {
throw new Error(
"Failed to generate OG images. Please see the errors above."
);
}
performance.mark("plugin-end");
performance.measure("plugin", "plugin-start", "plugin-end");
}
}
}
};
}
var browser;
async function getBrowserInstance(options = {}) {
if (browser) {
return browser;
}
performance.mark("browser-launch-start");
browser = await (0, import_puppeteer.launch)({
headless: true,
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-gpu",
"--disable-software-rasterizer"
],
executablePath: options.executablePath
});
performance.mark("browser-launch-end");
performance.measure(
"browser-launch",
"browser-launch-start",
"browser-launch-end"
);
return browser;
}
async function runVitePreviewServer(viteConfig) {
process.env.NODE_ENV = "development";
performance.mark("vite-preview-resolve-config-start");
const previewViteConfig = await (0, import_vite.resolveConfig)(
{
root: viteConfig.root,
configFile: viteConfig.configFile,
logLevel: "error"
},
"serve",
// Using `production` mode is important.
// It will skip all the built-in development-oriented plugins in Vite.
"production",
"development",
false
);
performance.mark("vite-preview-resolve-config-end");
performance.measure(
"vite-preview-resolve-config",
"vite-preview-resolve-config-start",
"vite-preview-resolve-config-end"
);
performance.mark("vite-preview-server-start");
const server = await (0, import_vite.createServer)(previewViteConfig.inlineConfig);
performance.mark("vite-preview-server-end");
performance.measure(
"vite-preview-server",
"vite-preview-server-start",
"vite-preview-server-end"
);
return server.listen();
}
var Cache = class extends Map {
cachePath;
constructor() {
super();
}
async open(cachePath) {
if (this.cachePath) {
return;
}
this.cachePath = cachePath;
if (import_node_fs.default.existsSync(this.cachePath)) {
const cacheContent = await import_node_fs.default.promises.readFile(this.cachePath, "utf-8").then(JSON.parse).catch(() => ({}));
for (const [key, value] of Object.entries(cacheContent)) {
this.set(key, value);
}
}
}
async close() {
if (!this.cachePath) {
throw new Error(`Failed to close cache: cache is not open`);
}
const cacheContent = JSON.stringify(Object.fromEntries(this.entries()));
const baseDirectory = import_node_path.default.dirname(this.cachePath);
if (!import_node_fs.default.existsSync(baseDirectory)) {
await import_node_fs.default.promises.mkdir(baseDirectory, { recursive: true });
}
return import_node_fs.default.promises.writeFile(this.cachePath, cacheContent, "utf8");
}
};
async function getLoaderData(route, appUrl, useSingleFetch) {
const url = createResourceRouteUrl(route, appUrl, useSingleFetch);
const response = await fetch(url, {
headers: {
"user-agent": OPEN_GRAPH_USER_AGENT_HEADER
}
}).catch((error) => {
throw new Error(
`Failed to fetch Open Graph image data for route "${url.href}": ${error}`
);
});
if (!response.ok) {
throw new Error(
`Failed to fetch Open Graph image data for route "${url.href}": loader responsed with ${response.status}`
);
}
if (!response.body) {
throw new Error(
`Failed to fetch Open Graph image data for route "${url.href}": loader responsed with no body. Did you forget to throw \`json(openGraphImage())\` in your loader?`
);
}
const responseContentType = response.headers.get("content-type") || "";
const expectedContentTypes = useSingleFetch ? ["text/x-turbo", "text/x-script"] : ["application/json"];
if (!expectedContentTypes.includes(responseContentType)) {
throw new Error(
`Failed to fetch Open Graph image data for route "${url.href}": loader responsed with invalid content type ("${responseContentType}"). Did you forget to throw \`json(openGraphImage())\` in your loader?`
);
}
const data = await consumeLoaderResponse(response, route, useSingleFetch);
if (!Array.isArray(data)) {
throw new Error(
`Failed to fetch Open Graph image data for route "${url.href}": loader responded with invalid response. Did you forget to throw \`json(openGraphImage())\` in your loader?`
);
}
return data;
}
function createResourceRouteUrl(route, appUrl, useSingleFetch) {
if (!route.path) {
throw new Error(
`Failed to create resource route URL for route "${route.id}": route has no path`
);
}
const url = new URL(route.path, appUrl);
if (useSingleFetch) {
url.pathname += ".data";
url.searchParams.set("_route", route.id);
} else {
url.searchParams.set("_data", route.id);
}
return url;
}
async function decodeTurboStreamResponse(response) {
if (!response.body) {
throw new Error(
`Failed to decode turbo-stream response: response has no body`
);
}
const bodyStream = await (0, import_turbo_stream.decode)(response.body);
await bodyStream.done;
const decodedBody = bodyStream.value;
if (!decodedBody) {
throw new Error(`Failed to decode turbo-stream response`);
}
return decodedBody;
}
async function consumeLoaderResponse(response, route, useSingleFetch) {
if (!response.body) {
throw new Error(`Failed to read loader response: response has no body`);
}
if (useSingleFetch) {
const decodedBody = await decodeTurboStreamResponse(response);
const routePayload = decodedBody[route.id];
if (!routePayload) {
throw new Error(
`Failed to consume loader response for route "${route.id}": route not found in decoded response`
);
}
const data = routePayload.data;
if (!data) {
throw new Error(
`Failed to consume loader response for route "${route.id}": route has no data`
);
}
return data;
}
return response.json();
}
async function ensureDirectory(directory) {
if (import_node_fs.default.existsSync(directory)) {
return;
}
await import_node_fs.default.promises.mkdir(directory, { recursive: true });
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
openGraphImage
});
;