nuxt-og-image 6.9.0 → 6.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,12 @@
1
1
  /**
2
2
  * Generate a deterministic hash from options object
3
- * Excludes _path so images with same options can be cached across pages
3
+ * Excludes _path so images with same options can be cached across pages,
4
+ * except for PageScreenshot, which renders the page itself.
4
5
  * Optionally includes componentHash and version for cache busting
6
+ *
7
+ * Uses the first 64 bits of a SHA-256 digest over a canonical (key-sorted)
8
+ * serialization. The previous 32-bit rolling hash collided on large sites,
9
+ * silently overwriting other pages' prerendered images.
5
10
  */
6
11
  export declare function hashOgImageOptions(options: Record<string, any>, componentHash?: string, version?: string): string;
7
12
  /**
@@ -25,7 +30,8 @@ export interface BuildOgImageUrlResult {
25
30
  /**
26
31
  * Build full OG image URL
27
32
  *
28
- * When encoded params exceed MAX_PATH_LENGTH, falls back to hash mode:
33
+ * Static URLs fall back to hash mode when encoded params exceed MAX_PATH_LENGTH
34
+ * or hold a character outside [A-Za-z0-9_.~-]:
29
35
  * - Returns short path: /_og/s/o_<hash>.png
30
36
  * - Returns hash in result for cache storage
31
37
  *
@@ -34,7 +40,7 @@ export interface BuildOgImageUrlResult {
34
40
  * @param isStatic - Whether this is a static/prerendered image
35
41
  * @param defaults - Optional defaults to skip from URL (keeps URLs shorter)
36
42
  * @example buildOgImageUrl({ width: 1200, props: { title: 'Hello' } }, 'png', true)
37
- * // Returns: { url: "/_og/s/w_1200,title_Hello.png" }
43
+ * // Returns: { url: "/_og/s/o_<hash>.png", hash: "<hash>" }
38
44
  */
39
45
  export declare function buildOgImageUrl(options: Record<string, any>, extension?: string, isStatic?: boolean, defaults?: Record<string, any>, secret?: string): BuildOgImageUrlResult;
40
46
  /**
@@ -19,6 +19,7 @@ const RE_COMMA_PARAM_SEPARATOR = /,(?=\w+_)/;
19
19
  const RE_SIGNATURE_SUFFIX = /,s_[^,]+$/;
20
20
  const RE_NON_ASCII = /[^\u0000-\u007F]/;
21
21
  const RE_WINDOWS_RESERVED_FILENAME_CHARACTERS = /[<>:"/\\|?*\u0000-\u001F]/;
22
+ const RE_NOT_UNRESERVED = /[^\w.~-]/;
22
23
  const PARAM_ALIASES = {
23
24
  w: "width",
24
25
  h: "height",
@@ -85,19 +86,35 @@ function b64Decode(str) {
85
86
  }
86
87
  return Buffer.from(padded, "base64").toString("utf8");
87
88
  }
88
- function simpleHash(str) {
89
- let hash = 0;
90
- for (let i = 0; i < str.length; i++) {
91
- const char = str.charCodeAt(i);
92
- hash = (hash << 5) - hash + char;
93
- hash = hash & hash;
89
+ function canonicalJson(value) {
90
+ if (value === null || typeof value !== "object")
91
+ return JSON.stringify(value) ?? "null";
92
+ if (Array.isArray(value))
93
+ return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
94
+ const record = value;
95
+ const entries = Object.keys(record).sort().filter((key) => record[key] !== void 0).map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`);
96
+ return `{${entries.join(",")}}`;
97
+ }
98
+ const B64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
99
+ function base64UrlToHex(input) {
100
+ let hex = "";
101
+ let buffer = 0;
102
+ let bits = 0;
103
+ for (const char of input) {
104
+ buffer = buffer << 6 | B64URL_ALPHABET.indexOf(char);
105
+ bits += 6;
106
+ if (bits >= 8) {
107
+ bits -= 8;
108
+ hex += (buffer >> bits & 255).toString(16).padStart(2, "0");
109
+ }
94
110
  }
95
- return Math.abs(hash).toString(36);
111
+ return hex;
96
112
  }
97
113
  export function hashOgImageOptions(options, componentHash, version) {
98
- const { _path, _hash, ...hashableOptions } = options;
114
+ const { _path, _hash, ...rest } = options;
115
+ const hashableOptions = rest.component === "PageScreenshot" ? { ...rest, _path } : rest;
99
116
  const hashInput = componentHash || version ? [hashableOptions, componentHash || "", version || ""] : hashableOptions;
100
- return simpleHash(JSON.stringify(hashInput));
117
+ return base64UrlToHex(digest(canonicalJson(hashInput))).slice(0, 16);
101
118
  }
102
119
  export function encodeOgImageParams(options, defaults) {
103
120
  const parts = [];
@@ -233,7 +250,7 @@ export function decodeOgImageParams(encoded) {
233
250
  export function buildOgImageUrl(options, extension = "png", isStatic = false, defaults, secret) {
234
251
  const encoded = encodeOgImageParams(options, defaults);
235
252
  const prefix = isStatic ? "/_og/s" : "/_og/d";
236
- if (isStatic && (encoded.length > MAX_PATH_LENGTH || encoded.includes("%"))) {
253
+ if (isStatic && (encoded.length > MAX_PATH_LENGTH || RE_NOT_UNRESERVED.test(encoded))) {
237
254
  const hash = hashOgImageOptions(options);
238
255
  return {
239
256
  url: `${prefix}/o_${hash}.${extension}`,
@@ -31,6 +31,7 @@ export interface OgImageIconsData {
31
31
  height: number;
32
32
  }
33
33
  export interface OgImageRuntimeConfig {
34
+ browserEnabled: boolean;
34
35
  version: string;
35
36
  satoriOptions: SatoriOptions;
36
37
  resvgOptions: ResvgRenderOptions;
@@ -77,7 +78,7 @@ export interface OgImageRuntimeConfig {
77
78
  }
78
79
  export type RendererType = 'satori' | 'browser' | 'takumi';
79
80
  export type BrowserProvider = 'playwright' | 'chrome-launcher' | 'on-demand' | 'cloudflare';
80
- export type BrowserConfig = false | {
81
+ export type BrowserConfig = boolean | {
81
82
  provider: BrowserProvider;
82
83
  /** Required when provider is 'cloudflare' - the wrangler binding name */
