nuxt-og-image 6.7.8 → 6.9.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.
Files changed (38) hide show
  1. package/dist/chunks/tw4.mjs +2 -2
  2. package/dist/chunks/uno.mjs +4 -4
  3. package/dist/cli.mjs +1 -1
  4. package/dist/devtools/nuxt.config.ts +4 -1
  5. package/dist/module.d.mts +10 -0
  6. package/dist/module.d.ts +10 -0
  7. package/dist/module.json +1 -1
  8. package/dist/module.mjs +3 -3
  9. package/dist/runtime/app/client-utils.js +4 -3
  10. package/dist/runtime/app/utils.js +3 -2
  11. package/dist/runtime/server/nitro.d.ts +9 -0
  12. package/dist/runtime/server/nitro.js +10 -0
  13. package/dist/runtime/server/og-image/bindings/fetch/node.d.ts +2 -0
  14. package/dist/runtime/server/og-image/bindings/fetch/node.js +42 -0
  15. package/dist/runtime/server/og-image/bindings/fetch/web.d.ts +7 -0
  16. package/dist/runtime/server/og-image/bindings/fetch/web.js +3 -0
  17. package/dist/runtime/server/og-image/bindings/font-assets/external-url.d.ts +0 -11
  18. package/dist/runtime/server/og-image/bindings/font-assets/external-url.js +2 -19
  19. package/dist/runtime/server/og-image/bindings/satori/harfbuzz.d.ts +4 -0
  20. package/dist/runtime/server/og-image/bindings/satori/harfbuzz.js +33 -0
  21. package/dist/runtime/server/og-image/browser/screenshot.js +4 -1
  22. package/dist/runtime/server/og-image/context.js +9 -1
  23. package/dist/runtime/server/og-image/font-subsets.d.ts +3 -11
  24. package/dist/runtime/server/og-image/font-subsets.js +50 -27
  25. package/dist/runtime/server/og-image/fonts.d.ts +1 -1
  26. package/dist/runtime/server/routes/resolve.js +2 -2
  27. package/dist/runtime/server/util/cloudflare.js +2 -2
  28. package/dist/runtime/server/util/eventHandlers.js +3 -3
  29. package/dist/runtime/server/util/ssrf.d.ts +31 -0
  30. package/dist/runtime/server/util/ssrf.js +64 -5
  31. package/dist/runtime/server/utils.d.ts +1 -1
  32. package/dist/runtime/server/utils.js +3 -3
  33. package/dist/runtime/shared.d.ts +10 -1
  34. package/dist/runtime/shared.js +2 -2
  35. package/dist/runtime/types.d.ts +5 -1
  36. package/dist/shared/{nuxt-og-image.BMHzg1Vz.mjs → nuxt-og-image.CTD0ZPNd.mjs} +634 -468
  37. package/package.json +70 -66
  38. package/types/virtual.d.ts +26 -0
@@ -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.BMHzg1Vz.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.CTD0ZPNd.mjs';
6
6
  import 'node:crypto';
7
7
  import 'node:fs';
8
8
  import '@nuxt/kit';
@@ -12,6 +12,7 @@ import 'nuxt-site-config/kit';
12
12
  import 'nuxtseo-shared/kit';
13
13
  import 'pkg-types';
14
14
  import 'std-env';
15
+ import 'magic-string';
15
16
  import 'oxc-walker';
16
17
  import '../../dist/runtime/logger.js';
17
18
  import 'chrome-launcher';
@@ -20,7 +21,6 @@ import 'nuxtseo-shared/devtools';
20
21
  import 'unstorage';
21
22
  import 'unstorage/drivers/fs-lite';
22
23
  import '../../dist/runtime/server/og-image/utils/css.js';
23
- import 'magic-string';
24
24
  import 'strip-literal';
25
25
  import 'ufo';
26
26
  import 'unplugin';
@@ -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.BMHzg1Vz.mjs';
5
- export { j as importResolvedModule, k as resolveOptionalModulePath } from '../shared/nuxt-og-image.BMHzg1Vz.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.CTD0ZPNd.mjs';
5
+ export { j as importResolvedModule, k as resolveOptionalModulePath } from '../shared/nuxt-og-image.CTD0ZPNd.mjs';
6
6
  import 'node:crypto';
7
7
  import 'node:fs';
8
8
  import 'node:url';
@@ -13,14 +13,15 @@ import 'nuxtseo-shared/kit';
13
13
  import 'pathe';
14
14
  import 'pkg-types';
15
15
  import 'std-env';
