nuxtseo-layer-devtools 0.4.5 → 0.5.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.
@@ -0,0 +1,486 @@
1
+ import type { ChecklistItemDefinition, NuxtSEOModule } from 'nuxtseo-shared/const'
2
+ import { computed, ref, toValue } from 'vue'
3
+ import { installedModules } from './modules'
4
+ import { appFetch } from './rpc'
5
+
6
+ export interface ChecklistDetectResult {
7
+ passed: boolean
8
+ detail?: string
9
+ }
10
+
11
+ interface ChecklistItemWithDetect extends ChecklistItemDefinition {
12
+ detect: (data: Record<string, any>, ctx: DetectContext) => ChecklistDetectResult
13
+ }
14
+
15
+ interface DetectContext {
16
+ installedModuleSlugs: Set<string>
17
+ debugData: Map<string, Record<string, any>>
18
+ }
19
+
20
+ export interface ChecklistItemResult extends ChecklistItemDefinition {
21
+ passed: boolean
22
+ detail?: string
23
+ }
24
+
25
+ export interface ModuleChecklistResult {
26
+ moduleSlug: NuxtSEOModule['slug']
27
+ moduleLabel: string
28
+ moduleIcon: string
29
+ items: ChecklistItemResult[]
30
+ requiredPending: number
31
+ recommendedPending: number
32
+ totalPending: number
33
+ }
34
+
35
+ export interface ChecklistSummary {
36
+ total: number
37
+ passed: number
38
+ requiredPending: number
39
+ recommendedPending: number
40
+ }
41
+
42
+ // Debug endpoint paths for each module
43
+ const DEBUG_ENDPOINTS: Partial<Record<NuxtSEOModule['slug'], string>> = {
44
+ 'site-config': '/__site-config__/debug',
45
+ 'robots': '/__robots__/debug.json',
46
+ 'sitemap': '/__sitemap__/debug.json',
47
+ 'og-image': '/__nuxt-og-image/debug.json',
48
+ 'schema-org': '/__schema-org__/debug.json',
49
+ }
50
+
51
+ // Module slug used internally by devtools → catalog slug mapping
52
+ const DEVTOOLS_NAME_TO_SLUG: Record<string, NuxtSEOModule['slug']> = {
53
+ 'nuxt-robots': 'robots',
54
+ 'sitemap': 'sitemap',
55
+ 'nuxt-og-image': 'og-image',
56
+ 'nuxt-schema-org': 'schema-org',
57
+ 'nuxt-seo-utils': 'seo-utils',
58
+ 'nuxt-link-checker': 'link-checker',
59
+ 'nuxt-site-config': 'site-config',
60
+ 'nuxt-ai-ready': 'ai-ready',
61
+ 'nuxt-skew-protection': 'skew-protection',
62
+ }
63
+
64
+ const MODULE_META: Record<string, { label: string, icon: string }> = {
65
+ 'site-config': { label: 'Site Config', icon: 'carbon:settings-check' },
66
+ 'robots': { label: 'Robots', icon: 'carbon:bot' },
67
+ 'sitemap': { label: 'Sitemap', icon: 'carbon:load-balancer-application' },
68
+ 'og-image': { label: 'OG Image', icon: 'carbon:image-search' },
69
+ 'seo-utils': { label: 'SEO Utils', icon: 'carbon:tools' },
70
+ 'schema-org': { label: 'Schema.org', icon: 'carbon:chart-relationship' },
71
+ }
72
+
73
+ function isValidUrl(url?: string): boolean {
74
+ if (!url)
75
+ return false
76
+ return !url.includes('localhost') && !url.includes('127.0.0.1') && url.length > 0
77
+ }
78
+
79
+ // Checklist definitions with detection logic per module
80
+ const CHECKLIST_DEFINITIONS: Partial<Record<NuxtSEOModule['slug'], ChecklistItemWithDetect[]>> = {
81
+ 'site-config': [
82
+ {
83
+ id: 'site-url',
84
+ label: 'Site URL configured',
85
+ description: 'A production site URL is needed for canonical URLs, sitemaps, and OG images to work correctly.',
86
+ level: 'required',
87
+ docsUrl: 'https://nuxtseo.com/docs/site-config/getting-started/how-it-works',
88
+ detect: (data) => {
89
+ const url = data?.config?.url || ''
90
+ const passed = isValidUrl(url)
91
+ return { passed, detail: passed ? url : 'Not set or using localhost' }
92
+ },
93
+ },
94
+ {
95
+ id: 'site-name',
96
+ label: 'Site name set',
97
+ description: 'Used for default meta tags, Schema.org, and OG tags across all modules.',
98
+ level: 'required',
99
+ docsUrl: 'https://nuxtseo.com/docs/site-config/getting-started/how-it-works',
100
+ detect: (data) => {
101
+ const name = data?.config?.name || ''
102
+ const passed = !!name && name !== 'My Site'
103
+ return { passed, detail: passed ? name : 'Not configured' }
104
+ },
105
+ },
106
+ {
107
+ id: 'default-locale',
108
+ label: 'Default locale configured',
109
+ description: 'Ensures correct hreflang tags and locale-aware sitemaps when using i18n.',
110
+ level: 'recommended',
111
+ docsUrl: 'https://nuxtseo.com/docs/site-config/guides/setting-site-config',
112
+ detect: (data) => {
113
+ const locale = data?.config?.defaultLocale
114
+ const passed = !!locale
115
+ return { passed, detail: passed ? locale : 'Not set' }
116
+ },
117
+ },
118
+ {
119
+ id: 'trailing-slash',
120
+ label: 'Trailing slash preference set',
121
+ description: 'Prevents duplicate content from inconsistent URL formats. Set explicitly to true or false.',
122
+ level: 'recommended',
123
+ docsUrl: 'https://nuxtseo.com/docs/site-config/guides/setting-site-config',
124
+ detect: (data) => {
125
+ const trailingSlash = data?.config?.trailingSlash
126
+ const passed = typeof trailingSlash === 'boolean'
127
+ return { passed, detail: passed ? (trailingSlash ? 'Enabled' : 'Disabled') : 'Not explicitly set' }
128
+ },
129
+ },
130
+ {
131
+ id: 'robots-installed',
132
+ label: 'Robots module installed',
133
+ description: 'Controls crawling and indexing across all SEO modules. Strongly recommended.',
134
+ level: 'recommended',
135
+ docsUrl: 'https://nuxtseo.com/docs/robots/getting-started/installation',
136
+ detect: (_data, ctx) => {
137
+ const passed = ctx.installedModuleSlugs.has('robots')
138
+ return { passed, detail: passed ? 'Installed' : 'Not installed' }
139
+ },
140
+ },
141
+ ],
142
+ 'robots': [
143
+ {
144
+ id: 'no-validation-errors',
145
+ label: 'No robots.txt validation errors',
146
+ description: 'Your robots.txt should be free of syntax errors that could confuse crawlers.',
147
+ level: 'required',
148
+ docsUrl: 'https://nuxtseo.com/docs/robots/getting-started/installation',
149
+ detect: (data) => {
150
+ const errors = data?.validation?.errors || []
151
+ const passed = errors.length === 0
152
+ return { passed, detail: passed ? 'No errors' : `${errors.length} error(s) found` }
153
+ },
154
+ },
155
+ {
156
+ id: 'ai-directives',
157
+ label: 'AI bot directives configured',
158
+ description: 'Configure how AI crawlers interact with your content using blockAiBots or content signal directives.',
159
+ level: 'recommended',
160
+ docsUrl: 'https://nuxtseo.com/docs/robots/guides/ai-bots',
161
+ detect: (data) => {
162
+ const groups = data?.runtimeConfig?.groups || []
163
+ const hasContentSignal = groups.some((g: any) => g.contentSignal?.length || g.contentUsage?.length)
164
+ const aiAgents = ['gptbot', 'chatgpt-user', 'anthropic-ai', 'claudebot', 'claude-web', 'google-extended', 'ccbot']
165
+ const hasAiAgent = groups.some((g: any) =>
166
+ (g.userAgent || []).some((ua: string) => aiAgents.includes(ua.toLowerCase())),
167
+ )
168
+ const passed = hasContentSignal || hasAiAgent
169
+ return { passed, detail: passed ? (hasContentSignal ? 'Content signals configured' : 'AI agent rules configured') : 'No AI bot directives found' }
170
+ },
171
+ },
172
+ {
173
+ id: 'bot-detection',
174
+ label: 'Bot detection enabled',
175
+ description: 'Classify bots via headers and fingerprinting to reduce server load from non-SEO crawlers.',
176
+ level: 'recommended',
177
+ docsUrl: 'https://nuxtseo.com/docs/robots/guides/bot-detection',
178
+ detect: (data) => {
179
+ const enabled = data?.runtimeConfig?.botDetection
180
+ return { passed: !!enabled, detail: enabled ? 'Enabled' : 'Disabled' }
181
+ },
182
+ },
183
+ {
184
+ id: 'sitemap-reference',
185
+ label: 'Sitemap referenced in robots.txt',
186
+ description: 'Crawlers use the Sitemap directive in robots.txt to discover your sitemap URL.',
187
+ level: 'recommended',
188
+ docsUrl: 'https://nuxtseo.com/docs/robots/guides/robots-txt',
189
+ detect: (data) => {
190
+ const sitemaps = data?.validation?.sitemaps || []
191
+ const passed = sitemaps.length > 0
192
+ return { passed, detail: passed ? `${sitemaps.length} sitemap(s) referenced` : 'No sitemap directive found' }
193
+ },
194
+ },
195
+ ],
196
+ 'sitemap': [
197
+ {
198
+ id: 'site-url-set',
199
+ label: 'Site URL set for sitemaps',
200
+ description: 'Sitemaps require an absolute site URL. Without it, URLs will use localhost in production.',
201
+ level: 'required',
202
+ docsUrl: 'https://nuxtseo.com/docs/sitemap/getting-started/installation',
203
+ detect: (data) => {
204
+ const url = data?.siteConfig?.url || ''
205
+ const passed = isValidUrl(url)
206
+ return { passed, detail: passed ? url : 'Site URL not configured' }
207
+ },
208
+ },
209
+ {
210
+ id: 'has-sources',
211
+ label: 'URL sources configured',
212
+ description: 'Add dynamic URL sources for CMS or database content so all pages appear in your sitemap.',
213
+ level: 'recommended',
214
+ docsUrl: 'https://nuxtseo.com/docs/sitemap/guides/dynamic-urls',
215
+ detect: (data) => {
216
+ const sources = data?.globalSources || []
217
+ const sitemaps = data?.sitemaps || {}
218
+ const userSources = sources.filter((s: any) => s.sourceType === 'user' || (s.context?.name && !s.context.name.startsWith('nuxt:')))
219
+ const sitemapUserSources = Object.values(sitemaps).flatMap((s: any) =>
220
+ (s.sources || []).filter((src: any) => src.sourceType === 'user'),
221
+ )
222
+ const totalUser = userSources.length + sitemapUserSources.length
223
+ const passed = totalUser > 0
224
+ return { passed, detail: passed ? `${totalUser} custom source(s)` : 'Only default app sources detected' }
225
+ },
226
+ },
227
+ {
228
+ id: 'no-source-errors',
229
+ label: 'No sitemap source errors',
230
+ description: 'All configured sitemap sources should resolve successfully.',
231
+ level: 'required',
232
+ docsUrl: 'https://nuxtseo.com/docs/sitemap/guides/dynamic-urls',
233
+ detect: (data) => {
234
+ const sources = data?.globalSources || []
235
+ const sitemaps = data?.sitemaps || {}
236
+ const allSources = [
237
+ ...sources,
238
+ ...Object.values(sitemaps).flatMap((s: any) => s.sources || []),
239
+ ]
240
+ const failures = allSources.filter((s: any) => s._isFailure || s.error)
241
+ const passed = failures.length === 0
242
+ return { passed, detail: passed ? 'All sources OK' : `${failures.length} source(s) failing` }
243
+ },
244
+ },
245
+ {
246
+ id: 'url-warnings',
247
+ label: 'No URL validation warnings',
248
+ description: 'URLs in your sitemap should follow best practices (no whitespace, lowercase, etc).',
249
+ level: 'recommended',
250
+ docsUrl: 'https://nuxtseo.com/docs/sitemap/guides/best-practices',
251
+ detect: (data) => {
252
+ const sources = data?.globalSources || []
253
+ const sitemaps = data?.sitemaps || {}
254
+ const allSources = [
255
+ ...sources,
256
+ ...Object.values(sitemaps).flatMap((s: any) => s.sources || []),
257
+ ]
258
+ const warningCount = allSources.reduce((sum: number, s: any) => sum + (s._urlWarnings?.length || 0), 0)
259
+ const passed = warningCount === 0
260
+ return { passed, detail: passed ? 'No warnings' : `${warningCount} URL warning(s)` }
261
+ },
262
+ },
263
+ ],
264
+ 'og-image': [
265
+ {
266
+ id: 'renderer',
267
+ label: 'Renderer installed',
268
+ description: 'A renderer (Takumi, Satori, or Browser) is required to generate OG images.',
269
+ level: 'required',
270
+ docsUrl: 'https://nuxtseo.com/docs/og-image/getting-started/installation',
271
+ detect: (data) => {
272
+ const compat = data?.compatibility || {}
273
+ const hasTakumi = compat.takumi && compat.takumi !== false
274
+ const hasSatori = compat.satori && compat.satori !== false
275
+ const hasBrowser = compat.browser && compat.browser !== false
276
+ const passed = hasTakumi || hasSatori || hasBrowser
277
+ const renderers = [hasTakumi && 'takumi', hasSatori && 'satori', hasBrowser && 'browser'].filter(Boolean)
278
+ return { passed, detail: passed ? `Available: ${renderers.join(', ')}` : 'No renderer installed' }
279
+ },
280
+ },
281
+ {
282
+ id: 'custom-template',
283
+ label: 'Custom OG template created',
284
+ description: 'Community templates are for development only. Create a custom template for production.',
285
+ level: 'recommended',
286
+ docsUrl: 'https://nuxtseo.com/docs/og-image/guides/templates',
287
+ detect: (data) => {
288
+ const components = data?.componentNames || []
289
+ const appTemplates = components.filter((c: any) => c.category === 'app')
290
+ const communityTemplates = components.filter((c: any) => c.category === 'community')
291
+ const passed = appTemplates.length > 0
292
+ if (passed)
293
+ return { passed, detail: `${appTemplates.length} custom template(s)` }
294
+ if (communityTemplates.length > 0)
295
+ return { passed: false, detail: `${communityTemplates.length} community template(s), eject before production` }
296
+ return { passed: false, detail: 'No templates found' }
297
+ },
298
+ },
299
+ ],
300
+ 'seo-utils': [
301
+ {
302
+ id: 'schema-org-installed',
303
+ label: 'Schema.org module installed',
304
+ description: 'Adds structured data to your pages, improving rich search results. Pairs well with SEO Utils.',
305
+ level: 'recommended',
306
+ docsUrl: 'https://nuxtseo.com/docs/schema-org/getting-started/installation',
307
+ detect: (_data, ctx) => {
308
+ const passed = ctx.installedModuleSlugs.has('schema-org')
309
+ return { passed, detail: passed ? 'Installed' : 'Not installed' }
310
+ },
311
+ },
312
+ {
313
+ id: 'sitemap-installed',
314
+ label: 'Sitemap module installed',
315
+ description: 'Generates XML sitemaps so search engines can discover all your pages efficiently.',
316
+ level: 'recommended',
317
+ docsUrl: 'https://nuxtseo.com/docs/sitemap/getting-started/installation',
318
+ detect: (_data, ctx) => {
319
+ const passed = ctx.installedModuleSlugs.has('sitemap')
320
+ return { passed, detail: passed ? 'Installed' : 'Not installed' }
321
+ },
322
+ },
323
+ ],
324
+ 'schema-org': [
325
+ {
326
+ id: 'identity',
327
+ label: 'Identity configured',
328
+ description: 'Set up your Organization or Person identity for rich Schema.org knowledge graph results.',
329
+ level: 'recommended',
330
+ docsUrl: 'https://nuxtseo.com/docs/schema-org/guides/setup-identity',
331
+ detect: (data) => {
332
+ const config = data?.runtimeConfig || {}
333
+ const identity = config.identity
334
+ if (!identity)
335
+ return { passed: false, detail: 'No identity set' }
336
+ const type = typeof identity === 'string' ? identity : identity['@type'] || 'Unknown'
337
+ const name = typeof identity === 'object' ? (identity.name || '') : ''
338
+ return { passed: true, detail: name ? `${type}: ${name}` : type }
339
+ },
340
+ },
341
+ {
342
+ id: 'robots-companion',
343
+ label: 'Robots module installed',
344
+ description: 'Robots module auto-excludes non-indexable paths from Schema.org output.',
345
+ level: 'recommended',
346
+ docsUrl: 'https://nuxtseo.com/docs/schema-org/getting-started/installation',
347
+ detect: (_data, ctx) => {
348
+ const passed = ctx.installedModuleSlugs.has('robots')
349
+ return { passed, detail: passed ? 'Installed' : 'Not installed' }
350
+ },
351
+ },
352
+ ],
353
+ }
354
+
355
+ const debugCache = ref<Map<string, Record<string, any>>>(new Map())
356
+ const loading = ref(false)
357
+ const evaluated = ref(false)
358
+
359
+ function getInstalledSlugs(): Set<string> {
360
+ const slugs = new Set<string>()
361
+ for (const mod of toValue(installedModules)) {
362
+ const slug = DEVTOOLS_NAME_TO_SLUG[mod.name]
363
+ if (slug)
364
+ slugs.add(slug)
365
+ }
366
+ return slugs
367
+ }
368
+
369
+ async function fetchDebugData(): Promise<void> {
370
+ const fetch = toValue(appFetch)
371
+ if (!fetch)
372
+ return
373
+
374
+ const slugs = getInstalledSlugs()
375
+ const cache = new Map<string, Record<string, any>>()
376
+
377
+ const fetches = Object.entries(DEBUG_ENDPOINTS)
378
+ .filter(([slug]) => slugs.has(slug))
379
+ .map(async ([slug, endpoint]) => {
380
+ const data = await fetch(endpoint!).catch(() => null)
381
+ if (data)
382
+ cache.set(slug, data)
383
+ })
384
+
385
+ await Promise.all(fetches)
386
+ debugCache.value = cache
387
+ }
388
+
389
+ function evaluateModule(slug: NuxtSEOModule['slug'], ctx: DetectContext): ModuleChecklistResult | undefined {
390
+ const definitions = CHECKLIST_DEFINITIONS[slug]
391
+ if (!definitions?.length)
392
+ return undefined
393
+
394
+ const meta = MODULE_META[slug]
395
+ if (!meta)
396
+ return undefined
397
+
398
+ const data = debugCache.value.get(slug) || {}
399
+ const items: ChecklistItemResult[] = definitions.map((def) => {
400
+ const result = def.detect(data, ctx)
401
+ return {
402
+ id: def.id,
403
+ label: def.label,
404
+ description: def.description,
405
+ level: def.level,
406
+ docsUrl: def.docsUrl,
407
+ passed: result.passed,
408
+ detail: result.detail,
409
+ }
410
+ })
411
+
412
+ const requiredPending = items.filter(i => i.level === 'required' && !i.passed).length
413
+ const recommendedPending = items.filter(i => i.level === 'recommended' && !i.passed).length
414
+
415
+ return {
416
+ moduleSlug: slug,
417
+ moduleLabel: meta.label,
418
+ moduleIcon: meta.icon,
419
+ items,
420
+ requiredPending,
421
+ recommendedPending,
422
+ totalPending: requiredPending + recommendedPending,
423
+ }
424
+ }
425
+
426
+ const results = computed<ModuleChecklistResult[]>(() => {
427
+ const slugs = getInstalledSlugs()
428
+ const ctx: DetectContext = { installedModuleSlugs: slugs, debugData: debugCache.value }
429
+ const moduleResults: ModuleChecklistResult[] = []
430
+
431
+ // Evaluate in a consistent order
432
+ const orderedSlugs: NuxtSEOModule['slug'][] = ['site-config', 'robots', 'sitemap', 'og-image', 'schema-org', 'seo-utils']
433
+ for (const slug of orderedSlugs) {
434
+ if (!slugs.has(slug) && slug !== 'site-config')
435
+ continue
436
+ // Site config is always evaluated (it's the foundation)
437
+ const result = evaluateModule(slug, ctx)
438
+ if (result)
439
+ moduleResults.push(result)
440
+ }
441
+
442
+ return moduleResults
443
+ })
444
+
445
+ const summary = computed<ChecklistSummary>(() => {
446
+ let total = 0; let passed = 0; let requiredPending = 0; let recommendedPending = 0
447
+ for (const r of results.value) {
448
+ total += r.items.length
449
+ passed += r.items.filter(i => i.passed).length
450
+ requiredPending += r.requiredPending
451
+ recommendedPending += r.recommendedPending
452
+ }
453
+ return { total, passed, requiredPending, recommendedPending }
454
+ })
455
+
456
+ export function getModuleResult(slug: string): ModuleChecklistResult | undefined {
457
+ return results.value.find(r => r.moduleSlug === slug)
458
+ }
459
+
460
+ export function getModuleResultByName(devtoolsName: string): ModuleChecklistResult | undefined {
461
+ const slug = DEVTOOLS_NAME_TO_SLUG[devtoolsName]
462
+ if (!slug)
463
+ return undefined
464
+ return getModuleResult(slug)
465
+ }
466
+
467
+ export async function evaluate(): Promise<void> {
468
+ if (loading.value)
469
+ return
470
+ loading.value = true
471
+ await fetchDebugData().catch(() => {})
472
+ evaluated.value = true
473
+ loading.value = false
474
+ }
475
+
476
+ export function useSetupChecklist() {
477
+ return {
478
+ results,
479
+ summary,
480
+ loading,
481
+ evaluated,
482
+ evaluate,
483
+ getModuleResult,
484
+ getModuleResultByName,
485
+ }
486
+ }
@@ -1,5 +1,7 @@
1
1
  import type { NuxtDevtoolsIframeClient } from '@nuxt/devtools-kit/types'