83
84
  binding?: string;
@@ -3,26 +3,25 @@ import * as fs from 'node:fs';
3
3
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, rmSync } from 'node:fs';
4
4
  import { readFile, mkdir, writeFile, readdir } from 'node:fs/promises';
5
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
6
- import { resolvePath, useNuxt, getNuxtVersion, addTemplate, updateTemplates, directoryToURL, tryResolveModule, addTypeTemplate, defineNuxtModule, createResolver, addImports, hasNuxtModule, hasNuxtModuleCompatibility, getNuxtModuleVersion, addVitePlugin, addBuildPlugin, addServerHandler, addServerImports, addPlugin, addComponentsDir, addServerPlugin } from '@nuxt/kit';
6
+ import { resolvePath, useNuxt, getNuxtVersion, addTemplate, updateTemplates, directoryToURL, tryResolveModule, addTypeTemplate, defineNuxtModule, createResolver, addImports, hasNuxtModule, hasNuxtModuleCompatibility, getNuxtModuleVersion, addVitePlugin, addServerHandler, addServerImports, addPlugin, addComponentsDir, addServerPlugin } from '@nuxt/kit';
7
7
  import { defu } from 'defu';
8
8
  import { fnv1a64Base36 } from 'fnv1a-64';
9
- import { installNuxtSiteConfig } from 'nuxt-site-config/kit';
9
+ import { useSiteConfig, installNuxtSiteConfig } from 'nuxt-site-config/kit';
10
10
  import { resolveNitroPreset, renderNitroTypeAugmentations, setupNitroRuntimeCompatibility, getNuxtModuleOptions, isNuxtGenerate } from 'nuxtseo-shared/kit';
11
11
  import { join, basename, dirname, relative, isAbsolute, resolve } from 'pathe';
12
12
  import { readPackageJSON } from 'pkg-types';
13
- import { isCI, isAgent, hasTTY } from 'std-env';
14
13
  import MagicString from 'magic-string';
15
14
  import { parseAndWalk } from 'oxc-walker';
16
15
  import { resolveModulePath } from 'exsolve';
17
16
  import { logger } from '../../dist/runtime/logger.js';
18
17
  import { Launcher } from 'chrome-launcher';
18
+ import { isCI, isAgent, hasTTY } from 'std-env';
19
19
  import { detectPackageManager, addDependency } from 'nypm';
20
20
  import { setupDevToolsUI as setupDevToolsUI$1, setupDevToolsRpc } from 'nuxtseo-shared/devtools';
21
21
  import { createStorage } from 'unstorage';
22
22
  import fsDriver from 'unstorage/drivers/fs-lite';
23
23
  import { resolveColorMix, GRADIENT_COLOR_SPACE_RE, splitCssDeclarations } from '../../dist/runtime/server/og-image/utils/css.js';
24
24
  import { stripLiteral } from 'strip-literal';
25
- import { parseURL, parseQuery } from 'ufo';
26
25
  import { createUnplugin } from 'unplugin';
27
26
  import { parse } from '@vue/compiler-sfc';
28
27
  import { ofetch } from 'ofetch';
