nuxtseo-layer-devtools 0.3.0 → 0.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.
@@ -1,6 +1,7 @@
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
5
  import { colorMode } from '../composables/rpc'
5
6
  import { hasProductionUrl, isConnected, isProductionMode, isStandalone, path, previewSource, productionUrl, standaloneUrl } from '../composables/state'
6
7
 
@@ -16,6 +17,7 @@ const {
16
17
  title,
17
18
  icon,
18
19
  version,
20
+ moduleName,
19
21
  navItems,
20
22
  githubUrl,
21
23
  loading = false,
@@ -23,6 +25,7 @@ const {
23
25
  title: string
24
26
  icon: string
25
27
  version?: string
28
+ moduleName?: string
26
29
  navItems: DevtoolsNavItem[]
27
30
  githubUrl: string
28
31
  loading?: boolean
@@ -32,6 +35,9 @@ const emit = defineEmits<{
32
35
  refresh: []
33
36
  }>()
34
37
 
38
+ // Fetch installed modules for the splash screen
39
+ fetchInstalledModules()
40
+
35
41
  const activeTab = defineModel<string>('activeTab')
36
42
 
37
43
  const isDark = computed(() => colorMode.value === 'dark')
@@ -96,15 +102,14 @@ function disconnectStandalone() {
96
102
  <div class="devtools-header-content">
97
103
  <!-- Logo & Brand -->
98
104
  <div class="flex items-center gap-3 sm:gap-4">
99
- <a
100
- href="https://nuxtseo.com"
101
- target="_blank"
102
- rel="noopener"
103
- aria-label="Nuxt SEO"
104
- class="flex items-center opacity-90 hover:opacity-100 transition-opacity"
105
+ <button
106
+ type="button"
107
+ aria-label="Nuxt SEO Modules"
108
+ class="flex items-center opacity-90 hover:opacity-100 transition-opacity cursor-pointer"
109
+ @click="showModuleSplash = true"
105
110
  >
106
111
  <NuxtSeoLogo class="h-6 sm:h-7" />
107
- </a>
112
+ </button>
108
113
 
109
114
  <div class="devtools-divider" />
110
115
 
@@ -271,6 +276,8 @@ function disconnectStandalone() {
271
276
  </main>
272
277
  </div>
273
278
  </div>
279
+
280
+ <DevtoolsModuleSplash :current-module="moduleName" />
274
281
  </UApp>
275
282
  </template>
276
283
 
@@ -0,0 +1,291 @@
1
+ <script setup lang="ts">
2
+ import { onClickOutside } from '@vueuse/core'
3
+ import { ref } from 'vue'
4
+ import { moduleCatalog, showModuleSplash, switchToModule } from '../composables/modules'
5
+ import { isConnected } from '../composables/state'
6
+
7
+ const props = defineProps<{
8
+ currentModule?: string
9
+ }>()
10
+
11
+ const panelRef = ref<HTMLElement>()
12
+ onClickOutside(panelRef, () => {
13
+ showModuleSplash.value = false
14
+ })
15
+
16
+ function handleModuleClick(mod: typeof moduleCatalog.value[0]) {
17
+ if (!mod.installed || mod.name === props.currentModule)
18
+ return
19
+ switchToModule(mod.name)
20
+ }
21
+ </script>
22
+
23
+ <template>
24
+ <Teleport to="body">
25
+ <Transition name="splash">
26
+ <div v-if="showModuleSplash" class="splash-overlay">
27
+ <div ref="panelRef" class="splash-panel">
28
+ <div class="splash-header">
29
+ <NuxtSeoLogo class="h-7" />
30
+ <p class="splash-subtitle">
31
+ All the SEO modules you need for Nuxt, in one place.
32
+ </p>
33
+ </div>
34
+
35
+ <div class="splash-grid">
36
+ <button
37
+ v-for="mod of moduleCatalog"
38
+ :key="mod.name"
39
+ type="button"
40
+ class="splash-module"
41
+ :class="{
42
+ 'is-installed': mod.installed,
43
+ 'is-current': mod.name === currentModule,
44
+ 'is-unavailable': !mod.installed,
45
+ 'is-switchable': mod.installed && mod.name !== currentModule && isConnected,
46
+ }"
47
+ :disabled="!mod.installed || mod.name === currentModule"
48
+ @click="handleModuleClick(mod)"
49
+ >
50
+ <div class="splash-module-icon">
51
+ <UIcon :name="mod.icon" class="text-xl" />
52
+ </div>
53
+ <div class="splash-module-info">
54
+ <div class="splash-module-title">
55
+ {{ mod.title }}
56
+ <UBadge v-if="mod.pro" size="xs" color="neutral" variant="outline" class="ml-1 text-[9px]">
57
+ PRO
58
+ </UBadge>
59
+ </div>
60
+ <div class="splash-module-desc">
61
+ {{ mod.description }}
62
+ </div>
63
+ </div>
64
+ <div class="splash-module-status">
65
+ <span v-if="mod.name === currentModule" class="splash-current-badge">Current</span>
66
+ <UIcon v-else-if="mod.installed && isConnected" name="carbon:arrow-right" class="text-sm opacity-0 group-hover:opacity-100 transition-opacity" />
67
+ <span v-else-if="!mod.installed" class="splash-not-installed">Not installed</span>
68
+ </div>
69
+ </button>
70
+ </div>
71
+
72
+ <div class="splash-footer">
73
+ <a href="https://nuxtseo.com" target="_blank" rel="noopener" class="splash-link">
74
+ <UIcon name="carbon:earth" class="w-3.5 h-3.5" />
75
+ nuxtseo.com
76
+ </a>
77
+ <button type="button" class="splash-close" @click="showModuleSplash = false">
78
+ Close
79
+ <UIcon name="carbon:close" class="w-3.5 h-3.5" />
80
+ </button>
81
+ </div>
82
+ </div>
83
+ </div>
84
+ </Transition>
85
+ </Teleport>
86
+ </template>
87
+
88
+ <style scoped>
89
+ .splash-overlay {
90
+ position: fixed;
91
+ inset: 0;
92
+ z-index: 200;
93
+ display: flex;
94
+ align-items: center;
95
+ justify-content: center;
96
+ background: oklch(0% 0 0 / 0.5);
97
+ backdrop-filter: blur(4px);
98
+ }
99
+
100
+ .splash-panel {
101
+ width: min(580px, calc(100vw - 2rem));
102
+ max-height: calc(100vh - 2rem);
103
+ overflow-y: auto;
104
+ border-radius: var(--radius-lg);
105
+ border: 1px solid var(--color-border);
106
+ background: var(--color-surface);
107
+ box-shadow: 0 24px 48px oklch(0% 0 0 / 0.2);
108
+ }
109
+
110
+ .dark .splash-panel {
111
+ box-shadow: 0 24px 48px oklch(0% 0 0 / 0.5);
112
+ }
113
+
114
+ .splash-header {
115
+ padding: 1.5rem 1.5rem 0;
116
+ text-align: center;
117
+ }
118
+
119
+ .splash-subtitle {
120
+ margin-top: 0.5rem;
121
+ font-size: 0.8125rem;
122
+ color: var(--color-text-muted);
123
+ }
124
+
125
+ .splash-grid {
126
+ display: flex;
127
+ flex-direction: column;
128
+ gap: 2px;
129
+ padding: 1rem 0.75rem;
130
+ }
131
+
132
+ .splash-module {
133
+ display: flex;
134
+ align-items: center;
135
+ gap: 0.75rem;
136
+ padding: 0.625rem 0.75rem;
137
+ border-radius: var(--radius-md);
138
+ text-align: left;
139
+ cursor: default;
140
+ transition: background 100ms, opacity 100ms;
141
+ }
142
+
143
+ .splash-module.is-switchable {
144
+ cursor: pointer;
145
+ }
146
+
147
+ .splash-module.is-switchable:hover {
148
+ background: var(--color-surface-elevated);
149
+ }
150
+
151
+ .splash-module.is-switchable:hover .splash-module-icon {
152
+ color: var(--seo-green);
153
+ }
154
+
155
+ .splash-module.is-current {
156
+ background: oklch(from var(--seo-green) l c h / 0.08);
157
+ }
158
+
159
+ .splash-module.is-unavailable {
160
+ opacity: 0.45;
161
+ }
162
+
163
+ .splash-module-icon {
164
+ display: flex;
165
+ align-items: center;
166
+ justify-content: center;
167
+ width: 2.25rem;
168
+ height: 2.25rem;
169
+ flex-shrink: 0;
170
+ border-radius: var(--radius-sm);
171
+ background: var(--color-surface-sunken);
172
+ color: var(--color-text-muted);
173
+ transition: color 100ms;
174
+ }
175
+
176
+ .splash-module.is-current .splash-module-icon {
177
+ background: oklch(from var(--seo-green) l c h / 0.15);
178
+ color: var(--seo-green);
179
+ }
180
+
181
+ .splash-module-info {
182
+ flex: 1;
183
+ min-width: 0;
184
+ }
185
+
186
+ .splash-module-title {
187
+ display: flex;
188
+ align-items: center;
189
+ font-size: 0.8125rem;
190
+ font-weight: 600;
191
+ color: var(--color-text);
192
+ }
193
+
194
+ .splash-module-desc {
195
+ font-size: 0.6875rem;
196
+ color: var(--color-text-muted);
197
+ margin-top: 1px;
198
+ }
199
+
200
+ .splash-module-status {
201
+ flex-shrink: 0;
202
+ }
203
+
204
+ .splash-current-badge {
205
+ font-size: 0.625rem;
206
+ font-weight: 600;
207
+ text-transform: uppercase;
208
+ letter-spacing: 0.05em;
209
+ color: var(--seo-green);
210
+ }
211
+
212
+ .splash-not-installed {
213
+ font-size: 0.6875rem;
214
+ color: var(--color-text-subtle);
215
+ }
216
+
217
+ .splash-footer {
218
+ display: flex;
219
+ align-items: center;
220
+ justify-content: space-between;
221
+ padding: 0.75rem 1.25rem;
222
+ border-top: 1px solid var(--color-border);
223
+ }
224
+
225
+ .splash-link {
226
+ display: flex;
227
+ align-items: center;
228
+ gap: 0.375rem;
229
+ font-size: 0.75rem;
230
+ color: var(--color-text-muted);
231
+ text-decoration: none;
232
+ transition: color 100ms;
233
+ }
234
+
235
+ .splash-link:hover {
236
+ color: var(--seo-green);
237
+ }
238
+
239
+ .splash-close {
240
+ display: flex;
241
+ align-items: center;
242
+ gap: 0.375rem;
243
+ font-size: 0.75rem;
244
+ font-weight: 500;
245
+ color: var(--color-text-muted);
246
+ padding: 0.25rem 0.625rem;
247
+ border-radius: var(--radius-sm);
248
+ cursor: pointer;
249
+ transition: background 100ms, color 100ms;
250
+ }
251
+
252
+ .splash-close:hover {
253
+ background: var(--color-surface-sunken);
254
+ color: var(--color-text);
255
+ }
256
+
257
+ /* Transitions */
258
+ .splash-enter-active {
259
+ transition: opacity 200ms ease;
260
+ }
261
+
262
+ .splash-enter-active .splash-panel {
263
+ transition: transform 200ms cubic-bezier(0.34, 1.56, 0.64, 1), opacity 200ms ease;
264
+ }
265
+
266
+ .splash-leave-active {
267
+ transition: opacity 150ms ease;
268
+ }
269
+
270
+ .splash-leave-active .splash-panel {
271
+ transition: transform 150ms ease, opacity 150ms ease;
272
+ }
273
+
274
+ .splash-enter-from {
275
+ opacity: 0;
276
+ }
277
+
278
+ .splash-enter-from .splash-panel {
279
+ transform: scale(0.95);
280
+ opacity: 0;
281
+ }
282
+
283
+ .splash-leave-to {
284
+ opacity: 0;
285
+ }
286
+
287
+ .splash-leave-to .splash-panel {
288
+ transform: scale(0.97);
289
+ opacity: 0;
290
+ }
291
+ </style>
@@ -2,6 +2,7 @@
2
2
  import { ref } from 'vue'
3
3
  import { standaloneUrl } from '../composables/state'
4
4
 
5
+ const trailingSlashes = /\/+$/
5
6
  const urlInput = ref(standaloneUrl.value || 'http://localhost:3000')
6
7
  const error = ref('')
7
8
  const connecting = ref(false)
@@ -9,7 +10,7 @@ const connecting = ref(false)
9
10
  async function connect() {
10
11
  error.value = ''
11
12
  connecting.value = true
12
- const url = urlInput.value.replace(/\/+$/, '')
13
+ const url = urlInput.value.replace(trailingSlashes, '')
13
14
  try {
14
15
  // Verify the dev server is reachable
15
16
  await fetch(url, { mode: 'no-cors', signal: AbortSignal.timeout(5000) })
@@ -0,0 +1,96 @@
1
+ import type { NuxtDevtoolsIframeClient } from '@nuxt/devtools-kit/types'
2
+ import { onDevtoolsClientConnected } from '@nuxt/devtools-kit/iframe-client'
3
+ import { computed, ref } from 'vue'
4
+ import { isConnected } from './state'
5
+
6
+ export interface SeoModuleInfo {
7
+ name: string
8
+ title: string
9
+ icon: string
10
+ route: string
11
+ }
12
+
13
+ export interface SeoModuleCatalogEntry {
14
+ name: string
15
+ title: string
16
+ description: string
17
+ icon: string
18
+ installed: boolean
19
+ route?: string
20
+ npmUrl: string
21
+ pro?: boolean
22
+ }
23
+
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
+ ]
36
+
37
+ export const installedModules = ref<SeoModuleInfo[]>([])
38
+ export const showModuleSplash = ref(false)
39
+
40
+ export const moduleCatalog = computed<SeoModuleCatalogEntry[]>(() => {
41
+ return MODULE_CATALOG.map((entry) => {
42
+ const installed = installedModules.value.find(m => m.name === entry.name)
43
+ return {
44
+ ...entry,
45
+ installed: !!installed,
46
+ route: installed?.route,
47
+ }
48
+ })
49
+ })
50
+
51
+ export function fetchInstalledModules(): void {
52
+ const inIframe = window.parent !== window
53
+ if (!inIframe)
54
+ return
55
+
56
+ onDevtoolsClientConnected(async (client) => {
57
+ const rpc = client.devtools.extendClientRpc('nuxt-seo-modules', {})
58
+ try {
59
+ const modules = await (rpc as any).getInstalledSeoModules()
60
+ if (Array.isArray(modules))
61
+ installedModules.value = modules
62
+ }
63
+ catch {
64
+ // RPC not available (module might not have the shared registration)
65
+ }
66
+ })
67
+ }
68
+
69
+ function resolveDevtoolsIframe(): NuxtDevtoolsIframeClient | undefined {
70
+ // We're inside the devtools iframe, so we need to go up to the parent devtools frame
71
+ try {
72
+ const devtoolsWindow = window.parent
73
+ if (!devtoolsWindow)
74
+ return
75
+ return (devtoolsWindow as any).__NUXT_DEVTOOLS__ as NuxtDevtoolsIframeClient
76
+ }
77
+ catch {
78
+ return undefined
79
+ }
80
+ }
81
+
82
+ export function switchToModule(moduleName: string): void {
83
+ if (!isConnected.value)
84
+ return
85
+
86
+ const devtoolsClient = resolveDevtoolsIframe()
87
+ if (!devtoolsClient)
88
+ return
89
+
90
+ const iframe = devtoolsClient.host?.getIframe?.()
91
+ if (iframe) {
92
+ iframe.src = `/__nuxt_devtools__/client/modules/custom-${moduleName}`
93
+ }
94
+
95
+ showModuleSplash.value = false
96
+ }
@@ -9,6 +9,11 @@ export const path = ref('/')
9
9
  export const query = ref()
10
10
  export const base = ref('/')
11
11
 
12
+ // Standalone mode state
13
+ export const standaloneUrl = useLocalStorage<string>('nuxt-seo:standalone-url', '')
14
+ export const isConnected = ref(false)
15
+ export const isStandalone = computed(() => !isConnected.value && !!standaloneUrl.value)
16
+
12
17
  export const host = computed(() => {
13
18
  if (isStandalone.value)
14
19
  return standaloneUrl.value
@@ -35,8 +40,3 @@ export const hasProductionUrl = computed(() => {
35
40
  })
36
41
 
37
42
  export const isProductionMode = computed(() => previewSource.value === 'production' && hasProductionUrl.value)
38
-
39
- // Standalone mode state
40
- export const standaloneUrl = useLocalStorage<string>('nuxt-seo:standalone-url', '')
41
- export const isConnected = ref(false)
42
- export const isStandalone = computed(() => !isConnected.value && !!standaloneUrl.value)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxtseo-layer-devtools",
3
3
  "type": "module",
4
- "version": "0.3.0",
4
+ "version": "0.3.1",
5
5
  "description": "Shared Nuxt layer for Nuxt SEO devtools clients.",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",
@@ -38,6 +38,6 @@
38
38
  },
39
39
  "devDependencies": {
40
40
  "nuxt": "^4.4.2",
41
- "vue": "^3.5.30"
41
+ "vue": "^3.5.31"
42
42
  }
43
43
  }