2
+ import type { NuxtSEOModule } from 'nuxtseo-shared/const'
2
3
  import { onDevtoolsClientConnected } from '@nuxt/devtools-kit/iframe-client'
4
+ import { modules as seoModules } from 'nuxtseo-shared/const'
3
5
  import { computed, ref } from 'vue'
4
6
  import { isConnected } from './state'
5
7
 
@@ -17,22 +19,51 @@ export interface SeoModuleCatalogEntry {
17
19
  icon: string
18
20
  installed: boolean
19
21
  route?: string
20
- npmUrl: string
22
+ npm: string
23
+ repo: string
21
24
  pro?: boolean
25
+ playgrounds?: Record<string, string>
22
26
  }
23
27
 
24
- // Full catalog of all Nuxt SEO modules for the splash screen
25
- const MODULE_CATALOG: Omit<SeoModuleCatalogEntry, 'installed' | 'route'>[] = [
26
- { name: 'nuxt-robots', title: 'Robots', description: 'Manage robots.txt and meta robots', icon: 'carbon:bot', npmUrl: 'https://npmjs.com/package/@nuxtjs/robots' },
27
- { name: 'sitemap', title: 'Sitemap', description: 'Generate XML sitemaps', icon: 'carbon:load-balancer-application', npmUrl: 'https://npmjs.com/package/@nuxtjs/sitemap' },
28
- { name: 'nuxt-og-image', title: 'OG Image', description: 'Generate dynamic Open Graph images', icon: 'carbon:image-search', npmUrl: 'https://npmjs.com/package/nuxt-og-image' },
29
- { name: 'nuxt-schema-org', title: 'Schema.org', description: 'Add structured data with Schema.org', icon: 'carbon:chart-relationship', npmUrl: 'https://npmjs.com/package/nuxt-schema-org' },
30
- { name: 'nuxt-seo-utils', title: 'SEO Utils', description: 'Core SEO utilities and meta tags', icon: 'carbon:search-locate', npmUrl: 'https://npmjs.com/package/nuxt-seo-utils' },
31
- { name: 'nuxt-link-checker', title: 'Link Checker', description: 'Find and fix broken links', icon: 'carbon:cloud-satellite-link', npmUrl: 'https://npmjs.com/package/nuxt-link-checker' },
32
- { name: 'nuxt-site-config', title: 'Site Config', description: 'Shared site configuration', icon: 'carbon:settings', npmUrl: 'https://npmjs.com/package/nuxt-site-config' },
33
- { name: 'nuxt-ai-ready', title: 'AI Ready', description: 'Optimize for AI search engines', icon: 'carbon:machine-learning-model', npmUrl: 'https://npmjs.com/package/nuxt-ai-ready', pro: true },
34
- { name: 'nuxt-skew-protection', title: 'Skew Protection', description: 'Protect against deployment skew', icon: 'carbon:shield-check', npmUrl: 'https://npmjs.com/package/nuxt-skew-protection', pro: true },
35
- ]
28
+ // Map slug to the internal module name used by devtools routing
29
+ const SLUG_TO_MODULE_NAME: Record<string, string> = {
30
+ 'robots': 'nuxt-robots',
31
+ 'sitemap': 'sitemap',
32
+ 'og-image': 'nuxt-og-image',
33
+ 'schema-org': 'nuxt-schema-org',
34
+ 'seo-utils': 'nuxt-seo-utils',
35
+ 'link-checker': 'nuxt-link-checker',
36
+ 'site-config': 'nuxt-site-config',
37
+ 'ai-ready': 'nuxt-ai-ready',
38
+ 'skew-protection': 'nuxt-skew-protection',
39
+ 'ai-kit': 'nuxt-ai-kit',
40
+ 'nuxt-seo': 'nuxt-seo',
41
+ }
42
+
43
+ const ICONIFY_RE = /^i-([^-]+)-/
44
+
45
+ function toIconify(icon: string): string {
46
+ // Convert i-carbon-bot -> carbon:bot
47
+ return icon.replace(ICONIFY_RE, '$1:')
48
+ }
49
+
50
+ function moduleToCatalogEntry(mod: NuxtSEOModule): Omit<SeoModuleCatalogEntry, 'installed' | 'route'> {
51
+ return {
52
+ name: SLUG_TO_MODULE_NAME[mod.slug] || mod.slug,
53
+ title: mod.label,
54
+ description: mod.description,
55
+ icon: toIconify(mod.icon),
56
+ npm: mod.npm,
57
+ repo: mod.repo,
58
+ pro: mod.pro,
59
+ playgrounds: mod.playgrounds,
60
+ }
61
+ }
62
+
63
+ // Exclude the meta 'nuxt-seo' entry from the catalog, it's not a standalone devtools module
64
+ const MODULE_CATALOG = seoModules
65
+ .filter(m => m.slug !== 'nuxt-seo')
66
+ .map(moduleToCatalogEntry)
36
67
 
