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.
package/README.md CHANGED
@@ -7,6 +7,14 @@
7
7
  [![License][license-src]][license-href]
8
8
  [![Nuxt][nuxt-src]][nuxt-href]
9
9
 
10
+ <a href="https://skilld.dev/gh/nuxt-modules/og-image">
11
+ <picture>
12
+ <source media="(prefers-color-scheme: dark)" srcset="https://skilld.dev/b/nuxt-modules/og-image?theme=dark">
13
+ <source media="(prefers-color-scheme: light)" srcset="https://skilld.dev/b/nuxt-modules/og-image?theme=light">
14
+ <img alt="Skill repository on skilld.dev" src="https://skilld.dev/b/nuxt-modules/og-image?theme=light">
15
+ </picture>
16
+ </a>
17
+
10
18
  Nuxt OG Image allows you to easily generate OG Images using either Vue components or screenshots of your pages.
11
19
 
12
20
  OG Images are known to improve click-through rates on social media.
@@ -42,10 +50,7 @@ npx nuxi@latest module add og-image
42
50
  ```
43
51
 
44
52
  > [!TIP]
45
- > Generate an Agent Skill for this package using [skilld](https://github.com/harlan-zw/skilld):
46
- > ```bash
47
- > npx skilld add nuxt-og-image
48
- > ```
53
+ > Using an AI agent? Get the nuxt-og-image Skill on [skilld.dev/gh/nuxt-modules/og-image](https://skilld.dev/gh/nuxt-modules/og-image).
49
54
 
50
55
  💡 Built your OG images? Check how they render across Twitter, Facebook, LinkedIn and Slack with the free [Social Share Debugger](https://nuxtseo.com/tools/social-share-debugger), or monitor them site-wide with [Nuxt SEO Pro](https://nuxtseo.com/pro).
51
56
 
@@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises';
2
2
  import { pathToFileURL } from 'node:url';
3
3
  import { resolveModulePath } from 'exsolve';
4
4
  import { dirname, join } from 'pathe';
5
- import { e as extractVariantBaseClasses, r as resolveVariantPrefixes, h as resolveExtractedVars, l as resolveVarsDeep, s as simplifyCss, p as postProcessStyles, m as loadLightningCss, a as extractCssVars, c as extractUniversalVars, d as extractPerClassVars, g as extractVarsFromCss, f as extractClassStyles } from '../shared/nuxt-og-image.CTD0ZPNd.mjs';
5
+ import { e as extractVariantBaseClasses, r as resolveVariantPrefixes, h as resolveExtractedVars, l as resolveVarsDeep, s as simplifyCss, p as postProcessStyles, m as loadLightningCss, a as extractCssVars, c as extractUniversalVars, d as extractPerClassVars, g as extractVarsFromCss, f as extractClassStyles } from '../shared/nuxt-og-image.DWcVJOVy.mjs';
6
6
  import 'node:crypto';
7
7
  import 'node:fs';
8
8
  import '@nuxt/kit';
@@ -11,18 +11,17 @@ import 'fnv1a-64';
11
11
  import 'nuxt-site-config/kit';
12
12
  import 'nuxtseo-shared/kit';
13
13
  import 'pkg-types';
14
- import 'std-env';
15
14
  import 'magic-string';
16
15
  import 'oxc-walker';
17
16
  import '../../dist/runtime/logger.js';
18
17
  import 'chrome-launcher';
18
+ import 'std-env';
19
19
  import 'nypm';
20
20
  import 'nuxtseo-shared/devtools';
21
21
  import 'unstorage';
22
22
  import 'unstorage/drivers/fs-lite';
23
23
  import '../../dist/runtime/server/og-image/utils/css.js';
24
24
  import 'strip-literal';
25
- import 'ufo';
26
25
  import 'unplugin';
27
26
  import '@vue/compiler-sfc';
28
27
  import 'ofetch';
@@ -1,8 +1,8 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { defu } from 'defu';
3
3
  import { logger } from '../../dist/runtime/logger.js';
4
- import { e as extractVariantBaseClasses, s as simplifyCss, a as extractCssVars, b as extractPropertyInitialValues, c as extractUniversalVars, d as extractPerClassVars, f as extractClassStyles, p as postProcessStyles, r as resolveVariantPrefixes, i as importOptionalPeer, g as extractVarsFromCss, h as resolveExtractedVars } from '../shared/nuxt-og-image.CTD0ZPNd.mjs';
5
- export { j as importResolvedModule, k as resolveOptionalModulePath } from '../shared/nuxt-og-image.CTD0ZPNd.mjs';
4
+ import { e as extractVariantBaseClasses, s as simplifyCss, a as extractCssVars, b as extractPropertyInitialValues, c as extractUniversalVars, d as extractPerClassVars, f as extractClassStyles, p as postProcessStyles, r as resolveVariantPrefixes, i as importOptionalPeer, g as extractVarsFromCss, h as resolveExtractedVars } from '../shared/nuxt-og-image.DWcVJOVy.mjs';
5
+ export { j as importResolvedModule, k as resolveOptionalModulePath } from '../shared/nuxt-og-image.DWcVJOVy.mjs';
6
6
  import 'node:crypto';
7
7
  import 'node:fs';
8
8
  import 'node:url';
@@ -12,18 +12,17 @@ import 'nuxt-site-config/kit';
12
12
  import 'nuxtseo-shared/kit';
13
13
  import 'pathe';
14
14
  import 'pkg-types';
15
- import 'std-env';
16
15
  import 'magic-string';
17
16
  import 'oxc-walker';
18
17
  import 'exsolve';
19
18
  import 'chrome-launcher';
19
+ import 'std-env';
20
20
  import 'nypm';
21
21
  import 'nuxtseo-shared/devtools';
22
22
  import 'unstorage';
23
23
  import 'unstorage/drivers/fs-lite';
24
24
  import '../../dist/runtime/server/og-image/utils/css.js';
25
25
  import 'strip-literal';
26
- import 'ufo';
27
26
  import 'unplugin';
28
27
  import '@vue/compiler-sfc';
29
28
  import 'ofetch';
package/dist/cli.mjs CHANGED
@@ -13,6 +13,67 @@ import 'magicast';
13
13
  import 'magicast/helpers';
14
14
  import '../dist/runtime/logger.js';
15
15
 
16
+ const RENDERER_PREFERENCE = ["takumi", "satori", "browser"];
17
+ const RE_TEMPLATE_FILE = /^(.+)\.(takumi|satori|browser)\.vue$/;
18
+ const RENDERER_PACKAGES = {
19
+ takumi: ["@takumi-rs/core", "@takumi-rs/wasm"],
20
+ satori: ["satori"],
21
+ browser: ["playwright-core"]
22
+ };
23
+ function parseTemplateFile(file) {
24
+ const match = RE_TEMPLATE_FILE.exec(file);
25
+ if (!match)
26
+ return null;
27
+ return { baseName: match[1], renderer: match[2] };
28
+ }
29
+ function listEjectableNames(templateFiles) {
30
+ const names = /* @__PURE__ */ new Set();
31
+ for (const file of templateFiles) {
32
+ const parsed = parseTemplateFile(file);
33
+ if (!parsed)
34
+ continue;
35
+ names.add(parsed.baseName);
36
+ names.add(`${parsed.baseName}.${parsed.renderer}`);
37
+ }
38
+ return [...names].sort();
39
+ }
40
+ function resolveEjectTemplate(name, ctx) {
41
+ const requested = name.replace(/\.vue$/, "");
42
+ const variants = ctx.templateFiles.map((file) => ({ file, parsed: parseTemplateFile(file) })).filter((v) => !!v.parsed);
43
+ const exact = variants.find((v) => `${v.parsed.baseName}.${v.parsed.renderer}` === requested);
44
+ if (exact)
45
+ return { _tag: "Ok", file: exact.file, renderer: exact.parsed.renderer, reason: "exact" };
46
+ const candidates = variants.filter((v) => v.parsed.baseName === requested);
47
+ if (candidates.length === 0)
48
+ return { _tag: "NotFound", name, available: listEjectableNames(ctx.templateFiles) };
49
+ if (candidates.length === 1)
50
+ return { _tag: "Ok", file: candidates[0].file, renderer: candidates[0].parsed.renderer, reason: "only-variant" };
51
+ const pick = (renderers, reason) => {
52
+ for (const renderer of RENDERER_PREFERENCE) {
53
+ if (!renderers.includes(renderer))
54
+ continue;
55
+ const match = candidates.find((c) => c.parsed.renderer === renderer);
56
+ if (match)
57
+ return { _tag: "Ok", file: match.file, renderer, reason };
58
+ }
59
+ return null;
60
+ };
61
+ return pick(ctx.appRenderers, "app-templates") ?? pick(ctx.installedRenderers, "installed") ?? pick(RENDERER_PREFERENCE, "default");
62
+ }
63
+ function detectInstalledRenderers(pkg) {
64
+ const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
65
+ return RENDERER_PREFERENCE.filter((r) => RENDERER_PACKAGES[r].some((p) => p in deps));
66
+ }
67
+ function detectAppRenderers(files) {
68
+ const found = /* @__PURE__ */ new Set();
69
+ for (const file of files) {
70
+ const parsed = parseTemplateFile(file.split("/").pop() || file);
71
+ if (parsed)
72
+ found.add(parsed.renderer);
73
+ }
74
+ return RENDERER_PREFERENCE.filter((r) => found.has(r));
75
+ }
76
+
16
77
  const __dirname$1 = dirname(fileURLToPath(import.meta.url));
17
78
  const communityDir = resolve(__dirname$1, "runtime/app/components/Templates/Community");
18
79
  const RE_RENDERER_SUFFIX = /\.(satori|browser|takumi)\.vue$/;
@@ -195,28 +256,30 @@ function listTemplates() {
195
256
  const templates = [...new Set(
196
257
  readdirSync(communityDir).filter((f) => f.endsWith(".vue")).map(getBaseName)
197
258
  )];
259
+ const variants = listEjectableNames(readdirSync(communityDir)).filter((n) => n.includes("."));
198
260
  p.intro("Community Templates");
199
261
  p.note(templates.map((t) => `\u2022 ${t}`).join("\n"), "Available");
200
262
  p.log.info("Usage: npx nuxt-og-image eject <template-name>");
263
+ p.log.info(`To pick a renderer, add its suffix: ${variants.slice(0, 2).join(", ")}`);
201
264
  p.outro("");
202
265
  }
203
- function findTemplateFile(name) {
204
- const files = readdirSync(communityDir).filter((f) => f.endsWith(".vue"));
205
- const match = files.find((f) => getBaseName(f) === name);
206
- return match || null;
207
- }
208
- function ejectTemplate(name, targetDir) {
209
- const templateFile = findTemplateFile(name);
210
- if (!templateFile) {
266
+ function ejectTemplate(name, targetDir, cwd = process.cwd()) {
267
+ const appComponents = [.../* @__PURE__ */ new Set([cwd, targetDir])].flatMap(findOgImageComponents);
268
+ const resolution = resolveEjectTemplate(name, {
269
+ templateFiles: readdirSync(communityDir).filter((f) => f.endsWith(".vue")),
270
+ appRenderers: detectAppRenderers(appComponents),
271
+ installedRenderers: detectInstalledRenderers(readPackageJson(cwd))
272
+ });
273
+ if (resolution._tag === "NotFound") {
211
274
  p.log.error(`Template "${name}" not found.`);
212
275
  listTemplates();
213
276
  process.exit(1);
214
277
  }
215
- const templatePath = join(communityDir, templateFile);
278
+ const templatePath = join(communityDir, resolution.file);
216
279
  const outputDir = resolve(targetDir, "components", "OgImage");
217
280
  if (!existsSync(outputDir))
218
281
  mkdirSync(outputDir, { recursive: true });
219
- const outputPath = join(outputDir, templateFile);
282
+ const outputPath = join(outputDir, resolution.file);
220
283
  if (existsSync(outputPath)) {
221
284
  p.log.error(`File already exists: ${outputPath}`);
222
285
  process.exit(1);
@@ -224,6 +287,17 @@ function ejectTemplate(name, targetDir) {
224
287
  const content = readFileSync(templatePath, "utf-8");
225
288
  writeFileSync(outputPath, content, "utf-8");
226
289
  p.log.success(`Ejected "${name}" to ${outputPath}`);
290
+ const why = {
291
+ "exact": null,
292
+ "only-variant": null,
293
+ "app-templates": `your templates use ${resolution.renderer}`,
294
+ "installed": `${resolution.renderer} is in package.json`,
295
+ "default": `${resolution.renderer} is the default renderer`
296
+ }[resolution.reason];
297
+ if (why) {
298
+ const others = listEjectableNames(readdirSync(communityDir)).filter((n) => n.startsWith(`${getBaseName(resolution.file)}.`) && n !== resolution.file.replace(".vue", ""));
299
+ p.log.info(`Picked the ${resolution.renderer} variant because ${why}.${others.length ? ` For another renderer, run: npx nuxt-og-image eject ${others[0]}` : ""}`);
300
+ }
227
301
  }
228
302
  function findOgImageComponents(dir) {
229
303
  const components = [];
@@ -445,11 +519,19 @@ function collectTemplateReplacements(templateHtml) {
445
519
  if (el.name !== "OgImage" && el.name !== "OgImageScreenshot")
446
520
  return;
447
521
  const isScreenshot = el.name === "OgImageScreenshot";
448
- const composable = isScreenshot ? "defineOgImageScreenshot" : "defineOgImage";
449
- const propsStr = attrsToProps(el.attributes);
450
522
  const start = el.loc[0].start;
451
523
  const end = el.loc[1].end;
452
- const text = propsStr ? `<!-- Migrated: use ${composable}(${propsStr}) in <script setup> -->` : `<!-- Migrated: use ${composable}() in <script setup> -->`;
524
+ let call;
525
+ if (isScreenshot) {
526
+ const propsStr = attrsToProps(el.attributes);
527
+ call = `defineOgImageScreenshot(${propsStr})`;
528
+ } else {
529
+ const { component, ":component": dynamicComponent, ...props } = el.attributes;
530
+ const componentArg = dynamicComponent || (component ? `'${component}'` : `'NuxtSeo'`);
531
+ const propsStr = attrsToProps(props);
532
+ call = `defineOgImage(${componentArg}${propsStr ? `, ${propsStr}` : ""})`;
533
+ }
534
+ const text = `<!-- Migrated: use ${call} in <script setup> -->`;
453
535
  replacements.push({ start, end, text });
454
536
  });
455
537
  return replacements;
@@ -755,7 +837,7 @@ async function runMigrate(args2) {
755
837
  if (shouldEject) {
756
838
  const targetDir = existsSync(join(cwd, "app")) ? join(cwd, "app") : cwd;
757
839
  for (const name of migrationCheck.usedCommunityTemplates) {
758
- ejectTemplate(name, targetDir);
840
+ ejectTemplate(name, targetDir, cwd);
759
841
  }
760
842
  } else {
761
843
  p.log.info("Run manually before building for production:");
@@ -1273,6 +1355,7 @@ function showHelp() {
1273
1355
  " Options: --renderer <renderer>, --path <dir>",
1274
1356
  "list List available community templates",
1275
1357
  "eject <name> Eject a community template to your project",
1358
+ " Picks the variant for your renderer; add .takumi or .satori to choose",
1276
1359
  "switch Switch components between renderers",
1277
1360
  " Options: --from <renderer>, --to <renderer>, --dry-run, --yes",
1278
1361
  "enable <renderer> Install dependencies for a renderer (satori, browser, takumi)",
@@ -1299,7 +1382,7 @@ if (hasHelp || !command) {
1299
1382
  }
1300
1383
  const cwd = process.cwd();
1301
1384
  const targetDir = existsSync(join(cwd, "app")) ? join(cwd, "app") : cwd;
1302
- ejectTemplate(templateName, targetDir);
1385
+ ejectTemplate(templateName, targetDir, cwd);
1303
1386
  } else if (command === "list") {
1304
1387
  listTemplates();
1305
1388
  } else if (command === "migrate") {
package/dist/module.d.mts CHANGED
@@ -142,7 +142,7 @@ interface ModuleOptions {
142
142
  /**
143
143
  * Browser renderer configuration.
144
144
  *
145
- * When using browser-based rendering (screenshots), configure the browser provider.
145
+ * Set to true to enable defineOgImageScreenshot(), or configure a browser provider.
146
146
  * For Cloudflare deployments, specify the browser binding name.
147
147
  *
148
148
  * @example { provider: 'cloudflare', binding: 'BROWSER' }
package/dist/module.d.ts CHANGED
@@ -142,7 +142,7 @@ interface ModuleOptions {
142
142
  /**
143
143
  * Browser renderer configuration.
144
144
  *
145
- * When using browser-based rendering (screenshots), configure the browser provider.
145
+ * Set to true to enable defineOgImageScreenshot(), or configure a browser provider.
146
146
  * For Cloudflare deployments, specify the browser binding name.
147
147
  *
148
148
  * @example { provider: 'cloudflare', binding: 'BROWSER' }
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "nuxt": ">=3.16.0"
5
5
  },
6
6
  "configKey": "ogImage",
7
- "version": "6.9.0",
7
+ "version": "6.10.0",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.3",
10
10
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -9,20 +9,19 @@ import 'nuxt-site-config/kit';
9
9
  import 'nuxtseo-shared/kit';
10
10
  import 'pathe';
11
11
  import 'pkg-types';
12
- import 'std-env';
13
- export { n as default } from './shared/nuxt-og-image.CTD0ZPNd.mjs';
12
+ export { n as default } from './shared/nuxt-og-image.DWcVJOVy.mjs';
14
13
  import '../dist/runtime/logger.js';
15
14
  import 'magic-string';
16
15
  import 'oxc-walker';
17
16
  import 'exsolve';
18
17
  import 'chrome-launcher';
18
+ import 'std-env';
19
19
  import 'nypm';
20
20
  import 'nuxtseo-shared/devtools';
21
21
  import 'unstorage';
22
22
  import 'unstorage/drivers/fs-lite';
23
23
  import '../dist/runtime/server/og-image/utils/css.js';
24
24
  import 'strip-literal';
25
- import 'ufo';
26
25
  import 'unplugin';
27
26
  import '@vue/compiler-sfc';
28
27
  import 'ofetch';
@@ -1,6 +1,9 @@
1
- import { useRouter } from "#app";
1
+ import { createError, useRouter } from "#app";
2
+ import { useOgImageRuntimeConfig } from "../utils.js";
2
3
  import { defineOgImageRaw } from "./_defineOgImageRaw.js";
3
4
  export function defineOgImageScreenshot(options = {}) {
5
+ if (!useOgImageRuntimeConfig().browserEnabled)
6
+ throw createError({ statusCode: 500, statusMessage: "[nuxt-og-image] Screenshots require browser opt-in. Set ogImage.browser to true or a browser provider in nuxt.config.ts." });
4
7
  const router = useRouter();
5
8
  const route = router.currentRoute.value?.path || "/";
6
9
  const { delay, mask, selector, colorScheme, ...rest } = options;
@@ -0,0 +1,9 @@
1
+ import { defineOgImage as _defineOgImage } from './defineOgImage.js';
2
+ import { defineOgImageComponent as _defineOgImageComponent } from './defineOgImageComponent.js';
3
+ import { defineOgImageScreenshot as _defineOgImageScreenshot } from './defineOgImageScreenshot.js';
4
+ export declare const defineOgImage: typeof _defineOgImage;
5
+ /**
6
+ * @deprecated Use `defineOgImage()` instead.
7
+ */
8
+ export declare const defineOgImageComponent: typeof _defineOgImageComponent;
9
+ export declare const defineOgImageScreenshot: typeof _defineOgImageScreenshot;
@@ -0,0 +1,6 @@
1
+ import { defineOgImage as _defineOgImage } from "./defineOgImage.js";
2
+ import { defineOgImageComponent as _defineOgImageComponent } from "./defineOgImageComponent.js";
3
+ import { defineOgImageScreenshot as _defineOgImageScreenshot } from "./defineOgImageScreenshot.js";
4
+ export const defineOgImage = (...args) => import.meta.prerender ? _defineOgImage(...args) : [];
5
+ export const defineOgImageComponent = (...args) => import.meta.prerender ? _defineOgImageComponent(...args) : [];
6
+ export const defineOgImageScreenshot = (...args) => import.meta.prerender ? _defineOgImageScreenshot(...args) : [];
@@ -184,9 +184,12 @@ export function resolveComponentName(component) {
184
184
  const originalName = component;
185
185
  const normalizedName = originalName.split(".").map((s, i) => i === 0 ? s : s.charAt(0).toUpperCase() + s.slice(1)).join("");
186
186
  const inputBase = normalizedName.replace(RE_RENDERER_SUFFIX, "");
187
+ const requestedRenderer = normalizedName.match(RE_RENDERER_SUFFIX)?.[1]?.toLowerCase();
187
188
  for (const component2 of componentNames) {
188
189
  if (component2.pascalName === normalizedName)
189
190
  return component2.pascalName;
191
+ if (requestedRenderer && component2.renderer !== requestedRenderer)
192
+ continue;
190
193
  const basePascalName = component2.pascalName.replace(RE_RENDERER_SUFFIX, "");
191
194
  if (basePascalName === originalName || basePascalName === inputBase)
192
195
  return component2.pascalName;
@@ -9,7 +9,6 @@ import { createSitePathResolver } from "#site-config/server/composables/utils";
9
9
  import { logger } from "../../logger.js";
10
10
  import { decodeOgImageParams, extractEncodedSegment, sanitizeProps, separateProps, verifyOgImageSignature } from "../../shared.js";
11
11
  import { hashKey } from "../../shared/hash.js";
12
- import { autoEjectCommunityTemplate } from "../util/auto-eject.js";
13
12
  import { createNitroRouteRuleMatcher } from "../util/kit.js";
14
13
  import { normaliseOptions } from "../util/options.js";
15
14
  import { getEventQuery } from "../util/query.js";
@@ -174,8 +173,6 @@ export async function resolveContext(e) {
174
173
  }
175
174
  normalised.options.props = filtered;
176
175
  }
177
- if (normalised.component?.category === "community")
178
- autoEjectCommunityTemplate(normalised.component, runtimeConfig, { requestPath: e.path });
179
176
  const rendererType = normalised.renderer;
180
177
  const baseCacheKey = normalised.options.cacheKey || (hashMatch ? `hash:${hashMatch[1]}` : resolvePathCacheKey(e, basePathWithQuery, normalised.options));
181
178
  const key = componentHash ? `${baseCacheKey}:${componentHash}` : baseCacheKey;
@@ -1,4 +1,4 @@
1
1
  import type { Node } from '@takumi-rs/helpers';
2
2
  import type { OgImageRenderEventContext, VNode } from '../../../types.js';
3
3
  export declare function createTakumiNodes(ctx: OgImageRenderEventContext): Promise<Node>;
4
- export declare function vnodeToTakumiNode(vnode: VNode, inheritedFontSize: number, inheritedColor?: string): Promise<Node>;
4
+ export declare function vnodeToTakumiNode(vnode: VNode, inheritedColor?: string): Promise<Node>;
@@ -1,8 +1,7 @@
1
1
  import { createVNodes, resolveSvgDimension, SVG_CAMEL_ATTR_VALUES } from "../core/vnodes.js";
2
- const RE_RELATIVE_UNIT = /^([\d.]+)(em|rem)$/;
3
- const RE_TW_TEXT_ARBITRARY = /(?:^|\s)text-\[(\d+(?:\.\d+)?)(px|rem|em)\]/;
4
- const RE_FONT_SIZE_PX = /^(\d+(?:\.\d+)?)(px)?$/;
5
- const DEFAULT_FONT_SIZE = 16;
2
+ const RE_RELATIVE_UNIT = /^[\d.]+r?em$/;
3
+ const RE_LEADING_SPACE = /^\s/;
4
+ const RE_TRAILING_SPACE = /\s$/;
6
5
  const RE_UPPERCASE = /[A-Z]/g;
7
6
  const RE_DQUOTE = /"/g;
8
7
  const RE_AMP = /&/g;
@@ -10,7 +9,7 @@ const RE_LT = /</g;
10
9
  const RE_GT = />/g;
11
10
  export async function createTakumiNodes(ctx) {
12
11
  const vnodeTree = await createVNodes(ctx);
13
- return await ctx.timings.measure("takumi-nodes", () => vnodeToTakumiNode(vnodeTree, DEFAULT_FONT_SIZE));
12
+ return await ctx.timings.measure("takumi-nodes", () => vnodeToTakumiNode(vnodeTree));
14
13
  }
15
14
  function pickNumericDimension(props, key) {
16
15
  const v = props[key];
@@ -19,35 +18,11 @@ function pickNumericDimension(props, key) {
19
18
  const n = Number(v);
20
19
  return Number.isNaN(n) ? void 0 : n;
21
20
  }
22
- function resolveRelativeUnit(value, inheritedFontSize) {
23
- if (value == null)
24
- return void 0;
25
- const match = String(value).match(RE_RELATIVE_UNIT);
26
- if (!match)
27
- return void 0;
28
- const n = Number.parseFloat(match[1]);
29
- return match[2] === "rem" ? n * DEFAULT_FONT_SIZE : n * inheritedFontSize;
30
- }
31
- function extractFontSize(props, style) {
32
- if (style?.fontSize != null) {
33
- const m = String(style.fontSize).match(RE_FONT_SIZE_PX);
34
- if (m)
35
- return Number.parseFloat(m[1]);
36
- }
37
- const twStr = props.tw || props.class || "";
38
- const twMatch = twStr.match(RE_TW_TEXT_ARBITRARY);
39
- if (twMatch) {
40
- const val = Number.parseFloat(twMatch[1]);
41
- if (twMatch[2] === "px")
42
- return val;
43
- }
44
- return void 0;
45
- }
46
21
  function extractColor(style) {
47
22
  const color = style?.color;
48
23
  return typeof color === "string" && color ? color : void 0;
49
24
  }
50
- export async function vnodeToTakumiNode(vnode, inheritedFontSize, inheritedColor) {
25
+ export async function vnodeToTakumiNode(vnode, inheritedColor) {
51
26
  const { style, children, class: cls, tw, src, ...rest } = vnode.props;
52
27
  const nodeColor = extractColor(style) ?? inheritedColor;
53
28
  const baseMetadata = {
@@ -55,18 +30,16 @@ export async function vnodeToTakumiNode(vnode, inheritedFontSize, inheritedColor
55
30
  style
56
31
  };
57
32
  if (vnode.type === "svg") {
58
- const hasExplicitFontSize = inheritedFontSize !== DEFAULT_FONT_SIZE;
59
- const isRelativeW = RE_RELATIVE_UNIT.test(String(rest.width ?? ""));
60
- const isRelativeH = RE_RELATIVE_UNIT.test(String(rest.height ?? ""));
33
+ const relativeW = RE_RELATIVE_UNIT.test(String(rest.width ?? "")) ? String(rest.width) : void 0;
34
+ const relativeH = RE_RELATIVE_UNIT.test(String(rest.height ?? "")) ? String(rest.height) : void 0;
35
+ const imageStyle = relativeW || relativeH ? { ...style, ...relativeW && style?.width == null ? { width: relativeW } : {}, ...relativeH && style?.height == null ? { height: relativeH } : {} } : style;
61
36
  return {
62
37
  ...baseMetadata,
38
+ style: imageStyle,
63
39
  type: "image",
64
40
  src: vnodeToHtmlString(vnode, nodeColor),
65
- // When em/rem + explicit parent font size → resolve to px.
66
- // When em/rem + default font size → leave undefined (takumi handles natively).
67
- // Otherwise → use standard resolution chain (numeric attrs → style → viewBox).
68
- width: isRelativeW ? hasExplicitFontSize ? resolveRelativeUnit(rest.width, inheritedFontSize) : void 0 : resolveSvgDimension(rest, style, "width"),
69
- height: isRelativeH ? hasExplicitFontSize ? resolveRelativeUnit(rest.height, inheritedFontSize) : void 0 : resolveSvgDimension(rest, style, "height")
41
+ width: relativeW ? void 0 : resolveSvgDimension(rest, style, "width"),
42
+ height: relativeH ? void 0 : resolveSvgDimension(rest, style, "height")
70
43
  };
71
44
  }
72
45
  if (vnode.type === "img") {
@@ -78,8 +51,6 @@ export async function vnodeToTakumiNode(vnode, inheritedFontSize, inheritedColor
78
51
  height: pickNumericDimension(rest, "height")
79
52
  };
80
53
  }
81
- const nodeFontSize = extractFontSize(vnode.props, style);
82
- const childFontSize = nodeFontSize ?? inheritedFontSize;
83
54
  const containerStyle = { ...style };
84
55
  const w = pickNumericDimension(rest, "width");
85
56
  const h = pickNumericDimension(rest, "height");
@@ -102,11 +73,15 @@ export async function vnodeToTakumiNode(vnode, inheritedFontSize, inheritedColor
102
73
  }
103
74
  if (Array.isArray(children)) {
104
75
  const takumiChildren = [];
105
- for (const child of children) {
106
- if (child && typeof child === "object")
107
- takumiChildren.push(await vnodeToTakumiNode(child, childFontSize, nodeColor));
108
- else if (typeof child === "string" && child.trim())
109
- takumiChildren.push({ type: "text", text: child.trim() });
76
+ const isInlineImage = (c) => !!c && typeof c === "object" && ["svg", "img"].includes(c.type);
77
+ for (const [i, child] of children.entries()) {
78
+ if (child && typeof child === "object") {
79
+ takumiChildren.push(await vnodeToTakumiNode(child, nodeColor));
80
+ } else if (typeof child === "string" && child.trim()) {
81
+ const start = isInlineImage(children[i - 1]) && RE_LEADING_SPACE.test(child) ? " " : "";
82
+ const end = isInlineImage(children[i + 1]) && RE_TRAILING_SPACE.test(child) ? " " : "";
83
+ takumiChildren.push({ type: "text", text: `${start}${child.trim()}${end}` });
84
+ }
110
85
  }
111
86
  return {
112
87
  ...containerMetadata,
@@ -0,0 +1,2 @@
1
+ declare const _default: any;
2
+ export default _default;
@@ -0,0 +1,36 @@
1
+ import { parse } from "devalue";
2
+ import { defineNitroPlugin } from "#nuxtseo/nitro";
3
+ import { autoEjectCommunityTemplate } from "../util/auto-eject.js";
4
+ import { normaliseOptions } from "../util/options.js";
5
+ import { useOgImageRuntimeConfig } from "../utils.js";
6
+ const PAYLOAD_REGEX = /<script.+id="nuxt-og-image-options"[^>]*>(.+?)<\/script>/;
7
+ export default defineNitroPlugin((nitro) => {
8
+ if (!import.meta.dev)
9
+ return;
10
+ nitro.hooks.hook("render:html", (html, ctx) => {
11
+ const payload = [...html.head, ...html.bodyAppend].join("\n").match(PAYLOAD_REGEX)?.[1];
12
+ if (!payload)
13
+ return;
14
+ for (const options of parse(payload)) {
15
+ if (!options?.component)
16
+ continue;
17
+ const normalised = (() => {
18
+ try {
19
+ return normaliseOptions({ ...options });
20
+ } catch {
21
+ return null;
22
+ }
23
+ })();
24
+ if (normalised?.component?.category === "community")
25
+ (ctx.event.context._ogImageAutoEject ||= []).push(normalised.component);
26
+ }
27
+ });
28
+ nitro.hooks.hook("afterResponse", (event) => {
29
+ const components = event.context._ogImageAutoEject;
30
+ if (!components?.length)
31
+ return;
32
+ const runtimeConfig = useOgImageRuntimeConfig(event);
33
+ for (const component of components)
34
+ autoEjectCommunityTemplate(component, runtimeConfig);
35
+ });
36
+ });
@@ -1,7 +1,7 @@
1
1
  import { parse } from "devalue";
2
- import { parseURL } from "ufo";
2
+ import { parseURL, withoutBase } from "ufo";
3
3
  import { appendResponseHeader } from "#nuxtseo/h3";
4
- import { defineNitroPlugin } from "#nuxtseo/nitro";
4
+ import { defineNitroPlugin, useRuntimeConfig } from "#nuxtseo/nitro";
5
5
  import { prerenderOptionsCache } from "#og-image-cache";
6
6
  import { createSitePathResolver } from "#site-config/server/composables/utils";
7
7
  import { isInternalRoute } from "../../shared.js";
@@ -37,9 +37,10 @@ export default defineNitroPlugin(async (nitro) => {
37
37
  });
38
38
  const key = resolvePathCacheKey(ctx.event, resolvePathWithBase(path));
39
39
  await prerenderOptionsCache.setItem(key, payloads);
40
+ const pagePath = withoutBase(path, useRuntimeConfig().app.baseURL);
40
41
  for (const [_ogKey, opt] of payloads) {
41
42
  if (opt._hash) {
42
- await prerenderOptionsCache.setItem(`hash:${opt._hash}`, opt);
43
+ await prerenderOptionsCache.setItem(`hash:${opt._hash}`, { ...opt, _path: pagePath });
43
44
  }
44
45
  }
45
46
  const prerenderPaths = ctx.event.context._ogImagePrerenderPaths;
@@ -1,4 +1,2 @@
1
1
  import type { OgImageComponent, OgImageRuntimeConfig } from '../../types.js';
2
- export declare function autoEjectCommunityTemplate(component: OgImageComponent, runtimeConfig: OgImageRuntimeConfig, options?: {
3
- requestPath?: string;
4
- }): void;
2
+ export declare function autoEjectCommunityTemplate(component: OgImageComponent, runtimeConfig: OgImageRuntimeConfig): void;
@@ -4,12 +4,9 @@ import { logger } from "./logger.js";
4
4
  const RE_OG_IMAGE_PREFIX = /^OgImage/;
5
5
  const RE_RENDERER_SUFFIX = /(Satori|Browser|Takumi)$/;
6
6
  const ejectedTemplates = /* @__PURE__ */ new Set();
7
- export function autoEjectCommunityTemplate(component, runtimeConfig, options) {
7
+ export function autoEjectCommunityTemplate(component, runtimeConfig) {
8
8
  if (!import.meta.dev)
9
9
  return;
10
- if (options?.requestPath?.includes("/_og/")) {
11
- return;
12
- }
13
10
  const { srcDir, communityTemplatesDir } = runtimeConfig;
14
11
  if (!srcDir || !communityTemplatesDir)
15
12
  return;
@@ -104,21 +104,11 @@ export function normaliseOptions(_options) {
104
104
  renderer = resolved.renderer;
105
105
  }
106
106
  options.component = resolved.pascalName;
107
- if (resolved.category === "community") {
108
- if (!import.meta.dev) {
109
- const appComponent = componentNames.find((c) => c.category !== "community");
110
- if (appComponent) {
111
- resolved = appComponent;
112
- renderer = resolved.renderer;
113
- options.component = resolved.pascalName;
114
- } else {
115
- throw createError({
116
- statusCode: 500,
117
- message: `Community template "${resolved.pascalName}" must be ejected before production use. Run: npx nuxt-og-image eject ${resolved.pascalName}
118
- No app components found \u2014 create one in components/OgImage/`
119
- });
120
- }
121
- }
107
+ if (resolved.category === "community" && !import.meta.dev) {
108
+ throw createError({
109
+ statusCode: 500,
110
+ message: `[Nuxt OG Image] Community template "${resolved.pascalName}" is not ejected, and production builds do not include community templates. Run: npx nuxt-og-image eject ${stripRenderer(resolved.pascalName)}.${resolved.renderer}`
111
+ });
122
112
  }
123
113
  return {
124
114
  options,