nuxt-og-image 6.5.2 → 6.6.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 (88) hide show
  1. package/dist/chunks/tw4.cjs +1 -1
  2. package/dist/chunks/tw4.mjs +1 -1
  3. package/dist/chunks/uno.cjs +1 -1
  4. package/dist/chunks/uno.mjs +1 -1
  5. package/dist/devtools/components/og-image/AddComponentDialog.vue +85 -0
  6. package/dist/devtools/components/og-image/BlueskyCardRenderer.vue +134 -0
  7. package/dist/devtools/components/og-image/CreateOgImageDialog.vue +56 -0
  8. package/dist/devtools/components/og-image/DiscordCardRenderer.vue +125 -0
  9. package/dist/devtools/components/og-image/FacebookCardRenderer.vue +128 -0
  10. package/dist/devtools/components/og-image/IFrameLoader.vue +93 -0
  11. package/dist/devtools/components/og-image/ImageLoader.vue +197 -0
  12. package/dist/devtools/components/og-image/LinkedInCardRenderer.vue +100 -0
  13. package/dist/devtools/components/og-image/RendererSelectModal.vue +356 -0
  14. package/dist/devtools/components/og-image/SlackCardRenderer.vue +140 -0
  15. package/dist/devtools/components/og-image/TemplateComponentPreview.vue +186 -0
  16. package/dist/devtools/components/og-image/TwitterCardRenderer.vue +170 -0
  17. package/dist/devtools/components/og-image/WhatsAppRenderer.vue +294 -0
  18. package/dist/devtools/lib/og-image/keys.ts +8 -0
  19. package/dist/devtools/lib/og-image/og-image.ts +536 -0
  20. package/dist/devtools/lib/og-image/renderer-select.ts +3 -0
  21. package/dist/devtools/lib/og-image/rpc-types.ts +2 -0
  22. package/dist/devtools/lib/og-image/rpc.ts +73 -0
  23. package/dist/devtools/lib/og-image/runtime-types.ts +10 -0
  24. package/dist/devtools/lib/og-image/shared/urlEncoding.ts +502 -0
  25. package/dist/devtools/lib/og-image/shared.ts +30 -0
  26. package/dist/devtools/lib/og-image/templates.ts +9 -0
  27. package/dist/devtools/lib/og-image/types.ts +38 -0
  28. package/dist/devtools/lib/og-image/util/logic.ts +21 -0
  29. package/dist/devtools/nuxt.config.ts +6 -0
  30. package/dist/devtools/pages/og-image/debug.vue +32 -0
  31. package/dist/devtools/pages/og-image/docs.vue +3 -0
  32. package/dist/devtools/pages/og-image/index.vue +1682 -0
  33. package/dist/devtools/pages/og-image/templates.vue +150 -0
  34. package/dist/devtools/pages/og-image.vue +184 -0
  35. package/dist/module.cjs +1 -1
  36. package/dist/module.json +1 -1
  37. package/dist/module.mjs +1 -1
  38. package/dist/runtime/server/og-image/core/plugins/imageSrc.js +2 -2
  39. package/dist/runtime/server/og-image/core/style-attr.d.ts +8 -0
  40. package/dist/runtime/server/og-image/core/style-attr.js +34 -0
  41. package/dist/runtime/server/og-image/core/vnodes.d.ts +2 -1
  42. package/dist/runtime/server/og-image/core/vnodes.js +3 -27
  43. package/dist/shared/{nuxt-og-image.CgPzmzQY.cjs → nuxt-og-image.CfTPCtaS.cjs} +38 -24
  44. package/dist/shared/{nuxt-og-image.DGAMxBol.mjs → nuxt-og-image.DdbTs-xp.mjs} +37 -23
  45. package/package.json +17 -18
  46. package/dist/devtools/200.html +0 -1
  47. package/dist/devtools/404.html +0 -1
  48. package/dist/devtools/_fonts/4ppnHhMi-pBsWSPo7mY0avYxlDoAg1N3PTzCwXLZ5rA-d9oibkGnTd1JL3tc_xnaVgBLYmOB8kjrK2cvZaqwj9s.woff2 +0 -0
  49. package/dist/devtools/_fonts/PV2hrQG6wq5BlIPDjdL1IcOflycaghyt5MHzlBqZtlo-lb_WexLz3VZqfTN0oi554iBH5tT2j2UFEV-XErCAS3E.woff2 +0 -0
  50. package/dist/devtools/_fonts/VE4cDVCv5MxbFM7ZLoLCGbIpNd71zhp7MDI9lmN5Y7I-xZyDYCUVrd6LV8eVGF3Um3UZjBFuUtDGtvdyTBBRYBo.woff2 +0 -0
  51. package/dist/devtools/_fonts/fVoGbnMbBFd5L9BBp9fUPavUSkZ_EmsQNSyadkT-108-U4T0khaeLQSIhtt9eVvaCEKJjtWJ4ioRJOf8hvqkWY0.woff2 +0 -0
  52. package/dist/devtools/_fonts/lQAxeCEs1R0Lw-H9XRU1RlOARQN8J6npRsPjyEDMe5s-_DUSLEkO3tKTuun_gSnDLoQPVEnpOnyqZMOw0ByZ6PA.woff2 +0 -0
  53. package/dist/devtools/_fonts/lntlqNHKLV2n82yTwMde70QqOjcfLE2XJ5oKZ3vRPWc-z6TxpIZQdWXztWLr9_OFWqt_WJJoeGtuK_-XQMZGQwE.woff2 +0 -0
  54. package/dist/devtools/_nuxt/B9jrmesR.js +0 -1
  55. package/dist/devtools/_nuxt/BA-4cUNc.js +0 -1
  56. package/dist/devtools/_nuxt/BOEXnX7x.js +0 -3
  57. package/dist/devtools/_nuxt/BWKJ0Uxb.js +0 -30
  58. package/dist/devtools/_nuxt/Bdtz4ZK9.js +0 -4
  59. package/dist/devtools/_nuxt/C5797Ieg.js +0 -1
  60. package/dist/devtools/_nuxt/C5jzcy9i.js +0 -1
  61. package/dist/devtools/_nuxt/CCTv7mmB.js +0 -1
  62. package/dist/devtools/_nuxt/CP0tQR2M.js +0 -1
  63. package/dist/devtools/_nuxt/C_JnDlx-.js +0 -1
  64. package/dist/devtools/_nuxt/CdmciVPJ.js +0 -1
  65. package/dist/devtools/_nuxt/CqjbkMN9.js +0 -3
  66. package/dist/devtools/_nuxt/CuJezOxb.js +0 -1
  67. package/dist/devtools/_nuxt/D4EkL0XJ.js +0 -6
  68. package/dist/devtools/_nuxt/DKaW7clv.js +0 -1
  69. package/dist/devtools/_nuxt/DSl3mlLY.js +0 -2
  70. package/dist/devtools/_nuxt/DYta7Mdi.js +0 -3
  71. package/dist/devtools/_nuxt/DevtoolsSection.CcOMr_vO.css +0 -1
  72. package/dist/devtools/_nuxt/DevtoolsSnippet.BhrTdbXn.css +0 -1
  73. package/dist/devtools/_nuxt/E8AZ6HoH.js +0 -1
  74. package/dist/devtools/_nuxt/IFrameLoader.k_861Nnq.css +0 -1
  75. package/dist/devtools/_nuxt/O5eLyffU.js +0 -1
  76. package/dist/devtools/_nuxt/builds/latest.json +0 -1
  77. package/dist/devtools/_nuxt/builds/meta/a36bc212-7179-4aa2-a534-6366229bd7b1.json +0 -1
  78. package/dist/devtools/_nuxt/entry.CcrXpo2y.css +0 -2
  79. package/dist/devtools/_nuxt/fira-code.Bc8wnsZt.woff2 +0 -0
  80. package/dist/devtools/_nuxt/hubot-sans.DLGyhQVu.woff2 +0 -0
  81. package/dist/devtools/_nuxt/pages.Ci_xvfJ8.css +0 -1
  82. package/dist/devtools/_nuxt/renderer-select.COGJ4ZQe.css +0 -1
  83. package/dist/devtools/_nuxt/templates.BByln3BG.css +0 -1
  84. package/dist/devtools/_nuxt/wMBdlVF-.js +0 -152
  85. package/dist/devtools/debug/index.html +0 -1
  86. package/dist/devtools/docs/index.html +0 -1
  87. package/dist/devtools/index.html +0 -1
  88. package/dist/devtools/templates/index.html +0 -1