37
68
  export const installedModules = ref<SeoModuleInfo[]>([])
38
69
  export const showModuleSplash = ref(false)
@@ -48,6 +79,10 @@ export const moduleCatalog = computed<SeoModuleCatalogEntry[]>(() => {
48
79
  })
49
80
  })
50
81
 
82
+ export function findModuleByName(moduleName: string): SeoModuleCatalogEntry | undefined {
83
+ return moduleCatalog.value.find(m => m.name === moduleName)
84
+ }
85
+
51
86
  export function fetchInstalledModules(): void {
52
87
  const inIframe = window.parent !== window
53
88
  if (!inIframe)
@@ -0,0 +1,41 @@
1
+ import { ref } from 'vue'
2
+
3
+ export type PackageManager = 'pnpm' | 'yarn' | 'bun' | 'npm'
4
+
5
+ export const packageManager = ref<PackageManager>('npm')
6
+
7
+ const PM_COMMANDS: Record<PackageManager, { run: string, exec: string, update: string, dedupe: string }> = {
8
+ pnpm: { run: 'pnpm', exec: 'pnpm dlx', update: 'pnpm update', dedupe: 'pnpm dedupe' },
9
+ yarn: { run: 'yarn', exec: 'yarn dlx', update: 'yarn upgrade', dedupe: 'yarn dedupe' },
10
+ bun: { run: 'bun', exec: 'bunx', update: 'bun update', dedupe: '' },
11
+ npm: { run: 'npm', exec: 'npx', update: 'npm update', dedupe: 'npm dedupe' },
12
+ }
13
+
14
+ export function pmCommands() {
15
+ return PM_COMMANDS[packageManager.value]
16
+ }
17
+
18
+ // Detect by checking lock files via Vite's @fs route (dev only)
19
+ const LOCK_FILES: [string, PackageManager][] = [
20
+ ['pnpm-lock.yaml', 'pnpm'],
21
+ ['yarn.lock', 'yarn'],
22
+ ['bun.lockb', 'bun'],
23
+ ]
24
+
25
+ let detected = false
26
+ export function detectPackageManager() {
27
+ if (detected)
28
+ return
29
+ detected = true
30
+
31
+ // Check each lock file with a HEAD request
32
+ for (const [file, pm] of LOCK_FILES) {
33
+ fetch(`/${file}`, { method: 'HEAD' })
34
+ .then((r) => {
35
+ if (r.ok) {
36
+ packageManager.value = pm
37
+ }
38
+ })
39
+ .catch(() => {})
40
+ }
41
+ }
@@ -0,0 +1,39 @@
1
+ import type { ComputedRef } from 'vue'
2
+ import { computed, ref } from 'vue'
3
+
4
+ export interface ModuleUpdateInfo {
5
+ currentVersion: string
6
+ latestVersion: string
7
+ hasUpdate: boolean
8
+ }
9
+
10
+ const updateCache = ref<Record<string, ModuleUpdateInfo>>({})
11
+
12
+ export function useModuleUpdate(npmPackage: string | undefined, currentVersion: string | undefined): { hasUpdate: ComputedRef<boolean>, latestVersion: ComputedRef<string | undefined>, info: ComputedRef<ModuleUpdateInfo | undefined> } {
13
+ const info = computed(() => {
14
+ if (!npmPackage || !currentVersion)
15
+ return undefined
16
+ return updateCache.value[npmPackage]
17
+ })
18
+
19
+ const hasUpdate = computed(() => info.value?.hasUpdate ?? false)
20
+ const latestVersion = computed(() => info.value?.latestVersion)
21
+
22
+ if (npmPackage && currentVersion && !updateCache.value[npmPackage]) {
23
+ fetch(`https://registry.npmjs.org/${npmPackage}/latest`)
24
+ .then(r => r.json())
25
+ .then((data) => {
26
+ const latest = data.version as string
27
+ if (!latest)
28
+ return
29
+ updateCache.value[npmPackage] = {
30
+ currentVersion,
31
+ latestVersion: latest,
32
+ hasUpdate: latest !== currentVersion,
33
+ }
34
+ })
35
+ .catch(() => {})
36
+ }
37
+
38
+ return { hasUpdate, latestVersion, info }
39
+ }
package/error.vue CHANGED
@@ -5,6 +5,7 @@ const { error } = defineProps<{
5
5
  error: NuxtError
6
6
  }>()
7
7
 
8
+ // eslint-disable-next-line no-control-regex
8
9
  const ANSI_RE = /\u001B\[[0-9;]*m/g
9
10
 
10
11
  const stack = computed(() => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxtseo-layer-devtools",
3
3
  "type": "module",
4
- "version": "0.4.5",
4
+ "version": "0.5.1",
5
5
  "description": "Shared Nuxt layer for Nuxt SEO devtools clients.",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",
@@ -36,7 +36,8 @@
36
36
  "@vueuse/nuxt": "^14.2.1",
37
37
  "ofetch": "^1.5.1",
38
38
  "shiki": "^4.0.2",
39
- "ufo": "^1.6.3"
39
+ "ufo": "^1.6.3",
40
+ "nuxtseo-shared": "0.9.0"
40
41
  },
41
42
  "devDependencies": {
42
43
  "nuxt": "^4.4.2",