@@ -132,6 +131,7 @@ function isSupportedSatoriVersion(version) {
132
131
  const VALID_RENDERER_SUFFIXES = ["satori", "browser", "takumi"];
133
132
  const RE_RENDERER_SUFFIX_CI = /\.?(satori|browser|takumi)$/i;
134
133
  const RE_RENDERER_SUFFIX = /(Satori|Browser|Takumi)$/;
134
+ const RE_OG_IMAGE_PREFIX = /^OgImage/;
135
135
  const RE_LEGACY_SUFFIX = /-legacy$/;
136
136
  const RE_WHITESPACE = /\s+/;
137
137
  function getRendererFromFilename(filepath) {
@@ -167,6 +167,28 @@ function getRegisteredBaseNames(registeredPascalName) {
167
167
  names.push(stripped);
168
168
  return names;
169
169
  }
170
+ function matchesComponentName(registeredPascalName, inputName) {
171
+ const baseNames = getRegisteredBaseNames(registeredPascalName);
172
+ const { baseName } = parseComponentName(inputName);
173
+ const strippedBaseName = baseName.replace(RE_OG_IMAGE_PREFIX, "");
174
+ return baseNames.some(
175
+ (cBase) => cBase === baseName || cBase === strippedBaseName || cBase === `OgImage${baseName}` || cBase === `OgImage${strippedBaseName}`
176
+ );
177
+ }
178
+ function parseComponentName(name) {
179
+ for (const suffix of VALID_RENDERER_SUFFIXES) {
180
+ if (name.endsWith(`.${suffix}`)) {
181
+ return { baseName: name.slice(0, -(suffix.length + 1)), renderer: suffix };
182
+ }
183
+ }
184
+ for (const suffix of VALID_RENDERER_SUFFIXES) {
185
+ const pascalSuffix = suffix.charAt(0).toUpperCase() + suffix.slice(1);
186
+ if (name.endsWith(pascalSuffix)) {
187
+ return { baseName: name.slice(0, -pascalSuffix.length), renderer: suffix };
188
+ }
189
+ }
190
+ return { baseName: name, renderer: null };
191
+ }
170
192
 
171
193
  const NodeRuntime = {
172
194
  // node-server runtime
@@ -446,6 +468,12 @@ async function setupBuildHandler(config, resolve, getDetectedRenderers, getCompa
446
468
  metadata: getCompatibilityMeta()
447
469
  });
448
470
  nuxt.hooks.hook("nitro:init", async (nitro) => {
471
+ nitro.hooks.hook("prerender:config", (prerenderConfig) => {
472
+ if (prerenderConfig.virtual?.["playwright-core"]) {
473
+ const { "playwright-core": _, ...virtual } = prerenderConfig.virtual;
474
+ prerenderConfig.virtual = virtual;
475
+ }
476
+ });
449
477
  await applyNitroPresetCompatibility(nitro.options, {
450
478
  compatibility: config.compatibility?.runtime,
451
479
  resolve,
@@ -651,6 +679,25 @@ const PROVIDER_DEPENDENCIES = [
651
679
  }
652
680
  }
653
681
  ];
682
+ function resolveAutoDetectedProvider(input) {
683
+ if (input.hasUserComponents)
684
+ return { preferred: null, noRenderer: false };
685
+ const preferred = input.installedProviders.find((p) => p === "takumi") ?? input.installedProviders[0] ?? null;
686
+ if (preferred)
687
+ return { preferred, noRenderer: false };
688
+ return { preferred: null, noRenderer: true };
689
+ }
690
+ function getMissingRendererMessage(renderer, installSpecs) {
691
+ return `The ${renderer} renderer is missing dependencies, so templates that use it cannot render. Install them with: npx nypm add ${installSpecs.join(" ")}`;
692
+ }
693
+ function resolveMissingRendererAction(input) {
694
+ if (!input.dev)
695
+ return { _tag: "CheckUsage" };
696
+ if (input.interactive)
697
+ return { _tag: "AskToInstall" };
698
+ return { _tag: "Report", message: getMissingRendererMessage(input.renderer, input.installSpecs) };
699
+ }
700
+ const NO_RENDERER_MESSAGE = `No OG image renderer is installed, so OG images are off. To add one, run: npx nuxt-og-image enable takumi`;
654
701
  async function getInstalledProviders() {
655
702
  const installed = [];
656
703
  for (const provider of PROVIDER_DEPENDENCIES) {
@@ -765,15 +812,6 @@ function canPromptInteractively(env = {
765
812
  }) {
766
813
  return env.hasTTY && env.hasStdinTTY && !env.isAgent && !env.isCI;
767
814
  }
768
- async function promptForRendererSelection() {
769
- logger.info("Welcome to Nuxt OG Image! No renderer dependencies detected.");
770
- const renderer = await logger.prompt("Which renderer would you like to use?", {
771
- type: "select",
772
- options: PROVIDER_DEPENDENCIES.map((p) => p.name),
773
- initial: "takumi"
774
- });
775
- return renderer || "takumi";
776
- }
777
815
  async function validateProviderSetup(renderer, compatibility) {
778
816
  const issues = [];
779
817
  const binding = getRecommendedBinding(renderer, compatibility);
@@ -2774,6 +2812,11 @@ async function importOptionalPeer(id, resolvedPath, usage) {
2774
2812
  }
2775
2813
 
2776
2814
  const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1e3;
2815
+ function getMissingSiteUrlWarning(input) {
2816
+ if (input.siteUrl || input.prerenderedImageCount === 0)
2817
+ return null;
2818
+ return `Prerendered ${input.prerenderedImageCount} OG image${input.prerenderedImageCount > 1 ? "s" : ""} without a site URL, so each og:image is a relative path such as \`/_og/s/...\`. Social crawlers need an absolute URL. Set \`site.url\` in nuxt.config.ts or the \`NUXT_SITE_URL\` environment variable.`;
2819
+ }
2777
2820
  function setupPrerenderHandler(options, resolve, getDetectedRenderers, getCompatibilityMeta = () => ({}), nuxt = useNuxt()) {
2778
2821
  nuxt.hooks.hook("nitro:init", async (nitro) => {
2779
2822
  nitro.hooks.hook("prerender:config", async (nitroConfig) => {
@@ -2787,6 +2830,16 @@ function setupPrerenderHandler(options, resolve, getDetectedRenderers, getCompat
2787
2830
  nitroConfig.prerender.ignore.push("/_og/r/");
2788
2831
  }
2789
2832
  });
2833
+ let prerenderedImageCount = 0;
2834
+ nitro.hooks.hook("prerender:route", (route) => {
2835
+ if (!route.error && route.route.includes("/_og/s/"))
2836
+ prerenderedImageCount++;
2837
+ });
2838
+ nitro.hooks.hook("prerender:done", () => {
2839
+ const warning = getMissingSiteUrlWarning({ siteUrl: useSiteConfig(nuxt).url, prerenderedImageCount });
2840
+ if (warning)
2841
+ logger.warn(warning);
2842
+ });
2790
2843
  const orphanedOgHashes = [];
2791
2844
  nitro.hooks.hook("prerender:generate", (route) => {
2792
2845
  if (!route.error || route.error.statusCode !== 404)
@@ -2874,68 +2927,138 @@ function resolveSigningSecret(configSecret, envSecret, generate) {
2874
2927
  return { secret: generate(), hasExplicit: false, optOut: false, generated: true };
2875
2928
  }
2876
2929
 
2877
- function isVue(id, opts = {}) {
2878
- const { search } = parseURL(decodeURIComponent(pathToFileURL(id).href));
2879
- if (id.endsWith(".vue") && !search) {
2880
- return true;
2881
- }
2882
- if (!search) {
2883
- return false;
2884
- }
2885
- const query = parseQuery(search);
2886
- if (query.nuxt_component) {
2887
- return false;
2930
+ const RE_NAMED_CALL = /\b(defineOgImage(?:Component)?)\s*\(\s*(['"`])([\w.-]+)\2/g;
2931
+ const RE_SCREENSHOT_CALL = /\bdefineOgImageScreenshot\s*\(/g;
2932
+ const RE_ANY_CALL = /\bdefineOgImage(?:Component)?\s*\(/g;
2933
+ const RE_VUE_OR_SCRIPT = /\.(?:vue|[cm]?[jt]sx?)$/;
2934
+ function findOgImageUsageErrors(code, ctx) {
2935
+ if (!code.includes("defineOgImage"))
2936
+ return [];
2937
+ const stripped = stripLiteral(code);
2938
+ const errors = [];
2939
+ for (const match of code.matchAll(RE_NAMED_CALL)) {
2940
+ if (!stripped.startsWith(match[1], match.index))
2941
+ continue;
2942
+ const name = match[3];
2943
+ const { renderer } = parseComponentName(name);
2944
+ const matches = ctx.components.filter(
2945
+ (c) => matchesComponentName(c.pascalName, name) && (!renderer || c.renderer === renderer)
2946
+ );
2947
+ if (matches.length > 0 && matches.every((c) => c.category === "community"))
2948
+ errors.push(`\`${match[1]}('${name}')\` uses a community template that is not ejected. Production builds do not include community templates. Run: npx nuxt-og-image eject ${name}`);
2949
+ }
2950
+ if (!ctx.browserEnabled && RE_SCREENSHOT_CALL.test(stripped))
2951
+ errors.push("`defineOgImageScreenshot()` needs the browser renderer. Set ogImage.browser to true or a browser provider in nuxt.config.ts, or remove the call.");
2952
+ RE_SCREENSHOT_CALL.lastIndex = 0;
2953
+ return errors;
2954
+ }
2955
+ function collectOgImageReferences(code) {
2956
+ const refs = { names: [], hasDynamicName: false, usesDefault: false };
2957
+ if (!code.includes("defineOgImage"))
2958
+ return refs;
2959
+ const stripped = stripLiteral(code);
2960
+ const literalCalls = /* @__PURE__ */ new Set();
2961
+ for (const match of code.matchAll(RE_NAMED_CALL)) {
2962
+ if (!stripped.startsWith(match[1], match.index))
2963
+ continue;
2964
+ literalCalls.add(match.index);
2965
+ refs.names.push(match[3]);
2888
2966
  }
2889
- if (query.macro && (search === "?macro=true" || !opts.type || opts.type.includes("script"))) {
2890
- return true;
2967
+ for (const match of stripped.matchAll(RE_ANY_CALL)) {
2968
+ if (!literalCalls.has(match.index))
2969
+ refs.hasDynamicName = true;
2891
2970
  }
2892
- const type = "setup" in query ? "script" : query.type;
2893
- if (!("vue" in query) || opts.type && !opts.type.includes(type)) {
2894
- return false;
2971
+ return refs;
2972
+ }
2973
+ function collectRouteRuleReferences(routeRules) {
2974
+ const refs = { names: [], hasDynamicName: false, usesDefault: false };
2975
+ for (const rule of Object.values(routeRules || {})) {
2976
+ const ogImage = rule?.ogImage;
2977
+ if (!ogImage || typeof ogImage !== "object")
2978
+ continue;
2979
+ const component = ogImage.component;
2980
+ if (typeof component === "string")
2981
+ refs.names.push(component);
2982
+ else
2983
+ refs.usesDefault = true;
2984
+ }
2985
+ return refs;
2986
+ }
2987
+ function installHint(missing) {
2988
+ return `npx nypm add ${missing.join(" ")}`;
2989
+ }
2990
+ function checkMissingRendererUsage(input) {
2991
+ const errors = [];
2992
+ const warnings = [];
2993
+ const appComponents = input.components.filter((c) => c.category !== "community");
2994
+ const isMissing = (c) => !!input.missingRenderers[c.renderer]?.length;
2995
+ if (!appComponents.some(isMissing))
2996
+ return { errors, warnings };
2997
+ const used = /* @__PURE__ */ new Set();
2998
+ for (const name of input.references.names) {
2999
+ const { renderer } = parseComponentName(name);
3000
+ const matches = appComponents.filter((c) => matchesComponentName(c.pascalName, name) && (!renderer || c.renderer === renderer));
3001
+ for (const c of matches)
3002
+ used.add(c);
3003
+ const broken = matches.find(isMissing);
3004
+ if (broken && matches.every(isMissing))
3005
+ errors.push(`\`defineOgImage('${name}')\` uses ${broken.path || broken.pascalName}, but the ${broken.renderer} renderer is not installed. Run: ${installHint(input.missingRenderers[broken.renderer])}`);
3006
+ }
3007
+ const fallback = appComponents[0];
3008
+ if (input.references.usesDefault && fallback) {
3009
+ used.add(fallback);
3010
+ if (isMissing(fallback))
3011
+ errors.push(`A route rule renders the default template ${fallback.path || fallback.pascalName}, but the ${fallback.renderer} renderer is not installed. Run: ${installHint(input.missingRenderers[fallback.renderer])}`);
3012
+ }
3013
+ const unused = appComponents.filter((c) => isMissing(c) && !used.has(c));
3014
+ if (unused.length > 0) {
3015
+ const lines = unused.map((c) => ` ${c.path || c.pascalName}: ${installHint(input.missingRenderers[c.renderer])}`);
3016
+ const why = input.references.hasDynamicName ? "The build found a component name built at runtime, so it cannot tell whether they render. If one does, that image returns 500." : "No page or route rule uses them, so the build continues.";
3017
+ warnings.push(`These OG image templates need a renderer that is not installed:
3018
+ ${lines.join("\n")}
3019
+ ${why}`);
2895
3020
  }
2896
- return true;
3021
+ return { errors: [...new Set(errors)], warnings };
2897
3022
  }
2898
- const VUE_RE = /\.vue(?:\?|$)/;
2899
- const JS_RE = /\.[cm]?[jt]sx?(?:\?|$)/;
2900
- const ISLANDS_RE = /components\.islands\.mjs(?:\?|$)/;
2901
- const COMPOSABLE_CODE_MARKER = "defineOgImage";
2902
- const TreeShakeComposablesPlugin = createUnplugin(() => {
2903
- const composableNames = [
2904
- "defineOgImage",
2905
- "defineOgImageComponent",
2906
- "defineOgImageScreenshot"
2907
- ];
2908
- const regexp = `(^\\s*)(${composableNames.join("|")})(?=\\((?!\\) \\{))`;
2909
- const COMPOSABLE_RE = new RegExp(regexp, "m");
2910
- const COMPOSABLE_RE_GLOBAL = new RegExp(regexp, "gm");
3023
+ const OgImageUsageCheckPlugin = createUnplugin((options) => {
3024
+ const errors = [];
3025
+ const references = { names: [], hasDynamicName: false, usesDefault: false };
2911
3026
  return {
2912
- name: "nuxt-og-image:zero-runtime:transform",
2913
- enforce: "pre",
2914
- transform: {
2915
- filter: {
2916
- // @todo re-implement composable tree-shaking for island files
2917
- id: { include: [VUE_RE, JS_RE], exclude: [ISLANDS_RE] },
2918
- code: COMPOSABLE_CODE_MARKER
2919
- },
2920
- handler(code, id) {
2921
- if (VUE_RE.test(id) && !isVue(id, { type: ["script"] })) {
2922
- return;
2923
- }
2924
- if (!COMPOSABLE_RE.test(code)) {
2925
- return;
2926
- }
2927
- const s = new MagicString(code);
2928
- for (const match of stripLiteral(code).matchAll(COMPOSABLE_RE_GLOBAL)) {
2929
- s.overwrite(match.index, match.index + match[0].length, `${match[1]} import.meta.prerender && ${match[2]}`);
2930
- }
2931
- if (!s.hasChanged()) {
2932
- return;
3027
+ name: "nuxt-og-image:usage-check",
3028
+ buildStart() {
3029
+ errors.length = 0;
3030
+ references.names.length = 0;
3031
+ references.hasDynamicName = false;
3032
+ references.usesDefault = false;
3033
+ },
3034
+ transform(code, id) {
3035
+ const [path] = id.split("?");
3036
+ if (!path || !RE_VUE_OR_SCRIPT.test(path) || path.includes("/node_modules/") || options.ignoreDirs.some((dir) => path.startsWith(dir)))
3037
+ return;
3038
+ for (const error of findOgImageUsageErrors(code, options.getContext()))
3039
+ errors.push(`${path}: ${error}`);
3040
+ const found = collectOgImageReferences(code);
3041
+ references.names.push(...found.names);
3042
+ references.hasDynamicName ||= found.hasDynamicName;
3043
+ },
3044
+ buildEnd() {
3045
+ const ctx = options.getContext();
3046
+ const routeRules = ctx.routeRuleReferences;
3047
+ const renderers = checkMissingRendererUsage({
3048
+ components: ctx.components,
3049
+ missingRenderers: ctx.missingRenderers || {},
3050
+ references: {
3051
+ names: [...references.names, ...routeRules?.names || []],
3052
+ hasDynamicName: references.hasDynamicName,
3053
+ usesDefault: !!routeRules?.usesDefault
2933
3054
  }
2934
- return {
2935
- code: s.toString(),
2936
- map: s.generateMap({ hires: true })
2937
- };
2938
- }
3055
+ });
3056
+ errors.push(...renderers.errors);
3057
+ for (const warning of renderers.warnings)
3058
+ options.warn(warning);
3059
+ if (errors.length > 0)
3060
+ throw new Error(`[nuxt-og-image] OG images in this build cannot render:
3061
+ ${[...new Set(errors)].map((e) => ` ${e}`).join("\n")}`);
2939
3062
  }
2940
3063
  };
2941
3064
  });
@@ -3536,7 +3659,7 @@ function buildEmojiSvg(emoji, icons, emojiSet) {
3536
3659
  const body = wrapDefsElements(iconData.body || "");
3537
3660
  const width = iconData.width || icons.width || 128;
3538
3661
  const height = iconData.height || icons.height || 128;
3539
- let svg = `<span style="display:flex"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="1em" height="1em">${body}</svg></span>`;
3662
+ let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="1em" height="1em">${body}</svg>`;
3540
3663
  svg = makeIdsUnique(svg);
3541
3664
  return svg;
3542
3665
  }
@@ -3561,7 +3684,7 @@ async function fetchEmojiSvg(emoji, emojiSet) {
3561
3684
  const viewBox = viewBoxMatch ? viewBoxMatch[1] : "0 0 128 128";
3562
3685
  const bodyMatch = svg.match(RE_SVG_BODY);
3563
3686
  const body = bodyMatch ? bodyMatch[1] : "";
3564
- let result = `<span style="display:flex"><svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" width="1em" height="1em">${body}</svg></span>`;
3687
+ let result = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" width="1em" height="1em">${body}</svg>`;
3565
3688
  result = makeIdsUnique(result);
3566
3689
  emojiFetchCache.set(cacheKey, result);
3567
3690
  if (emojiFetchCache.size > 200)
@@ -4532,21 +4655,8 @@ async function onInstall(nuxt) {
4532
4655
  }
4533
4656
  if (nuxt.options._prepare)
4534
4657
  return;
4535
- if (isCI) {
4536
- const installedProviders2 = await getInstalledProviders();
4537
- if (installedProviders2.length === 0) {
4538
- throw new Error(
4539
- `[nuxt-og-image] No OG image provider dependencies found. Install a provider before running in CI:
4540
- npm add ${TAKUMI_CORE_PACKAGE} # for takumi
4541
- npm add @resvg/resvg-js satori yoga-wasm-web # for satori
4542
- npm add playwright-core # for browser
4543
- See: https://nuxtseo.com/og-image/getting-started`
4544
- );
4545
- }
4546
- return;
4547
- }
4548
- if (!canPromptInteractively()) {
4549
- logger.info("Skipping interactive onboarding (non-interactive environment). Defaulting to the takumi renderer.");
4658
+ if (isCI || !canPromptInteractively()) {
4659
+ logger.info("Skipping interactive onboarding (non-interactive environment).");
4550
4660
  return;
4551
4661
  }
4552
4662
  logger.info("");
@@ -5443,10 +5553,21 @@ ${familyInfo}`);
5443
5553
  takumi: false
5444
5554
  }
5445
5555
  });
5446
- if (!nuxt.options.dev) {
5447
- addBuildPlugin(TreeShakeComposablesPlugin, { server: true, client: true });
5556
+ if (!nuxt.options.dev)
5448
5557
  setRuntimeAlias("#og-image-cache", resolve("./runtime/server/og-image/cache/mock"));
5449
- }
5558
+ }
5559
+ const missingRenderers = {};
5560
+ if (!nuxt.options.dev && !nuxt.options._prepare) {
5561
+ addVitePlugin(OgImageUsageCheckPlugin.vite({
5562
+ getContext: () => ({
5563
+ components: ogImageComponentCtx.components,
5564
+ browserEnabled: !!config.browser,
5565
+ missingRenderers,
5566
+ routeRuleReferences: collectRouteRuleReferences(nuxt.options.routeRules)
5567
+ }),
5568
+ warn: (message) => logger.warn(message),
5569
+ ignoreDirs: [resolve("./runtime"), nuxt.options.buildDir]
5570
+ }), { server: false });
5450
5571
  }
5451
5572
  const basePath = config.zeroRuntime ? "./runtime/server/routes/__zero-runtime" : "./runtime/server/routes";
5452
5573
  let publicDirAbs = nuxt.options.dir.public;
@@ -5600,7 +5721,8 @@ ${familyInfo}`);
5600
5721
  }
5601
5722
  addImports({
5602
5723
  name,
5603
- from: resolve(`./runtime/app/composables/${name}`)
5724
+ // zeroRuntime production builds get wrappers that only run during prerender
5725
+ from: config.zeroRuntime && !nuxt.options.dev ? resolve("./runtime/app/composables/zero-runtime") : resolve(`./runtime/app/composables/${name}`)
5604
5726
  });
5605
5727
  });
5606
5728
  addImports({
@@ -5657,19 +5779,18 @@ ${familyInfo}`);
5657
5779
  }
5658
5780
  }
5659
5781
  }
5782
+ if (config.browser)
5783
+ ogImageComponentCtx.detectedRenderers.add("browser");
5660
5784
  if (!nuxt.options._prepare && !hasUserComponents) {
5661
- const installedProviders = await getInstalledProviders();
5662
- const preferred = installedProviders.find((p) => p.provider === "takumi") || installedProviders[0];
5785
+ const { preferred, noRenderer } = resolveAutoDetectedProvider({
5786
+ hasUserComponents,
5787
+ installedProviders: (await getInstalledProviders()).map((p) => p.provider)
5788
+ });
5663
5789
  if (preferred) {
5664
- ogImageComponentCtx.detectedRenderers.add(preferred.provider);
5665
- logger.debug(`Using ${preferred.provider} renderer`);
5666
- } else if (nuxt.options.dev && !nuxt.options._prepare && canPromptInteractively()) {
5667
- const renderer = await promptForRendererSelection();
5668
- ogImageComponentCtx.detectedRenderers.add(renderer);
5669
- logger.debug(`Using ${renderer} renderer`);
5670
- } else {
5671
- ogImageComponentCtx.detectedRenderers.add("takumi");
5672
- logger.warn(`No OG image renderer dependency detected. Defaulting to \`takumi\` (non-interactive environment). Install \`${TAKUMI_CORE_PACKAGE}\`, or add a renderer component (e.g. components/OgImage/Default.satori.vue) to choose explicitly.`);
5790
+ ogImageComponentCtx.detectedRenderers.add(preferred);
5791
+ logger.debug(`Using ${preferred} renderer`);
5792
+ } else if (noRenderer) {
5793
+ logger.warn(NO_RENDERER_MESSAGE);
5673
5794
  }
5674
5795
  }
5675
5796
  const availableRenderers = /* @__PURE__ */ new Set();
@@ -5678,25 +5799,22 @@ ${familyInfo}`);
5678
5799
  for (const renderer of ogImageComponentCtx.detectedRenderers) {
5679
5800
  const binding = getRecommendedBinding(renderer, targetCompatibility);
5680
5801
  const missing = await getMissingDependencies(renderer, binding);
5681
- if (missing.length === 0) {
5802
+ if (missing.length === 0 || renderer === "browser") {
5682
5803
  availableRenderers.add(renderer);
5683
- } else if (nuxt.options.dev && !nuxt.options._prepare) {
5804
+ } else {
5684
5805
  const installSpecs = await getMissingDependencyInstallSpecs(renderer, binding);
5685
- logger.warn(`${renderer} renderer requires: ${installSpecs.join(", ")}`);
5686
- const { success } = await ensureProviderDependencies(renderer, binding, nuxt);
5687
- if (success) {
5688
- availableRenderers.add(renderer);
5689
- } else if (isAgent) {
5690
- throw new Error(`[nuxt-og-image] Failed to install ${renderer} dependencies: ${missing.join(", ")}. Install manually: npx nypm add ${installSpecs.join(" ")}`);
5806
+ const action = resolveMissingRendererAction({ renderer, installSpecs, dev: nuxt.options.dev, interactive: canPromptInteractively() });
5807
+ if (action._tag === "CheckUsage") {
5808
+ missingRenderers[renderer] = installSpecs;
5809
+ } else if (action._tag === "Report") {
5810
+ logger.error(action.message);
5691
5811
  } else {
5692
- logger.error(`Failed to install ${renderer} dependencies. Templates using this renderer won't work.`);
5812
+ const accepted = await logger.prompt(`The ${renderer} renderer needs ${installSpecs.join(", ")}. Install now?`, { type: "confirm", initial: true });
5813
+ if (accepted === true && (await ensureProviderDependencies(renderer, binding, nuxt)).success)
5814
+ availableRenderers.add(renderer);
5815
+ else
5816
+ logger.error(getMissingRendererMessage(renderer, installSpecs));
5693
5817
  }
5694
- } else if (isAgent) {
5695
- const installSpecs = await getMissingDependencyInstallSpecs(renderer, binding);
5696
- throw new Error(`[nuxt-og-image] ${renderer} renderer missing dependencies: ${missing.join(", ")}. Install with: npx nypm add ${installSpecs.join(" ")}`);
5697
- } else {
5698
- const installSpecs = await getMissingDependencyInstallSpecs(renderer, binding);
5699
- logger.error(`${renderer} renderer missing dependencies: ${missing.join(", ")}. Install with: npx nypm add ${installSpecs.join(" ")}`);
5700
5818
  }
5701
5819
  if (renderer === "satori" && availableRenderers.has(renderer) && binding !== "node") {
5702
5820
  logger.warn("ReSVG native binding not available. Falling back to WASM version, this may slow down PNG rendering.");
@@ -6075,6 +6193,7 @@ export const staticFontCacheDir = ${JSON.stringify(getStaticFontCacheDir(nuxt.op
6075
6193
  // @ts-expect-error runtime type
6076
6194
  isNuxtContentDocumentDriven: !!nuxt.options.content?.documentDriven,
6077
6195
  cssFramework: cssFramework || "none",
6196
+ browserEnabled: !!config.browser,
6078
6197
  // Browser renderer config for cloudflare binding access
6079
6198
  browser: typeof config.browser === "object" ? {
6080
6199
  provider: config.browser.provider,
@@ -6109,6 +6228,7 @@ export const staticFontCacheDir = ${JSON.stringify(getStaticFontCacheDir(nuxt.op
6109
6228
  "nuxt-og-image": {
6110
6229
  defaults: runtimeConfig.defaults,
6111
6230
  includeTwitter: runtimeConfig.includeTwitter,
6231
+ browserEnabled: runtimeConfig.browserEnabled,
6112
6232
  hasServerRuntime: !nuxt.options._generate && !nuxt.options.nitro?.static
6113
6233
  }
6114
6234
  };
@@ -6123,7 +6243,7 @@ export const staticFontCacheDir = ${JSON.stringify(getStaticFontCacheDir(nuxt.op
6123
6243
  }
6124
6244
  };
6125
6245
  });
6126
- const getDetectedRenderers = () => ogImageComponentCtx.detectedRenderers;
6246
+ const getDetectedRenderers = () => new Set([...ogImageComponentCtx.detectedRenderers].filter((r) => !missingRenderers[r]));
6127
6247
  const getCompatibilityMeta = () => runtimeCompatibilityMeta;
6128
6248
  if (nuxt.options.dev) {
6129
6249
  await setupDevHandler(config, resolver, getDetectedRenderers, getCompatibilityMeta);
@@ -6167,6 +6287,8 @@ export const staticFontCacheDir = ${JSON.stringify(getStaticFontCacheDir(nuxt.op
6167
6287
  setRuntimeAlias("#og-image/types", resolve("./runtime/types"));
6168
6288
  if (nuxt.options.build)
6169
6289
  addServerPlugin(resolve("./runtime/server/plugins/prerender"));
6290
+ if (nuxt.options.dev)
6291
+ addServerPlugin(resolve("./runtime/server/plugins/auto-eject"));
6170
6292
  setupPrerenderHandler(config, resolver, getDetectedRenderers, getCompatibilityMeta);
6171
6293
  if (nuxt.options.dev && hasWarnings()) {
6172
6294
  nuxt.hook("ready", () => {