nuxt-seo-utils 8.2.0 → 8.3.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 (51) hide show
  1. package/dist/cli.mjs +1 -1
  2. package/dist/devtools/lib/seo-utils/rpc.ts +3 -0
  3. package/dist/devtools/lib/seo-utils/state.ts +35 -0
  4. package/dist/devtools/lib/seo-utils/tools.ts +98 -0
  5. package/dist/devtools/nuxt.config.ts +3 -0
  6. package/dist/devtools/pages/seo-utils/debug.vue +50 -0
  7. package/dist/devtools/pages/seo-utils/docs.vue +3 -0
  8. package/dist/devtools/pages/seo-utils/identity.vue +1168 -0
  9. package/dist/devtools/pages/seo-utils/index.vue +609 -0
  10. package/dist/devtools/pages/seo-utils/social.vue +515 -0
  11. package/dist/devtools/pages/seo-utils.vue +64 -0
  12. package/dist/module.json +1 -1
  13. package/dist/module.mjs +2 -2
  14. package/dist/runtime/app/composables/polyfills.js +1 -1
  15. package/dist/runtime/app/composables/useBreadcrumbItems.js +3 -3
  16. package/dist/runtime/app/composables/useFallbackTitle.js +1 -1
  17. package/dist/runtime/app/composables/useShareLinks.js +2 -2
  18. package/dist/runtime/app/logic/applyDefaults.js +2 -2
  19. package/dist/runtime/app/plugins/1.absoluteImageUrls.server.js +1 -1
  20. package/dist/runtime/app/plugins/siteConfig.js +1 -1
  21. package/dist/runtime/server/middleware/redirectCanonical.js +1 -1
  22. package/dist/runtime/server/middleware/resolveImagesInPagesDir.js +1 -1
  23. package/dist/runtime/server/routes/__nuxt-seo-utils/debug.d.ts +1 -1
  24. package/dist/runtime/server/routes/__nuxt-seo-utils/debug.js +1 -1
  25. package/package.json +32 -31
  26. package/dist/devtools/200.html +0 -1
  27. package/dist/devtools/404.html +0 -1
  28. package/dist/devtools/_nuxt/BEw_EXq1.js +0 -14
  29. package/dist/devtools/_nuxt/BgCgKrEE.js +0 -1
  30. package/dist/devtools/_nuxt/C4gkMyix.js +0 -1
  31. package/dist/devtools/_nuxt/CQ4Jy7Nm.js +0 -1
  32. package/dist/devtools/_nuxt/DcgcSEa-.js +0 -33
  33. package/dist/devtools/_nuxt/DeIiaCYF.js +0 -1
  34. package/dist/devtools/_nuxt/DevtoolsMetric.DabK8i8g.css +0 -1
  35. package/dist/devtools/_nuxt/DfZjB7w2.js +0 -1
  36. package/dist/devtools/_nuxt/DjEWf-n1.js +0 -1
  37. package/dist/devtools/_nuxt/builds/latest.json +0 -1
  38. package/dist/devtools/_nuxt/builds/meta/21c3fe25-8926-44ca-9d37-f478243d148a.json +0 -1
  39. package/dist/devtools/_nuxt/debug.B9T5jmI-.css +0 -1
  40. package/dist/devtools/_nuxt/entry.aDw8hoj_.css +0 -1
  41. package/dist/devtools/_nuxt/fira-code.Bc8wnsZt.woff2 +0 -0
  42. package/dist/devtools/_nuxt/hubot-sans.DLGyhQVu.woff2 +0 -0
  43. package/dist/devtools/_nuxt/identity.Dl98KbLG.css +0 -1
  44. package/dist/devtools/_nuxt/index.BrQ8jYVK.css +0 -1
  45. package/dist/devtools/_nuxt/social.DrJTEVxX.css +0 -1
  46. package/dist/devtools/_nuxt/tools.DM91U8p4.css +0 -1
  47. package/dist/devtools/debug/index.html +0 -1
  48. package/dist/devtools/docs/index.html +0 -1
  49. package/dist/devtools/identity/index.html +0 -1
  50. package/dist/devtools/index.html +0 -1
  51. package/dist/devtools/social/index.html +0 -1
