nuxtseo-layer-devtools 0.1.2 → 0.2.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.
@@ -42,8 +42,8 @@
42
42
  --color-surface: var(--color-neutral-950);
43
43
  --color-surface-elevated: var(--color-neutral-900);
44
44
  --color-surface-sunken: oklch(7% 0.004 260);
45
- --color-border: var(--color-neutral-800);
46
- --color-border-subtle: oklch(16% 0.008 260);
45
+ --color-border: oklch(25% 0.012 260);
46
+ --color-border-subtle: oklch(20% 0.01 260);
47
47
  --color-text: var(--color-neutral-100);
48
48
  --color-text-muted: var(--color-neutral-400);
49
49
  --color-text-subtle: var(--color-neutral-500);
@@ -507,42 +507,26 @@ html.dark {
507
507
  }
508
508
 
509
509
  /* Preview source toggle */
510
- .devtools-preview-toggle {
511
- display: flex;
512
- gap: 1px;
513
- background: var(--color-border);
514
- border-radius: 6px;
515
- overflow: hidden;
516
- }
517
-
518
- .devtools-preview-btn {
519
- display: flex;
510
+ .devtools-mode-btn {
511
+ display: inline-flex;
520
512
  align-items: center;
521
513
  gap: 0.25rem;
522
- padding: 0.25rem 0.5rem;
514
+ padding: 0.2rem 0.5rem;
523
515
  font-size: 0.6875rem;
524
516
  font-weight: 500;
525
517
  color: var(--color-text-muted);
526
518
  background: var(--color-surface-sunken);
527
- border: none;
519
+ border: 1px solid var(--color-border);
520
+ border-radius: 6px;
528
521
  cursor: pointer;
529
- transition: color 150ms, background 150ms;
522
+ transition: color 150ms, background 150ms, border-color 150ms;
530
523
  white-space: nowrap;
531
524
  }
532
525
 
533
- .devtools-preview-btn:hover {
526
+ .devtools-mode-btn:hover {
534
527
  color: var(--color-text);
535
528
  background: var(--color-surface-elevated);
536
- }
537
-
538
- .devtools-preview-btn.active {
539
- color: var(--color-text);
540
- background: var(--color-surface-elevated);
541
- box-shadow: 0 1px 2px oklch(0% 0 0 / 0.06);
542
- }
543
-
544
- .dark .devtools-preview-btn.active {
545
- box-shadow: 0 1px 2px oklch(0% 0 0 / 0.2);
529
+ border-color: var(--color-border-hover, var(--color-border));
546
530
  }
547
531
 
548
532
  /* Production URL badge */
@@ -5,6 +5,8 @@ export interface KeyValueItem {
5
5
  copyable?: boolean
6
6
  mono?: boolean
7
7
  link?: string
8
+ /** Render value using DevtoolsSnippet. Pass the language for syntax highlighting. */
9
+ code?: 'js' | 'json' | 'xml'
8
10
  }
9
11
 
10
12
  const { items, striped = false } = defineProps<{
@@ -19,10 +21,13 @@ const { items, striped = false } = defineProps<{
19
21
  v-for="item in items"
20
22
  :key="item.key"
21
23
  class="devtools-kv-row group"
22
- :class="{ 'devtools-kv-striped': striped }"
24
+ :class="{
25
+ 'devtools-kv-striped': striped,
26
+ 'devtools-kv-stacked': !!item.code,
27
+ }"
23
28
  >
24
29
  <span class="devtools-kv-key">{{ item.key }}</span>
25
- <div class="devtools-kv-value-wrap">
30
+ <div class="devtools-kv-value-wrap" :class="{ 'devtools-kv-value-wrap-full': !!item.code }">
26
31
  <a
27
32
  v-if="item.link"
28
33
  :href="item.link"
@@ -32,6 +37,12 @@ const { items, striped = false } = defineProps<{
32
37
  >
33
38
  {{ item.value }}
34
39
  </a>
40
+ <DevtoolsSnippet
41
+ v-else-if="item.code && item.value !== undefined && item.value !== ''"
42
+ :code="String(item.value)"
43
+ :lang="item.code"
44
+ class="devtools-kv-snippet"
45
+ />
35
46
  <span
36
47
  v-else
37
48
  class="devtools-kv-value"
@@ -45,7 +56,7 @@ const { items, striped = false } = defineProps<{
45
56
  {{ item.value === undefined || item.value === '' ? '(empty)' : item.value }}
46
57
  </span>
47
58
  <DevtoolsCopyButton
48
- v-if="item.copyable && item.value !== undefined && item.value !== ''"
59
+ v-if="!item.code && item.copyable && item.value !== undefined && item.value !== ''"
49
60
  :text="String(item.value)"
50
61
  class="opacity-0 group-hover:opacity-100 transition-opacity"
51
62
  />
@@ -64,6 +75,12 @@ const { items, striped = false } = defineProps<{
64
75
  transition: background-color 150ms ease;
65
76
  }
66
77
 
78
+ .devtools-kv-row.devtools-kv-stacked {
79
+ flex-direction: column;
80
+ align-items: flex-start;
81
+ gap: 0.375rem;
82
+ }
83
+
67
84
  .devtools-kv-row:hover {
68
85
  background: var(--color-surface-sunken);
69
86
  }
@@ -86,12 +103,20 @@ const { items, striped = false } = defineProps<{
86
103
  min-width: 0;
87
104
  }
88
105
 
106
+ .devtools-kv-value-wrap-full {
107
+ width: 100%;
108
+ }
109
+
89
110
  .devtools-kv-value {
90
111
  font-size: 0.8125rem;
91
112
  text-align: right;
92
- overflow: hidden;
93
- text-overflow: ellipsis;
94
- white-space: nowrap;
113
+ overflow-wrap: break-word;
114
+ word-break: break-all;
115
+ }
116
+
117
+ .devtools-kv-snippet {
118
+ width: 100%;
119
+ margin: 0 !important;
95
120
  }
96
121
 
97
122
  .devtools-kv-true {
@@ -92,8 +92,34 @@ const isRouteNav = computed(() => navItems.some(item => item.to))
92
92
  v-if="version"
93
93
  class="font-mono text-[10px] sm:text-xs hidden sm:inline-flex"
94
94
  >
95
- {{ version }}
95
+ v{{ version }}
96
96
  </UBadge>
97
+ <UDropdownMenu
98
+ v-if="hasProductionUrl"
99
+ :items="[
100
+ { label: 'Local', icon: 'carbon:laptop', onSelect: () => previewSource = 'local' },
101
+ { label: 'Production', icon: 'carbon:cloud', hostname: productionHostname, onSelect: () => previewSource = 'production' },
102
+ ]"
103
+ >
104
+ <button type="button" class="devtools-mode-btn">
105
+ <UIcon :name="isProductionMode ? 'carbon:cloud' : 'carbon:laptop'" class="w-3.5 h-3.5" />
106
+ <span class="hidden sm:inline">{{ isProductionMode ? 'Production' : 'Local' }}</span>
107
+ <template v-if="isProductionMode">
108
+ <span class="devtools-production-badge">
109
+ <span class="devtools-production-dot" />
110
+ {{ productionHostname }}
111
+ </span>
112
+ </template>
113
+ <UIcon name="carbon:chevron-down" class="w-3 h-3 opacity-50" />
114
+ </button>
115
+ <template #item-label="{ item }">
116
+ <span>{{ item.label }}</span>
117
+ <span v-if="(item as any).hostname" class="devtools-production-badge text-[10px]">
118
+ <span class="devtools-production-dot" />
119
+ {{ (item as any).hostname }}
120
+ </span>
121
+ </template>
122
+ </UDropdownMenu>
97
123
  </div>
98
124
  </div>
99
125
 
@@ -150,36 +176,6 @@ const isRouteNav = computed(() => navItems.some(item => item.to))
150
176
  </template>
151
177
  </div>
152
178
 
153
- <!-- Preview source toggle -->
154
- <div v-if="hasProductionUrl" class="devtools-preview-toggle">
155
- <button
156
- type="button"
157
- class="devtools-preview-btn"
158
- :class="{ active: previewSource === 'local' }"
159
- @click="previewSource = 'local'"
160
- >
161
- <UIcon name="carbon:laptop" class="w-3.5 h-3.5" aria-hidden="true" />
162
- <span class="hidden sm:inline">Local</span>
163
- </button>
164
- <button
165
- type="button"
166
- class="devtools-preview-btn"
167
- :class="{ active: previewSource === 'production' }"
168
- @click="previewSource = 'production'"
169
- >
170
- <UIcon name="carbon:cloud" class="w-3.5 h-3.5" aria-hidden="true" />
171
- <span class="hidden sm:inline">Production</span>
172
- </button>
173
- </div>
174
-
175
- <!-- Production URL indicator -->
176
- <UTooltip v-if="isProductionMode" :text="productionUrl">
177
- <span class="devtools-production-badge">
178
- <span class="devtools-production-dot" />
179
- <span class="hidden sm:inline text-xs">{{ productionHostname }}</span>
180
- </span>
181
- </UTooltip>
182
-
183
179
  <!-- Actions -->
184
180
  <div class="flex items-center gap-1">
185
181
  <slot name="actions" />
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxtseo-layer-devtools",
3
3
  "type": "module",
4
- "version": "0.1.2",
4
+ "version": "0.2.0",
5
5
  "description": "Shared Nuxt layer for Nuxt SEO devtools clients.",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",
@@ -15,13 +15,14 @@
15
15
  "assets",
16
16
  "components",
17
17
  "composables",
18
- "nuxt.config.ts"
18
+ "nuxt.config.ts",
19
+ "skills"
19
20
  ],
20
21
  "dependencies": {
21
22
  "@nuxt/devtools-kit": "^3.2.4",
22
23
  "@nuxt/fonts": "^0.14.0",
23
24
  "@nuxt/kit": "^4.4.2",
24
- "@nuxt/ui": "^4.5.1",
25
+ "@nuxt/ui": "^4.6.0",
25
26
  "@shikijs/langs": "^4.0.2",
26
27
  "@shikijs/themes": "^4.0.2",
27
28
  "@vueuse/core": "^14.2.1",
@@ -0,0 +1,107 @@
1
+ ---
2
+ name: devtools-layer-skilld
3
+ description: "nuxtseo-layer-devtools shared devtools layer for Nuxt SEO modules. ALWAYS use when building, modifying, or reviewing devtools client code in any Nuxt SEO module. Consult for component API, composables, implementation patterns, or debugging devtools clients."
4
+ ---
5
+
6
+ # nuxtseo-layer-devtools
7
+
8
+ Shared Nuxt layer providing components, composables, and a design system for all Nuxt SEO module devtools clients.
9
+
10
+ **Source:** `packages/devtools-layer/` (published as `nuxtseo-layer-devtools`)
11
+
12
+ ## Architecture
13
+
14
+ 1. **nuxtseo-shared/devtools** (`packages/shared/src/devtools.ts`): `setupDevToolsUI()` registers iframe tab, handles dev proxy + production sirv
15
+ 2. **nuxtseo-layer-devtools** (`packages/devtools-layer/`): Nuxt layer with shared UI, composables, CSS
16
+ 3. **Module client** (`<module>/client/`): Extends the layer, adds module specific UI and debug routes
17
+
18
+ ## Rules
19
+
20
+ 1. ALWAYS extend `nuxtseo-layer-devtools` in client/nuxt.config.ts
21
+ 2. ALWAYS create `client/composables/rpc.ts` that calls `useDevtoolsConnection()`
22
+ 3. ALWAYS use layer components over custom HTML: `DevtoolsSection` not custom details, `DevtoolsKeyValue` not custom tables, `DevtoolsSnippet` not custom code blocks
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
30
+
31
+ ## Required File Structure
32
+
33
+ ```
34
+ client/
35
+ ├── nuxt.config.ts # extends nuxtseo-layer-devtools
36
+ ├── app.vue # OR pages/ directory
37
+ ├── composables/
38
+ │ └── rpc.ts # connection setup (REQUIRED)
39
+ src/
40
+ ├── devtools.ts # calls setupDevToolsUI from nuxtseo-shared/devtools
41
+ ├── module.ts # calls setupDevToolsUI in dev, registers debug routes
42
+ └── runtime/server/routes/__<module>__/
43
+ └── debug.ts # JSON debug endpoint
44
+ ```
45
+
46
+ ## Implementation Templates
47
+
48
+ For full component/composable API reference, read [reference.md](./reference.md).
49
+
50
+ ### client/nuxt.config.ts
51
+
52
+ ```ts
53
+ export default defineNuxtConfig({
54
+ extends: ['nuxtseo-layer-devtools'],
55
+ <moduleName>: false,
56
+ app: { baseURL: '/__<module-route>' },
57
+ nitro: { output: { publicDir: resolve('./dist/client') } },
58
+ })
59
+ ```
60
+
61
+ ### client/composables/rpc.ts
62
+
63
+ ```ts
64
+ useDevtoolsConnection({
65
+ onConnected() { refreshSources() },
66
+ onRouteChange() { refreshSources() },
67
+ })
68
+ ```
69
+
70
+ ### src/devtools.ts
71
+
72
+ ```ts
73
+ import { setupDevToolsUI as _setup } from 'nuxtseo-shared/devtools'
74
+
75
+ export function setupDevToolsUI(config: any, resolve: Resolver['resolve'], nuxt?: Nuxt) {
76
+ return _setup({ route: '/__<route>', name: '<name>', title: '<Title>', icon: 'carbon:<icon>' }, resolve, nuxt)
77
+ }
78
+ ```
79
+
80
+ ### src/module.ts
81
+
82
+ ```ts
83
+ if (nuxt.options.dev) {
84
+ addServerHandler({ route: '/__<module>__/debug', handler: resolve('./runtime/server/routes/__<module>__/debug') })
85
+ setupDevToolsUI(config, resolve)
86
+ }
87
+ ```
88
+
89
+ ### app.vue pattern
90
+
91
+ ```vue
92
+ <script setup lang="ts">
93
+ const activeTab = ref('overview')
94
+ const loading = ref(true)
95
+ const { data } = await useAsyncData('debug', () => $fetch('/__<module>__/debug.json'), { watch: [refreshTime] })
96
+ watch(data, () => { loading.value = false })
97
+ </script>
98
+
99
+ <template>
100
+ <DevtoolsLayout v-model:active-tab="activeTab" title="Name" icon="carbon:icon" :nav-items="navItems" github-url="..." :loading @refresh="refreshSources">
101
+ <DevtoolsLoading v-if="loading" />
102
+ <template v-else-if="activeTab === 'overview'">
103
+ <!-- content -->
104
+ </template>
105
+ </DevtoolsLayout>
106
+ </template>
107
+ ```
@@ -0,0 +1,148 @@
1
+ # Devtools Layer API Reference
2
+
3
+ ## Components (auto imported)
4
+
5
+ ### Layout & Structure
6
+
7
+ | Component | Props | Key Slots | Purpose |
8
+ |---|---|---|---|
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 |
11
+ | `DevtoolsToolbar` | `variant` ('default'\|'minimal') | default | Horizontal toolbar strip |
12
+ | `DevtoolsSection` | `icon?`, `text?`, `description?`, `collapse?`, `open?`, `padding?` | `text`, `description`, `actions`, `details`, default, `footer` | Collapsible details/summary block |
13
+
14
+ ### Feedback & States
15
+
16
+ | Component | Props | Key Slots | Purpose |
17
+ |---|---|---|---|
18
+ | `DevtoolsAlert` | `icon?`, `variant` ('info'\|'warning'\|'success'\|'production') | default, `action` | Color coded status bar |
19
+ | `DevtoolsError` | `icon?`, `title?`, `error?` | default | Centered error display |
20
+ | `DevtoolsEmptyState` | `icon?`, `title`, `description?`, `variant` ('default'\|'error') | default, `description` | No results placeholder |
21
+ | `DevtoolsProductionError` | `error?` | none | Production URL unreachable |
22
+ | `DevtoolsLoading` | none | none | Centered spinner |
23
+
24
+ ### Data Display
25
+
26
+ | Component | Props | Key Slots | Purpose |
27
+ |---|---|---|---|
28
+ | `DevtoolsKeyValue` | `items: KeyValueItem[]`, `striped?` | none | Key value table with copy, links, booleans |
29
+ | `DevtoolsMetric` | `label?`, `value`, `icon?`, `variant` | none | Small inline metric badge |
30
+ | `DevtoolsCopyButton` | `text` | none | Copy to clipboard button |
31
+ | `DevtoolsSnippet` | `label?`, `code`, `lang` ('js'\|'json'\|'xml') | `header` | Code block with header and copy |
32
+ | `OCodeBlock` | `code`, `lang`, `lines?`, `transformRendered?` | none | Shiki syntax highlighted pre |
33
+ | `DevtoolsDocs` | `url` | none | Full height iframe |
34
+
35
+ ### KeyValueItem Interface
36
+
37
+ ```ts
38
+ interface KeyValueItem {
39
+ key: string
40
+ value: any
41
+ copyable?: boolean
42
+ mono?: boolean
43
+ link?: string
44
+ }
45
+ ```
46
+
47
+ ## Composables (auto imported)
48
+
49
+ ### Connection (`composables/rpc.ts`)
50
+
51
+ ```ts
52
+ const appFetch: Ref<$Fetch | undefined> // Host app's $fetch
53
+ const devtools: Ref<NuxtDevtoolsClient> // Devtools client instance
54
+ const colorMode: Ref<'dark' | 'light'> // Synced from host
55
+
56
+ function useDevtoolsConnection(options?: {
57
+ onConnected?: (client: any) => void
58
+ onRouteChange?: (route: any) => void
59
+ }): void
60
+ ```
61
+
62
+ ### State (`composables/state.ts`)
63
+
64
+ ```ts
65
+ const refreshTime: Ref<number> // Watch to trigger re-fetch
66
+ const hostname: string
67
+ const path: Ref<string>
68
+ const query: Ref<any>
69
+ const base: Ref<string>
70
+ const host: ComputedRef<string>
71
+ const refreshSources: Function // Debounced 200ms
72
+ const slowRefreshSources: Function // Debounced 1000ms
73
+
74
+ // Production preview mode
75
+ const previewSource: Ref<'local' | 'production'> // localStorage persisted
76
+ const productionUrl: Ref<string>
77
+ const hasProductionUrl: ComputedRef<boolean>
78
+ const isProductionMode: ComputedRef<boolean>
79
+ ```
80
+
81
+ ### Shiki (`composables/shiki.ts`)
82
+
83
+ ```ts
84
+ async function loadShiki(options?: { extraLangs?: LanguageRegistration[] }): Promise<HighlighterCore>
85
+ function useRenderCodeHighlight(code: MaybeRef<string>, lang: string): ComputedRef<string>
86
+ ```
87
+
88
+ ### Clipboard (`composables/clipboard.ts`)
89
+
90
+ ```ts
91
+ function useCopy(timeout?: number): { copy: (text: string) => Promise<void>, copied: Ref<boolean> }
92
+ ```
93
+
94
+ ## CSS Design System
95
+
96
+ Do NOT add custom CSS unless layer components are insufficient.
97
+
98
+ ### Semantic Variables
99
+
100
+ `--color-surface`, `--color-surface-elevated`, `--color-surface-sunken`, `--color-border`, `--color-border-subtle`, `--color-text`, `--color-text-muted`, `--color-text-subtle`, `--seo-green`, `--radius-sm/md/lg`
101
+
102
+ ### Utility Classes
103
+
104
+ `.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`
105
+
106
+ ### Nuxt UI Defaults (app.config.ts)
107
+
108
+ Primary: green, Neutral: neutral. Buttons: ghost/neutral/sm. Badges: subtle/neutral/xs. Tooltips: zero delay.
109
+
110
+ ## Common Patterns
111
+
112
+ ### Production Mode Data Fetching
113
+
114
+ ```ts
115
+ async function refreshSources() {
116
+ if (isProductionMode.value)
117
+ data.value = await $fetch(`/__<module>__/debug-production.json`, { query: { url: productionUrl.value } })
118
+ else
119
+ data.value = await $fetch('/__<module>__/debug.json', { query: { path: path.value } })
120
+ }
121
+ watch(isProductionMode, refreshSources)
122
+ ```
123
+
124
+ ### Custom Shiki Language
125
+
126
+ ```ts
127
+ import { loadShiki as _loadShiki } from '#imports'
128
+
129
+ const customLang: LanguageRegistration = { name: 'my-lang', scopeName: 'source.my-lang', patterns: [] }
130
+ export function loadShiki() { return _loadShiki({ extraLangs: [customLang] }) }
131
+ ```
132
+
133
+ ### Fallback Connection
134
+
135
+ ```ts
136
+ const connectionState = ref<'connecting' | 'connected' | 'fallback' | 'failed'>('connecting')
137
+ useDevtoolsConnection({
138
+ onConnected() { connectionState.value = 'connected'; refreshSources() },
139
+ onRouteChange(route) { path.value = route.path; refreshSources() },
140
+ })
141
+ setTimeout(() => {
142
+ if (connectionState.value === 'connecting') {
143
+ connectionState.value = 'fallback'
144
+ appFetch.value = $fetch.create({ baseURL: 'http://localhost:3000' })
145
+ refreshSources()
146
+ }
147
+ }, 2000)
148
+ ```