16
+ import 'magic-string';
16
17
  import 'oxc-walker';
18
+ import 'exsolve';
17
19
  import 'chrome-launcher';
18
20
  import 'nypm';
19
21
  import 'nuxtseo-shared/devtools';
20
22
  import 'unstorage';
21
23
  import 'unstorage/drivers/fs-lite';
22
24
  import '../../dist/runtime/server/og-image/utils/css.js';
23
- import 'magic-string';
24
25
  import 'strip-literal';
25
26
  import 'ufo';
26
27
  import 'unplugin';
@@ -28,7 +29,6 @@ import '@vue/compiler-sfc';
28
29
  import 'ofetch';
29
30
  import 'ultrahtml';
30
31
  import '../../dist/runtime/server/og-image/core/transforms/emojis/emoji-utils.js';
31
- import 'exsolve';
32
32
  import '../shared/nuxt-og-image.CJa2KCie.mjs';
33
33
  import 'magicast';
34
34
  import 'magicast/helpers';
package/dist/cli.mjs CHANGED
@@ -1211,7 +1211,7 @@ async function runCreate(name, args2) {
1211
1211
  message: "Add defineOgImage to a page? (relative path from pages/, leave empty to skip)",
1212
1212
  placeholder: "index.vue"
1213
1213
  });