@@ -0,0 +1,515 @@
1
+ <script setup lang="ts">
2
+ import { useClipboard } from '@vueuse/core'
3
+ import { computed, ref, watch } from 'vue'
4
+ import { data } from '../../lib/seo-utils/state'
5
+ import { parseMetaTags } from '../../lib/seo-utils/tools'
6
+
7
+ const { copy, copied } = useClipboard()
8
+
9
+ const loading = ref(false)
10
+ const error = ref<string | null>(null)
11
+
12
+ interface ParsedSocialMeta {
13
+ title: string
14
+ description: string
15
+ url: string
16
+ siteName: string
17
+ og: Record<string, string>
18
+ ogImages: Array<{ url: string, width?: string, height?: string, alt?: string }>
19
+ twitter: Record<string, string>
20
+ twitterCard: string
21
+ }
22
+
23
+ const result = ref<ParsedSocialMeta | null>(null)
24
+ const activePreview = ref('twitter')
25
+
26
+ const previewTabs = [
27
+ { label: 'X / Twitter', value: 'twitter', icon: 'carbon:logo-x' },
28
+ { label: 'Facebook', value: 'facebook', icon: 'carbon:logo-facebook' },
29
+ { label: 'LinkedIn', value: 'linkedin', icon: 'carbon:logo-linkedin' },
30
+ { label: 'Discord', value: 'discord', icon: 'carbon:logo-discord' },
31
+ { label: 'Slack', value: 'slack', icon: 'carbon:logo-slack' },
32
+ ]
33
+
34
+ const warnings = computed(() => {
35
+ if (!result.value)
36
+ return []
37
+ const w: Array<{ type: 'error' | 'warning', property: string, message: string }> = []
38
+
39
+ if (!result.value.og['og:title'])
40
+ w.push({ type: 'warning', property: 'og:title', message: 'Missing og:title. Social previews will use the page title as fallback.' })
41
+ if (!result.value.og['og:description'])
42
+ w.push({ type: 'warning', property: 'og:description', message: 'Missing og:description.' })
43
+ if (!result.value.ogImages.length)
44
+ w.push({ type: 'warning', property: 'og:image', message: 'No og:image found. Posts with images get higher engagement.' })
45
+ if (!result.value.og['og:url'])
46
+ w.push({ type: 'warning', property: 'og:url', message: 'Missing og:url.' })
47
+ if (!result.value.twitter['twitter:card'])
48
+ w.push({ type: 'warning', property: 'twitter:card', message: 'Missing twitter:card. Defaults to "summary".' })
49
+
50
+ if (result.value.ogImages.length) {
51
+ const img = result.value.ogImages[0]!
52
+ if (img.url && !img.url.startsWith('http'))
53
+ w.push({ type: 'error', property: 'og:image', message: 'og:image must be an absolute URL.' })
54
+ const width = Number.parseInt(img.width || '0')
55
+ const height = Number.parseInt(img.height || '0')
56
+ if (width && height && (width < 200 || height < 200))
57
+ w.push({ type: 'warning', property: 'og:image', message: `Image is ${width}x${height}. Minimum recommended: 200x200, ideal: 1200x630.` })
58
+ }
59
+
60
+ return w
61
+ })
62
+
63
+ const previewTitle = computed(() =>
64
+ result.value?.og['og:title'] || result.value?.twitter['twitter:title'] || result.value?.title || 'Untitled',
65
+ )
66
+
67
+ const previewDescription = computed(() =>
68
+ result.value?.og['og:description'] || result.value?.twitter['twitter:description'] || result.value?.description || '',
69
+ )
70
+
71
+ const previewImage = computed(() =>
72
+ result.value?.ogImages[0]?.url || result.value?.twitter['twitter:image'] || null,
73
+ )
74
+
75
+ const previewSiteName = computed(() => {
76
+ if (result.value?.og['og:site_name'])
77
+ return result.value.og['og:site_name']
78
+ try {
79
+ return new URL(result.value?.url || '').hostname
80
+ }
81
+ catch {
82
+ return ''
83
+ }
84
+ })
85
+
86
+ const ogTagCount = computed(() => result.value ? Object.keys(result.value.og).length : 0)
87
+ const twitterTagCount = computed(() => result.value ? Object.keys(result.value.twitter).length : 0)
88
+
89
+ function parseSocialMeta(html: string, url: string): ParsedSocialMeta {
90
+ const parsed = parseMetaTags(html)
91
+
92
+ const ogImages: ParsedSocialMeta['ogImages'] = []
93
+ if (parsed.ogTags['og:image']) {
94
+ ogImages.push({
95
+ url: parsed.ogTags['og:image'],
96
+ width: parsed.ogTags['og:image:width'],
97
+ height: parsed.ogTags['og:image:height'],
98
+ alt: parsed.ogTags['og:image:alt'],
99
+ })
100
+ }
101
+
102
+ let hostname = ''
103
+ try {
104
+ hostname = new URL(url).hostname
105
+ }
106
+ catch {
107
+ hostname = url
108
+ }
109
+
110
+ return {
111
+ title: parsed.title,
112
+ description: parsed.description,
113
+ url,
114
+ siteName: parsed.ogTags['og:site_name'] || hostname,
115
+ og: parsed.ogTags,
116
+ ogImages,
117
+ twitter: parsed.twitterTags,
118
+ twitterCard: parsed.twitterTags['twitter:card'] || 'summary',
119
+ }
120
+ }
121
+
122
+ async function checkSocial() {
123
+ loading.value = true
124
+ error.value = null
125
+ result.value = null
126
+
127
+ try {
128
+ const baseUrl = window.parent?.location?.origin || window.location.origin
129
+ const fullUrl = `${baseUrl}${path.value}`
130
+ const response = await fetch(fullUrl, { headers: { Accept: 'text/html' } })
131
+ if (!response.ok)
132
+ throw new Error(`HTTP ${response.status}`)
133
+ const html = await response.text()
134
+ result.value = parseSocialMeta(html, fullUrl)
135
+ }
136
+ catch (e: any) {
137
+ error.value = e.message || 'Failed to fetch page'
138
+ }
139
+ finally {
140
+ loading.value = false
141
+ }
142
+ }
143
+
144
+ watch([() => data.value, path, refreshTime], () => {
145
+ if (data.value)
146
+ checkSocial()
147
+ }, { immediate: true })
148
+ </script>
149
+
150
+ <template>
151
+ <div class="space-y-5 stagger-children">
152
+ <!-- Loading -->
153
+ <DevtoolsLoading v-if="loading" />
154
+
155
+ <!-- Error -->
156
+ <DevtoolsError v-else-if="error" :error="error" title="Failed to check page">
157
+ <UButton size="sm" variant="ghost" icon="carbon:reset" label="Retry" @click="checkSocial" />
158
+ </DevtoolsError>
159
+
160
+ <!-- Results -->
161
+ <template v-else-if="result">
162
+ <!-- Warnings -->
163
+ <DevtoolsSection v-if="warnings.length" icon="carbon:warning" text="Warnings">
164
+ <template #actions>
165
+ <DevtoolsMetric
166
+ :value="warnings.filter(w => w.type === 'error').length"
167
+ label="errors"
168
+ :variant="warnings.some(w => w.type === 'error') ? 'danger' : undefined"
169
+ />
170
+ <DevtoolsMetric
171
+ :value="warnings.filter(w => w.type === 'warning').length"
172
+ label="warnings"
173
+ :variant="warnings.some(w => w.type === 'warning') ? 'warning' : undefined"
174
+ />
175
+ </template>
176
+ <div class="space-y-1">
177
+ <div v-for="w of warnings" :key="w.property + w.message" class="social-issue-row">
178
+ <UIcon
179
+ :name="w.type === 'error' ? 'carbon:close-filled' : 'carbon:warning-filled'"
180
+ class="mt-0.5 shrink-0"
181
+ :class="w.type === 'error' ? 'text-red-500' : 'text-amber-500'"
182
+ />
183
+ <div class="min-w-0">
184
+ <span class="font-mono text-xs">{{ w.property }}</span>
185
+ <p class="text-xs text-[var(--color-text-muted)]">
186
+ {{ w.message }}
187
+ </p>
188
+ </div>
189
+ </div>
190
+ </div>
191
+ </DevtoolsSection>
192
+
193
+ <!-- Preview tabs -->
194
+ <DevtoolsSection icon="carbon:view" text="Social Preview" :padding="false">
195
+ <div class="social-tab-bar">
196
+ <button
197
+ v-for="tab of previewTabs"
198
+ :key="tab.value"
199
+ class="social-tab"
200
+ :class="{ 'social-tab--active': activePreview === tab.value }"
201
+ @click="activePreview = tab.value"
202
+ >
203
+ <UIcon :name="tab.icon" class="text-sm" />
204
+ <span class="social-tab__label">{{ tab.label }}</span>
205
+ </button>
206
+ </div>
207
+
208
+ <div class="social-preview-stage">
209
+ <!-- Twitter preview -->
210
+ <div v-if="activePreview === 'twitter'" class="social-preview-card">
211
+ <div class="rounded-2xl overflow-hidden border border-neutral-200 dark:border-neutral-700">
212
+ <div v-if="previewImage" class="aspect-[1.91/1] bg-neutral-100 dark:bg-neutral-800">
213
+ <img :src="previewImage" class="w-full h-full object-cover" :alt="previewTitle">
214
+ </div>
215
+ <div v-else class="social-image-placeholder aspect-[1.91/1]">
216
+ <UIcon name="carbon:image" class="text-2xl" />
217
+ <span>No og:image</span>
218
+ </div>
219
+ <div class="p-3">
220
+ <p class="text-xs text-neutral-500">
221
+ {{ previewSiteName }}
222
+ </p>
223
+ <p class="text-sm font-medium line-clamp-1">
224
+ {{ previewTitle }}
225
+ </p>
226
+ <p class="text-xs text-neutral-500 line-clamp-2">
227
+ {{ previewDescription }}
228
+ </p>
229
+ </div>
230
+ </div>
231
+ </div>
232
+
233
+ <!-- Facebook preview -->
234
+ <div v-if="activePreview === 'facebook'" class="social-preview-card">
235
+ <div class="rounded-lg overflow-hidden border border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800">
236
+ <div v-if="previewImage" class="aspect-[1.91/1] bg-neutral-100 dark:bg-neutral-700">
237
+ <img :src="previewImage" class="w-full h-full object-cover" :alt="previewTitle">
238
+ </div>
239
+ <div v-else class="social-image-placeholder aspect-[1.91/1]">
240
+ <UIcon name="carbon:image" class="text-2xl" />
241
+ <span>No og:image</span>
242
+ </div>
243
+ <div class="p-3">
244
+ <p class="text-[11px] text-neutral-500 uppercase tracking-wider">
245
+ {{ previewSiteName }}
246
+ </p>
247
+ <p class="text-base font-semibold line-clamp-1 mt-0.5">
248
+ {{ previewTitle }}
249
+ </p>
250
+ <p class="text-sm text-neutral-500 line-clamp-1 mt-0.5">
251
+ {{ previewDescription }}
252
+ </p>
253
+ </div>
254
+ </div>
255
+ </div>
256
+
257
+ <!-- LinkedIn preview -->
258
+ <div v-if="activePreview === 'linkedin'" class="social-preview-card">
259
+ <div class="rounded-lg overflow-hidden border border-neutral-200 dark:border-neutral-700">
260
+ <div v-if="previewImage" class="aspect-[1.91/1] bg-neutral-100 dark:bg-neutral-800">
261
+ <img :src="previewImage" class="w-full h-full object-cover" :alt="previewTitle">
262
+ </div>
263
+ <div v-else class="social-image-placeholder aspect-[1.91/1]">
264
+ <UIcon name="carbon:image" class="text-2xl" />
265
+ <span>No og:image</span>
266
+ </div>
267
+ <div class="p-3 bg-neutral-50 dark:bg-neutral-800">
268
+ <p class="text-sm font-semibold line-clamp-2">
269
+ {{ previewTitle }}
270
+ </p>
271
+ <p class="text-xs text-neutral-500 mt-1">
272
+ {{ previewSiteName }}
273
+ </p>
274
+ </div>
275
+ </div>
276
+ </div>
277
+
278
+ <!-- Discord preview -->
279
+ <div v-if="activePreview === 'discord'" class="social-preview-card">
280
+ <div class="rounded-lg overflow-hidden border-l-4 border-[#5865F2] bg-[#2f3136] text-white p-4">
281
+ <p class="text-xs text-neutral-400 mb-1">
282
+ {{ previewSiteName }}
283
+ </p>
284
+ <p class="text-[#00aff4] text-sm font-semibold hover:underline cursor-pointer">
285
+ {{ previewTitle }}
286
+ </p>
287
+ <p class="text-sm text-neutral-300 mt-1 line-clamp-2">
288
+ {{ previewDescription }}
289
+ </p>
290
+ <div v-if="previewImage" class="mt-3 rounded-lg overflow-hidden max-w-[300px]">
291
+ <img :src="previewImage" class="w-full object-cover" :alt="previewTitle">
292
+ </div>
293
+ <div v-else class="social-image-placeholder social-image-placeholder--discord mt-3 max-w-[300px] aspect-video rounded-lg">
294
+ <UIcon name="carbon:image" class="text-xl" />
295
+ <span>No image</span>
296
+ </div>
297
+ </div>
298
+ </div>
299
+
300
+ <!-- Slack preview -->
301
+ <div v-if="activePreview === 'slack'" class="social-preview-card">
302
+ <div class="rounded-lg overflow-hidden border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 py-2">
303
+ <p class="text-xs text-neutral-500 font-bold mb-1">
304
+ {{ previewSiteName }}
305
+ </p>
306
+ <p class="text-[#1264a3] dark:text-[#1d9bd1] text-sm font-bold hover:underline cursor-pointer">
307
+ {{ previewTitle }}
308
+ </p>
309
+ <p class="text-sm text-neutral-600 dark:text-neutral-400 mt-0.5 line-clamp-2">
310
+ {{ previewDescription }}
311
+ </p>
312
+ <div v-if="previewImage" class="mt-2 rounded overflow-hidden max-w-[360px]">
313
+ <img :src="previewImage" class="w-full object-cover" :alt="previewTitle">
314
+ </div>
315
+ </div>
316
+ </div>
317
+ </div>
318
+ </DevtoolsSection>
319
+
320
+ <!-- OG Tags table -->
321
+ <DevtoolsSection icon="carbon:tag-group" text="Open Graph Tags">
322
+ <template #actions>
323
+ <DevtoolsMetric :value="ogTagCount" label="og" />
324
+ <DevtoolsMetric :value="twitterTagCount" label="twitter" />
325
+ <UButton
326
+ size="xs"
327
+ variant="ghost"
328
+ color="neutral"
329
+ icon="carbon:copy"
330
+ :label="copied ? 'Copied!' : 'Copy'"
331
+ @click="copy(JSON.stringify({ og: result.og, twitter: result.twitter }, null, 2))"
332
+ />
333
+ </template>
334
+ <div class="social-tags-table">
335
+ <div v-for="(value, key) of result.og" :key="key" class="social-tags-table__row">
336
+ <UBadge color="primary" variant="subtle" size="xs">
337
+ og
338
+ </UBadge>
339
+ <span class="text-sm font-mono shrink-0 text-[var(--color-text)]">{{ key }}</span>
340
+ <span class="text-xs text-[var(--color-text-muted)] truncate min-w-0 flex-1 text-right font-mono">{{ value }}</span>
341
+ </div>
342
+ <div v-for="(value, key) of result.twitter" :key="key" class="social-tags-table__row">
343
+ <UBadge color="neutral" variant="subtle" size="xs">
344
+ twitter
345
+ </UBadge>
346
+ <span class="text-sm font-mono shrink-0 text-[var(--color-text)]">{{ key }}</span>
347
+ <span class="text-xs text-[var(--color-text-muted)] truncate min-w-0 flex-1 text-right font-mono">{{ value }}</span>
348
+ </div>
349
+ </div>
350
+ </DevtoolsSection>
351
+
352
+ <!-- OG Image preview -->
353
+ <DevtoolsSection v-if="result.ogImages.length" icon="carbon:image" text="OG Image">
354
+ <template #actions>
355
+ <DevtoolsMetric
356
+ v-if="result.ogImages[0]?.width && result.ogImages[0]?.height"
357
+ :value="`${result.ogImages[0].width}×${result.ogImages[0].height}`"
358
+ label="px"
359
+ />
360
+ </template>
361
+ <div v-for="(img, i) of result.ogImages" :key="i" class="space-y-2">
362
+ <div class="rounded-lg overflow-hidden border border-[var(--color-border)]">
363
+ <img :src="img.url" class="w-full max-h-64 object-contain bg-neutral-100 dark:bg-neutral-800" :alt="img.alt || 'OG Image'">
364
+ </div>
365
+ <div class="flex items-center gap-2 text-xs text-[var(--color-text-muted)] font-mono">
366
+ <span class="truncate">{{ img.url }}</span>
367
+ </div>
368
+ </div>
369
+ </DevtoolsSection>
370
+ </template>
371
+
372
+ <!-- Not connected -->
373
+ <DevtoolsEmptyState
374
+ v-else-if="!loading && !error"
375
+ icon="carbon:share"
376
+ title="Waiting for connection"
377
+ description="Waiting for connection to host app..."
378
+ />
379
+ </div>
380
+ </template>
381
+
382
+ <style scoped>
383
+ /* Issue rows */
384
+ .social-issue-row {
385
+ display: flex;
386
+ align-items: flex-start;
387
+ gap: 0.5rem;
388
+ padding: 0.625rem 0.75rem;
389
+ border-radius: var(--radius-sm);
390
+ font-size: 0.8125rem;
391
+ transition: background 150ms;
392
+ }
393
+
394
+ .social-issue-row:hover {
395
+ background: var(--color-surface-sunken);
396
+ }
397
+
398
+ /* Platform tabs */
399
+ .social-tab-bar {
400
+ display: flex;
401
+ gap: 0.125rem;
402
+ padding: 0.5rem 0.75rem;
403
+ border-bottom: 1px solid var(--color-border);
404
+ overflow-x: auto;
405
+ }
406
+
407
+ .social-tab {
408
+ display: flex;
409
+ align-items: center;
410
+ gap: 0.375rem;
411
+ padding: 0.375rem 0.625rem;
412
+ border-radius: var(--radius-sm);
413
+ font-size: 0.75rem;
414
+ font-weight: 500;
415
+ color: var(--color-text-muted);
416
+ white-space: nowrap;
417
+ transition: color 150ms, background 150ms;
418
+ }
419
+
420
+ .social-tab:hover {
421
+ color: var(--color-text);
422
+ background: var(--color-surface-sunken);
423
+ }
424
+
425
+ .social-tab--active {
426
+ color: var(--seo-green);
427
+ background: oklch(65% 0.2 145 / 0.08);
428
+ }
429
+
430
+ .dark .social-tab--active {
431
+ background: oklch(65% 0.2 145 / 0.12);
432
+ }
433
+
434
+ .social-tab__label {
435
+ display: none;
436
+ }
437
+
438
+ @media (min-width: 640px) {
439
+ .social-tab__label {
440
+ display: inline;
441
+ }
442
+ }
443
+
444
+ /* Preview stage */
445
+ .social-preview-stage {
446
+ padding: 1.5rem;
447
+ }
448
+
449
+ .social-preview-card {
450
+ max-width: 500px;
451
+ margin: 0 auto;
452
+ animation: fade-up 250ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
453
+ }
454
+
455
+ /* Image placeholder */
456
+ .social-image-placeholder {
457
+ display: flex;
458
+ flex-direction: column;
459
+ align-items: center;
460
+ justify-content: center;
461
+ gap: 0.375rem;
462
+ background: var(--color-surface-sunken);
463
+ color: var(--color-text-subtle);
464
+ font-size: 0.6875rem;
465
+ font-weight: 500;
466
+ background-image:
467
+ linear-gradient(45deg, oklch(0% 0 0 / 0.03) 25%, transparent 25%),
468
+ linear-gradient(-45deg, oklch(0% 0 0 / 0.03) 25%, transparent 25%),
469
+ linear-gradient(45deg, transparent 75%, oklch(0% 0 0 / 0.03) 75%),
470
+ linear-gradient(-45deg, transparent 75%, oklch(0% 0 0 / 0.03) 75%);
471
+ background-size: 16px 16px;
472
+ background-position: 0 0, 0 8px, 8px -8px, -8px 0;
473
+ }
474
+
475
+ .dark .social-image-placeholder {
476
+ background-image:
477
+ linear-gradient(45deg, oklch(100% 0 0 / 0.03) 25%, transparent 25%),
478
+ linear-gradient(-45deg, oklch(100% 0 0 / 0.03) 25%, transparent 25%),
479
+ linear-gradient(45deg, transparent 75%, oklch(100% 0 0 / 0.03) 75%),
480
+ linear-gradient(-45deg, transparent 75%, oklch(100% 0 0 / 0.03) 75%);
481
+ background-size: 16px 16px;
482
+ background-position: 0 0, 0 8px, 8px -8px, -8px 0;
483
+ }
484
+
485
+ .social-image-placeholder--discord {
486
+ background-color: #36393f;
487
+ color: #72767d;
488
+ }
489
+
490
+ /* Tags table */
491
+ .social-tags-table__row {
492
+ display: flex;
493
+ align-items: center;
494
+ gap: 0.75rem;
495
+ padding: 0.5rem 0.625rem;
496
+ border-radius: var(--radius-sm);
497
+ transition: background 150ms;
498
+ }
499
+
500
+ .social-tags-table__row:hover {
501
+ background: var(--color-surface-sunken);
502
+ }
503
+
504
+ .social-tags-table__row:nth-child(even) {
505
+ background: oklch(0% 0 0 / 0.015);
506
+ }
507
+
508
+ .dark .social-tags-table__row:nth-child(even) {
509
+ background: oklch(100% 0 0 / 0.015);
510
+ }
511
+
512
+ .social-tags-table__row:nth-child(even):hover {
513
+ background: var(--color-surface-sunken);
514
+ }
515
+ </style>
@@ -0,0 +1,64 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+ import { isProductionMode, navigateTo, useRoute } from '#imports'
4
+ import { data, refreshSources } from '../lib/seo-utils/state'
5
+ import '../lib/seo-utils/rpc'
6
+
7
+ const refreshing = ref(false)
8
+
9
+ async function refresh() {
10
+ if (refreshing.value)
11
+ return
12
+ refreshing.value = true
13
+ data.value = null
14
+ await refreshSources()
15
+ setTimeout(() => {
16
+ refreshing.value = false
17
+ }, 300)
18
+ }
19
+
20
+ const route = useRoute()
21
+ const currentTab = computed(() => {
22
+ const p = route.path
23
+ if (p.startsWith('/seo-utils/social'))
24
+ return 'social'
25
+ if (p.startsWith('/seo-utils/identity'))
26
+ return 'identity'
27
+ if (p.startsWith('/seo-utils/debug'))
28
+ return 'debug'
29
+ if (p.startsWith('/seo-utils/docs'))
30
+ return 'docs'
31
+ return 'meta'
32
+ })
33
+
34
+ const navItems = [
35
+ { value: 'meta', to: '/seo-utils', icon: 'carbon:tag', label: 'Meta Tags', devOnly: false },
36
+ { value: 'social', to: '/seo-utils/social', icon: 'carbon:share', label: 'Social', devOnly: false },
37
+ { value: 'identity', to: '/seo-utils/identity', icon: 'carbon:identification', label: 'Identity', devOnly: false },
38
+ { value: 'debug', to: '/seo-utils/debug', icon: 'carbon:debug', label: 'Debug', devOnly: true },
39
+ { value: 'docs', to: '/seo-utils/docs', icon: 'carbon:book', label: 'Docs', devOnly: false },
40
+ ]
41
+
42
+ const version = computed(() => data.value?.runtimeConfig?.version || '')
43
+
44
+ watch(isProductionMode, (isProd) => {
45
+ if (isProd && currentTab.value === 'debug')
46
+ return navigateTo('/seo-utils')
47
+ })
48
+ </script>
49
+
50
+ <template>
51
+ <DevtoolsLayout
52
+ v-model:active-tab="currentTab"
53
+ module-name="nuxt-seo-utils"
54
+ title="SEO Utils"
55
+ icon="carbon:search-locate"
56
+ :version="version"
57
+ :nav-items="navItems"
58
+ github-url="https://github.com/harlan-zw/nuxt-seo-utils"
59
+ :loading="!data || refreshing"
60
+ @refresh="refresh"
61
+ >
62
+ <NuxtPage />
63
+ </DevtoolsLayout>
64
+ </template>
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=3.16.0"
6
6
  },
