nuxtseo-layer-devtools 0.4.5 → 0.5.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.
@@ -1,9 +1,10 @@
1
1
  <script setup lang="ts">
2
2
  import { onClickOutside } from '@vueuse/core'
3
3
  import { computed, ref } from 'vue'
4
- import { fetchInstalledModules, showModuleSplash } from '../composables/modules'
4
+ import { fetchInstalledModules, findModuleByName, showModuleSplash } from '../composables/modules'
5
5
  import { colorMode } from '../composables/rpc'
6
6
  import { hasProductionUrl, isConnected, isProductionMode, isStandalone, path, previewSource, productionUrl, standaloneUrl } from '../composables/state'
7
+ import { useModuleUpdate } from '../composables/update-check'
7
8
 
8
9
  export interface DevtoolsNavItem {
9
10
  value: string
@@ -35,6 +36,10 @@ const emit = defineEmits<{
35
36
  refresh: []
36
37
  }>()
37
38
 
39
+ const moduleInfo = computed(() => moduleName ? findModuleByName(moduleName) : undefined)
40
+ const npmPackage = computed(() => moduleInfo.value?.npm)
41
+ const { hasUpdate, latestVersion } = useModuleUpdate(npmPackage.value, version)
42
+
38
43
  // Fetch installed modules for the splash screen
39
44
  fetchInstalledModules()
40
45
 
@@ -127,12 +132,21 @@ function disconnectStandalone() {
127
132
  </span>
128
133
  <UIcon name="carbon:chevron-down" class="w-3 h-3 opacity-50 transition-transform" :class="showModuleSplash ? 'rotate-180' : ''" />
129
134
  </button>
130
- <UBadge
131
- v-if="version"
132
- class="font-mono text-[10px] sm:text-xs hidden sm:inline-flex"
133
- >
134
- v{{ version }}
135
- </UBadge>
135
+ <UTooltip v-if="version" :text="hasUpdate ? `Update available: v${latestVersion}` : `v${version}`">
136
+ <a
137
+ :href="hasUpdate && npmPackage ? `https://npmjs.com/package/${npmPackage}` : undefined"
138
+ :target="hasUpdate ? '_blank' : undefined"
139
+ rel="noopener"
140
+ class="version-badge-wrapper"
141
+ >
142
+ <UBadge
143
+ class="font-mono text-[10px] sm:text-xs hidden sm:inline-flex"
144
+ >
145
+ v{{ version }}
146
+ </UBadge>
147
+ <span v-if="hasUpdate" class="update-dot" />
148
+ </a>
149
+ </UTooltip>
136
150
  <!-- Mode dropdown: embedded with production URL -->
137
151
  <div v-if="hasProductionUrl && !isStandalone" ref="modeDropdownRef" class="mode-dropdown-wrapper">
138
152
  <button type="button" class="devtools-mode-btn" @click="modeDropdownOpen = !modeDropdownOpen">
@@ -279,6 +293,9 @@ function disconnectStandalone() {
279
293
  <DevtoolsLoading v-show="!showStandaloneSetup && loading" />
280
294
  <div v-show="!showStandaloneSetup && !loading">
281
295
  <slot />
296
+ <div v-if="activeTab === 'debug' && moduleName" class="devtools-troubleshooting-section">
297
+ <DevtoolsTroubleshooting :module-name="moduleName" :version="version" />
298
+ </div>
282
299
  </div>
283
300
  </main>
284
301
  </div>
@@ -289,6 +306,25 @@ function disconnectStandalone() {
289
306
  </template>
290
307
 
291
308
  <style scoped>
309
+ .version-badge-wrapper {
310
+ position: relative;
311
+ display: inline-flex;
312
+ align-items: center;
313
+ text-decoration: none;
314
+ }
315
+
316
+ .update-dot {
317
+ position: absolute;
318
+ top: -2px;
319
+ right: -2px;
320
+ width: 7px;
321
+ height: 7px;
322
+ border-radius: 50%;
323
+ background: var(--seo-green);
324
+ border: 1.5px solid var(--color-surface);
325
+ pointer-events: none;
326
+ }
327
+
292
328
  .devtools-module-switcher {
293
329
  display: flex;
294
330
  align-items: center;
@@ -418,4 +454,9 @@ function disconnectStandalone() {
418
454
  .standalone-path-input:focus {
419
455
  border-color: var(--seo-green);
420
456
  }
457
+
458
+ .devtools-troubleshooting-section {
459
+ padding: 1.5rem 1rem 1rem;
460
+ max-width: 48rem;
461
+ }
421
462
  </style>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { onClickOutside } from '@vueuse/core'
2
+ import { onClickOutside, useClipboard } from '@vueuse/core'
3
3
  import { computed, ref } from 'vue'
4
4
  import { moduleCatalog, showModuleSplash, switchToModule } from '../composables/modules'
5
5
  import { isConnected } from '../composables/state'
@@ -16,8 +16,28 @@ onClickOutside(panelRef, () => {
16
16
  const coreModules = computed(() => moduleCatalog.value.filter(m => !m.pro))
17
17
  const proModules = computed(() => moduleCatalog.value.filter(m => m.pro))
18
18
 
19
+ const selectedForInstall = ref(new Set<string>())
20
+
21
+ const installCommand = computed(() => {
22
+ if (!selectedForInstall.value.size)
23
+ return ''
24
+ const names = Array.from(selectedForInstall.value, name => moduleCatalog.value.find(m => m.name === name)?.npm).filter(Boolean)
25
+ return `npx nuxt module add ${names.join(' ')}`
26
+ })
27
+
28
+ const { copy, copied } = useClipboard({ source: installCommand })
29
+
19
30
  function handleModuleClick(mod: typeof moduleCatalog.value[0]) {
20
- if (!mod.installed || mod.name === props.currentModule)
31
+ if (!mod.installed) {
32
+ const set = new Set(selectedForInstall.value)
33
+ if (set.has(mod.name))
34
+ set.delete(mod.name)
35
+ else
36
+ set.add(mod.name)
37
+ selectedForInstall.value = set
38
+ return
39
+ }
40
+ if (mod.name === props.currentModule)
21
41
  return
22
42
  switchToModule(mod.name)
23
43
  }
@@ -50,10 +70,11 @@ function handleModuleClick(mod: typeof moduleCatalog.value[0]) {
50
70
  class="splash-module"
51
71
  :class="{
52
72
  'is-current': mod.name === currentModule,
53
- 'is-unavailable': !mod.installed,
73
+ 'is-unavailable': !mod.installed && !selectedForInstall.has(mod.name) && mod.name !== currentModule,
74
+ 'is-selected-install': selectedForInstall.has(mod.name),
54
75
  'is-switchable': mod.installed && mod.name !== currentModule && isConnected,
55
76
  }"
56
- :disabled="!mod.installed || mod.name === currentModule"
77
+ :disabled="mod.name === currentModule"
57
78
  @click="handleModuleClick(mod)"
58
79
  >
59
80
  <div class="splash-module-icon">
@@ -61,11 +82,26 @@ function handleModuleClick(mod: typeof moduleCatalog.value[0]) {
61
82
  </div>
62
83
  <span class="splash-module-title">{{ mod.title }}</span>
63
84
  <span v-if="mod.name === currentModule" class="splash-current-badge">Current</span>
64
- <span v-else-if="!mod.installed" class="splash-not-installed">Not installed</span>
85
+ <span v-else-if="!mod.installed" class="splash-not-installed">{{ selectedForInstall.has(mod.name) ? 'Selected' : 'Not installed' }}</span>
65
86
  </button>
66
87
  </div>
67
88
  </div>
68
89
 
90
+ <!-- Install command -->
91
+ <Transition name="install-bar">
92
+ <div v-if="installCommand" class="splash-install-section">
93
+ <div class="splash-install">
94
+ <div class="splash-install-code">
95
+ <code>{{ installCommand }}</code>
96
+ </div>
97
+ <button type="button" class="splash-install-copy" @click="copy()">
98
+ <UIcon :name="copied ? 'carbon:checkmark' : 'carbon:copy'" class="w-3.5 h-3.5" />
99
+ {{ copied ? 'Copied' : 'Copy' }}
100
+ </button>
101
+ </div>
102
+ </div>
103
+ </Transition>
104
+
69
105
  <!-- Pro Section -->
70
106
  <div v-if="proModules.length" class="splash-section splash-pro-area">
71
107
  <div class="splash-section-label">
@@ -201,7 +237,7 @@ function handleModuleClick(mod: typeof moduleCatalog.value[0]) {
201
237
  }
202
238
 
203
239
  .splash-section-dot--pro {
204
- background: var(--seo-green);
240
+ background: oklch(65% 0.25 290);
205
241
  }
206
242
 
207
243
  /* Grid */
@@ -241,7 +277,29 @@ function handleModuleClick(mod: typeof moduleCatalog.value[0]) {
241
277
  }
242
278
 
243
279
  .splash-module.is-unavailable {
244
- opacity: 0.5;
280
+ opacity: 0.4;
281
+ cursor: pointer;
282
+ }
283
+
284
+ .splash-module.is-unavailable:hover {
285
+ opacity: 0.7;
286
+ background: var(--color-surface-elevated);
287
+ }
288
+
289
+ .splash-module.is-selected-install {
290
+ opacity: 1;
291
+ background: oklch(from var(--seo-green) l c h / 0.08);
292
+ cursor: pointer;
293
+ }
294
+
295
+ .splash-module.is-selected-install .splash-module-icon {
296
+ background: oklch(from var(--seo-green) l c h / 0.15);
297
+ color: var(--seo-green);
298
+ }
299
+
300
+ .splash-module.is-selected-install .splash-not-installed {
301
+ color: var(--seo-green);
302
+ font-weight: 600;
245
303
  }
246
304
 
247
305
  .splash-module-icon {
@@ -288,6 +346,8 @@ function handleModuleClick(mod: typeof moduleCatalog.value[0]) {
288
346
  .splash-pro-badge {
289
347
  font-size: 9px !important;
290
348
  flex-shrink: 0;
349
+ color: oklch(65% 0.25 290) !important;
350
+ border-color: oklch(65% 0.25 290 / 0.3) !important;
291
351
  }
292
352
 
293
353
  /* Pro status / CTA */
@@ -298,7 +358,8 @@ function handleModuleClick(mod: typeof moduleCatalog.value[0]) {
298
358
  margin: 0.375rem 0.25rem 0;
299
359
  padding: 0.4rem 0.625rem;
300
360
  border-radius: var(--radius-md);
301
- background: var(--color-surface-sunken);
361
+ background: oklch(65% 0.25 290 / 0.06);
362
+ border: 1px solid oklch(65% 0.25 290 / 0.12);
302
363
  font-size: 0.6875rem;
303
364
  color: var(--color-text-muted);
304
365
  }
@@ -315,7 +376,7 @@ function handleModuleClick(mod: typeof moduleCatalog.value[0]) {
315
376
  gap: 0.25rem;
316
377
  font-size: 0.6875rem;
317
378
  font-weight: 500;
318
- color: var(--seo-green);
379
+ color: oklch(65% 0.25 290);
319
380
  text-decoration: none;
320
381
  transition: opacity 100ms;
321
382
  }
@@ -324,6 +385,71 @@ function handleModuleClick(mod: typeof moduleCatalog.value[0]) {
324
385
  opacity: 0.8;
325
386
  }
326
387
 
388
+ /* Install bar */
389
+ .splash-install-section {
390
+ padding: 0.5rem 0.75rem;
391
+ border-top: 1px solid var(--color-border);
392
+ }
393
+
394
+ .splash-install {
395
+ display: flex;
396
+ align-items: center;
397
+ gap: 0.5rem;
398
+ padding: 0.5rem 0.625rem;
399
+ border-radius: var(--radius-md);
400
+ background: var(--color-surface-sunken);
401
+ border: 1px solid oklch(from var(--seo-green) l c h / 0.2);
402
+ }
403
+
404
+ .splash-install-code {
405
+ flex: 1;
406
+ min-width: 0;
407
+ overflow-x: auto;
408
+ }
409
+
410
+ .splash-install-code code {
411
+ font-size: 0.6875rem;
412
+ font-family: var(--font-mono, monospace);
413
+ color: var(--color-text);
414
+ white-space: nowrap;
415
+ }
416
+
417
+ .splash-install-copy {
418
+ display: flex;
419
+ align-items: center;
420
+ gap: 0.25rem;
421
+ flex-shrink: 0;
422
+ padding: 0.25rem 0.5rem;
423
+ border-radius: var(--radius-sm);
424
+ font-size: 0.625rem;
425
+ font-weight: 600;
426
+ color: var(--seo-green);
427
+ background: oklch(from var(--seo-green) l c h / 0.1);
428
+ cursor: pointer;
429
+ transition: background 100ms;
430
+ }
431
+
432
+ .splash-install-copy:hover {
433
+ background: oklch(from var(--seo-green) l c h / 0.18);
434
+ }
435
+
436
+ .install-bar-enter-active,
437
+ .install-bar-leave-active {
438
+ transition: opacity 150ms ease, max-height 150ms ease;
439
+ overflow: hidden;
440
+ }
441
+
442
+ .install-bar-enter-from,
443
+ .install-bar-leave-to {
444
+ opacity: 0;
445
+ max-height: 0;
446
+ }
447
+
448
+ .install-bar-enter-to,
449
+ .install-bar-leave-from {
450
+ max-height: 4rem;
451
+ }
452
+
327
453
  /* Footer */
328
454
  .splash-footer {
329
455
  display: flex;
@@ -0,0 +1,99 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { moduleCatalog } from '../composables/modules'
4
+
5
+ const { moduleName } = defineProps<{
6
+ moduleName: string
7
+ }>()
8
+
9
+ const keyLabels: Record<string, string> = {
10
+ 'basic': 'Basic',
11
+ 'i18n': 'Nuxt I18n',
12
+ 'content': 'Nuxt Content',
13
+ 'basic-satori': 'Basic (Satori)',
14
+ 'basic-takumi': 'Basic (Takumi)',
15
+ 'dynamic-urls': 'Dynamic URLs',
16
+ 'custom-rules': 'Custom Rules',
17
+ 'broken-links': 'Broken Links',
18
+ 'skip-inspection': 'Skip Inspection',
19
+ 'breadcrumbs': 'Breadcrumbs',
20
+ 'meta-tags': 'Meta Tags',
21
+ 'blog': 'Blog',
22
+ 'e-commerce': 'E-Commerce',
23
+ 'env-driven': 'Env Driven',
24
+ 'multi-site': 'Multi Site',
25
+ }
26
+
27
+ const CAMEL_RE = /([a-z])([A-Z])/g
28
+ const SEP_RE = /[-_]/g
29
+ const WORD_RE = /\b\w/g
30
+
31
+ function humanizeKey(key: string) {
32
+ if (keyLabels[key])
33
+ return keyLabels[key]
34
+ return key
35
+ .replace(CAMEL_RE, '$1 $2')
36
+ .replace(SEP_RE, ' ')
37
+ .replace(WORD_RE, c => c.toUpperCase())
38
+ }
39
+
40
+ const playgrounds = computed(() => {
41
+ const mod = moduleCatalog.value.find(m => m.name === moduleName)
42
+ if (!mod?.playgrounds)
43
+ return []
44
+ return Object.entries(mod.playgrounds).map(([key, url]) => ({
45
+ key,
46
+ label: humanizeKey(key),
47
+ url,
48
+ }))
49
+ })
50
+ </script>
51
+
52
+ <template>
53
+ <div v-if="playgrounds.length" class="playgrounds-list">
54
+ <a
55
+ v-for="pg in playgrounds"
56
+ :key="pg.key"
57
+ :href="pg.url"
58
+ target="_blank"
59
+ rel="noopener"
60
+ class="playground-btn"
61
+ >
62
+ <UIcon name="carbon:launch" class="w-3 h-3 shrink-0 opacity-50" />
63
+ <span>{{ pg.label }}</span>
64
+ </a>
65
+ </div>
66
+ </template>
67
+
68
+ <style scoped>
69
+ .playgrounds-list {
70
+ display: flex;
71
+ flex-wrap: wrap;
72
+ gap: 0.375rem;
73
+ }
74
+
75
+ .playground-btn {
76
+ display: inline-flex;
77
+ align-items: center;
78
+ gap: 0.375rem;
79
+ padding: 0.25rem 0.625rem;
80
+ border-radius: var(--radius-sm);
81
+ font-size: 0.75rem;
82
+ font-weight: 500;
83
+ color: var(--color-text-muted);
84
+ background: var(--color-surface-elevated);
85
+ border: 1px solid var(--color-border);
86
+ text-decoration: none;
87
+ transition: background 100ms, color 100ms, border-color 100ms;
88
+ }
89
+
90
+ .playground-btn:hover {
91
+ background: var(--color-surface-sunken);
92
+ color: var(--color-text);
93
+ border-color: var(--color-neutral-400);
94
+ }
95
+
96
+ .dark .playground-btn:hover {
97
+ border-color: var(--color-neutral-600);
98
+ }
99
+ </style>
@@ -0,0 +1,332 @@
1
+ <script setup lang="ts">
2
+ import { useClipboard } from '@vueuse/core'
3
+ import { computed } from 'vue'
4
+ import { installedModules, moduleCatalog } from '../composables/modules'
5
+ import { detectPackageManager, pmCommands } from '../composables/package-manager'
6
+ import { useModuleUpdate } from '../composables/update-check'
7
+
8
+ const { moduleName, version } = defineProps<{
9
+ moduleName: string
10
+ version?: string
11
+ }>()
12
+
13
+ detectPackageManager()
14
+
15
+ const nuxtApp = useNuxtApp()
16
+ const nuxtVersion = nuxtApp.versions?.nuxt ?? 'unknown'
17
+ const vueVersion = nuxtApp.versions?.vue ?? 'unknown'
18
+
19
+ const moduleInfo = computed(() => moduleCatalog.value.find(m => m.name === moduleName))
20
+ const npmPackage = computed(() => moduleInfo.value?.npm)
21
+ const { latestVersion, hasUpdate } = useModuleUpdate(npmPackage.value, version)
22
+
23
+ const githubNewIssueUrl = computed(() => {
24
+ if (!moduleInfo.value?.repo)
25
+ return ''
26
+ return `https://github.com/${moduleInfo.value.repo}/issues/new`
27
+ })
28
+
29
+ // Build environment info for copy
30
+ const envInfo = computed(() => {
31
+ const lines: string[] = []
32
+ lines.push('### Environment')
33
+ lines.push('')
34
+
35
+ // Current module
36
+ if (version)
37
+ lines.push(`- **${npmPackage.value}**: v${version}${hasUpdate.value ? ` (latest: v${latestVersion.value})` : ''}`)
38
+
39
+ // Other installed Nuxt SEO modules
40
+ for (const mod of installedModules.value) {
41
+ if (mod.name === moduleName)
42
+ continue
43
+ const catalogEntry = moduleCatalog.value.find(m => m.name === mod.name)
44
+ if (catalogEntry?.npm)
45
+ lines.push(`- **${catalogEntry.npm}**: installed`)
46
+ }
47
+
48
+ lines.push('')
49
+ lines.push(`- **Nuxt**: v${nuxtVersion}`)
50
+ lines.push(`- **Vue**: v${vueVersion}`)
51
+
52
+ return lines.join('\n')
53
+ })
54
+
55
+ const { copy: copyEnv, copied: envCopied } = useClipboard({ source: envInfo })
56
+
57
+ const steps = computed(() => {
58
+ const pm = pmCommands()
59
+ const updateCmd = [pm.update, pm.dedupe].filter(Boolean).join(' && ')
60
+ return [
61
+ {
62
+ icon: 'carbon:reset',
63
+ title: 'Clear generated files and update dependencies',
64
+ description: 'Delete your .nuxt directory, update packages, then restart.',
65
+ codes: [
66
+ `rm -rf .nuxt && ${pm.exec} nuxi dev`,
67
+ updateCmd,
68
+ ],
69
+ },
70
+ {
71
+ icon: 'carbon:debug',
72
+ title: 'Enable debug mode',
73
+ description: 'Add debug: true to your module config for verbose logging.',
74
+ codes: [],
75
+ },
76
+ {
77
+ icon: 'carbon:code',
78
+ title: 'Create a minimal reproduction',
79
+ description: 'Use a StackBlitz playground to isolate the issue. This helps maintainers debug quickly.',
80
+ codes: [],
81
+ },
82
+ {
83
+ icon: 'carbon:flag',
84
+ title: 'Report the issue',
85
+ description: 'Open a GitHub issue with your reproduction link and environment info.',
86
+ codes: [],
87
+ },
88
+ ]
89
+ })
90
+ </script>
91
+
92
+ <template>
93
+ <DevtoolsSection icon="carbon:help" text="Troubleshooting" description="Steps to diagnose and report issues">
94
+ <div class="troubleshoot-steps">
95
+ <div v-for="(step, i) of steps" :key="i" class="troubleshoot-step">
96
+ <div class="troubleshoot-step-num">
97
+ {{ i + 1 }}
98
+ </div>
99
+ <div class="troubleshoot-step-content">
100
+ <div class="troubleshoot-step-title">
101
+ <UIcon :name="step.icon" class="w-3.5 h-3.5 text-[var(--color-text-muted)]" />
102
+ {{ step.title }}
103
+ </div>
104
+ <div class="troubleshoot-step-desc">
105
+ {{ step.description }}
106
+ </div>
107
+ <code v-for="code of step.codes" :key="code" class="troubleshoot-code">{{ code }}</code>
108
+ </div>
109
+ </div>
110
+ </div>
111
+
112
+ <!-- Playgrounds for repro -->
113
+ <div class="troubleshoot-section">
114
+ <div class="troubleshoot-section-label">
115
+ <UIcon name="carbon:game-console" class="w-3.5 h-3.5" />
116
+ Playgrounds for reproduction
117
+ </div>
118
+ <DevtoolsPlaygrounds :module-name="moduleName" />
119
+ </div>
120
+
121
+ <!-- Environment info -->
122
+ <div class="troubleshoot-section">
123
+ <div class="troubleshoot-section-header">
124
+ <div class="troubleshoot-section-label">
125
+ <UIcon name="carbon:information" class="w-3.5 h-3.5" />
126
+ Environment info
127
+ </div>
128
+ <button type="button" class="troubleshoot-copy-btn" @click="copyEnv()">
129
+ <UIcon :name="envCopied ? 'carbon:checkmark' : 'carbon:copy'" class="w-3 h-3" />
130
+ {{ envCopied ? 'Copied' : 'Copy for issue' }}
131
+ </button>
132
+ </div>
133
+ <div class="troubleshoot-env">
134
+ <div class="troubleshoot-env-row">
135
+ <span class="troubleshoot-env-label">{{ npmPackage }}</span>
136
+ <span class="troubleshoot-env-value">
137
+ v{{ version || 'unknown' }}
138
+ <UBadge v-if="hasUpdate" size="xs" color="warning" variant="subtle">
139
+ v{{ latestVersion }} available
140
+ </UBadge>
141
+ </span>
142
+ </div>
143
+ <template v-for="mod of installedModules" :key="mod.name">
144
+ <div v-if="mod.name !== moduleName" class="troubleshoot-env-row">
145
+ <span class="troubleshoot-env-label">{{ moduleCatalog.find(m => m.name === mod.name)?.npm || mod.name }}</span>
146
+ <span class="troubleshoot-env-value">installed</span>
147
+ </div>
148
+ </template>
149
+ <div class="troubleshoot-env-row">
150
+ <span class="troubleshoot-env-label">nuxt</span>
151
+ <span class="troubleshoot-env-value">v{{ nuxtVersion }}</span>
152
+ </div>
153
+ <div class="troubleshoot-env-row">
154
+ <span class="troubleshoot-env-label">vue</span>
155
+ <span class="troubleshoot-env-value">v{{ vueVersion }}</span>
156
+ </div>
157
+ </div>
158
+ </div>
159
+
160
+ <!-- Report button -->
161
+ <a
162
+ v-if="githubNewIssueUrl"
163
+ :href="githubNewIssueUrl"
164
+ target="_blank"
165
+ rel="noopener"
166
+ class="troubleshoot-report-btn"
167
+ >
168
+ <UIcon name="simple-icons:github" class="w-3.5 h-3.5" />
169
+ Open an issue on GitHub
170
+ <UIcon name="carbon:arrow-up-right" class="w-3 h-3 opacity-40 ml-auto" />
171
+ </a>
172
+ </DevtoolsSection>
173
+ </template>
174
+
175
+ <style scoped>
176
+ .troubleshoot-steps {
177
+ display: flex;
178
+ flex-direction: column;
179
+ gap: 0.125rem;
180
+ }
181
+
182
+ .troubleshoot-step {
183
+ display: flex;
184
+ align-items: flex-start;
185
+ gap: 0.75rem;
186
+ padding: 0.5rem 0;
187
+ }
188
+
189
+ .troubleshoot-step-num {
190
+ display: flex;
191
+ align-items: center;
192
+ justify-content: center;
193
+ width: 1.375rem;
194
+ height: 1.375rem;
195
+ flex-shrink: 0;
196
+ border-radius: 50%;
197
+ background: var(--color-surface-elevated);
198
+ border: 1px solid var(--color-border);
199
+ font-size: 0.625rem;
200
+ font-weight: 700;
201
+ color: var(--color-text-muted);
202
+ }
203
+
204
+ .troubleshoot-step-content {
205
+ flex: 1;
206
+ min-width: 0;
207
+ }
208
+
209
+ .troubleshoot-step-title {
210
+ display: flex;
211
+ align-items: center;
212
+ gap: 0.375rem;
213
+ font-size: 0.8125rem;
214
+ font-weight: 600;
215
+ color: var(--color-text);
216
+ }
217
+
218
+ .troubleshoot-step-desc {
219
+ font-size: 0.75rem;
220
+ color: var(--color-text-muted);
221
+ margin-top: 0.125rem;
222
+ }
223
+
224
+ .troubleshoot-code {
225
+ display: inline-block;
226
+ margin-top: 0.375rem;
227
+ margin-right: 0.375rem;
228
+ padding: 0.25rem 0.5rem;
229
+ border-radius: var(--radius-sm);
230
+ background: var(--color-surface-elevated);
231
+ border: 1px solid var(--color-border);
232
+ font-family: var(--font-mono, monospace);
233
+ font-size: 0.6875rem;
234
+ color: var(--color-text);
235
+ }
236
+
237
+ .troubleshoot-section {
238
+ margin-top: 0.5rem;
239
+ padding-top: 0.75rem;
240
+ border-top: 1px solid var(--color-border);
241
+ }
242
+
243
+ .troubleshoot-section-header {
244
+ display: flex;
245
+ align-items: center;
246
+ justify-content: space-between;
247
+ }
248
+
249
+ .troubleshoot-section-label {
250
+ display: flex;
251
+ align-items: center;
252
+ gap: 0.375rem;
253
+ font-size: 0.75rem;
254
+ font-weight: 600;
255
+ color: var(--color-text-muted);
256
+ margin-bottom: 0.5rem;
257
+ }
258
+
259
+ .troubleshoot-copy-btn {
260
+ display: flex;
261
+ align-items: center;
262
+ gap: 0.25rem;
263
+ padding: 0.2rem 0.5rem;
264
+ border-radius: var(--radius-sm);
265
+ font-size: 0.625rem;
266
+ font-weight: 600;
267
+ color: var(--seo-green);
268
+ background: oklch(from var(--seo-green) l c h / 0.1);
269
+ cursor: pointer;
270
+ transition: background 100ms;
271
+ }
272
+
273
+ .troubleshoot-copy-btn:hover {
274
+ background: oklch(from var(--seo-green) l c h / 0.18);
275
+ }
276
+
277
+ .troubleshoot-env {
278
+ display: flex;
279
+ flex-direction: column;
280
+ gap: 1px;
281
+ border-radius: var(--radius-sm);
282
+ overflow: hidden;
283
+ }
284
+
285
+ .troubleshoot-env-row {
286
+ display: flex;
287
+ align-items: center;
288
+ justify-content: space-between;
289
+ padding: 0.3rem 0.5rem;
290
+ background: var(--color-surface-elevated);
291
+ }
292
+
293
+ .troubleshoot-env-label {
294
+ font-size: 0.6875rem;
295
+ font-family: var(--font-mono, monospace);
296
+ color: var(--color-text-muted);
297
+ }
298
+
299
+ .troubleshoot-env-value {
300
+ display: flex;
301
+ align-items: center;
302
+ gap: 0.375rem;
303
+ font-size: 0.6875rem;
304
+ font-family: var(--font-mono, monospace);
305
+ color: var(--color-text);
306
+ }
307
+
308
+ .troubleshoot-report-btn {
309
+ display: flex;
310
+ align-items: center;
311
+ gap: 0.5rem;
312
+ margin-top: 0.75rem;
313
+ padding: 0.5rem 0.625rem;
314
+ border-radius: var(--radius-sm);
315
+ font-size: 0.8125rem;
316
+ font-weight: 500;
317
+ color: var(--color-text-muted);
318
+ text-decoration: none;
319
+ border: 1px solid var(--color-border);
320
+ transition: background 100ms, color 100ms, border-color 100ms;
321
+ }
322
+
323
+ .troubleshoot-report-btn:hover {
324
+ background: var(--color-surface-elevated);
325
+ color: var(--color-text);
326
+ border-color: var(--color-neutral-400);
327
+ }
328
+
329
+ .dark .troubleshoot-report-btn:hover {
330
+ border-color: var(--color-neutral-600);
331
+ }
332
+ </style>
@@ -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.0",
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.8.2"
40
41
  },
41
42
  "devDependencies": {
42
43
  "nuxt": "^4.4.2",
@@ -9,6 +9,14 @@ Shared Nuxt layer providing components, composables, and a design system for all
9
9
 
10
10
  **Source:** `packages/devtools-layer/` (published as `nuxtseo-layer-devtools`)
11
11
 
12
+ ## Available Libraries
13
+
14
+ The layer registers these Nuxt modules, so all consumers have them available without extra config:
15
+
16
+ - **`@nuxt/ui`** (v4): Full component library. Use `UButton`, `UBadge`, `UIcon`, `UInput`, `UTooltip`, `UApp`, etc. freely in devtools clients. Default variants configured via `app.config.ts` (primary: green, buttons: ghost/neutral/sm, badges: subtle/neutral/xs, tooltips: zero delay).
17
+ - **`@vueuse/nuxt`**: All VueUse composables auto imported.
18
+ - **Shiki**: Syntax highlighting via the layer's `loadShiki` / `useRenderCodeHighlight` composables.
19
+
12
20
  ## Architecture
13
21
 
14
22
  1. **nuxtseo-shared/devtools** (`packages/shared/src/devtools.ts`): `setupDevToolsUI()` registers iframe tab, handles dev proxy + production sirv
@@ -20,13 +28,14 @@ Shared Nuxt layer providing components, composables, and a design system for all
20
28
  1. ALWAYS extend `nuxtseo-layer-devtools` in client/nuxt.config.ts
21
29
  2. ALWAYS create `client/composables/rpc.ts` that calls `useDevtoolsConnection()`
22
30
  3. ALWAYS use layer components over custom HTML: `DevtoolsSection` not custom details, `DevtoolsKeyValue` not custom tables, `DevtoolsSnippet` not custom code blocks. Use `KeyValueItem.code` for inline code rendering instead of separate snippets
23
- 4. NEVER add custom CSS that duplicates what the layer provides
24
- 5. NEVER enable SSR in the client (it runs in an iframe)
25
- 6. ALWAYS disable the module itself in the client nuxt config (e.g. `robots: false`)
26
- 7. ALWAYS guard devtools setup with `if (nuxt.options.dev)` in module.ts
27
- 8. ALWAYS use `useAsyncData` with `watch: [refreshTime]` for reactive data fetching
28
- 9. Use Carbon icons consistently (prefix: `carbon:`)
29
- 10. Debug server routes ONLY registered in dev mode
31
+ 4. ALWAYS use `@nuxt/ui` components (`UButton`, `UInput`, `UBadge`, `UIcon`, `UTooltip`, etc.) for interactive elements. Never add a custom button, input, badge, or tooltip when a Nuxt UI component exists.
32
+ 5. NEVER add custom CSS that duplicates what the layer or Nuxt UI provides
33
+ 6. NEVER enable SSR in the client (it runs in an iframe)
34
+ 7. ALWAYS disable the module itself in the client nuxt config (e.g. `robots: false`)
35
+ 8. ALWAYS guard devtools setup with `if (nuxt.options.dev)` in module.ts
36
+ 9. ALWAYS use `useAsyncData` with `watch: [refreshTime]` for reactive data fetching
37
+ 10. Use Carbon icons consistently (prefix: `carbon:`)
38
+ 11. Debug server routes ONLY registered in dev mode
30
39
 
31
40
  ## Required File Structure
32
41
 
@@ -99,7 +108,7 @@ watch(data, () => {
99
108
  </script>
100
109
 
101
110
  <template>
102
- <DevtoolsLayout v-model:active-tab="activeTab" title="Name" icon="carbon:icon" :nav-items="navItems" github-url="..." :loading @refresh="refreshSources">
111
+ <DevtoolsLayout v-model:active-tab="activeTab" title="Name" icon="carbon:icon" module-name="nuxt-<module>" :nav-items="navItems" github-url="..." :loading @refresh="refreshSources">
103
112
  <DevtoolsLoading v-if="loading" />
104
113
  <template v-else-if="activeTab === 'overview'">
105
114
  <!-- content -->
@@ -1,16 +1,41 @@
1
1
  # Devtools Layer API Reference
2
2
 
3
+ ## Nuxt UI (available to all consumers)
4
+
5
+ The layer registers `@nuxt/ui` v4, so all Nuxt UI components are auto imported. Use them freely:
6
+
7
+ - `UButton`, `UInput`, `UTextarea`, `USelect`, `UCheckbox`, `UToggle`, `URadio`
8
+ - `UBadge`, `UIcon`, `UTooltip`, `UPopover`, `UModal`, `UDrawer`
9
+ - `UApp` (wraps the root, already used by `DevtoolsLayout`)
10
+ - `UCard`, `UAccordion`, `UTabs`, `UDropdownMenu`
11
+
12
+ Default variants via `app.config.ts`: primary green, neutral neutral. Buttons: ghost/neutral/sm. Badges: subtle/neutral/xs. Tooltips: zero delay.
13
+
14
+ Icons use the Iconify format. Convention: `carbon:*` prefix for consistency.
15
+
3
16
  ## Components (auto imported)
4
17
 
5
18
  ### Layout & Structure
6
19
 
7
20
  | Component | Props | Key Slots | Purpose |
8
21
  |---|---|---|---|
9
- | `DevtoolsLayout` | `title`, `icon`, `version?`, `navItems`, `githubUrl`, `loading?` | `actions`, default | Main shell with header, tabs, refresh |
10
- | `DevtoolsPanel` | `title?` | `header`, `actions`, default | Card container with close button |
22
+ | `DevtoolsLayout` | `title`, `icon`, `version?`, `moduleName?`, `npmPackage?`, `navItems: DevtoolsNavItem[]`, `githubUrl`, `loading?` | `actions`, default | Main shell with header, tabs, refresh, module splash, standalone mode, production mode. Pass `npmPackage` to enable update check indicator on version badge. |
23
+ | `DevtoolsPanel` | `title?`, `icon?`, `closable?` (false), `padding?` (true) | `header`, `actions`, default | Card container. Emits `close` when closable button clicked. |
11
24
  | `DevtoolsToolbar` | `variant` ('default'\|'minimal') | default | Horizontal toolbar strip |
12
25
  | `DevtoolsSection` | `icon?`, `text?`, `description?`, `collapse?`, `open?`, `padding?` | `text`, `description`, `actions`, `details`, default, `footer` | Collapsible details/summary block |
13
26
 
27
+ ### DevtoolsNavItem Interface
28
+
29
+ ```ts
30
+ interface DevtoolsNavItem {
31
+ value: string
32
+ to?: string // If set, renders as NuxtLink (route navigation)
33
+ icon: string
34
+ label: string
35
+ devOnly?: boolean // Hidden in production mode
36
+ }
37
+ ```
38
+
14
39
  ### Feedback & States
15
40
 
16
41
  | Component | Props | Key Slots | Purpose |
@@ -31,6 +56,16 @@
31
56
  | `DevtoolsSnippet` | `label?`, `code`, `lang` ('js'\|'json'\|'xml') | `header` | Code block with header, copy, max 300px scroll |
32
57
  | `OCodeBlock` | `code`, `lang`, `lines?`, `transformRendered?` | none | Shiki syntax highlighted pre |
33
58
  | `DevtoolsDocs` | `url` | none | Full height iframe |
59
+ | `DevtoolsPlaygrounds` | `moduleName` | none | StackBlitz playground links with UTabs for variant selection. Reusable standalone or inside other components. Shows nothing if module has no playgrounds. |
60
+ | `DevtoolsTroubleshooting` | `moduleName`, `version?` | none | Guided troubleshooting section: clear .nuxt, debug mode, create repro (with playgrounds), report issue. Shows all installed module versions with copy for GitHub issues. |
61
+
62
+ ### Module Navigation
63
+
64
+ | Component | Props | Key Slots | Purpose |
65
+ |---|---|---|---|
66
+ | `DevtoolsModuleSplash` | `currentModule?` | none | Modal overlay showing all Nuxt SEO modules with install status, switch between modules, Pro section |
67
+ | `DevtoolsStandaloneConnect` | none | none | Connection form for standalone mode (outside devtools iframe) |
68
+ | `NuxtSeoLogo` | none | none | Nuxt SEO brand logo SVG |
34
69
 
35
70
  ### KeyValueItem Interface
36
71
 
@@ -73,6 +108,11 @@ const host: ComputedRef<string>
73
108
  const refreshSources: () => void // Debounced 200ms
74
109
  const slowRefreshSources: () => void // Debounced 1000ms
75
110
 
111
+ // Standalone mode (running outside devtools iframe)
112
+ const standaloneUrl: Ref<string> // localStorage persisted
113
+ const isConnected: Ref<boolean>
114
+ const isStandalone: ComputedRef<boolean> // true when not connected but has standaloneUrl
115
+
76
116
  // Production preview mode
77
117
  const previewSource: Ref<'local' | 'production'> // localStorage persisted
78
118
  const productionUrl: Ref<string>
@@ -80,6 +120,21 @@ const hasProductionUrl: ComputedRef<boolean>
80
120
  const isProductionMode: ComputedRef<boolean>
81
121
  ```
82
122
 
123
+ ### Modules (`composables/modules.ts`)
124
+
125
+ ```ts
126
+ interface SeoModuleInfo { name: string, title: string, icon: string, route: string }
127
+ interface SeoModuleCatalogEntry extends SeoModuleInfo { description: string, installed: boolean, npm: string, pro?: boolean, playgrounds?: Record<string, string> }
128
+
129
+ const installedModules: Ref<SeoModuleInfo[]>
130
+ const showModuleSplash: Ref<boolean>
131
+ const moduleCatalog: ComputedRef<SeoModuleCatalogEntry[]>
132
+
133
+ function fetchInstalledModules(): void // Called by DevtoolsLayout automatically
134
+ function findModuleByName(moduleName: string): SeoModuleCatalogEntry | undefined // Look up module info by name
135
+ function switchToModule(moduleName: string): void // Navigate devtools iframe to another module
136
+ ```
137
+
83
138
  ### Shiki (`composables/shiki.ts`)
84
139
 
85
140
  ```ts
@@ -93,9 +148,21 @@ function useRenderCodeHighlight(code: MaybeRef<string>, lang: string): ComputedR
93
148
  function useCopy(timeout?: number): { copy: (text: string) => Promise<void>, copied: Ref<boolean> }
94
149
  ```
95
150
 
151
+ ### Update Check (`composables/update-check.ts`)
152
+
153
+ ```ts
154
+ function useModuleUpdate(npmPackage: string | undefined, currentVersion: string | undefined): {
155
+ hasUpdate: ComputedRef<boolean>
156
+ latestVersion: ComputedRef<string | undefined>
157
+ info: ComputedRef<ModuleUpdateInfo | undefined>
158
+ }
159
+ ```
160
+
161
+ Fetches latest version from npm registry. Results are cached per package name. Already integrated into `DevtoolsLayout` when `npmPackage` prop is provided.
162
+
96
163
  ## CSS Design System
97
164
 
98
- Do NOT add custom CSS unless layer components are insufficient.
165
+ Do NOT add custom CSS unless layer components or Nuxt UI are insufficient.
99
166
 
100
167
  ### Semantic Variables
101
168
 
@@ -105,10 +172,6 @@ Do NOT add custom CSS unless layer components are insufficient.
105
172
 
106
173
  `.glass` (backdrop blur), `.gradient-bg` (green/blue radials), `.card` (elevated hover), `.code-block`, `.status-enabled/.status-disabled`, `.link-external` (with arrow), `.hint-callout`, `.panel-grids`, `.animate-fade-up/.scale-in/.spin`, `.stagger-children`, `.devtools-main-content` (max-width 80rem centered container)
107
174
 
108
- ### Nuxt UI Defaults (app.config.ts)
109
-
110
- Primary: green, Neutral: neutral. Buttons: ghost/neutral/sm. Badges: subtle/neutral/xs. Tooltips: zero delay.
111
-
112
175
  ## Common Patterns
113
176
 
114
177
  ### Production Mode Data Fetching