UNPKG

nuxt-og-image

Version:

Enlightened OG Image generation for Nuxt.

279 lines (278 loc) 9.37 kB
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; }