7
- "version": "8.2.0",
7
+ "version": "8.3.0",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.2",
10
10
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -4,6 +4,7 @@ import { useNuxt, addTypeTemplate, useLogger, defineNuxtModule, createResolver,
4
4
  import { defu } from 'defu';
5
5
  import { resolveModulePath } from 'exsolve';
6
6
  import { installNuxtSiteConfig } from 'nuxt-site-config/kit';
7
+ import { resolveHostUnheadMajor } from 'nuxtseo-shared/kit';
7
8
  import { resolve, extname, relative, dirname, basename } from 'pathe';
8
9
  import { readPackageJSON } from 'pkg-types';
9
10
  import { unpackMeta } from '@unhead/vue/utils';
@@ -718,8 +719,7 @@ export {}
718
719
  const viteOpts = nuxt.options.unhead.vite;
719
720
  const nuxtRegistersUnheadVite = nuxt.options.future?.compatibilityVersion >= 5 && nuxt.options.builder === "@nuxt/vite-builder" && await hasNuxtCompatibility({ nuxt: ">=4.5.0" });
720
721
  if (!nuxtRegistersUnheadVite && viteOpts !== false) {
721
- const unheadPkg = await readPackageJSON("@unhead/vue", { from: nuxt.options.rootDir }).catch(() => null);
722
- const unheadMajor = Number.parseInt((unheadPkg?.version || "2").split(".")[0], 10);
722
+ const unheadMajor = await resolveHostUnheadMajor(nuxt.options.rootDir);
723
723
  const importPaths = nuxt.options.modulesDir.map((d) => directoryToURL(d));
724
724
  const rolldownPath = resolveModulePath("rolldown/experimental", { try: true, from: importPaths });
725
725
  const lightningcssPath = resolveModulePath("lightningcss", { try: true, from: importPaths });
@@ -1,5 +1,5 @@
1
- import { useSiteConfig } from "#site-config/app/composables/useSiteConfig";
2
1
  import { computed, toValue } from "vue";
2
+ import { useSiteConfig } from "#site-config/app/composables/useSiteConfig";
3
3
  export function useSchemaOrg() {
4
4
  }
5
5
  export function defineWebSite() {
@@ -1,6 +1,3 @@
1
- import { defineBreadcrumb, useI18n, useSchemaOrg } from "#imports";
2
- import { useSiteConfig } from "#site-config/app/composables/useSiteConfig";
3
- import { createSitePathResolver } from "#site-config/app/composables/utils";
4
1
  import { defu } from "defu";
5
2
  import { fixSlashes } from "nuxt-site-config/urls";
6
3
  import { useNuxtApp, useRoute, useRouter, useState } from "nuxt/app";
@@ -17,6 +14,9 @@ import {
17
14
  toValue,
18
15
  watch
19
16
  } from "vue";
17
+ import { defineBreadcrumb, useI18n, useSchemaOrg } from "#imports";
18
+ import { useSiteConfig } from "#site-config/app/composables/useSiteConfig";
19
+ import { createSitePathResolver } from "#site-config/app/composables/utils";
20
20
  import { pathBreadcrumbSegments } from "../../shared/breadcrumbs.js";
21
21
  function withoutQuery(path) {
22
22
  return path.split("?")[0] || "";
@@ -1,8 +1,8 @@
1
- import { useI18n } from "#imports";
2
1
  import { useError, useRoute } from "nuxt/app";
3
2
  import { titleCase } from "scule";
4
3
  import { withoutTrailingSlash } from "ufo";
5
4
  import { computed } from "vue";
5
+ import { useI18n } from "#imports";
6
6
  export function useFallbackTitle() {
7
7
  const route = useRoute();
8
8
  const err = useError();
@@ -1,8 +1,8 @@
1
- import { useSiteConfig } from "#site-config/app/composables/useSiteConfig";
2
- import { createSitePathResolver } from "#site-config/app/composables/utils";
3
1
  import { useRoute, useRuntimeConfig } from "nuxt/app";
4
2
  import { stringifyQuery, withQuery } from "ufo";
5
3
  import { computed, toValue } from "vue";
4
+ import { useSiteConfig } from "#site-config/app/composables/useSiteConfig";
5
+ import { createSitePathResolver } from "#site-config/app/composables/utils";
6
6
  function buildShareUrl(platform, url, title, options) {
7
7
  const encodedUrl = encodeURIComponent(url);
8
8
  const encodedTitle = encodeURIComponent(title);
@@ -1,10 +1,10 @@
1
- import { useSiteConfig } from "#site-config/app/composables/useSiteConfig";
2
- import { createSitePathResolver } from "#site-config/app/composables/utils";
3
1
  import { injectHead, useHead, useSeoMeta } from "@unhead/vue";
4
2
  import { TemplateParamsPlugin } from "@unhead/vue/plugins";
5
3
  import { useError, useRoute, useRuntimeConfig } from "nuxt/app";
6
4
  import { stringifyQuery } from "ufo";
7
5
  import { computed, toValue } from "vue";
6
+ import { useSiteConfig } from "#site-config/app/composables/useSiteConfig";
7
+ import { createSitePathResolver } from "#site-config/app/composables/utils";
8
8
  const LOCALE_UNDERSCORE_RE = /_/g;
9
9
  export function applyDefaults() {
10
10
  const siteConfig = useSiteConfig({
@@ -1,7 +1,7 @@
1
- import { createSitePathResolver } from "#site-config/app/composables/utils";
2
1
  import { injectHead } from "@unhead/vue";
3
2
  import { defineNuxtPlugin } from "nuxt/app";
4
3
  import { unref } from "vue";
4
+ import { createSitePathResolver } from "#site-config/app/composables/utils";
5
5
  export default defineNuxtPlugin({
6
6
  enforce: "post",
7
7
  setup() {