nuxt-og-image 7.0.0 → 7.0.2

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.
@@ -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.DR_SxdDv.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.CSPRgfu6.mjs';
6
6
  import 'node:fs';
7
7
  import 'node:module';
8
8
  import '@nuxt/kit';
@@ -225,7 +225,7 @@ async function parseCssOutput(rawCss, vars, extractedAccum) {
225
225
  if (!extractedAccum.rootVars.has(n))
226
226
  extractedAccum.rootVars.set(n, v);
227
227
  }
228
- extractedAccum.qualifiedRules.push(...fresh.qualifiedRules);
228
+ extractedAccum.qualifiedRules = fresh.qualifiedRules;
229
229
  for (const [n, v] of fresh.universalVars) {
230
230
  if (!extractedAccum.universalVars.has(n))
231
231
  extractedAccum.universalVars.set(n, v);
@@ -254,7 +254,16 @@ const LINEAR_GRADIENT_DIRECTIONS = {
254
254
  "bg-gradient-to-b": "to bottom",
255
255
  "bg-gradient-to-bl": "to bottom left",
256
256
  "bg-gradient-to-l": "to left",
257
- "bg-gradient-to-tl": "to top left"
257
+ "bg-gradient-to-tl": "to top left",
258
+ // Tailwind v4 names
259
+ "bg-linear-to-t": "to top",
260
+ "bg-linear-to-tr": "to top right",
261
+ "bg-linear-to-r": "to right",
262
+ "bg-linear-to-br": "to bottom right",
263
+ "bg-linear-to-b": "to bottom",
264
+ "bg-linear-to-bl": "to bottom left",
265
+ "bg-linear-to-l": "to left",
266
+ "bg-linear-to-tl": "to top left"
258
267
  };
259
268
  const RADIAL_GRADIENT_SHAPES = {
260
269
  "bg-radial": "circle at center",
@@ -268,32 +277,31 @@ const RADIAL_GRADIENT_SHAPES = {
268
277
  "bg-radial-at-tl": "circle at top left",
269
278
  "bg-radial-at-c": "circle at center"
270
279
  };
271
- async function resolveGradientColor(cls, vars) {
280
+ async function toGradientColor(value, vars) {
281
+ const resolved = await resolveVarsDeep(value, vars);
282
+ if (resolved.includes("var("))
283
+ return null;
284
+ const simplified = await simplifyCss(`.x{color:${resolved}}`);
285
+ return simplified.match(RE_CSS_COLOR_VALUE)?.[1]?.trim() ?? resolved;
286
+ }
287
+ async function resolveGradientColor(cls, vars, classVars) {
272
288
  const colorName = cls.replace(RE_GRADIENT_STOP_PREFIX, "");
273
289
  const shadeMatch = colorName.match(RE_COLOR_SHADE);
274
290
  if (shadeMatch?.[1] && shadeMatch?.[2]) {
275
- const varName2 = `--color-${shadeMatch[1]}-${shadeMatch[2]}`;
276
- const value2 = vars.get(varName2);
277
- if (value2) {
278
- const resolved = await resolveVarsDeep(value2, vars);
279
- if (!resolved.includes("var(")) {
280
- const simplified = await simplifyCss(`.x{color:${resolved}}`);
281
- return simplified.match(RE_CSS_COLOR_VALUE)?.[1]?.trim() ?? resolved;
282
- }
283
- }
291
+ const value2 = vars.get(`--color-${shadeMatch[1]}-${shadeMatch[2]}`);
292
+ const color2 = value2 ? await toGradientColor(value2, vars) : null;
293
+ if (color2)
294
+ return color2;
284
295
  }
285
- const varName = `--color-${colorName}`;
286
- const value = vars.get(varName);
287
- if (value) {
288
- const resolved = await resolveVarsDeep(value, vars);
289
- if (!resolved.includes("var(")) {
290
- const simplified = await simplifyCss(`.x{color:${resolved}}`);
291
- return simplified.match(RE_CSS_COLOR_VALUE)?.[1]?.trim() ?? resolved;
292
- }
293
- }
294
- return null;
296
+ const value = vars.get(`--color-${colorName}`);
297
+ const color = value ? await toGradientColor(value, vars) : null;
298
+ if (color)
299
+ return color;
300
+ const stop = cls.match(RE_GRADIENT_STOP_PREFIX)?.[1];
301
+ const own = stop ? classVars?.get(`--tw-gradient-${stop}`) : void 0;
302
+ return own ? toGradientColor(own, vars) : null;
295
303
  }
296
- async function buildGradient(classes, vars) {
304
+ async function buildGradient(classes, vars, perClassVars) {
297
305
  const linearDir = classes.find((c) => LINEAR_GRADIENT_DIRECTIONS[c]);
298
306
  const radialShape = classes.find((c) => RADIAL_GRADIENT_SHAPES[c]);
299
307
  const fromClass = classes.find((c) => c.startsWith("from-"));
@@ -303,9 +311,9 @@ async function buildGradient(classes, vars) {
303
311
  return null;
304
312
  if (!fromClass && !toClass)
305
313
  return null;
306
- const fromColor = fromClass ? await resolveGradientColor(fromClass, vars) : null;
307
- const viaColor = viaClass ? await resolveGradientColor(viaClass, vars) : null;
308
- const toColor = toClass ? await resolveGradientColor(toClass, vars) : null;
314
+ const fromColor = fromClass ? await resolveGradientColor(fromClass, vars, perClassVars?.get(fromClass)) : null;
315
+ const viaColor = viaClass ? await resolveGradientColor(viaClass, vars, perClassVars?.get(viaClass)) : null;
316
+ const toColor = toClass ? await resolveGradientColor(toClass, vars, perClassVars?.get(toClass)) : null;
309
317
  if (!fromColor && !toColor)
310
318
  return null;
311
319
  const stops = [fromColor, viaColor, toColor].filter(Boolean).join(", ");
@@ -348,21 +356,20 @@ async function resolveClassesToStyles(classes, options, context, theme) {
348
356
  const outputCss = compiler.build(uncached);
349
357
  const { classes: parsedClasses, perClassVars } = await parseCssOutput(outputCss, vars, cachedExtractedVars || void 0);
350
358
  const resolveVarsForClass = theme ? buildThemeVars(vars, cachedExtractedVars, theme, options.nuxtUiColors) : vars;
351
- for (const [className, rawStyles] of parsedClasses) {
352
- const classVars = perClassVars.get(className);
353
- const mergedVars = classVars ? new Map([...resolveVarsForClass, ...classVars]) : resolveVarsForClass;
354
- const styles = await postProcessStyles(rawStyles, mergedVars, void 0, context);
359
+ for (const className of uncached) {
355
360
  const cacheKey = cacheKeyFor(className);
356
- if (!styles && className.startsWith("bg-gradient-")) {
357
- resolvedStyleCache.set(cacheKey, rawStyles);
358
- } else {
359
- resolvedStyleCache.set(cacheKey, styles);
361
+ const rawStyles = parsedClasses.get(className);
362
+ if (!rawStyles) {
363
+ resolvedStyleCache.set(cacheKey, null);
364
+ continue;
360
365
  }
361
- }
362
- for (const c of uncached) {
363
- const key = cacheKeyFor(c);
364
- if (!resolvedStyleCache.has(key))
365
- resolvedStyleCache.set(key, null);
366
+ if (LINEAR_GRADIENT_DIRECTIONS[className] || RADIAL_GRADIENT_SHAPES[className]) {
367
+ resolvedStyleCache.set(cacheKey, {});
368
+ continue;
369
+ }
370
+ const classVars = perClassVars.get(className);
371
+ const mergedVars = classVars ? new Map([...resolveVarsForClass, ...classVars]) : resolveVarsForClass;
372
+ resolvedStyleCache.set(cacheKey, await postProcessStyles(rawStyles, mergedVars, void 0, context));
366
373
  }
367
374
  }
368
375
  const result = {};
@@ -372,7 +379,7 @@ async function resolveClassesToStyles(classes, options, context, theme) {
372
379
  result[cls] = resolved;
373
380
  }
374
381
  const gradientVars = theme ? buildThemeVars(vars, cachedExtractedVars, theme, options.nuxtUiColors) : vars;
375
- const gradient = await buildGradient(classes, gradientVars);
382
+ const gradient = await buildGradient(classes, gradientVars, cachedExtractedVars?.perClassVars);
376
383
  if (gradient) {
377
384
  result[gradient.gradientClass] = { "background-image": gradient.value };
378
385
  for (const colorClass of gradient.colorClasses) {
@@ -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.DR_SxdDv.mjs';
5
- export { j as importResolvedModule, k as resolveOptionalModulePath } from '../shared/nuxt-og-image.DR_SxdDv.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.CSPRgfu6.mjs';
5
+ export { j as importResolvedModule, k as resolveOptionalModulePath } from '../shared/nuxt-og-image.CSPRgfu6.mjs';
6
6
  import 'node:fs';
7
7
  import 'node:module';
8
8
  import 'node:url';
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "nuxt": "^4.6.1 || ^5.0.0-0"
5
5
  },
6
6
  "configKey": "ogImage",
7
- "version": "7.0.0",
7
+ "version": "7.0.2",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.3",
10
10
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -7,7 +7,7 @@ import 'defu';
7
7
  import 'fnv1a-64';
8
8
  import 'nuxtseo-shared/kit';
9
9
  import 'pathe';
10
- export { n as default } from './shared/nuxt-og-image.DR_SxdDv.mjs';
10
+ export { n as default } from './shared/nuxt-og-image.CSPRgfu6.mjs';
11
11
  import '../dist/runtime/logger.js';
12
12
  import '../dist/runtime/shared/publish.js';
13
13
  import 'node:crypto';
@@ -26,7 +26,10 @@ interface Unavailable {
26
26
  _tag: 'Unavailable';
27
27
  reason: unknown;
28
28
  }
29
- /** Normalize signed image paths without loading a renderer during page SSR. */
29
+ /**
30
+ * Normalize signed image paths without loading a renderer during page SSR.
31
+ * The key is a full SHA-256 hex digest, so it has one fixed format.
32
+ */
30
33
  export declare function getPublishKey(url: string, defaults: Record<string, unknown>, cacheVersion: string): string;
31
34
  /** A page may reference the bucket only while its manifest and object both exist. */
32
35
  export declare function getPublishedImage(input: PublishInput): Promise<Published | Unavailable | {
@@ -1,12 +1,12 @@
1
1
  import { parseURL } from "ufo";
2
2
  import { parsePublishConfig } from "../../../shared/publish.js";
3
- import { extractEncodedSegment, hashOgImageOptions } from "../../../shared/urlEncoding.js";
3
+ import { digestCanonical, extractEncodedSegment } from "../../../shared/urlEncoding.js";
4
4
  import { withTimeout } from "../../util/withTimeout.js";
5
5
  export function getPublishKey(url, defaults, cacheVersion) {
6
6
  const { pathname: path, host, protocol } = parseURL(url);
7
7
  const extension = path.split(".").pop();
8
8
  const segment = decodeURIComponent(extractEncodedSegment(path, extension)).replace(/,s_[^,]+$/, "");
9
- return hashOgImageOptions({ segment, defaults, host, protocol }, "", cacheVersion);
9
+ return digestCanonical([{ segment, defaults, host, protocol }, cacheVersion]);
10
10
  }
11
11
  export async function getPublishedImage(input) {
12
12
  return withTimeout((async () => {
@@ -1,4 +1,3 @@
1
1
  import type { H3Error, H3Event } from '#nuxtseo/h3';
2
2
  import type { OgImageRenderEventContext } from '../../types.js';
3
- export declare function resolvePathCacheKey(e: H3Event, path: string, resolvedOptions?: Record<string, any>): string;
4
3
  export declare function resolveContext(e: H3Event): Promise<H3Error | OgImageRenderEventContext>;
@@ -1,6 +1,5 @@
1
1
  import { defu } from "defu";
2
- import { parseURL, withoutLeadingSlash, withoutTrailingSlash, withQuery } from "ufo";
3
- import { normalizeKey } from "unstorage";
2
+ import { parseURL, withoutTrailingSlash, withQuery } from "ufo";
4
3
  import { createError } from "#nuxtseo/h3";
5
4
  import { useNitroApp } from "#nuxtseo/nitro";
6
5
  import { prerenderOptionsCache } from "#og-image-cache";
@@ -8,33 +7,18 @@ import { getSiteConfig } from "#site-config/server/composables/getSiteConfig";
8
7
  import { createSitePathResolver } from "#site-config/server/composables/utils";
9
8
  import { logger } from "../../logger.js";
10
9
  import { decodeOgImageParams, extractEncodedSegment, sanitizeProps, separateProps, verifyOgImageSignature } from "../../shared.js";
11
- import { hashKey } from "../../shared/hash.js";
10
+ import { digestCanonical } from "../../shared/urlEncoding.js";
12
11
  import { createNitroRouteRuleMatcher } from "../util/kit.js";
13
12
  import { normaliseOptions } from "../util/options.js";
14
13
  import { getEventQuery } from "../util/query.js";
15
14
  import { parseSameOriginPath } from "../util/ssrf.js";
15
+ import { toStorageKey } from "../util/storageKey.js";
16
16
  import { createTimings, TIMING_CTX_KEY } from "../util/timings.js";
17
17
  import { withTimeout } from "../util/withTimeout.js";
18
18
  import { useOgImageRuntimeConfig } from "../utils.js";
19
19
  import { getBrowserRenderer, getSatoriRenderer, getTakumiRenderer } from "./instances.js";
20
20
  const RE_HASH_MODE = /^o_([a-z0-9]+)$/i;
21
21
  const RE_SIGNATURE_SUFFIX = /,s_([^,]+)$/;
22
- export function resolvePathCacheKey(e, path, resolvedOptions) {
23
- const siteConfig = getSiteConfig(e, {
24
- resolveRefs: true
25
- });
26
- const basePath = withoutTrailingSlash(withoutLeadingSlash(normalizeKey(path)));
27
- const hashParts = [
28
- basePath,
29
- import.meta.prerender ? "" : siteConfig.url
30
- ];
31
- if (resolvedOptions)
32
- hashParts.push(hashKey(resolvedOptions));
33
- return [
34
- !basePath || basePath === "/" ? "index" : basePath,
35
- hashKey(hashParts)
36
- ].join(":");
37
- }
38
22
  export async function resolveContext(e) {
39
23
  const runtimeConfig = useOgImageRuntimeConfig(e);
40
24
  const resolvePathWithBase = createSitePathResolver(e, {
@@ -73,7 +57,7 @@ export async function resolveContext(e) {
73
57
  if (hashMatch) {
74
58
  const optionsHash = hashMatch[1];
75
59
  if (import.meta.prerender && prerenderOptionsCache) {
76
- const cached = await prerenderOptionsCache.getItem(`hash:${optionsHash}`);
60
+ const cached = await prerenderOptionsCache.getItem(toStorageKey("prerender", optionsHash));
77
61
  if (cached && typeof cached === "object") {
78
62
  urlOptions = cached;
79
63
  } else {
@@ -121,13 +105,14 @@ export async function resolveContext(e) {
121
105
  queryParams = separateProps(queryParams);
122
106
  }
123
107
  const rawPath = urlOptions._path ?? "/";
124
- const basePath = parseSameOriginPath(typeof rawPath === "string" ? withoutTrailingSlash(rawPath) : rawPath);
125
- if (!basePath) {
108
+ const parsedPath = parseSameOriginPath(typeof rawPath === "string" ? withoutTrailingSlash(rawPath) : rawPath);
109
+ if (parsedPath._tag === "Err") {
126
110
  return createError({
127
111
  statusCode: 400,
128
- statusMessage: "[Nuxt OG Image] The _path option must be a same-origin path."
112
+ statusMessage: `[Nuxt OG Image] The _path option must be a same-origin path. ${parsedPath.reason}`
129
113
  });
130
114
  }
115
+ const basePath = parsedPath.value;
131
116
  const componentHash = urlOptions._componentHash || "";
132
117
  delete urlOptions._path;
133
118
  delete urlOptions._hash;
@@ -174,8 +159,8 @@ export async function resolveContext(e) {
174
159
  normalised.options.props = filtered;
175
160
  }
176
161
  const rendererType = normalised.renderer;
177
- const baseCacheKey = normalised.options.cacheKey || (hashMatch ? `hash:${hashMatch[1]}` : resolvePathCacheKey(e, basePathWithQuery, normalised.options));
178
- const key = componentHash ? `${baseCacheKey}:${componentHash}` : baseCacheKey;
162
+ const identity = normalised.options.cacheKey ? ["cacheKey", String(normalised.options.cacheKey)] : hashMatch ? ["hash", hashMatch[1]] : ["page", basePathWithQuery, import.meta.prerender ? "" : getSiteConfig(e, { resolveRefs: true }).url, normalised.options];
163
+ const key = digestCanonical([...identity, componentHash]);
179
164
  let renderer;
180
165
  switch (rendererType) {
181
166
  case "satori":
@@ -1,4 +1,4 @@
1
- import { withBase, withoutLeadingSlash } from "ufo";
1
+ import { parseURL, withBase, withoutLeadingSlash } from "ufo";
2
2
  import { useStorage } from "#nuxtseo/nitro";
3
3
  import { toBase64Image } from "../../../../shared.js";
4
4
  import { decodeHtml } from "../../../util/encoding.js";
@@ -6,15 +6,18 @@ import { fetchLocalAsset } from "../../../util/fetchLocalAsset.js";
6
6
  import { getFetchTimeout } from "../../../util/fetchTimeout.js";
7
7
  import { logger } from "../../../util/logger.js";
8
8
  import { getOgImageOrigin } from "../../../util/origin.js";
9
- import { fetchWithRedirectValidation, isBlockedUrl } from "../../../util/ssrf.js";
9
+ import { fetchWithRedirectValidation, isBlockedUrl, parseSameOriginPath } from "../../../util/ssrf.js";
10
10
  import { getImageDimensions } from "../../utils/image-detector.js";
11
11
  import { defineTransformer } from "../plugins.js";
12
12
  const RE_URL_LEADING = /^url\(['"]?/;
13
13
  const RE_URL_TRAILING = /['"]?\)$/;
14
14
  const SUBREQUEST_HEADERS = { "x-nuxt-og-image": "1" };
15
15
  async function resolveLocalFilePathImage(publicStoragePath, src) {
16
+ const path = parseSameOriginPath(parseURL(src).pathname);
17
+ if (path._tag === "Err")
18
+ return;
16
19
  const normalizedSrc = withoutLeadingSlash(
17
- src.replace("_nuxt/@fs/", "").replace("_nuxt/", "").replace("./", "")
20
+ decodeURIComponent(path.value).replace("_nuxt/@fs/", "").replace("_nuxt/", "").replace("./", "")
18
21
  ).replaceAll("/", ":");
19
22
  const key = `${publicStoragePath}:${normalizedSrc}`;
20
23
  if (await useStorage().hasItem(key))
@@ -1,6 +1,7 @@
1
1
  import { $fetch } from "ofetch";
2
2
  import { emojiCache } from "#og-image-cache";
3
3
  import { getFetchTimeout } from "../../../../util/fetchTimeout.js";
4
+ import { toStorageKey } from "../../../../util/storageKey.js";
4
5
  import { getEmojiCodePoint, getEmojiIconNames } from "./emoji-utils.js";
5
6
  export async function getEmojiSvg(ctx, emojiChar) {
6
7
  const emojiSet = ctx.options.emojis;
@@ -8,7 +9,7 @@ export async function getEmojiSvg(ctx, emojiChar) {
8
9
  const possibleNames = getEmojiIconNames(codePoint, emojiSet);
9
10
  let svg = null;
10
11
  for (const iconName of possibleNames) {
11
- const key = ["1", emojiSet, iconName].join("|");
12
+ const key = toStorageKey("emoji", [emojiSet, iconName]);
12
13
  if (await emojiCache.hasItem(key)) {
13
14
  svg = await emojiCache.getItem(key);
14
15
  if (svg)
@@ -24,15 +25,14 @@ export async function getEmojiSvg(ctx, emojiChar) {
24
25
  if (deadline.aborted)
25
26
  break;
26
27
  try {
27
- svg = await $fetch(`https://api.iconify.design/${emojiSet}/${iconName}.svg`, {
28
+ svg = await $fetch(`https://api.iconify.design/${encodeURIComponent(emojiSet)}/${encodeURIComponent(iconName)}.svg`, {
28
29
  responseType: "text",
29
30
  retry: 0,
30
31
  signal: deadline,
31
32
  timeout
32
33
  });
33
34
  if (svg && svg !== "404") {
34
- const key = ["1", emojiSet, iconName].join("|");
35
- await emojiCache.setItem(key, svg);
35
+ await emojiCache.setItem(toStorageKey("emoji", [emojiSet, iconName]), svg);
36
36
  break;
37
37
  }
38
38
  } catch {
@@ -7,4 +7,4 @@ export interface DevToolsExtractPayload {
7
7
  images: DevToolsMetaDataExtraction[];
8
8
  };
9
9
  }
10
- export declare function fetchPathHtmlAndExtractOptions(e: H3Event, path: string, cacheKey: string): Promise<H3Error | DevToolsExtractPayload>;
10
+ export declare function fetchPathHtmlAndExtractOptions(e: H3Event, path: string, key: string): Promise<H3Error | DevToolsExtractPayload>;
@@ -3,6 +3,7 @@ import { createError } from "#nuxtseo/h3";
3
3
  import { htmlPayloadCache } from "#og-image-cache";
4
4
  import { extractSocialPreviewTags } from "../../pure.js";
5
5
  import { logger } from "../util/logger.js";
6
+ import { toStorageKey } from "../util/storageKey.js";
6
7
  const PAYLOAD_REGEX = /<script.+id="nuxt-og-image-options"[^>]*>(.+?)<\/script>/;
7
8
  function extractOptionsFromHtml(html) {
8
9
  const match = String(html).match(PAYLOAD_REGEX);
@@ -36,7 +37,8 @@ async function doFetchWithErrorHandling(fetch, path) {
36
37
  }
37
38
  return [res._data || await res.text(), null];
38
39
  }
39
- export async function fetchPathHtmlAndExtractOptions(e, path, cacheKey) {
40
+ export async function fetchPathHtmlAndExtractOptions(e, path, key) {
41
+ const cacheKey = toStorageKey("devtools", key);
40
42
  const cachedHtmlPayload = await htmlPayloadCache.getItem(cacheKey);
41
43
  if (!import.meta.dev && cachedHtmlPayload && cachedHtmlPayload.expiresAt > Date.now())
42
44
  return cachedHtmlPayload.value;
@@ -4,12 +4,11 @@ import { appendResponseHeader } from "#nuxtseo/h3";
4
4
  import { defineNitroPlugin, useRuntimeConfig, useStorage } from "#nuxtseo/nitro";
5
5
  import { prerenderOptionsCache } from "#og-image-cache";
6
6
  import { getSiteConfig } from "#site-config/server/composables/getSiteConfig";
7
- import { createSitePathResolver } from "#site-config/server/composables/utils";
8
7
  import { logger } from "../../logger.js";
9
8
  import { isInternalRoute } from "../../shared.js";
10
9
  import { getPublishKey, publishImage } from "../og-image/cache/publish.js";
11
- import { resolvePathCacheKey } from "../og-image/context.js";
12
10
  import { createNitroRouteRuleMatcher } from "../util/kit.js";
11
+ import { toStorageKey } from "../util/storageKey.js";
13
12
  import { useOgImageRuntimeConfig } from "../utils.js";
14
13
  const PAYLOAD_REGEX = /<script.+id="nuxt-og-image-options"[^>]*>(.+?)<\/script>/;
15
14
  const RE_SCRIPT_OPTIONS = /<script id="nuxt-og-image-options" type="application\/json">[\s\S]*?<\/script>/;
@@ -46,16 +45,10 @@ export default defineNitroPlugin(async (nitro) => {
46
45
  return;
47
46
  const parsed = parse(_payload);
48
47
  const payloads = parsed.map((opt) => [opt.key || "og", opt]);
49
- const resolvePathWithBase = createSitePathResolver(ctx.event, {
50
- absolute: false,
51
- withBase: true
52
- });
53
- const key = resolvePathCacheKey(ctx.event, resolvePathWithBase(path));
54
- await prerenderOptionsCache.setItem(key, payloads);
55
48
  const pagePath = withoutBase(path, useRuntimeConfig().app.baseURL);
56
49
  for (const [_ogKey, opt] of payloads) {
57
50
  if (opt._hash) {
58
- await prerenderOptionsCache.setItem(`hash:${opt._hash}`, { ...opt, _path: pagePath });
51
+ await prerenderOptionsCache.setItem(toStorageKey("prerender", opt._hash), { ...opt, _path: pagePath });
59
52
  }
60
53
  }
61
54
  const prerenderPaths = ctx.event.context._ogImagePrerenderPaths;
@@ -69,7 +62,7 @@ export default defineNitroPlugin(async (nitro) => {
69
62
  const extension = prerenderPath.split(".").pop();
70
63
  if (!prerenderPath.includes("/_og/s/") || extension !== "png" && extension !== "jpeg" && extension !== "jpg" && extension !== "webp")
71
64
  continue;
72
- const opt = payloads.find(([key2]) => key2 === ogKey)?.[1];
65
+ const opt = payloads.find(([key]) => key === ogKey)?.[1];
73
66
  if (!opt)
74
67
  continue;
75
68
  const result = await publishImage({
@@ -84,9 +77,8 @@ export default defineNitroPlugin(async (nitro) => {
84
77
  render: async () => {
85
78
  if (opt.component === "PageScreenshot") {
86
79
  opt._prerenderHtml = `<!DOCTYPE html><html ${(html.htmlAttrs || []).join(" ")}><head>${html.head.join("")}</head><body ${(html.bodyAttrs || []).join(" ")}>${[...html.bodyPrepend || [], ...html.body || [], ...html.bodyAppend].join("")}</body></html>`;
87
- await prerenderOptionsCache.setItem(key, payloads);
88
80
  if (opt._hash)
89
- await prerenderOptionsCache.setItem(`hash:${opt._hash}`, { ...opt, _path: pagePath });
81
+ await prerenderOptionsCache.setItem(toStorageKey("prerender", opt._hash), { ...opt, _path: pagePath });
90
82
  }
91
83
  const response = await nitro.localFetch(opt._query ? withQuery(prerenderPath, { _query: opt._query }) : prerenderPath);
92
84
  if (!response.ok || !response.headers.get("content-type")?.startsWith("image/"))
@@ -1,4 +1,4 @@
1
- import { parseURL, withLeadingSlash, withQuery } from "ufo";
1
+ import { parseURL, withQuery } from "ufo";
2
2
  import { createError, defineEventHandler, getRequestHost, sendRedirect } from "#nuxtseo/h3";
3
3
  import { fetchWithEvent } from "#nuxtseo/nitro";
4
4
  import { getSiteConfig } from "#site-config/server/composables/getSiteConfig";
@@ -78,13 +78,15 @@ 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("://") || !parseSameOriginPath(targetPath)) {
81
+ const parsedPath = parseSameOriginPath(targetPath);
82
+ if (targetPath.includes("://") || parsedPath._tag === "Err") {
82
83
  throw createError({
83
84
  statusCode: 400,
84
85
  statusMessage: "[Nuxt OG Image] Target path must be a same-origin path."
85
86
  });
86
87
  }
87
- if (isInternalRoute(targetPath)) {
88
+ const pagePath = parsedPath.value;
89
+ if (isInternalRoute(pagePath)) {
88
90
  throw createError({
89
91
  statusCode: 400,
90
92
  statusMessage: "[Nuxt OG Image] Cannot resolve og:image for internal route."
@@ -98,7 +100,7 @@ export default defineEventHandler(async (event) => {
98
100
  if (!k.startsWith("_og_"))
99
101
  forwardQuery[k] = v;
100
102
  }
101
- const fetchPath = withQuery(withLeadingSlash(targetPath), forwardQuery);
103
+ const fetchPath = withQuery(pagePath, forwardQuery);
102
104
  const html = await fetchWithEvent(event, fetchPath, {
103
105
  headers: { accept: "text/html" },
104
106
  responseType: "text"
@@ -1,10 +1,10 @@
1
- import type { H3Error } from '#nuxtseo/h3';
2
1
  import type { OgImageRenderEventContext } from '../../types.js';
3
2
  export declare function useOgImageBufferCache(ctx: OgImageRenderEventContext, options: {
4
3
  baseCacheKey: string | false;
5
4
  cacheMaxAgeSeconds?: number;
6
- secret?: string;
7
- }): Promise<void | H3Error | {
5
+ /** The caller has checked the purge token. Drop the stored entry and render again. */
6
+ purge?: boolean;
7
+ }): Promise<void | {
8
8
  cachedItem: false | BufferSource;
9
9
  enabled: boolean;
10
10
  expiresAt: number;
@@ -1,18 +1,10 @@
1
1
  import { fnv1a64Base36 } from "fnv1a-64";
2
2
  import { withTrailingSlash } from "ufo";
3
3
  import { prefixStorage } from "unstorage";
4
- import { createError, handleCacheHeaders, setHeader, setHeaders } from "#nuxtseo/h3";
4
+ import { handleCacheHeaders, setHeader, setHeaders } from "#nuxtseo/h3";
5
5
  import { useStorage } from "#nuxtseo/nitro";
6
6
  import { logger } from "../../logger.js";
7
- import { getEventQuery } from "./query.js";
8
- function safeCompare(a, b) {
9
- if (a.length !== b.length)
10
- return false;
11
- let mismatch = 0;
12
- for (let i = 0; i < a.length; i++)
13
- mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
14
- return mismatch === 0;
15
- }
7
+ import { toStorageKey } from "./storageKey.js";
16
8
  function warnCacheBackendUnreachable(ctx, baseCacheKey, e) {
17
9
  if (ctx._nitro._ogImageCacheBackendWarned)
18
10
  return;
@@ -24,7 +16,7 @@ export async function useOgImageBufferCache(ctx, options) {
24
16
  const intentionallyEnabled = options.baseCacheKey !== false && !import.meta.dev && !import.meta.prerender && maxAge > 0;
25
17
  let enabled = intentionallyEnabled;
26
18
  const cache = options.baseCacheKey === false ? void 0 : prefixStorage(useStorage(), withTrailingSlash(options.baseCacheKey));
27
- const key = ctx.key;
19
+ const key = toStorageKey("image", ctx.key);
28
20
  let cachedItem = false;
29
21
  let cacheExpiresAt = 0;
30
22
  if (enabled && cache) {
@@ -40,14 +32,7 @@ export async function useOgImageBufferCache(ctx, options) {
40
32
  }) : null;
41
33
  if (entry) {
42
34
  const { value, expiresAt, headers } = entry;
43
- const purgeValue = getEventQuery(ctx.e).purge;
44
- if (typeof purgeValue !== "undefined") {
45
- if (options.secret && !safeCompare(String(purgeValue), options.secret)) {
46
- return createError({
47
- statusCode: 403,
48
- statusMessage: "[Nuxt OG Image] Invalid purge token. Provide the signing secret as ?purge=<secret>."
49
- });
50
- }
35
+ if (options.purge) {
51
36
  await cache.removeItem(key).catch((err) => {
52
37
  void err;
53
38
  });
@@ -11,6 +11,7 @@ import { html } from "../og-image/templates/html.js";
11
11
  import { useOgImageRuntimeConfig } from "../utils.js";
12
12
  import { useOgImageBufferCache } from "./cache.js";
13
13
  import { warnPublishUnavailable } from "./publishWarnings.js";
14
+ import { parsePurgeRequest } from "./purge.js";
14
15
  import { getEventQuery } from "./query.js";
15
16
  export async function imageEventHandler(e) {
16
17
  const reqStart = performance.now();
@@ -64,6 +65,14 @@ async function renderOgImage(e, ctx) {
64
65
  }
65
66
  }
66
67
  }
68
+ const purgeRequest = import.meta.dev || import.meta.prerender ? { _tag: "None" } : parsePurgeRequest(getEventQuery(e).purge, security?.secret);
69
+ if (purgeRequest._tag === "Denied") {
70
+ return createError({
71
+ statusCode: 403,
72
+ statusMessage: "[Nuxt OG Image] Invalid purge token. Provide the signing secret as ?purge=<secret>."
73
+ });
74
+ }
75
+ const purge = purgeRequest._tag === "Authorized";
67
76
  if ((import.meta.dev || debug) && isDevToolsContextRequest) {
68
77
  setHeader(e, "Content-Type", "application/json");
69
78
  const [extract, rendererDebug] = await Promise.all([
@@ -122,7 +131,6 @@ async function renderOgImage(e, ctx) {
122
131
  timings.record("cache-hit", 0);
123
132
  return buildCachedImage;
124
133
  }
125
- const purge = typeof getEventQuery(e).purge !== "undefined";
126
134
  let publication = publish && !import.meta.dev && !import.meta.prerender ? (() => {
127
135
  const key = getPublishKey(new URL(e.path, getSiteConfig(e).url).href, config.defaults, publish.cacheVersion);
128
136
  return {
@@ -158,14 +166,11 @@ async function renderOgImage(e, ctx) {
158
166
  const cacheApi = await useOgImageBufferCache(ctx, {
159
167
  cacheMaxAgeSeconds: ctx.options.cacheMaxAgeSeconds,
160
168
  baseCacheKey,
161
- secret: security?.secret || void 0
169
+ purge
162
170
  }).finally(endCacheLookup);
163
171
  if (typeof cacheApi === "undefined") {
164
172
  return;
165
173
  }
166
- if (cacheApi instanceof H3Error) {
167
- return cacheApi;
168
- }
169
174
  if (publication && purge)
170
175
  publication.cache.forget(publication.cacheKey);
171
176
  let image = cacheApi.cachedItem;
@@ -0,0 +1,14 @@
1
+ /** What a request asks of the runtime cache through its `purge` query value. */
2
+ export type PurgeRequest = {
3
+ _tag: 'None';
4
+ } | {
5
+ _tag: 'Authorized';
6
+ } | {
7
+ _tag: 'Denied';
8
+ };
9
+ /**
10
+ * Parse the `purge` query value. A purge needs the signing secret as its
11
+ * value, the same secret that signs image URLs. Without a secret, no purge
12
+ * can pass.
13
+ */
14
+ export declare function parsePurgeRequest(value: unknown, secret: string | false | undefined): PurgeRequest;
@@ -0,0 +1,15 @@
1
+ function safeCompare(a, b) {
2
+ if (a.length !== b.length)
3
+ return false;
4
+ let mismatch = 0;
5
+ for (let i = 0; i < a.length; i++)
6
+ mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
7
+ return mismatch === 0;
8
+ }
9
+ export function parsePurgeRequest(value, secret) {
10
+ if (value === void 0)
11
+ return { _tag: "None" };
12
+ if (typeof secret !== "string" || !secret || typeof value !== "string")
13
+ return { _tag: "Denied" };
14
+ return safeCompare(value, secret) ? { _tag: "Authorized" } : { _tag: "Denied" };
15
+ }
@@ -21,17 +21,24 @@ declare const sameOriginPathBrand: unique symbol;
21
21
  export type SameOriginPath = string & {
22
22
  readonly [sameOriginPathBrand]: true;
23
23
  };
24
+ export type SameOriginPathResult = {
25
+ _tag: 'Ok';
26
+ value: SameOriginPath;
27
+ } | {
28
+ _tag: 'Err';
29
+ reason: string;
30
+ };
24
31
  /**
25
32
  * Parse an untrusted path (for example the `_path` URL option) into a
26
- * same-origin path. Returns `undefined` for anything that could leave the
27
- * origin: absolute URLs, other schemes, protocol-relative `//host`,
28
- * backslash variants, control characters, and paths without a leading `/`
29
- * (which a string concatenation with the origin turns into `origin@host`).
33
+ * normalized same-origin path. The input is decoded once and must then be a
34
+ * plain absolute path: one leading `/`, no `.` or `..` segments, no
35
+ * encoded separators, no `?` or `#`, and no control characters. The value
36
+ * is the percent-encoded pathname, so it resolves the same way everywhere.
30
37
  *
31
38
  * GHSA-h5qf-97cw-2h86: an absolute `_path` navigated the browser renderer to
32
39
  * an internal host and reflected the response into the image.
33
40
  */
34
- export declare function parseSameOriginPath(input: unknown): SameOriginPath | undefined;
41
+ export declare function parseSameOriginPath(input: unknown): SameOriginPathResult;
35
42
  /**
36
43
  * Resolve `path` against `origin` and return the href, but only when the result
37
44
  * stays on that origin. Returns null for a missing or unparseable origin, and for
@@ -153,28 +153,45 @@ export function isBlockedAddress(address) {
153
153
  return isBlockedUrl(`http://${address.includes(":") ? `[${address}]` : address}/`);
154
154
  }
155
155
  const PROBE_ORIGIN = "http://og-image.invalid";
156
- function hasUnsafePathChar(input) {
156
+ const RE_PERCENT_ESCAPE = /%[\da-f]{2}/i;
157
+ const RE_ENCODED_SEPARATOR = /%(?:2f|5c)/i;
158
+ function hasReservedPathChar(input) {
157
159
  for (let i = 0; i < input.length; i++) {
158
160
  const code = input.charCodeAt(i);
159
- if (code <= 31 || code === 127 || code === 92)
161
+ if (code <= 31 || code === 127 || code === 92 || code === 63 || code === 35)
160
162
  return true;
161
163
  }
162
164
  return false;
163
165
  }
166
+ function err(reason) {
167
+ return { _tag: "Err", reason };
168
+ }
164
169
  export function parseSameOriginPath(input) {
165
170
  if (typeof input !== "string" || input[0] !== "/" || input[1] === "/")
166
- return void 0;
167
- if (hasUnsafePathChar(input))
168
- return void 0;
171
+ return err("The path must start with a single /.");
172
+ if (RE_ENCODED_SEPARATOR.test(input))
173
+ return err("The path must not encode a / or \\.");
174
+ let decoded;
175
+ try {
176
+ decoded = decodeURIComponent(input);
177
+ } catch {
178
+ return err("The path has an invalid percent-encoding.");
179
+ }
180
+ if (RE_PERCENT_ESCAPE.test(decoded))
181
+ return err("The path must be percent-encoded once.");
182
+ if (hasReservedPathChar(decoded))
183
+ return err("The path must not contain ?, #, \\ or control characters.");
184
+ if (decoded.split("/").some((segment) => segment === "." || segment === ".."))
185
+ return err("The path must not contain . or .. segments.");
169
186
  let resolved;
170
187
  try {
171
188
  resolved = new URL(input, PROBE_ORIGIN);
172
189
  } catch {
173
- return void 0;
190
+ return err("The path is not a valid URL path.");
174
191
  }
175
- if (resolved.origin !== PROBE_ORIGIN)
176
- return void 0;
177
- return input;
192
+ if (resolved.origin !== PROBE_ORIGIN || resolved.search || resolved.hash)
193
+ return err("The path must stay on the same origin.");
194
+ return { _tag: "Ok", value: resolved.pathname };
178
195
  }
179
196
  export function resolveSameOriginUrl(path, origin) {
180
197
  if (!origin)
@@ -0,0 +1,14 @@
1
+ declare const storageKeyBrand: unique symbol;
2
+ /**
3
+ * A storage key made of a fixed namespace and a SHA-256 hex digest. Only
4
+ * `toStorageKey` creates one, so every key has the same short, fixed format
5
+ * whatever the identity it stands for.
6
+ */
7
+ export type StorageKey = string & {
8
+ readonly [storageKeyBrand]: true;
9
+ };
10
+ /** Each namespace keeps one kind of entry apart from the others. */
11
+ export type StorageKeyNamespace = 'image' | 'devtools' | 'emoji' | 'prerender';
12
+ /** Derive the storage key for an identity. The same identity always gives the same key. */
13
+ export declare function toStorageKey(namespace: StorageKeyNamespace, identity: unknown): StorageKey;
14
+ export {};
@@ -0,0 +1,4 @@
1
+ import { digestCanonical } from "../../shared/urlEncoding.js";
2
+ export function toStorageKey(namespace, identity) {
3
+ return `${namespace}:${digestCanonical(identity)}`;
4
+ }
@@ -1,3 +1,8 @@
1
+ /**
2
+ * SHA-256 hex digest (64 chars of [0-9a-f]) of a canonical, key-sorted
3
+ * serialization. Equal structures produce equal digests in every runtime.
4
+ */
5
+ export declare function digestCanonical(value: unknown): string;
1
6
  /**
2
7
  * Generate a deterministic hash from options object
3
8
  * Excludes _path so images with same options can be cached across pages,
@@ -112,11 +112,14 @@ function base64UrlToHex(input) {
112
112
  }
113
113
  return hex;
114
114
  }
115
+ export function digestCanonical(value) {
116
+ return base64UrlToHex(digest(canonicalJson(value)));
117
+ }
115
118
  export function hashOgImageOptions(options, componentHash, version) {
116
119
  const { _path, _hash, ...rest } = options;
117
120
  const hashableOptions = rest.component === "PageScreenshot" ? { ...rest, _path } : rest;
118
121
  const hashInput = componentHash || version ? [hashableOptions, componentHash || "", version || ""] : hashableOptions;
119
- return base64UrlToHex(digest(canonicalJson(hashInput))).slice(0, 16);
122
+ return digestCanonical(hashInput).slice(0, 16);
120
123
  }
121
124
  export function encodeOgImageParams(options, defaults) {
122
125
  const parts = [];
@@ -671,7 +671,7 @@ const RE_CSS_DECL = /([\w-]+)\s*:\s*([^\s;][^;]*)(?:;|$)/g;
671
671
  const RE_CSS_TOGGLE = /(--[\w-]+)\s*:\s+;/g;
672
672
  const RE_CSS_CLASS_RULE = /\.((?:\\[0-9a-f]{1,6}\s?|\\[^0-9a-f]|[^\\\s{])+)\s*\{([^}]+)\}/gi;
673
673
  const RE_ROOT_HOST_BLOCK = /:(?:root|host)[^\s{]*\s*\{([^}]+)\}/g;
674
- const RE_UNIVERSAL_BLOCK = /\*[^{]*\{([^}]+)\}/g;
674
+ const RE_UNIVERSAL_BLOCK = /(?:^|[{};])\s*\*[^{};]*\{([^{}]+)\}/g;
675
675
  const RE_PROPERTY_AT_RULE = /@property\s+(--[\w-]+)\s*\{([^}]+)\}/g;
676
676
  const RE_INITIAL_VALUE = /initial-value\s*:\s*([^\s;][^;]*);?/;
677
677
  const RE_CSS_ESCAPE_HEX = /\\([0-9a-f]{1,6})\s?/gi;
@@ -1112,15 +1112,24 @@ async function extractVarsFromCss(css) {
1112
1112
  }
1113
1113
  return { rootVars, qualifiedRules, universalVars, propertyInitials, perClassVars };
1114
1114
  }
1115
+ const MAX_VAR_SUBSTITUTIONS = 100;
1115
1116
  function resolveCssVars(css, vars) {
1116
1117
  let result = css;
1117
- let iterations = 0;
1118
- while (result.includes("var(") && iterations < 20) {
1119
- const next = resolveInnermostVar(result, vars);
1120
- if (next === result)
1118
+ let cursor = result.length;
1119
+ for (let substitutions = 0; substitutions < MAX_VAR_SUBSTITUTIONS; ) {
1120
+ const idx = result.lastIndexOf("var(", cursor);
1121
+ if (idx === -1)
1121
1122
  break;
1122
- result = next;
1123
- iterations++;
1123
+ const resolved = substituteVar(result, idx, vars);
1124
+ if (resolved === void 0) {
1125
+ if (idx === 0)
1126
+ break;
1127
+ cursor = idx - 1;
1128
+ continue;
1129
+ }
1130
+ result = result.substring(0, idx) + resolved.value + result.substring(resolved.end);
1131
+ cursor = idx + resolved.value.length;
1132
+ substitutions++;
1124
1133
  }
1125
1134
  return result;
1126
1135
  }
@@ -1136,23 +1145,19 @@ function findClosingParen(str, startIdx) {
1136
1145
  }
1137
1146
  return -1;
1138
1147
  }
1139
- function resolveInnermostVar(css, vars) {
1140
- const idx = css.lastIndexOf("var(");
1141
- if (idx === -1)
1142
- return css;
1148
+ function substituteVar(css, idx, vars) {
1143
1149
  const closeIdx = findClosingParen(css, idx + 4);
1144
1150
  if (closeIdx === -1)
1145
- return css;
1151
+ return void 0;
1146
1152
  const content = css.substring(idx + 4, closeIdx);
1147
- if (content.includes("var("))
1148
- return css;
1149
1153
  const comma = content.indexOf(",");
1150
1154
  const name = (comma >= 0 ? content.substring(0, comma) : content).trim();
1151
1155
  const fallback = comma >= 0 ? content.substring(comma + 1).trim() : void 0;
1152
- const resolved = vars.get(name) ?? fallback;
1156
+ const value = vars.get(name);
1157
+ const resolved = value !== void 0 && value.trim() !== "initial" ? value : fallback;
1153
1158
  if (resolved === void 0)
1154
- return css;
1155
- return css.substring(0, idx) + resolved + css.substring(closeIdx + 1);
1159
+ return void 0;
1160
+ return { value: resolved, end: closeIdx + 1 };
1156
1161
  }
1157
1162
  function decodeCssClassName(selector) {
1158
1163
  let className = selector.startsWith(".") ? selector.slice(1) : selector;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-og-image",
3
3
  "type": "module",
4
- "version": "7.0.0",
4
+ "version": "7.0.2",
5
5
  "description": "Enlightened OG Image generation for Nuxt.",
6
6
  "author": {
7
7
  "website": "https://harlanzw.com",