@@ -0,0 +1,502 @@
1
+ import { hash } from 'ohash'
2
+
3
+ /**
4
+ * URL encoding for OG image options (Cloudinary/IPX style)
5
+ *
6
+ * Format: /_og/s/w_1200,h_600,c_NuxtSeo,title_Hello+World.png
7
+ *
8
+ * When the encoded path exceeds MAX_PATH_LENGTH (200 chars), falls back to hash mode:
9
+ * Format: /_og/s/o_<hash>.png
10
+ *
11
+ * - Known OgImageOptions use short aliases (w, h, c, etc.)
12
+ * - Component props are encoded directly (title_Hello)
13
+ * - Complex objects are base64 encoded JSON
14
+ */
15
+
16
+ // Maximum path segment length (filesystem limit is 255, leave room for prefix/extension)
17
+ const MAX_PATH_LENGTH = 200
18
+
19
+ // Module-scope regex constants
20
+ const RE_BASE64_PADDING = /=/g
21
+ const RE_BASE64_PLUS = /\+/g
22
+ const RE_BASE64_SLASH = /\//g
23
+ const RE_BASE64_URL_DASH = /-/g
24
+ const RE_BASE64_URL_TILDE = /~/g
25
+ const RE_UNDERSCORE = /_/g
26
+ const RE_DOUBLE_UNDERSCORE = /__/g
27
+ const RE_PERCENT20 = /%20/g
28
+ const RE_PLUS = /\+/g
29
+ const RE_SINGLE_UNDERSCORE = /(?<!_)_(?!_)/
30
+ const RE_OG_PATH_PREFIX = /^\/_og\/[ds]\//
31
+ const RE_OG_ROUTE_PREFIX = /\/_og\/[ds]\//
32
+ const RE_FILE_EXTENSION_WITH_CAPTURE = /\.(\w+)$/
33
+ const RE_FILE_EXTENSION = /\.\w+$/
34
+ const RE_HASH_SEGMENT = /^o_([a-z0-9]+)$/i
35
+ const RE_COMMA_PARAM_SEPARATOR = /,(?=\w+_)/
36
+ const RE_SIGNATURE_SUFFIX = /,s_[^,]+$/
37
+ // eslint-disable-next-line no-control-regex
38
+ const RE_NON_ASCII = /[^\u0000-\u007F]/
39
+
40
+ // Short aliases for OgImageOptions params
41
+ const PARAM_ALIASES: Record<string, string> = {
42
+ w: 'width',
43
+ h: 'height',
44
+ c: 'component',
45
+ em: 'emojis',
46
+ k: 'key',
47
+ a: 'alt',
48
+ u: 'url',
49
+ cache: 'cacheMaxAgeSeconds',
50
+ p: '_path', // page path - needs alias since _path starts with underscore
51
+ q: '_query', // query params - needs alias since _query starts with underscore
52
+ ch: '_componentHash', // component template hash for cache busting prerendered URLs
53
+ }
54
+
55
+ // Reverse mapping (param name -> alias)
56
+ const PARAM_TO_ALIAS = Object.fromEntries(
57
+ Object.entries(PARAM_ALIASES).map(([alias, param]) => [param, alias]),
58
+ )
59
+
60
+ // Known OgImageOptions keys (not component props)
61
+ const KNOWN_PARAMS = new Set([
62
+ 'width',
63
+ 'height',
64
+ 'component',
65
+ 'renderer',
66
+ 'emojis',
67
+ 'key',
68
+ 'alt',
69
+ 'url',
70
+ 'cacheMaxAgeSeconds',
71
+ 'cacheKey',
72
+ 'extension',
73
+ 'satori',
74
+ 'resvg',
75
+ 'sharp',
76
+ 'screenshot',
77
+ 'takumi',
78
+ 'fonts',
79
+ '_query',
80
+ '_hash',
81
+ '_componentHash',
82
+ 'socialPreview',
83
+ 'props',
84
+ '_path',
85
+ ])
86
+
87
+ // Params that need base64 encoding (complex objects, or values with slashes)
88
+ const COMPLEX_PARAMS = new Set(['satori', 'resvg', 'sharp', 'screenshot', 'takumi', 'fonts', '_query', '_path'])
89
+
90
+ // URL-safe base64 encode/decode helpers (works in both browser and Node, handles Unicode)
91
+ // Uses ~ instead of / and - instead of + to avoid breaking URL path segments
92
+ function b64Encode(str: string): string {
93
+ // UTF-8 encode first to handle Unicode (emojis, etc.)
94
+ let encoded: string
95
+ if (typeof btoa === 'function') {
96
+ const utf8 = new TextEncoder().encode(str)
97
+ const binary = String.fromCharCode(...utf8)
98
+ encoded = btoa(binary)
99
+ }
100
+ else {
101
+ encoded = Buffer.from(str, 'utf8').toString('base64')
102
+ }
103
+ // Make URL-safe: strip padding, replace + with - and / with ~
104
+ // Using ~ instead of standard URL-safe _ to avoid conflict with param separator
105
+ return encoded.replace(RE_BASE64_PADDING, '').replace(RE_BASE64_PLUS, '-').replace(RE_BASE64_SLASH, '~')
106
+ }
107
+
108
+ function b64Decode(str: string): string {
109
+ // Reverse URL-safe encoding before standard base64 decode
110
+ // Also handles legacy standard base64 (no ~ or - chars to replace = no-op)
111
+ const standard = str.replace(RE_BASE64_URL_DASH, '+').replace(RE_BASE64_URL_TILDE, '/')
112
+ const padded = standard + '='.repeat((4 - (standard.length % 4)) % 4)
113
+ if (typeof atob === 'function') {
114
+ const binary = atob(padded)
115
+ const bytes = Uint8Array.from(binary, c => c.charCodeAt(0))
116
+ return new TextDecoder().decode(bytes)
117
+ }
118
+ return Buffer.from(padded, 'base64').toString('utf8')
119
+ }
120
+
121
+ /**
122
+ * Simple hash function for creating short deterministic hashes
123
+ * Works in both browser and Node environments
124
+ */
125
+ function simpleHash(str: string): string {
126
+ let hash = 0
127
+ for (let i = 0; i < str.length; i++) {
128
+ const char = str.charCodeAt(i)
129
+ hash = ((hash << 5) - hash) + char
130
+ hash = hash & hash // Convert to 32bit integer
131
+ }
132
+ // Convert to base36 for shorter string, ensure positive
133
+ return Math.abs(hash).toString(36)
134
+ }
135
+
136
+ /**
137
+ * Generate a deterministic hash from options object
138
+ * Excludes _path so images with same options can be cached across pages
139
+ * Optionally includes componentHash and version for cache busting
140
+ */
141
+ export function hashOgImageOptions(
142
+ options: Record<string, any>,
143
+ componentHash?: string,
144
+ version?: string,
145
+ ): string {
146
+ const { _path, _hash, ...hashableOptions } = options
147
+ const hashInput = componentHash || version
148
+ ? [hashableOptions, componentHash || '', version || '']
149
+ : hashableOptions
150
+ return simpleHash(JSON.stringify(hashInput))
151
+ }
152
+
153
+ /**
154
+ * Encode OG image options into a URL path segment
155
+ * @param options - The options to encode
156
+ * @param defaults - Optional defaults to skip (values matching defaults won't be encoded)
157
+ * @example encodeOgImageParams({ width: 1200, props: { title: 'Hello' } })
158
+ * // Returns: "w_1200,title_Hello"
159
+ */
160
+ export function encodeOgImageParams(options: Record<string, any>, defaults?: Record<string, any>): string {
161
+ const parts: string[] = []
162
+
163
+ // First, flatten props to top level for readability
164
+ const flattened: Record<string, any> = {}
165
+ for (const [key, value] of Object.entries(options)) {
166
+ if (key === 'props' && typeof value === 'object') {
167
+ // Flatten simple props to top level
168
+ for (const [propKey, propValue] of Object.entries(value)) {
169
+ if (propValue === undefined || propValue === null)
170
+ continue
171
+ if (typeof propValue === 'string' || typeof propValue === 'number' || typeof propValue === 'boolean') {
172
+ flattened[propKey] = propValue
173
+ }
174
+ else {
175
+ // Complex prop value - keep in props for base64 encoding
176
+ flattened.props = flattened.props || {}
177
+ flattened.props[propKey] = propValue
178
+ }
179
+ }
180
+ }
181
+ else {
182
+ flattened[key] = value
183
+ }
184
+ }
185
+
186
+ for (const [key, value] of Object.entries(flattened)) {
187
+ if (value === undefined || value === null || value === '') {
188
+ continue
189
+ }
190
+
191
+ // Skip internal/meta fields
192
+ if (key === 'extension' || key === 'socialPreview')
193
+ continue
194
+
195
+ // Skip default/empty values that the decoder already handles
196
+ // _path defaults to "/" on decode, _query empty object is a no-op
197
+ if (key === '_path' && value === '/')
198
+ continue
199
+ if (key === '_query' && typeof value === 'object' && Object.keys(value).length === 0)
200
+ continue
201
+
202
+ // Skip values that match defaults (but never skip component)
203
+ if (defaults && key in defaults && defaults[key] === value && key !== 'component')
204
+ continue
205
+
206
+ // Use alias for known params, otherwise use key as-is (for props)
207
+ const alias = PARAM_TO_ALIAS[key] || key
208
+
209
+ if (COMPLEX_PARAMS.has(key)) {
210
+ // Base64 encode complex objects
211
+ const json = JSON.stringify(value)
212
+ if (json === '{}')
213
+ continue
214
+ const b64 = b64Encode(json)
215
+ parts.push(`${alias}_${b64}`)
216
+ }
217
+ else if (typeof value === 'object') {
218
+ // Unexpected object (like remaining complex props), base64 encode
219
+ const json = JSON.stringify(value)
220
+ if (json === '{}')
221
+ continue
222
+ const b64 = b64Encode(json)
223
+ parts.push(`${alias}_${b64}`)
224
+ }
225
+ else {
226
+ const str = String(value)
227
+ if (RE_NON_ASCII.test(str)) {
228
+ // Non-ASCII values use base64 to avoid percent-encoded UTF-8 in the URL path.
229
+ // h3 v1.15.7+ decodes percent-encoded req.url which breaks proxies.
230
+ // Prefix with ~ so the decoder knows to b64-decode instead of URL-decode.
231
+ parts.push(`${alias}_~${b64Encode(str)}`)
232
+ }
233
+ else {
234
+ // ASCII-safe value: try URL encoding first, then check for problematic percent-encoding.
235
+ // Characters like #, ?, /, \, =, & produce %XX sequences that get decoded by
236
+ // proxies, CDNs, and prerender crawlers in unpredictable ways (#528, #529).
237
+ // If percent-encoding is needed, use b64 instead to avoid these issues entirely.
238
+ const escaped = str.startsWith('~') ? `~${str}` : str
239
+ const encoded = encodeURIComponent(escaped.replace(RE_UNDERSCORE, '__'))
240
+ .replace(RE_PERCENT20, '+') // spaces as +
241
+ if (encoded.includes('%')) {
242
+ // Value contains URL-sensitive chars; b64 encode to prevent intermediary decoding
243
+ parts.push(`${alias}_~${b64Encode(str)}`)
244
+ }
245
+ else {
246
+ parts.push(`${alias}_${encoded}`)
247
+ }
248
+ }
249
+ }
250
+ }
251
+
252
+ return parts.join(',')
253
+ }
254
+
255
+ const RE_NUMERIC = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/
256
+
257
+ /**
258
+ * Parse a string as a number only if it's actually numeric.
259
+ * Avoids false positives like Number('+') → 0 or Number('') → 0.
260
+ */
261
+ function tryParseNumber(value: string): string | number {
262
+ if (RE_NUMERIC.test(value)) {
263
+ const num = Number(value)
264
+ if (!Number.isNaN(num))
265
+ return num
266
+ }
267
+ return value
268
+ }
269
+
270
+ /**
271
+ * Decode a simple string value, handling ~ prefix for b64-encoded non-ASCII
272
+ * and ~~ escape for literal values starting with ~.
273
+ */
274
+ function decodeSimpleValue(raw: string): string {
275
+ if (raw.startsWith('~~')) {
276
+ // Escaped leading ~ — decode the rest normally
277
+ return decodeURIComponent(raw.slice(1).replace(RE_PLUS, '%20')).replace(RE_DOUBLE_UNDERSCORE, '_')
278
+ }
279
+ if (raw.startsWith('~')) {
280
+ // b64-encoded non-ASCII value
281
+ try {
282
+ return b64Decode(raw.slice(1))
283
+ }
284
+ catch {
285
+ // Fallback: treat as literal value if b64 decode fails
286
+ return decodeURIComponent(raw.replace(RE_PLUS, '%20')).replace(RE_DOUBLE_UNDERSCORE, '_')
287
+ }
288
+ }
289
+ return decodeURIComponent(raw.replace(RE_PLUS, '%20')).replace(RE_DOUBLE_UNDERSCORE, '_')
290
+ }
291
+
292
+ /**
293
+ * Decode URL path segment back into OG image options
294
+ * @example decodeOgImageParams("w_1200,title_Hello")
295
+ * // Returns: { width: 1200, props: { title: 'Hello' } }
296
+ */
297
+ export function decodeOgImageParams(encoded: string): Record<string, any> {
298
+ if (!encoded || encoded === 'default')
299
+ return {}
300
+
301
+ const options: Record<string, any> = {}
302
+
303
+ // Split on commas that are followed by a param key pattern (e.g. `key_`).
304
+ const parts = encoded.split(RE_COMMA_PARAM_SEPARATOR)
305
+
306
+ for (const part of parts) {
307
+ // Find first underscore that's not escaped (not preceded by another underscore)
308
+ const idx = part.search(RE_SINGLE_UNDERSCORE)
309
+ if (idx === -1)
310
+ continue
311
+
312
+ const alias = part.slice(0, idx)
313
+ let value = part.slice(idx + 1)
314
+
315
+ // Resolve alias to full param name
316
+ const paramName = PARAM_ALIASES[alias] || alias
317
+
318
+ if (COMPLEX_PARAMS.has(paramName)) {
319
+ // Base64 decode complex objects
320
+ try {
321
+ const json = b64Decode(value)
322
+ options[paramName] = JSON.parse(json)
323
+ }
324
+ catch {
325
+ options[paramName] = value
326
+ }
327
+ }
328
+ else if (paramName === 'props') {
329
+ // Base64 encoded remaining complex props
330
+ try {
331
+ const json = b64Decode(value)
332
+ options.props = { ...options.props, ...JSON.parse(json) }
333
+ }
334
+ catch {
335
+ // ignore
336
+ }
337
+ }
338
+ else if (KNOWN_PARAMS.has(paramName)) {
339
+ // Known OgImageOptions param - decode value
340
+ value = decodeSimpleValue(value)
341
+ // Try to parse as number or boolean
342
+ if (value === 'true') {
343
+ options[paramName] = true
344
+ }
345
+ else if (value === 'false') {
346
+ options[paramName] = false
347
+ }
348
+ else if (value !== '') {
349
+ options[paramName] = tryParseNumber(value)
350
+ }
351
+ }
352
+ else {
353
+ // Unknown param - treat as component prop
354
+ value = decodeSimpleValue(value)
355
+ options.props = options.props || {}
356
+ // Try to parse as number or boolean
357
+ if (value === 'true') {
358
+ options.props[paramName] = true
359
+ }
360
+ else if (value === 'false') {
361
+ options.props[paramName] = false
362
+ }
363
+ else if (value !== '') {
364
+ options.props[paramName] = tryParseNumber(value)
365
+ }
366
+ }
367
+ }
368
+
369
+ return options
370
+ }
371
+
372
+ export interface BuildOgImageUrlResult {
373
+ url: string
374
+ hash?: string
375
+ }
376
+
377
+ /**
378
+ * Build full OG image URL
379
+ *
380
+ * When encoded params exceed MAX_PATH_LENGTH, falls back to hash mode:
381
+ * - Returns short path: /_og/s/o_<hash>.png
382
+ * - Returns hash in result for cache storage
383
+ *
384
+ * @param options - The options to encode
385
+ * @param extension - Image extension (png, jpeg, etc.)
386
+ * @param isStatic - Whether this is a static/prerendered image
387
+ * @param defaults - Optional defaults to skip from URL (keeps URLs shorter)
388
+ * @example buildOgImageUrl({ width: 1200, props: { title: 'Hello' } }, 'png', true)
389
+ * // Returns: { url: "/_og/s/w_1200,title_Hello.png" }
390
+ */
391
+ export function buildOgImageUrl(
392
+ options: Record<string, any>,
393
+ extension: string = 'png',
394
+ isStatic: boolean = false,
395
+ defaults?: Record<string, any>,
396
+ secret?: string,
397
+ ): BuildOgImageUrlResult {
398
+ const encoded = encodeOgImageParams(options, defaults)
399
+ const prefix = isStatic ? '/_og/s' : '/_og/d'
400
+
401
+ // Check if encoded path is too long or contains percent-encoded chars (only applies to static/prerendered)
402
+ // Hash mode requires prerender options cache, so it can't work at runtime
403
+ // Percent-encoded chars (%23=#, %3F=?, %2C=, etc.) get decoded by prerender crawlers,
404
+ // proxies, and CDNs in unpredictable ways — hash mode avoids this entirely
405
+ if (isStatic && (encoded.length > MAX_PATH_LENGTH || encoded.includes('%'))) {
406
+ // Use hash mode - short deterministic path
407
+ const hash = hashOgImageOptions(options)
408
+ return {
409
+ url: `${prefix}/o_${hash}.${extension}`,
410
+ hash,
411
+ }
412
+ }
413
+
414
+ const segment = encoded || 'default'
415
+ // Sign dynamic URLs only; static/prerendered are served as files with no runtime verification
416
+ const signed = secret && !isStatic ? `${segment},s_${signEncodedParams(segment, secret)}` : segment
417
+
418
+ return {
419
+ url: `${prefix}/${signed}.${extension}`,
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Sign encoded params using a keyed hash (ohash, cross-runtime compatible).
425
+ * Returns first 16 chars of the base64url hash for URL brevity.
426
+ */
427
+ export function signEncodedParams(encoded: string, secret: string): string {
428
+ return hash(`${secret}:${encoded}`).slice(0, 16)
429
+ }
430
+
431
+ /**
432
+ * Verify a signature against encoded params.
433
+ * Uses constant-time string comparison to prevent timing attacks.
434
+ */
435
+ export function verifyOgImageSignature(encoded: string, signature: string, secret: string): boolean {
436
+ const expected = signEncodedParams(encoded, secret)
437
+ if (expected.length !== signature.length)
438
+ return false
439
+ // constant-time comparison
440
+ let result = 0
441
+ for (let i = 0; i < expected.length; i++)
442
+ result |= expected.charCodeAt(i) ^ signature.charCodeAt(i)
443
+ return result === 0
444
+ }
445
+
446
+ /**
447
+ * Parse OG image URL back into options
448
+ * @example parseOgImageUrl("/_og/s/w_1200,h_600.png")
449
+ * // Returns: { options: { width: 1200, height: 600 }, extension: 'png', isStatic: true, hash: undefined }
450
+ * @example parseOgImageUrl("/_og/s/o_abc123.png")
451
+ * // Returns: { options: {}, extension: 'png', isStatic: true, hash: 'abc123' }
452
+ */
453
+ export function parseOgImageUrl(url: string): {
454
+ options: Record<string, any>
455
+ extension: string
456
+ isStatic: boolean
457
+ hash?: string
458
+ } {
459
+ const isStatic = url.includes('/_og/s/')
460
+ const path = url.replace(RE_OG_PATH_PREFIX, '')
461
+
462
+ // Extract extension
463
+ const extMatch = path.match(RE_FILE_EXTENSION_WITH_CAPTURE)
464
+ const extension = extMatch?.[1] || 'png'
465
+
466
+ // Get encoded params (without extension)
467
+ const encoded = path.replace(RE_FILE_EXTENSION, '')
468
+
469
+ // Check for hash mode (o_<hash>)
470
+ const hashMatch = encoded.match(RE_HASH_SEGMENT)
471
+ if (hashMatch) {
472
+ return {
473
+ options: {},
474
+ extension,
475
+ isStatic,
476
+ hash: hashMatch[1],
477
+ }
478
+ }
479
+
480
+ // Strip URL signature suffix before decoding params
481
+ const paramsOnly = encoded.replace(RE_SIGNATURE_SUFFIX, '')
482
+
483
+ return {
484
+ options: decodeOgImageParams(paramsOnly),
485
+ extension,
486
+ isStatic,
487
+ }
488
+ }
489
+
490
+ /**
491
+ * Extract the encoded segment from the full path, handling the case where
492
+ * %2F in prop values has been decoded to / by intermediaries (#522).
493
+ * Uses the full catch-all match after /_og/d/ (or /_og/s/) instead of
494
+ * only the last path segment.
495
+ */
496
+ export function extractEncodedSegment(path: string, extension: string): string {
497
+ const match = path.match(RE_OG_ROUTE_PREFIX)
498
+ if (match?.index != null) {
499
+ return path.slice(match.index + match[0].length).replace(new RegExp(`\\.${extension}$`), '')
500
+ }
501
+ return (path.split('/').pop() as string).replace(new RegExp(`\\.${extension}$`), '')
502
+ }
@@ -0,0 +1,30 @@
1
+ // Vendored from nuxt-og-image src/runtime/shared.ts — only the runtime helpers the
2
+ // devtools panel needs (separateProps + a re-export of encodeOgImageParams). Kept local
3
+ // so the shipped layer has no dependency on the module's src.
4
+ import { defu } from 'defu'
5
+
6
+ export { encodeOgImageParams } from './shared/urlEncoding'
7
+
8
+ const RE_KEBAB_CASE = /-([a-z])/g
9
+ const OG_IMAGE_OPTION_KEYS: string[] = ['url', 'extension', 'width', 'height', 'alt', 'props', 'renderer', 'component', 'emojis', '_query', '_hash', 'fonts', 'satori', 'resvg', 'sharp', 'screenshot', 'takumi', 'cacheMaxAgeSeconds', 'cacheKey', 'key']
10
+
11
+ function filterIsOgImageOption(key: string): boolean {
12
+ return OG_IMAGE_OPTION_KEYS.includes(key)
13
+ }
14
+
15
+ export function separateProps(options: any | undefined, ignoreKeys: string[] = []): any {
16
+ options = options || {}
17
+ const _props = defu(options.props as Record<string, any>, Object.fromEntries(
18
+ Object.entries({ ...options }).filter(([k]) => !filterIsOgImageOption(k) && !ignoreKeys.includes(k)),
19
+ ))
20
+ const props: Record<string, any> = {}
21
+ Object.entries(_props).forEach(([key, val]) => {
22
+ props[key.replace(RE_KEBAB_CASE, g => String(g[1]).toUpperCase())] = val
23
+ })
24
+ const result: Record<string, any> = Object.fromEntries(
25
+ Object.entries({ ...options }).filter(([k]) => filterIsOgImageOption(k) || ignoreKeys.includes(k)),
26
+ )
27
+ if (Object.keys(props).length > 0)
28
+ result.props = props
29
+ return result
30
+ }
@@ -0,0 +1,9 @@
1
+ import { createTemplatePromise } from '@vueuse/core'
2
+
3
+ export const CreateOgImageDialogPromise = createTemplatePromise<string | false, [string]>()
4
+
5
+ export interface AddComponentResult {
6
+ name: string
7
+ }
8
+
9
+ export const AddComponentDialogPromise = createTemplatePromise<AddComponentResult | false>()
@@ -0,0 +1,38 @@
1
+ /* eslint-disable ts/ban-ts-comment */
2
+ // @ts-nocheck - vue-router type recursion causes excessive stack depth
3
+ import type {
4
+ DevToolsMetaDataExtraction,
5
+ FontConfig,
6
+ OgImageComponent,
7
+ OgImageOptions,
8
+ OgImageRuntimeConfig,
9
+ RuntimeCompatibilitySchema,
10
+ } from './runtime-types'
11
+
12
+ export interface DevToolsPayload {
13
+ options: OgImageOptions[]
14
+ socialPreview: {
15
+ root: Record<string, string>
16
+ images: DevToolsMetaDataExtraction[]
17
+ }
18
+ siteUrl?: string
19
+ }
20
+
21
+ export interface PathDebugResponse {
22
+ extract: DevToolsPayload
23
+ siteUrl?: string
24
+ compatibilityHints?: string[]
25
+ vnodes?: Record<string, unknown>
26
+ svg?: string
27
+ warnings?: string[]
28
+ fetchError?: { statusCode?: number, message: string, stack?: string[] }
29
+ }
30
+
31
+ export interface GlobalDebugResponse {
32
+ runtimeConfig: OgImageRuntimeConfig
33
+ componentNames: OgImageComponent[]
34
+ siteConfigUrl?: string
35
+ compatibility?: RuntimeCompatibilitySchema
36
+ resolvedFonts?: FontConfig[]
37
+ availableFonts?: FontConfig[]
38
+ }
@@ -0,0 +1,21 @@
1
+ import type { OgImageOptionsInternal } from '../runtime-types'
2
+ import { hasProductionUrl, host, previewSource, productionUrl } from 'nuxtseo-layer-devtools/composables/state'
3
+ import { computed, ref } from 'vue'
4
+
5
+ export const description = ref<string | null>(null)
6
+ export const ogImageKey = ref()
7
+
8
+ export const options = ref<OgImageOptionsInternal>({})
9
+
10
+ // prop editing
11
+ export const optionsOverrides = ref<OgImageOptionsInternal>({})
12
+ export const hasMadeChanges = ref(false)
13
+ export const propEditor = ref({})
14
+
15
+ const RE_TRAILING_SLASH = /\/$/
16
+
17
+ export const previewHost = computed(() => {
18
+ if (previewSource.value === 'production' && hasProductionUrl.value)
19
+ return productionUrl.value.replace(RE_TRAILING_SLASH, '')
20
+ return host.value
21
+ })
@@ -0,0 +1,6 @@
1
+ import { resolve } from 'pathe'
2
+
3
+ // Nuxt SEO devtools panel, shipped as a layer (Model C). Components flat-registered.
4
+ export default defineNuxtConfig({
5
+ components: [{ path: resolve(__dirname, './components'), pathPrefix: false }],
6
+ })
@@ -0,0 +1,32 @@
1
+ <script lang="ts" setup>
2
+ import { useOgImage } from '../../lib/og-image/og-image'
3
+
4
+ const {
5
+ globalDebug,
6
+ debug,
7
+ } = useOgImage()
8
+ </script>
9
+
10
+ <template>
11
+ <div class="h-full max-h-full overflow-hidden space-y-5">
12
+ <DevtoolsSection v-if="debug?.warnings?.length" icon="carbon:warning" text="Satori Warnings">
13
+ <div class="space-y-2 p-3 bg-amber-500/10 rounded-lg border border-amber-500/20">
14
+ <div v-for="(warning, i) in debug.warnings" :key="i" class="text-sm text-amber-200 font-mono">
15
+ {{ warning }}
16
+ </div>
17
+ </div>
18
+ </DevtoolsSection>
19
+ <DevtoolsSection icon="carbon:settings" text="Compatibility">
20
+ <DevtoolsSnippet :code="JSON.stringify(globalDebug?.compatibility || {}, null, 2)" lang="json" label="Compatibility" />
21
+ </DevtoolsSection>
22
+ <DevtoolsSection icon="carbon:ibm-cloud-pak-manta-automated-data-lineage" text="vNodes">
23
+ <DevtoolsSnippet :code="JSON.stringify(debug?.vnodes || {}, null, 2)" lang="json" label="vNodes" />
24
+ </DevtoolsSection>
25
+ <DevtoolsSection icon="carbon:ibm-cloud-pak-manta-automated-data-lineage" text="SVG">
26
+ <DevtoolsSnippet :code="debug?.svg?.replaceAll('>', '>\n') || ''" lang="xml" label="SVG" />
27
+ </DevtoolsSection>
28
+ <DevtoolsSection icon="carbon:settings" text="Runtime Config">
29
+ <DevtoolsSnippet :code="JSON.stringify(globalDebug?.runtimeConfig || {}, null, 2)" lang="json" label="Runtime Config" />
30
+ </DevtoolsSection>
31
+ </div>
32
+ </template>
@@ -0,0 +1,3 @@
1
+ <template>
2
+ <DevtoolsDocs url="https://nuxtseo.com/og-image" />
3
+ </template>