nuxtseo-layer-devtools 5.2.1 → 5.2.2

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.
@@ -619,6 +619,20 @@ html.dark {
619
619
  50% { opacity: 0.4; }
620
620
  }
621
621
 
622
+ /* Remote (standalone) dev-server badge inside the source select */
623
+ .devtools-remote-badge {
624
+ display: inline-flex;
625
+ align-items: center;
626
+ gap: 0.25rem;
627
+ padding: 0.125rem 0.5rem;
628
+ border-radius: 9999px;
629
+ background: var(--color-surface-elevated);
630
+ border: 1px solid var(--color-border);
631
+ color: var(--color-text-muted);
632
+ font-weight: 500;
633
+ font-family: var(--font-mono, ui-monospace, monospace);
634
+ }
635
+
622
636
  /* Main content wrapper */
623
637
  .devtools-main {
624
638
  flex: 1;
@@ -160,46 +160,61 @@ function disconnectStandalone() {
160
160
  <span v-if="hasUpdate" class="update-dot" />
161
161
  </a>
162
162
  </UTooltip>
163
- <!-- Mode dropdown: embedded with production URL -->
164
- <div v-if="hasProductionUrl && !isStandalone" ref="modeDropdownRef" class="mode-dropdown-wrapper">
163
+ <!-- Unified source select. One control for every data source (Local,
164
+ Production, and a remote/standalone dev server). Always a select,
165
+ never a dismissable chip; the standalone disconnect lives in the menu. -->
166
+ <div v-if="isConnected || isStandalone" ref="modeDropdownRef" class="mode-dropdown-wrapper">
165
167
  <button type="button" class="devtools-mode-btn" @click="modeDropdownOpen = !modeDropdownOpen">
166
168
  <UIcon :name="isProductionMode ? 'carbon:cloud' : 'carbon:laptop'" class="w-3.5 h-3.5" />
167
169
  <span class="hidden sm:inline">{{ isProductionMode ? 'Production' : 'Local' }}</span>
168
- <template v-if="isProductionMode">
169
- <span class="devtools-production-badge">
170
- <span class="devtools-production-dot" />
171
- {{ productionHostname }}
172
- </span>
173
- </template>
170
+ <span v-if="isProductionMode" class="devtools-production-badge">
171
+ <span class="devtools-production-dot" />
172
+ {{ productionHostname }}
173
+ </span>
174
+ <span v-else-if="isStandalone" class="devtools-remote-badge">
175
+ <UIcon name="carbon:plug" class="w-3 h-3" />
176
+ {{ standaloneHostname }}
177
+ </span>
174
178
  <UIcon name="carbon:chevron-down" class="w-3 h-3 opacity-50 transition-transform" :class="modeDropdownOpen ? 'rotate-180' : ''" />
175
179
  </button>
176
180
  <Transition name="dropdown">
177
181
  <div v-if="modeDropdownOpen" class="mode-dropdown-menu">
178
- <button type="button" class="mode-dropdown-item" @click="selectMode('local')">
182
+ <button type="button" class="mode-dropdown-item" :class="!isProductionMode ? 'is-active' : ''" @click="selectMode('local')">
179
183
  <UIcon name="carbon:laptop" class="w-4 h-4" />
180
184
  <span>Local</span>
185
+ <UIcon v-if="!isProductionMode" name="carbon:checkmark" class="w-3.5 h-3.5 ml-auto text-[var(--seo-green)]" />
181
186
  </button>
182
- <button type="button" class="mode-dropdown-item" @click="selectMode('production')">
187
+ <button
188
+ type="button"
189
+ class="mode-dropdown-item"
190
+ :class="[isProductionMode ? 'is-active' : '', !hasProductionUrl ? 'is-disabled' : '']"
191
+ :disabled="!hasProductionUrl"
192
+ @click="hasProductionUrl && selectMode('production')"
193
+ >
183
194
  <UIcon name="carbon:cloud" class="w-4 h-4" />
184
195
  <span>Production</span>
185
- <span class="devtools-production-badge text-[10px]">
196
+ <span v-if="hasProductionUrl" class="devtools-production-badge text-[10px]">
186
197
  <span class="devtools-production-dot" />
187
198
  {{ productionHostname }}
188
199
  </span>
200
+ <span v-else class="text-[10px] opacity-60 ml-auto">Set site url</span>
201
+ <UIcon v-if="isProductionMode" name="carbon:checkmark" class="w-3.5 h-3.5 ml-1 text-[var(--seo-green)]" />
189
202
  </button>
203
+ <!-- Remote connection context: shown when reading from a standalone dev server -->
204
+ <template v-if="isStandalone">
205
+ <div class="mode-dropdown-divider" />
206
+ <div class="mode-dropdown-meta">
207
+ <UIcon name="carbon:plug" class="w-3.5 h-3.5 text-[var(--seo-green)]" />
208
+ <span class="truncate">{{ standaloneHostname }}</span>
209
+ </div>
210
+ <button type="button" class="mode-dropdown-item" @click="disconnectStandalone(); modeDropdownOpen = false">
211
+ <UIcon name="carbon:logout" class="w-4 h-4" />
212
+ <span>Disconnect</span>
213
+ </button>
214
+ </template>
190
215
  </div>
191
216
  </Transition>
192
217
  </div>
193
- <!-- Standalone mode indicator -->
194
- <div v-if="isStandalone" class="standalone-indicator">
195
- <UIcon name="carbon:plug" class="w-3.5 h-3.5 text-[var(--seo-green)]" />
196
- <span class="text-xs font-mono">{{ standaloneHostname }}</span>
197
- <UTooltip text="Disconnect">
198
- <button type="button" class="standalone-disconnect" @click="disconnectStandalone">
199
- <UIcon name="carbon:close" class="w-3 h-3" />
200
- </button>
201
- </UTooltip>
202
- </div>
203
218
  </div>
204
219
  </div>
205
220
 
@@ -408,6 +423,36 @@ function disconnectStandalone() {
408
423
  color: var(--color-text);
409
424
  }
410
425
 
426
+ .mode-dropdown-item.is-active {
427
+ color: var(--color-text);
428
+ }
429
+
430
+ .mode-dropdown-item.is-disabled {
431
+ opacity: 0.5;
432
+ cursor: not-allowed;
433
+ }
434
+
435
+ .mode-dropdown-item.is-disabled:hover {
436
+ background: transparent;
437
+ color: var(--color-text-muted);
438
+ }
439
+
440
+ .mode-dropdown-divider {
441
+ height: 1px;
442
+ margin: 4px 0;
443
+ background: var(--color-border);
444
+ }
445
+
446
+ .mode-dropdown-meta {
447
+ display: flex;
448
+ align-items: center;
449
+ gap: 0.5rem;
450
+ padding: 0.25rem 0.625rem;
451
+ font-size: 0.6875rem;
452
+ font-family: var(--font-mono);
453
+ color: var(--color-text-subtle);
454
+ }
455
+
411
456
  .dropdown-enter-active {
412
457
  transition: opacity 150ms ease, transform 150ms ease;
413
458
  }
@@ -46,7 +46,7 @@ const {
46
46
  border-radius: var(--radius-sm);
47
47
  font-size: 0.6875rem;
48
48
  line-height: 1.6;
49
- padding: 0.5rem 0.625rem !important;
49
+ padding: 0.875rem 1rem !important;
50
50
  max-height: 300px;
51
51
  overflow-y: auto;
52
52
  }
@@ -0,0 +1,70 @@
1
+ import type { NuxtDevtoolsIframeClient } from '@nuxt/devtools-kit/types'
2
+ import type { BirpcReturn } from 'birpc'
3
+ import type { $Fetch } from 'nitropack/types'
4
+ import type { Ref } from 'vue'
5
+
6
+ /**
7
+ * A narrow, version-resilient view over the Nuxt DevTools host.
8
+ *
9
+ * The devtools-kit iframe client (`NuxtDevtoolsIframeClient`) only types a small
10
+ * stable surface (`host.app.$fetch`, `host.nuxt.$router`, `host.app.colorMode`,
11
+ * `devtools.extendClientRpc`). Module clients historically reached *past* it into
12
+ * Vue runtime internals (`vueApp._context.provides`, `vueApp._instance.appContext`,
13
+ * `globalProperties.$route`), which differ across @nuxt/devtools versions and
14
+ * threw, wedging the panel. This adapter is the single guarded place that touches
15
+ * those internals; every module reads from it instead.
16
+ */
17
+ export interface DevtoolsHost {
18
+ /** `host.app.$fetch`, or undefined when the embedded host hasn't exposed it. */
19
+ fetch: $Fetch | undefined
20
+ /** Current host route, derived from the typed `$router`, never `globalProperties.$route`. */
21
+ route: Ref<any> | undefined
22
+ /** Host app base URL, resolved from runtime config then router history, falling back to '/'. */
23
+ baseURL: string
24
+ /** Resolve an app-level provide (e.g. `'usehead'`) regardless of which Vue internal exposes it. */
25
+ inject: <T>(key: string | symbol) => T | undefined
26
+ /** Open a birpc channel to the host, or undefined when the devtools bridge is unavailable. */
27
+ rpc: <ServerFunctions = Record<string, never>, ClientFunctions = Record<string, never>>(
28
+ namespace: string,
29
+ handlers: ClientFunctions,
30
+ ) => BirpcReturn<ServerFunctions, ClientFunctions> | undefined
31
+ /** Open a file in the user's editor via the built-in devtools RPC (no-op if unavailable). */
32
+ openInEditor: (path: string) => void
33
+ }
34
+
35
+ /**
36
+ * Build a {@link DevtoolsHost} from a raw devtools-kit client.
37
+ *
38
+ * Pure: no import-time side effects, no connection logic. Takes the client as an
39
+ * argument so it can be unit-tested against a malformed host without booting devtools.
40
+ */
41
+ export function createDevtoolsHost(client: NuxtDevtoolsIframeClient | undefined): DevtoolsHost {
42
+ const nuxt: any = client?.host?.nuxt
43
+ const $router: any = nuxt?.$router
44
+ const baseURL: string = nuxt?.$config?.app?.baseURL
45
+ || $router?.options?.history?.base
46
+ // @ts-expect-error host.app.baseURL is not in the typed surface; kept only as a last resort
47
+ || client?.host?.app?.baseURL
48
+ || '/'
49
+
50
+ return {
51
+ // @ts-expect-error host.app.$fetch is untyped on some versions
52
+ fetch: client?.host?.app?.$fetch,
53
+ route: $router?.currentRoute,
54
+ baseURL,
55
+ inject<T>(key: string | symbol): T | undefined {
56
+ const app: any = nuxt?.vueApp
57
+ const provides = app?._context?.provides ?? app?._instance?.appContext?.provides
58
+ return provides?.[key]
59
+ },
60
+ rpc(namespace, handlers) {
61
+ const extend = client?.devtools?.extendClientRpc
62
+ if (typeof extend !== 'function')
63
+ return undefined
64
+ return extend(namespace, handlers) as any
65
+ },
66
+ openInEditor(path) {
67
+ client?.devtools?.rpc?.openInEditor?.(path)
68
+ },
69
+ }
70
+ }
@@ -1,17 +1,25 @@
1
1
  import type { NuxtDevtoolsClient } from '@nuxt/devtools-kit/types'
2
2
  import type { $Fetch } from 'nitropack/types'
3
3
  import type { Ref } from 'vue'
4
+ import type { DevtoolsHost } from './host'
4
5
  import { onDevtoolsClientConnected, useDevtoolsClient } from '@nuxt/devtools-kit/iframe-client'
5
6
  import { ofetch } from 'ofetch'
6
7
  import { ref, watch, watchEffect } from 'vue'
7
- import { isConnected, refreshSources, standaloneUrl } from './state'
8
+ import { createDevtoolsHost } from './host'
9
+ import { base, isConnected, path, query, refreshSources, standaloneUrl } from './state'
8
10
 
9
11
  export const appFetch: Ref<$Fetch | undefined> = ref()
10
12
  export const devtools: Ref<NuxtDevtoolsClient | undefined> = ref()
11
13
  export const colorMode: Ref<'dark' | 'light'> = ref('dark')
12
14
 
13
15
  export interface DevtoolsConnectionOptions {
14
- onConnected?: (client: any) => void
16
+ /**
17
+ * Called once the embedded host is connected, with a guarded {@link DevtoolsHost}.
18
+ * Only needed by modules that read host provides or open an RPC channel — route,
19
+ * fetch, colorMode and base are already wired into layer state by the time this fires.
20
+ */
21
+ onConnected?: (host: DevtoolsHost) => void
22
+ /** Called on every host route change (the layer already refreshes data itself). */
15
23
  onRouteChange?: (route: any) => void
16
24
  }
17
25
 
@@ -33,29 +41,49 @@ export function useDevtoolsConnection(options: DevtoolsConnectionOptions = {}):
33
41
  // Embedded mode: connect via devtools-kit iframe client
34
42
  onDevtoolsClientConnected(async (client) => {
35
43
  isConnected.value = true
36
- // @ts-expect-error untyped
37
- appFetch.value = client.host?.app?.$fetch
44
+ const host = createDevtoolsHost(client)
45
+ appFetch.value = host.fetch
46
+ devtools.value = client.devtools
47
+ // Kick an initial data load on connect regardless of route availability, so
48
+ // modules that only needed "refresh once connected" require no onConnected.
49
+ refreshSources()
50
+
38
51
  watchEffect(() => {
39
52
  colorMode.value = client.host?.app?.colorMode?.value ?? (
40
53
  window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
41
54
  )
42
55
  })
43
- devtools.value = client.devtools
44
- options.onConnected?.(client)
45
56
 
46
- if (options.onRouteChange) {
47
- const $route = client.host?.nuxt?.vueApp?.config?.globalProperties?.$route
48
- if ($route)
49
- options.onRouteChange($route)
50
- const $router = client.host?.nuxt?.$router
51
- if ($router) {
52
- const removeAfterEach = $router.afterEach((route: any) => {
53
- options.onRouteChange!(route)
54
- })
55
- // Clean up when devtools client disconnects
56
- // @ts-expect-error app:unmount exists at runtime but is not in RuntimeNuxtHooks
57
- client.host.nuxt.hook('app:unmount', removeAfterEach)
58
- }
57
+ // The layer owns host route tracking so individual modules never have to
58
+ // reach into the host Vue app. path/query/base feed every module's
59
+ // useAsyncData(watch: [refreshTime]); refreshSources() bumps that clock.
60
+ base.value = host.baseURL
61
+ const applyRoute = (route: any): void => {
62
+ if (!route)
63
+ return
64
+ path.value = route.path || '/'
65
+ query.value = route.query
66
+ refreshSources()
67
+ options.onRouteChange?.(route)
68
+ }
69
+ applyRoute(host.route?.value)
70
+ const $router = client.host?.nuxt?.$router
71
+ if ($router) {
72
+ const removeAfterEach = $router.afterEach((route: any) => applyRoute(route))
73
+ // Clean up when devtools client disconnects
74
+ // @ts-expect-error app:unmount exists at runtime but is not in RuntimeNuxtHooks
75
+ client.host?.nuxt?.hook('app:unmount', removeAfterEach)
76
+ }
77
+
78
+ // Module-specific wiring (host provides / RPC channels) with the guarded host.
79
+ // A throw here must not wedge the panel on "Connecting…": data loading only
80
+ // needs appFetch (set above) plus a refreshTime bump, so recover with a refresh.
81
+ try {
82
+ options.onConnected?.(host)
83
+ }
84
+ catch (err) {
85
+ console.error('[nuxt-seo] devtools onConnected handler failed, recovering with data refresh:', err)
86
+ refreshSources()
59
87
  }
60
88
  })
61
89
 
@@ -4,6 +4,10 @@ import { createHighlighterCore } from 'shiki/core'
4
4
  import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'
5
5
  import { computed, ref, toValue } from 'vue'
6
6
 
7
+ // Re-exported so consuming layers can type custom grammars without depending on
8
+ // `shiki` directly (it isn't a dependency of the module repos, only of this layer).
9
+ export type { LanguageRegistration } from 'shiki'
10
+
7
11
  export const shiki: Ref<HighlighterCore | undefined> = ref()
8
12
 
9
13
  export interface LoadShikiOptions {
@@ -41,6 +45,11 @@ export function useRenderCodeHighlight(code: MaybeRef<string>, lang: string): Co
41
45
  return ''
42
46
  return shiki.value.codeToHtml(toValue(code) || '', {
43
47
  lang,
48
+ // Emit `--shiki-light` / `--shiki-dark` CSS variables instead of a baked-in
49
+ // `color:` so global.css can swap colors per devtools color mode. Without this,
50
+ // shiki writes `color:#xxx` inline and the `.shiki span { color: var(--shiki-light) }`
51
+ // rule resolves to an undefined variable, wiping every syntax color to plain text.
52
+ defaultColor: false,
44
53
  themes: {
45
54
  light: 'vitesse-light',
46
55
  dark: 'vitesse-dark',
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxtseo-layer-devtools",
3
3
  "type": "module",
4
- "version": "5.2.1",
4
+ "version": "5.2.2",
5
5
  "description": "Shared Nuxt layer for Nuxt SEO devtools clients.",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",
@@ -51,7 +51,7 @@
51
51
  "shiki": "^4.2.0",
52
52
  "tailwindcss": "^4.0.0",
53
53
  "ufo": "^1.6.4",
54
- "nuxtseo-shared": "5.2.1"
54
+ "nuxtseo-shared": "5.2.2"
55
55
  },
56
56
  "devDependencies": {
57
57
  "nuxt": "^4.4.7",