nuxt-og-image
Version:
Enlightened OG Image generation for Nuxt.
279 lines (278 loc) • 9.37 kB
JavaScript
import { htmlPayloadCache, prerenderOptionsCache } from "#og-image-cache";
import { theme } from "#og-image-virtual/unocss-config.mjs";
import { useSiteConfig } from "#site-config/server/composables/useSiteConfig";
import { createSitePathResolver } from "#site-config/server/composables/utils";
import { createGenerator } from "@unocss/core";
import presetWind from "@unocss/preset-wind3";
import { defu } from "defu";
import { parse } from "devalue";
import { createError, getQuery } from "h3";
import { useNitroApp } from "nitropack/runtime";
import { hash } from "ohash";
import { parseURL, withoutLeadingSlash, withoutTrailingSlash, withQuery } from "ufo";
import { normalizeKey } from "unstorage";
import { separateProps, useOgImageRuntimeConfig } from "../../shared.js";
import { decodeObjectHtmlEntities } from "../util/encoding.js";
import { createNitroRouteRuleMatcher } from "../util/kit.js";
import { logger } from "../util/logger.js";
import { normaliseOptions } from "../util/options.js";
import { useChromiumRenderer, useSatoriRenderer } from "./instances.js";
export function resolvePathCacheKey(e, path) {
const siteConfig = useSiteConfig(e, {
resolveRefs: true
});
const basePath = withoutTrailingSlash(withoutLeadingSlash(normalizeKey(path)));
return [
!basePath || basePath === "/" ? "index" : basePath,
hash([
basePath,
import.meta.prerender ? "" : siteConfig.url,
hash(getQuery(e))
])
].join(":");
}
export async function resolveContext(e) {
const runtimeConfig = useOgImageRuntimeConfig();
const resolvePathWithBase = createSitePathResolver(e, {
absolute: false,
withBase: true
});
const path = resolvePathWithBase(parseURL(e.path).pathname);
const extension = path.split(".").pop();
if (!extension) {
return createError({
statusCode: 400,
statusMessage: `[Nuxt OG Image] Missing OG Image type.`
});
}
if (!["png", "jpeg", "jpg", "svg", "html", "json"].includes(extension)) {
return createError({
statusCode: 400,
statusMessage: `[Nuxt OG Image] Unknown OG Image type ${extension}.`
});
}
const query = getQuery(e);
let queryParams = {};
for (const k in query) {
const v = String(query[k]);
if (!v)
continue;
if (v.startsWith("{")) {
try {
queryParams[k] = JSON.parse(v);
} catch (error) {
if (import.meta.dev) {
logger.error(`[Nuxt OG Image] Invalid JSON in ${k} parameter: ${error.message}`);
}
}
} else {
queryParams[k] = v;
}
}
queryParams = separateProps(queryParams);
let basePath = withoutTrailingSlash(
path.replace(`/__og-image__/image`, "").replace(`/__og-image__/static`, "").replace(`/og.${extension}`, "")
);
if (queryParams._query && typeof queryParams._query === "object")
basePath = withQuery(basePath, queryParams._query);
const isDebugJsonPayload = extension === "json" && runtimeConfig.debug;
const key = resolvePathCacheKey(e, basePath);
let options = queryParams.options;
if (!options) {
if (import.meta.prerender) {
options = await prerenderOptionsCache.getItem(key);
}
if (!options) {
const payload = await fetchPathHtmlAndExtractOptions(e, basePath, key);
if (payload instanceof Error)
return payload;
options = payload;
}
}
delete queryParams.options;
const routeRuleMatcher = createNitroRouteRuleMatcher();
const routeRules = routeRuleMatcher(basePath);
if (typeof routeRules.ogImage === "undefined" && !options) {
return createError({
statusCode: 400,
statusMessage: "The route is missing the Nuxt OG Image payload or route rules."
});
}
const ogImageRouteRules = separateProps(routeRules.ogImage);
options = defu(queryParams, options, ogImageRouteRules, runtimeConfig.defaults);
if (!options) {
return createError({
statusCode: 404,
statusMessage: "[Nuxt OG Image] OG Image not found."
});
}
let renderer;
switch (options.renderer) {
case "satori":
renderer = await useSatoriRenderer();
break;
case "chromium":
renderer = await useChromiumRenderer();
break;
}
if (!renderer || renderer.__mock__) {
throw createError({
statusCode: 400,
statusMessage: `[Nuxt OG Image] Renderer ${options.renderer} is not enabled.`
});
}
const unocss = await createGenerator({ theme }, {
presets: [
presetWind()
]
});
const ctx = {
unocss,
e,
key,
renderer,
isDebugJsonPayload,
runtimeConfig,
publicStoragePath: runtimeConfig.publicStoragePath,
extension,
basePath,
options: normaliseOptions(options),
_nitro: useNitroApp()
};
await ctx._nitro.hooks.callHook("nuxt-og-image:context", ctx);
return ctx;
}
const PAYLOAD_REGEX = /<script.+id="nuxt-og-image-options"[^>]*>(.+?)<\/script>/;
function getPayloadFromHtml(html) {
const match = String(html).match(PAYLOAD_REGEX);
return match ? match[1] : null;
}
export function extractAndNormaliseOgImageOptions(html) {
const _payload = getPayloadFromHtml(html);
let options = false;
try {
const payload2 = parse(_payload || "{}");
Object.entries(payload2).forEach(([key, value]) => {
if (!value && value !== 0)
delete payload2[key];
});
options = payload2;
} catch (e) {
if (import.meta.dev)
console.warn("Failed to parse #nuxt-og-image-options", e, options);
}
if (options && typeof options?.props?.description === "undefined") {
const description = html.match(/<meta[^>]+name="description"[^>]*>/)?.[0];
if (description) {
const [, content] = description.match(/content="([^"]+)"/) || [];
if (content && !options.props.description)
options.props.description = content;
}
}
const payload = decodeObjectHtmlEntities(options || {});
if (import.meta.dev) {
const socialPreview = {};
const socialMetaTags = html.match(/<meta[^>]+(property|name)="(twitter|og):([^"]+)"[^>]*>/g);
if (socialMetaTags) {
socialMetaTags.forEach((tag) => {
const [, , type, key] = tag.match(/(property|name)="(twitter|og):([^"]+)"/);
const value = tag.match(/content="([^"]+)"/)?.[1];
if (!value)
return;
if (!socialPreview[type])
socialPreview[type] = {};
socialPreview[type][key] = value;
});
}
payload.socialPreview = socialPreview;
}
return payload;
}
async function doFetchWithErrorHandling(fetch, path) {
const res = await fetch(path, {
redirect: "follow",
headers: {
accept: "text/html"
}
}).catch((err) => {
return err;
});
let errorDescription;
if (res.status >= 300 && res.status < 400) {
if (res.headers.has("location")) {
return await doFetchWithErrorHandling(fetch, res.headers.get("location") || "");
}
errorDescription = `${res.status} redirected to ${res.headers.get("location") || "unknown"}`;
} else if (res.status >= 500) {
errorDescription = `${res.status} error: ${res.statusText}`;
}
if (errorDescription) {
return [null, createError({
statusCode: 500,
statusMessage: `[Nuxt OG Image] Failed to parse \`${path}\` for og-image extraction. ${errorDescription}`
})];
}
if (res._data) {
return [res._data, null];
} else if (res.text) {
return [await res.text(), null];
}
return ["", null];
}
async function fetchPathHtmlAndExtractOptions(e, path, key) {
const cachedHtmlPayload = await htmlPayloadCache.getItem(key);
if (!import.meta.dev && cachedHtmlPayload && cachedHtmlPayload.expiresAt < Date.now())
return cachedHtmlPayload.value;
let _payload = null;
let [html, err] = await doFetchWithErrorHandling(e.fetch, path);
if (err) {
logger.warn(err);
} else {
_payload = getPayloadFromHtml(html);
}
if (!_payload) {
const [fallbackHtml, err2] = await doFetchWithErrorHandling(globalThis.$fetch.raw, path);
if (err2) {
return err2;
}
_payload = getPayloadFromHtml(fallbackHtml);
if (_payload) {
html = fallbackHtml;
}
}
if (!html) {
return createError({
statusCode: 500,
statusMessage: `[Nuxt OG Image] Failed to read the path ${path} for og-image extraction, returning no HTML.`
});
}
if (!_payload) {
const payload2 = extractAndNormaliseOgImageOptions(html);
if (payload2?.socialPreview?.og?.image) {
const p = {
custom: true,
url: payload2.socialPreview.og.image
};
if (payload2.socialPreview.og.image["image:width"]) {
p.width = payload2.socialPreview.og.image["image:width"];
}
if (payload2.socialPreview.og.image["image:height"]) {
p.height = payload2.socialPreview.og.image["image:height"];
}
return p;
}
return createError({
statusCode: 500,
statusMessage: `[Nuxt OG Image] HTML response from ${path} is missing the #nuxt-og-image-options script tag. Make sure you have defined an og image for this page.`
});
}
const payload = extractAndNormaliseOgImageOptions(html);
if (!import.meta.dev && payload) {
await htmlPayloadCache.setItem(key, {
// 60 minutes for prerender, 10 seconds for runtime
expiresAt: Date.now() + 1e3 * (import.meta.prerender ? 60 * 60 : 10),
value: payload
});
}
return payload;
}