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.
- package/dist/chunks/tw4.cjs +1 -1
- package/dist/chunks/tw4.mjs +1 -1
- package/dist/chunks/uno.cjs +1 -1
- package/dist/chunks/uno.mjs +1 -1
- package/dist/devtools/components/og-image/AddComponentDialog.vue +85 -0
- package/dist/devtools/components/og-image/BlueskyCardRenderer.vue +134 -0
- package/dist/devtools/components/og-image/CreateOgImageDialog.vue +56 -0
- package/dist/devtools/components/og-image/DiscordCardRenderer.vue +125 -0
- package/dist/devtools/components/og-image/FacebookCardRenderer.vue +128 -0
- package/dist/devtools/components/og-image/IFrameLoader.vue +93 -0
- package/dist/devtools/components/og-image/ImageLoader.vue +197 -0
- package/dist/devtools/components/og-image/LinkedInCardRenderer.vue +100 -0
- package/dist/devtools/components/og-image/RendererSelectModal.vue +356 -0
- package/dist/devtools/components/og-image/SlackCardRenderer.vue +140 -0
- package/dist/devtools/components/og-image/TemplateComponentPreview.vue +186 -0
- package/dist/devtools/components/og-image/TwitterCardRenderer.vue +170 -0
- package/dist/devtools/components/og-image/WhatsAppRenderer.vue +294 -0
- package/dist/devtools/lib/og-image/keys.ts +8 -0
- package/dist/devtools/lib/og-image/og-image.ts +536 -0
- package/dist/devtools/lib/og-image/renderer-select.ts +3 -0
- package/dist/devtools/lib/og-image/rpc-types.ts +2 -0
- package/dist/devtools/lib/og-image/rpc.ts +73 -0
- package/dist/devtools/lib/og-image/runtime-types.ts +10 -0
- package/dist/devtools/lib/og-image/shared/urlEncoding.ts +502 -0
- package/dist/devtools/lib/og-image/shared.ts +30 -0
- package/dist/devtools/lib/og-image/templates.ts +9 -0
- package/dist/devtools/lib/og-image/types.ts +38 -0
- package/dist/devtools/lib/og-image/util/logic.ts +21 -0
- package/dist/devtools/nuxt.config.ts +6 -0
- package/dist/devtools/pages/og-image/debug.vue +32 -0
- package/dist/devtools/pages/og-image/docs.vue +3 -0
- package/dist/devtools/pages/og-image/index.vue +1682 -0
- package/dist/devtools/pages/og-image/templates.vue +150 -0
- package/dist/devtools/pages/og-image.vue +184 -0
- package/dist/module.cjs +1 -1
- package/dist/module.json +1 -1
- package/dist/module.mjs +1 -1
- package/dist/runtime/server/og-image/core/plugins/imageSrc.js +2 -2
- package/dist/runtime/server/og-image/core/style-attr.d.ts +8 -0
- package/dist/runtime/server/og-image/core/style-attr.js +34 -0
- package/dist/runtime/server/og-image/core/vnodes.d.ts +2 -1
- package/dist/runtime/server/og-image/core/vnodes.js +3 -27
- package/dist/shared/{nuxt-og-image.CgPzmzQY.cjs → nuxt-og-image.CfTPCtaS.cjs} +38 -24
- package/dist/shared/{nuxt-og-image.DGAMxBol.mjs → nuxt-og-image.DdbTs-xp.mjs} +37 -23
- package/package.json +17 -18
- package/dist/devtools/200.html +0 -1
- package/dist/devtools/404.html +0 -1
- package/dist/devtools/_fonts/4ppnHhMi-pBsWSPo7mY0avYxlDoAg1N3PTzCwXLZ5rA-d9oibkGnTd1JL3tc_xnaVgBLYmOB8kjrK2cvZaqwj9s.woff2 +0 -0
- package/dist/devtools/_fonts/PV2hrQG6wq5BlIPDjdL1IcOflycaghyt5MHzlBqZtlo-lb_WexLz3VZqfTN0oi554iBH5tT2j2UFEV-XErCAS3E.woff2 +0 -0
- package/dist/devtools/_fonts/VE4cDVCv5MxbFM7ZLoLCGbIpNd71zhp7MDI9lmN5Y7I-xZyDYCUVrd6LV8eVGF3Um3UZjBFuUtDGtvdyTBBRYBo.woff2 +0 -0
- package/dist/devtools/_fonts/fVoGbnMbBFd5L9BBp9fUPavUSkZ_EmsQNSyadkT-108-U4T0khaeLQSIhtt9eVvaCEKJjtWJ4ioRJOf8hvqkWY0.woff2 +0 -0
- package/dist/devtools/_fonts/lQAxeCEs1R0Lw-H9XRU1RlOARQN8J6npRsPjyEDMe5s-_DUSLEkO3tKTuun_gSnDLoQPVEnpOnyqZMOw0ByZ6PA.woff2 +0 -0
- package/dist/devtools/_fonts/lntlqNHKLV2n82yTwMde70QqOjcfLE2XJ5oKZ3vRPWc-z6TxpIZQdWXztWLr9_OFWqt_WJJoeGtuK_-XQMZGQwE.woff2 +0 -0
- package/dist/devtools/_nuxt/B9jrmesR.js +0 -1
- package/dist/devtools/_nuxt/BA-4cUNc.js +0 -1
- package/dist/devtools/_nuxt/BOEXnX7x.js +0 -3
- package/dist/devtools/_nuxt/BWKJ0Uxb.js +0 -30
- package/dist/devtools/_nuxt/Bdtz4ZK9.js +0 -4
- package/dist/devtools/_nuxt/C5797Ieg.js +0 -1
- package/dist/devtools/_nuxt/C5jzcy9i.js +0 -1
- package/dist/devtools/_nuxt/CCTv7mmB.js +0 -1
- package/dist/devtools/_nuxt/CP0tQR2M.js +0 -1
- package/dist/devtools/_nuxt/C_JnDlx-.js +0 -1
- package/dist/devtools/_nuxt/CdmciVPJ.js +0 -1
- package/dist/devtools/_nuxt/CqjbkMN9.js +0 -3
- package/dist/devtools/_nuxt/CuJezOxb.js +0 -1
- package/dist/devtools/_nuxt/D4EkL0XJ.js +0 -6
- package/dist/devtools/_nuxt/DKaW7clv.js +0 -1
- package/dist/devtools/_nuxt/DSl3mlLY.js +0 -2
- package/dist/devtools/_nuxt/DYta7Mdi.js +0 -3
- package/dist/devtools/_nuxt/DevtoolsSection.CcOMr_vO.css +0 -1
- package/dist/devtools/_nuxt/DevtoolsSnippet.BhrTdbXn.css +0 -1
- package/dist/devtools/_nuxt/E8AZ6HoH.js +0 -1
- package/dist/devtools/_nuxt/IFrameLoader.k_861Nnq.css +0 -1
- package/dist/devtools/_nuxt/O5eLyffU.js +0 -1
- package/dist/devtools/_nuxt/builds/latest.json +0 -1
- package/dist/devtools/_nuxt/builds/meta/a36bc212-7179-4aa2-a534-6366229bd7b1.json +0 -1
- package/dist/devtools/_nuxt/entry.CcrXpo2y.css +0 -2
- package/dist/devtools/_nuxt/fira-code.Bc8wnsZt.woff2 +0 -0
- package/dist/devtools/_nuxt/hubot-sans.DLGyhQVu.woff2 +0 -0
- package/dist/devtools/_nuxt/pages.Ci_xvfJ8.css +0 -1
- package/dist/devtools/_nuxt/renderer-select.COGJ4ZQe.css +0 -1
- package/dist/devtools/_nuxt/templates.BByln3BG.css +0 -1
- package/dist/devtools/_nuxt/wMBdlVF-.js +0 -152
- package/dist/devtools/debug/index.html +0 -1
- package/dist/devtools/docs/index.html +0 -1
- package/dist/devtools/index.html +0 -1
- 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,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>
|