1214
- if (!p.isCancel(pageInput) && pageInput && pageInput.trim()) {
1214
+ if (typeof pageInput === "string" && pageInput.trim()) {
1215
1215
  let pagePath = pageInput.trim();
1216
1216
  if (!pagePath.endsWith(".vue"))
1217
1217
  pagePath += ".vue";
@@ -1,6 +1,9 @@
1
+ import { fileURLToPath } from 'node:url'
1
2
  import { resolve } from 'pathe'
2
3
 
4
+ const currentDir = fileURLToPath(new URL('.', import.meta.url))
5
+
3
6
  // Nuxt SEO devtools panel, shipped as a layer (Model C). Components flat-registered.
4
7
  export default defineNuxtConfig({
5
- components: [{ path: resolve(__dirname, './components'), pathPrefix: false }],
8
+ components: [{ path: resolve(currentDir, './components'), pathPrefix: false }],
6
9
  })
package/dist/module.d.mts CHANGED
@@ -118,6 +118,16 @@ interface ModuleOptions {
118
118
  * @default 60 * 60 * 24 * 3 (3 days)
119
119
  */
120
120
  cacheMaxAgeSeconds?: number;
121
+ /**
122
+ * Include Twitter card meta tags (`twitter:card`, `twitter:image`, etc)
123
+ * alongside the Open Graph image tags.
124
+ *
125
+ * Set to `false` to only emit `og:image` tags. When unset, the module respects
126
+ * `seo.automaticTwitterTags` (nuxt-seo-kit) before defaulting to `true`.
127
+ *
128
+ * @default true
129
+ */
130
+ includeTwitter?: boolean;
121
131
  /**
122
132
  * Font subsets to download when resolving missing font families via fontless.
123
133
  *
package/dist/module.d.ts CHANGED
@@ -118,6 +118,16 @@ interface ModuleOptions {
118
118
  * @default 60 * 60 * 24 * 3 (3 days)
119
119
  */
120
120
  cacheMaxAgeSeconds?: number;
121
+ /**
122
+ * Include Twitter card meta tags (`twitter:card`, `twitter:image`, etc)
123
+ * alongside the Open Graph image tags.
124
+ *
125
+ * Set to `false` to only emit `og:image` tags. When unset, the module respects
126
+ * `seo.automaticTwitterTags` (nuxt-seo-kit) before defaulting to `true`.
127
+ *
128
+ * @default true
129
+ */
130
+ includeTwitter?: boolean;
121
131
  /**
122
132
  * Font subsets to download when resolving missing font families via fontless.
123
133
  *
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "nuxt": ">=3.16.0"
5
5
  },
6
6
  "configKey": "ogImage",
7
- "version": "6.7.8",
7
+ "version": "6.9.0",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.3",
10
10
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -10,16 +10,17 @@ import 'nuxtseo-shared/kit';
10
10
  import 'pathe';
11
11
  import 'pkg-types';
12
12
  import 'std-env';
13
- export { n as default } from './shared/nuxt-og-image.BMHzg1Vz.mjs';
13
+ export { n as default } from './shared/nuxt-og-image.CTD0ZPNd.mjs';
14
14
  import '../dist/runtime/logger.js';
15
+ import 'magic-string';
15
16
  import 'oxc-walker';
17
+ import 'exsolve';
16
18
  import 'chrome-launcher';
17
19
  import 'nypm';
18
20
  import 'nuxtseo-shared/devtools';
19
21
  import 'unstorage';
20
22
  import 'unstorage/drivers/fs-lite';
21
23
  import '../dist/runtime/server/og-image/utils/css.js';
22
- import 'magic-string';
23
24
  import 'strip-literal';
24
25
  import 'ufo';
25
26
  import 'unplugin';
@@ -27,7 +28,6 @@ import '@vue/compiler-sfc';
27
28
  import 'ofetch';
28
29
  import 'ultrahtml';
29
30
  import '../dist/runtime/server/og-image/core/transforms/emojis/emoji-utils.js';
30
- import 'exsolve';
31
31
  import './shared/nuxt-og-image.CJa2KCie.mjs';
32
32
  import 'magicast';
33
33
  import 'magicast/helpers';
@@ -63,6 +63,7 @@ export function clientProcessOgImageOptions(input, route, basePath) {
63
63
  const baseURL = rc.app.baseURL;
64
64
  const publicCfg = rc.public?.["nuxt-og-image"] || {};
65
65
  const defaults = publicCfg.defaults || {};
66
+ const metaOptions = { includeTwitter: publicCfg.includeTwitter };
66
67
  const paths = [];
67
68
  for (const rawInput of inputs) {
68
69
  const resolved = resolveReactiveOptions(rawInput);
@@ -78,13 +79,13 @@ export function clientProcessOgImageOptions(input, route, basePath) {
78
79
  const ogKey = validOptions.key || "og";
79
80
  if (validOptions.url) {
80
81
  const url = validOptions.url;
81
- registerClientOgHead(ogKey, { meta: generateMeta(url, validOptions) }, { tagPriority: "high" });
82
+ registerClientOgHead(ogKey, { meta: generateMeta(url, validOptions, metaOptions) }, { tagPriority: "high" });
82
83
  paths.push(url);
83
84
  continue;
84
85
  }
85
86
  if (publicCfg.hasServerRuntime) {
86
87
  const finalUrl2 = buildResolverUrl(baseURL, basePath, ogKey, route.query);
87
- registerClientOgHead(ogKey, { meta: generateMeta(finalUrl2, validOptions) }, { tagPriority: 35 });
88
+ registerClientOgHead(ogKey, { meta: generateMeta(finalUrl2, validOptions, metaOptions) }, { tagPriority: 35 });
88
89
  paths.push(finalUrl2);
89
90
  continue;
90
91
  }
@@ -98,7 +99,7 @@ export function clientProcessOgImageOptions(input, route, basePath) {
98
99
  const result = buildOgImageUrl(urlOpts, extension, true, defaults, void 0);
99
100
  const resolvedUrl = joinURL("/", baseURL, result.url);
100
101
  const finalUrl = opts._query && Object.keys(opts._query).length ? withQuery(resolvedUrl, { _query: opts._query }) : resolvedUrl;
101
- registerClientOgHead(ogKey, { meta: generateMeta(finalUrl, opts) }, { processTemplateParams: true, tagPriority: 35 });
102
+ registerClientOgHead(ogKey, { meta: generateMeta(finalUrl, opts, metaOptions) }, { processTemplateParams: true, tagPriority: 35 });
102
103
  paths.push(finalUrl);
103
104
  }
104
105
  return paths;
@@ -53,7 +53,8 @@ export function setHeadOgImagePrebuilt(input) {
53
53
  const url = input.url;
54
54
  if (!url)
55
55
  return;
56
- const meta = generateMeta(url, input);
56
+ const { includeTwitter } = useOgImageRuntimeConfig();
57
+ const meta = generateMeta(url, input, { includeTwitter });
57
58
  useHead({ meta }, { tagPriority: "high" });
58
59
  }
59
60
  export function createOgImageMeta(src, input, ssrContext, pagePath, head) {
@@ -117,7 +118,7 @@ export function createOgImageMeta(src, input, ssrContext, pagePath, head) {
117
118
  prerenderPaths.set(ogKey, (finalUrl.split("?")[0] || finalUrl).replace(/,/g, "%2C"));
118
119
  }
119
120
  }
120
- return generateMeta(finalUrl, opts);
121
+ return generateMeta(finalUrl, opts, { includeTwitter: ogImageConfig.includeTwitter });
121
122
  });
122
123
  }
123
124
  }, {
@@ -0,0 +1,9 @@
1
+ import type { H3Event } from '#nuxtseo/h3';
2
+ import type { OgImageOptionsInternal } from '../types.js';
3
+ type Options = Partial<OgImageOptionsInternal>;
4
+ /**
5
+ * Build the absolute OG image URL for a page. Without a page path, uses the current request path.
6
+ */
7
+ export declare function getOgImageUrl(event: H3Event, options?: Options): string;
8
+ export declare function getOgImageUrl(event: H3Event, pagePath: string, options?: Options): string;
9
+ export {};
@@ -0,0 +1,10 @@
1
+ import { parseURL, withoutBase } from "ufo";
2
+ import { useRuntimeConfig } from "#nuxtseo/nitro";
3
+ import { withSiteUrl } from "#site-config/server/composables/utils";
4
+ import { getOgImagePath } from "./utils.js";
5
+ export function getOgImageUrl(event, pathOrOptions, maybeOptions) {
6
+ const pagePath = typeof pathOrOptions === "string" ? pathOrOptions : withoutBase(parseURL(event.path).pathname, useRuntimeConfig(event).app.baseURL) || "/";
7
+ const options = typeof pathOrOptions === "string" ? maybeOptions : pathOrOptions;
8
+ const { path } = getOgImagePath(event, pagePath, options);
9
+ return withSiteUrl(event, path, { canonical: !import.meta.dev });
10
+ }
@@ -0,0 +1,2 @@
1
+ import type { GuardedFetchInit } from './web.js';
2
+ export declare function guardedFetch(url: string, { headers, signal, isBlockedAddress }: GuardedFetchInit): Promise<Response>;
@@ -0,0 +1,42 @@
1
+ import { lookup as dnsLookup } from "node:dns";
2
+ import { request as httpRequest } from "node:http";
3
+ import { request as httpsRequest } from "node:https";
4
+ import { Readable } from "node:stream";
5
+ function guardedLookup(isBlocked) {
6
+ return (hostname, options, callback) => {
7
+ dnsLookup(hostname, { ...options, all: true }, (err, addresses) => {
8
+ if (err)
9
+ return callback(err, "", 0);
10
+ const blocked = addresses.find((a) => isBlocked(a.address));
11
+ if (blocked)
12
+ return callback(new Error(`[Nuxt OG Image] ${hostname} resolves to blocked address ${blocked.address}.`), "", 0);
13
+ if (options.all)
14
+ return callback(null, addresses);
15
+ callback(null, addresses[0].address, addresses[0].family);
16
+ });
17
+ };
18
+ }
19
+ export function guardedFetch(url, { headers, signal, isBlockedAddress }) {
20
+ const request = url.startsWith("https:") ? httpsRequest : httpRequest;
21
+ return new Promise((resolve, reject) => {
22
+ const req = request(url, {
23
+ // Match the defaults that `fetch` sent before this transport existed.
24
+ headers: { "accept": "*/*", "user-agent": "node", ...headers },
25
+ signal,
26
+ lookup: guardedLookup(isBlockedAddress ?? (() => false)),
27
+ // A fresh socket per hop: never reuse a connection nobody checked.
28
+ agent: false
29
+ }, (res) => {
30
+ const status = res.statusCode ?? 0;
31
+ const responseHeaders = new Headers();
32
+ for (const [name, value] of Object.entries(res.headers)) {
33
+ for (const v of [value ?? []].flat())
34
+ responseHeaders.append(name, v);
35
+ }
36
+ const body = status === 204 || status === 304 ? null : Readable.toWeb(res);
37
+ resolve(new Response(body, { status, headers: responseHeaders }));
38
+ });
39
+ req.on("error", reject);
40
+ req.end();
41
+ });
42
+ }
@@ -0,0 +1,7 @@
1
+ export interface GuardedFetchInit {
2
+ headers?: Record<string, string>;
3
+ signal: AbortSignal;
4
+ /** Rejects the connection when a resolved address matches. Node only. */
5
+ isBlockedAddress?: (address: string) => boolean;
6
+ }
7
+ export declare function guardedFetch(url: string, { headers, signal }: GuardedFetchInit): Promise<Response>;
@@ -0,0 +1,3 @@
1
+ export function guardedFetch(url, { headers, signal }) {
2
+ return fetch(url, { redirect: "manual", headers, signal });
3
+ }
@@ -12,17 +12,6 @@ export declare function isDataFontUrl(path: string): boolean;
12
12
  * Check `isDataFontUrl` first — `data:` is non-http and would report true here.
13
13
  */
14
14
  export declare function isExternalFontUrl(path: string): boolean;
15
- /**
16
- * Resolve an authority-bearing font URL to the href to fetch, but only when it
17
- * is same-origin with the configured site URL. Returns null otherwise.
18
- *
19
- * Runtime external font URLs are unsupported — `@nuxt/fonts` is the only
20
- * supported way to load custom fonts, and it serves them same-origin. The single
21
- * allowed exception is the site's own origin, gated on an explicitly configured
22
- * site URL. Resolving against that origin also collapses canonicalization
23
- * bypasses (a crafted " //127.0.0.1" resolves cross-origin → rejected).
24
- */
25
- export declare function resolveSameOriginFontUrl(path: string, siteUrl: string | undefined): string | null;
26
15
  /**
27
16
  * Fetch a `data:` or authority-bearing font URL. `data:` is decoded directly
28
17
  * (no network). An external URL is fetched only when same-origin with the site
@@ -1,4 +1,4 @@
1
- import { fetchWithRedirectValidation } from "../../../util/ssrf.js";
1
+ import { fetchWithRedirectValidation, resolveSameOriginUrl } from "../../../util/ssrf.js";
2
2
  const SENTINEL_ORIGIN = "http://font-asset.invalid/";
3
3
  const SENTINEL_HOST = "font-asset.invalid";
4
4
  export function isDataFontUrl(path) {
@@ -13,23 +13,6 @@ export function isExternalFontUrl(path) {
13
13
  }
14
14
  return resolved.protocol !== "http:" || resolved.host !== SENTINEL_HOST;
15
15
  }
16
- export function resolveSameOriginFontUrl(path, siteUrl) {
17
- if (!siteUrl)
18
- return null;
19
- let siteOrigin;
20
- try {
21
- siteOrigin = new URL(siteUrl).origin;
22
- } catch {
23
- return null;
24
- }
25
- let target;
26
- try {
27
- target = new URL(path, siteOrigin);
28
- } catch {
29
- return null;
30
- }
31
- return target.origin === siteOrigin ? target.href : null;
32
- }
33
16
  export async function fetchSpecialFontUrl(path, siteUrl, timeout) {
34
17
  if (isDataFontUrl(path)) {
35
18
  const res = await fetch(path.trimStart(), { signal: AbortSignal.timeout(timeout) }).catch(() => {
@@ -39,7 +22,7 @@ export async function fetchSpecialFontUrl(path, siteUrl, timeout) {
39
22
  return Buffer.from(await res.arrayBuffer());
40
23
  throw new Error("[Nuxt OG Image] Invalid data: font URL.");
41
24
  }
42
- const href = resolveSameOriginFontUrl(path, siteUrl);
25
+ const href = resolveSameOriginUrl(path, siteUrl);
43
26
  if (!href)
44
27
  throw new Error("[Nuxt OG Image] External font URLs are not supported. Load custom fonts via @nuxt/fonts.");
45
28
  const ab = await fetchWithRedirectValidation(href, { timeout });
@@ -0,0 +1,4 @@
1
+ declare const _default: {
2
+ readonly then: <TResult1 = unknown, TResult2 = never>(onfulfilled?: ((value: unknown) => TResult1 | PromiseLike<TResult1>) | null | undefined, onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null | undefined) => Promise<TResult1 | TResult2>;
3
+ };
4
+ export default _default;
@@ -0,0 +1,33 @@
1
+ import wrapHarfBuzz from "#og-image/harfbuzz-adapter";
2
+ import callbackModules from "#og-image/harfbuzz-callbacks";
3
+ import createHarfBuzz from "#og-image/harfbuzz-factory";
4
+ function loadHarfBuzz() {
5
+ return import("#og-image/harfbuzz-wasm").then(async ({ default: importedWasm }) => {
6
+ const wasm = await importedWasm;
7
+ const callbacks = callbackModules && Object.fromEntries(await Promise.all(
8
+ Object.entries(callbackModules).map(async ([signature, module]) => [signature, await module])
9
+ ));
10
+ return new Promise((resolve, reject) => {
11
+ createHarfBuzz({
12
+ ...callbacks && {
13
+ convertJsFunctionToWasm(callback, signature) {
14
+ return new WebAssembly.Instance(callbacks[signature], { e: { f: callback } }).exports.f;
15
+ }
16
+ },
17
+ instantiateWasm(imports, receiveInstance) {
18
+ Promise.resolve().then(() => typeof wasm === "function" ? wasm(imports) : new WebAssembly.Instance(wasm, imports)).then((result) => {
19
+ receiveInstance("instance" in result ? result.instance : result);
20
+ }).catch(reject);
21
+ return {};
22
+ }
23
+ }).then(wrapHarfBuzz).then(resolve, reject);
24
+ });
25
+ });
26
+ }
27
+ let runtime;
28
+ export default {
29
+ get then() {
30
+ const promise = runtime ||= loadHarfBuzz();
31
+ return promise.then.bind(promise);
32
+ }
33
+ };
@@ -5,6 +5,7 @@ import { getNitroOrigin } from "#site-config/server/composables";
5
5
  import { buildOgImageUrl } from "../../../shared.js";
6
6
  import { getFetchTimeout } from "../../util/fetchTimeout.js";
7
7
  import { logger } from "../../util/logger.js";
8
+ import { resolveSameOriginUrl } from "../../util/ssrf.js";
8
9
  import { useOgImageRuntimeConfig } from "../../utils.js";
9
10
  function isPlaywrightPage(page) {
10
11
  return typeof page.setViewportSize === "function";
@@ -101,7 +102,9 @@ export async function createScreenshot({ basePath, e, options, extension, timing
101
102
  await page.setContent(html, { waitUntil: "networkidle0" });
102
103
  }
103
104
  } else {
104
- const url = isPlaywrightPage(page) ? withQuery(path, options.props || {}) : `${getNitroOrigin(e)}${withQuery(path, options.props || {})}`;
105
+ const url = resolveSameOriginUrl(withQuery(path, options.props || {}), getNitroOrigin(e));
106
+ if (!url)
107
+ throw new Error("[Nuxt OG Image] The browser renderer can only navigate to a same-origin path.");
105
108
  await gotoWithIdle(page, url, 1e4);
106
109
  }
107
110
  const screenshotOptions = {
@@ -13,6 +13,7 @@ import { autoEjectCommunityTemplate } from "../util/auto-eject.js";
13
13
  import { createNitroRouteRuleMatcher } from "../util/kit.js";
14
14
  import { normaliseOptions } from "../util/options.js";
15
15
  import { getEventQuery } from "../util/query.js";
16
+ import { parseSameOriginPath } from "../util/ssrf.js";
16
17
  import { createTimings, TIMING_CTX_KEY } from "../util/timings.js";
17
18
  import { withTimeout } from "../util/withTimeout.js";
18
19
  import { useOgImageRuntimeConfig } from "../utils.js";
@@ -120,7 +121,14 @@ export async function resolveContext(e) {
120
121
  delete queryParams.html;
121
122
  queryParams = separateProps(queryParams);
122
123
  }
123
- const basePath = withoutTrailingSlash(urlOptions._path || "/");
124
+ const rawPath = urlOptions._path ?? "/";
125
+ const basePath = parseSameOriginPath(typeof rawPath === "string" ? withoutTrailingSlash(rawPath) : rawPath);
126
+ if (!basePath) {
127
+ return createError({
128
+ statusCode: 400,
129
+ statusMessage: "[Nuxt OG Image] The _path option must be a same-origin path."
130
+ });
131
+ }
124
132
  const componentHash = urlOptions._componentHash || "";
125
133
  delete urlOptions._path;
126
134
  delete urlOptions._hash;
@@ -1,20 +1,12 @@
1
1
  import type { RuntimeFontConfig } from '../../types.js';
2
2
  /**
3
- * Rename unicode-range subset fonts so renderers can fall back between them.
4
- *
5
- * Both Satori and Takumi pick the first loaded font file for a given family
6
- * name and don't fall back to other font files for missing glyphs. When CJK
7
- * fonts like "Noto Sans SC" are split into 100+ unicode-range subsets by
8
- * fontsource, each covering ~200 characters, this means only one subset's
9
- * glyphs render while the rest show as .notdef boxes.
10
- *
11
- * Fix: rename each subset to "Family__N" and use font-family fallback chains
12
- * so renderers try each subset in order per character.
3
+ * Give subsets stable names so renderers can fall back between their glyphs.
4
+ * Renderers cache registrations by name across renders with different filtered subsets.
13
5
  */
14
6
  export declare function renameSubsetFonts(fonts: RuntimeFontConfig[]): RuntimeFontConfig[];
15
7
  /**
16
8
  * Build a mapping from original family names to their renamed subset chain.
17
- * E.g., "Noto Sans SC" → ["Noto Sans SC__0", "Noto Sans SC__1", ...]
9
+ * E.g., "Noto Sans SC" → ["Noto Sans SC__<hash-a>", "Noto Sans SC__<hash-b>", ...]
18
10
  */
19
11
  export declare function buildSubsetFamilyChain(fonts: RuntimeFontConfig[]): Map<string, string[]>;
20
12
  /**
@@ -1,44 +1,67 @@
1
+ import { fnv1a64Base36 } from "fnv1a-64";
1
2
  export function renameSubsetFonts(fonts) {
2
3
  const groups = /* @__PURE__ */ new Map();
3
- for (const f of fonts) {
4
- const key = `${f.family}\0${f.weight}\0${f.style}`;
5
- const arr = groups.get(key);
6
- if (arr)
7
- arr.push(f);
4
+ for (const font of fonts) {
5
+ const members = groups.get(font.family);
6
+ if (members)
7
+ members.push(font);
8
8
  else
9
- groups.set(key, [f]);
9
+ groups.set(font.family, [font]);
10
10
  }
11
- const result = [];
12
- let changed = false;
11
+ const aliases = /* @__PURE__ */ new Map();
13
12
  for (const members of groups.values()) {
14
- const needsRename = members.length > 1 && new Set(members.map((f) => f.cacheKey)).size > 1;
15
- if (!needsRename) {
16
- result.push(...members);
17
- continue;
13
+ const faces = /* @__PURE__ */ new Map();
14
+ for (const font of members) {
15
+ const face = `${font.weight}\0${font.style}`;
16
+ const subsets = faces.get(face);
17
+ if (subsets)
18
+ subsets.push(font);
19
+ else
20
+ faces.set(face, [font]);
18
21
  }
19
- changed = true;
20
- for (let i = 0; i < members.length; i++) {
21
- const f = members[i];
22
- result.push({
23
- ...f,
24
- originalFamily: f.originalFamily || f.family,
25
- family: `${f.family}__${i}`
22
+ const needsRename = members.some((font) => font.unicodeRange) || [...faces.values()].some((subsets) => new Set(subsets.map((font) => font.cacheKey)).size > 1);
23
+ if (!needsRename)
24
+ continue;
25
+ const slots = [];
26
+ for (const subsets of faces.values()) {
27
+ const binaries = /* @__PURE__ */ new Map();
28
+ for (const font of subsets) {
29
+ const source = font.src || font.localPath || font.cacheKey;
30
+ const entries = binaries.get(source);
31
+ if (entries)
32
+ entries.push(font);
33
+ else
34
+ binaries.set(source, [font]);
35
+ }
36
+ const ordered = [...binaries.values()].sort((a, b) => {
37
+ const left = `${a[0].unicodeRange || ""}\0${a[0].src || a[0].localPath || a[0].cacheKey}`;
38
+ const right = `${b[0].unicodeRange || ""}\0${b[0].src || b[0].localPath || b[0].cacheKey}`;
39
+ return left < right ? -1 : left > right ? 1 : 0;
26
40
  });
41
+ for (const [index, entries] of ordered.entries())
42
+ (slots[index] ||= []).push(...entries);
43
+ }
44
+ for (const slot of slots) {
45
+ const identities = [...new Set(slot.map((font) => JSON.stringify([
46
+ font.weight,
47
+ font.style,
48
+ font.src || font.localPath || font.cacheKey
49
+ ])))].sort();
50
+ const alias = `${slot[0].family}__${fnv1a64Base36(JSON.stringify(identities))}`;
51
+ for (const font of slot)
52
+ aliases.set(font, alias);
27
53
  }
28
54
  }
29
- return changed ? result : fonts;
55
+ return aliases.size ? fonts.map((font) => aliases.has(font) ? { ...font, originalFamily: font.originalFamily || font.family, family: aliases.get(font) } : font) : fonts;
30
56
  }
31
57
  export function buildSubsetFamilyChain(fonts) {
32
58
  const chains = /* @__PURE__ */ new Map();
33
59
  for (const f of fonts) {
34
- if (!f.originalFamily)
35
- continue;
36
- const arr = chains.get(f.originalFamily);
37
- if (arr)
38
- arr.push(f.family);
39
- else
40
- chains.set(f.originalFamily, [f.family]);
60
+ if (f.originalFamily)
61
+ chains.set(f.originalFamily, []);
41
62
  }
63
+ for (const f of fonts)
64
+ chains.get(f.originalFamily || f.family)?.push(f.family);
42
65
  return chains;
43
66
  }
44
67
  export function resolveSubsetChain(family, chains) {
@@ -51,7 +51,7 @@ export interface LoadFontsForRendererOptions extends LoadFontsOptions {
51
51
  *
52
52
  * When a font family has multiple unicode-range subsets loaded (e.g., CJK fonts
53
53
  * split into many small chunks), renames each subset to a unique family name
54
- * (e.g., "Noto Sans SC__0", "Noto Sans SC__1") so renderers use font-family
54
+ * (e.g., "Noto Sans SC__<stable hash per binary>") so renderers use font-family
55
55
  * fallback chains for per-character glyph coverage. Without this, both Satori
56
56
  * and Takumi pick the first font file and show .notdef for characters in other subsets.
57
57
  */
@@ -4,6 +4,7 @@ import { fetchWithEvent } from "#nuxtseo/nitro";
4
4
  import { getSiteConfig } from "#site-config/server/composables/getSiteConfig";
5
5
  import { isInternalRoute } from "../../shared.js";
6
6
  import { getEventQuery } from "../util/query.js";
7
+ import { parseSameOriginPath } from "../util/ssrf.js";
7
8
  import { useOgImageRuntimeConfig } from "../utils.js";
8
9
  const RE_META_TAG = /<meta\b[^>]*>/gi;
9
10
  const RE_META_KEY = /\b(?:property|name)\s*=\s*(?:"([^"]+)"|'([^']+)')/i;
@@ -11,7 +12,6 @@ const RE_META_CONTENT = /\bcontent\s*=\s*(?:"([^"]*)"|'([^']*)')/i;
11
12
  const MAX_RESOLVE_PATH_LENGTH = 2048;
12
13
  const RE_STRIP_PREFIX = /^.*?\/_og\/r/;
13
14
  const RE_IMAGE_EXT = /\.(?:png|jpe?g|webp|svg)$/i;
14
- const RE_DOUBLE_LEADING_SLASH = /^\/{2,}/;
15
15
  function extractMeta(html, key) {
16
16
  for (const tagMatch of html.matchAll(RE_META_TAG)) {
17
17
  const tag = tagMatch[0];
@@ -78,7 +78,7 @@ export default defineEventHandler(async (event) => {
78
78
  statusMessage: `[Nuxt OG Image] Target path exceeds ${MAX_RESOLVE_PATH_LENGTH} characters.`
79
79
  });
80
80
  }
81
- if (targetPath.includes("://") || RE_DOUBLE_LEADING_SLASH.test(targetPath)) {
81
+ if (targetPath.includes("://") || !parseSameOriginPath(targetPath)) {
82
82
  throw createError({
83
83
  statusCode: 400,
84
84
  statusMessage: "[Nuxt OG Image] Target path must be a same-origin path."
@@ -7,11 +7,11 @@ export function getCloudflareEnv(event) {
7
7
  const runtimeEnv = toRecord(runtimeCloudflare?.env);
8
8
  if (runtimeEnv)
9
9
  return runtimeEnv;
10
- const contextCloudflare = toRecord(event?.context.cloudflare);
10
+ const contextCloudflare = toRecord(event?.context?.cloudflare);
11
11
  const contextEnv = toRecord(contextCloudflare?.env);
12
12
  if (contextEnv)
13
13
  return contextEnv;
14
- const platform = toRecord(event?.context._platform);
14
+ const platform = toRecord(event?.context?._platform);
15
15
  const platformCloudflare = toRecord(platform?.cloudflare);
16
16
  return toRecord(platformCloudflare?.env);
17
17
  }
@@ -1,4 +1,4 @@
1
- import { createError, getRequestHost, H3Error, setHeader } from "#nuxtseo/h3";
1
+ import { appendResponseHeader, createError, getRequestHost, H3Error, setHeader } from "#nuxtseo/h3";
2
2
  import { getSiteConfig } from "#site-config/server/composables/getSiteConfig";
3
3
  import { logger } from "../../logger.js";
4
4
  import { getBuildCachedImage, setBuildCachedImage } from "../og-image/cache/buildCache.js";
@@ -22,8 +22,8 @@ export async function imageEventHandler(e) {
22
22
  } finally {
23
23
  timings.record("total", performance.now() - reqStart);
24
24
  const header = timings.header();
25
- if (header)
26
- setHeader(e, "Server-Timing", header);
25
+ if (header && !e.node?.res?.headersSent)
26
+ appendResponseHeader(e, "Server-Timing", header);
27
27
  }
28
28
  }
29
29
  async function renderOgImage(e, ctx) {