nuxt-seo-utils 8.2.1 → 8.3.1
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/cli.mjs +1 -1
- package/dist/devtools/lib/seo-utils/rpc.ts +3 -0
- package/dist/devtools/lib/seo-utils/state.ts +35 -0
- package/dist/devtools/lib/seo-utils/tools.ts +98 -0
- package/dist/devtools/nuxt.config.ts +3 -0
- package/dist/devtools/pages/seo-utils/debug.vue +50 -0
- package/dist/devtools/pages/seo-utils/docs.vue +3 -0
- package/dist/devtools/pages/seo-utils/identity.vue +1168 -0
- package/dist/devtools/pages/seo-utils/index.vue +609 -0
- package/dist/devtools/pages/seo-utils/social.vue +515 -0
- package/dist/devtools/pages/seo-utils.vue +64 -0
- package/dist/module.json +1 -1
- package/dist/runtime/app/composables/polyfills.js +1 -1
- package/dist/runtime/app/composables/useBreadcrumbItems.js +3 -3
- package/dist/runtime/app/composables/useFallbackTitle.js +1 -1
- package/dist/runtime/app/composables/useShareLinks.js +2 -2
- package/dist/runtime/app/logic/applyDefaults.js +2 -2
- package/dist/runtime/app/plugins/1.absoluteImageUrls.server.js +1 -1
- package/dist/runtime/app/plugins/siteConfig.js +1 -1
- package/dist/runtime/server/middleware/redirectCanonical.js +1 -1
- package/dist/runtime/server/middleware/resolveImagesInPagesDir.js +1 -1
- package/dist/runtime/server/routes/__nuxt-seo-utils/debug.js +1 -1
- package/package.json +32 -31
- package/dist/devtools/200.html +0 -1
- package/dist/devtools/404.html +0 -1
- package/dist/devtools/_nuxt/-x-fxsZ8.js +0 -33
- package/dist/devtools/_nuxt/9bFguHRm.js +0 -1
- package/dist/devtools/_nuxt/BFlHJz6O.js +0 -1
- package/dist/devtools/_nuxt/BgGLJ6MY.js +0 -14
- package/dist/devtools/_nuxt/BrktcVIY.js +0 -1
- package/dist/devtools/_nuxt/BvTRrGaN.js +0 -1
- package/dist/devtools/_nuxt/CwH3HOIZ.js +0 -1
- package/dist/devtools/_nuxt/DevtoolsMetric.DabK8i8g.css +0 -1
- package/dist/devtools/_nuxt/Drh0Ii4X.js +0 -1
- package/dist/devtools/_nuxt/builds/latest.json +0 -1
- package/dist/devtools/_nuxt/builds/meta/30a898c6-4010-4ba2-813b-88c7b3e82138.json +0 -1
- package/dist/devtools/_nuxt/debug.B9T5jmI-.css +0 -1
- package/dist/devtools/_nuxt/entry.aDw8hoj_.css +0 -1
- package/dist/devtools/_nuxt/fira-code.Bc8wnsZt.woff2 +0 -0
- package/dist/devtools/_nuxt/hubot-sans.DLGyhQVu.woff2 +0 -0
- package/dist/devtools/_nuxt/identity.Dl98KbLG.css +0 -1
- package/dist/devtools/_nuxt/index.BrQ8jYVK.css +0 -1
- package/dist/devtools/_nuxt/social.DrJTEVxX.css +0 -1
- package/dist/devtools/_nuxt/tools.DM91U8p4.css +0 -1
- package/dist/devtools/debug/index.html +0 -1
- package/dist/devtools/docs/index.html +0 -1
- package/dist/devtools/identity/index.html +0 -1
- package/dist/devtools/index.html +0 -1
- 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
|
@@ -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() {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { useSiteConfig } from "#site-config/app/composables/useSiteConfig";
|
|
2
1
|
import { injectHead } from "@unhead/vue";
|
|
3
2
|
import { defineNuxtPlugin, useRuntimeConfig } from "nuxt/app";
|
|
3
|
+
import { useSiteConfig } from "#site-config/app/composables/useSiteConfig";
|
|
4
4
|
export default defineNuxtPlugin(() => {
|
|
5
5
|
const head = injectHead();
|
|
6
6
|
if (!head)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { getSiteConfig } from "#site-config/server/composables/getSiteConfig";
|
|
2
1
|
import { defineEventHandler, getRequestHost, sendRedirect, setHeader } from "h3";
|
|
3
2
|
import { joinURL } from "ufo";
|
|
3
|
+
import { getSiteConfig } from "#site-config/server/composables/getSiteConfig";
|
|
4
4
|
export default defineEventHandler((e) => {
|
|
5
5
|
if (import.meta.prerender) {
|
|
6
6
|
return;
|