nuxtseo-layer-devtools 0.4.5 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,8 @@
1
1
  <script setup lang="ts">
2
- import { onClickOutside } from '@vueuse/core'
3
- import { computed, ref } from 'vue'
2
+ import type { TabsItem } from '@nuxt/ui'
3
+ import { onClickOutside, useClipboard } from '@vueuse/core'
4
+ import { computed, ref, watch } from 'vue'
5
+ import { useSetupChecklist } from '../composables/checklist'
4
6
  import { moduleCatalog, showModuleSplash, switchToModule } from '../composables/modules'
5
7
  import { isConnected } from '../composables/state'
6
8
 
@@ -16,8 +18,69 @@ onClickOutside(panelRef, () => {
16
18
  const coreModules = computed(() => moduleCatalog.value.filter(m => !m.pro))
17
19
  const proModules = computed(() => moduleCatalog.value.filter(m => m.pro))
18
20
 
21
+ const selectedForInstall = ref(new Set<string>())
22
+
23
+ const installCommand = computed(() => {
24
+ if (!selectedForInstall.value.size)
25
+ return ''
26
+ const names = Array.from(selectedForInstall.value, name => moduleCatalog.value.find(m => m.name === name)?.npm).filter(Boolean)
27
+ return `npx nuxt module add ${names.join(' ')}`
28
+ })
29
+
30
+ const { copy, copied } = useClipboard({ source: installCommand })
31
+
32
+ const { summary, evaluated, evaluate, getModuleResultByName } = useSetupChecklist()
33
+
34
+ // Evaluate checklist on first splash open
35
+ const hasEvaluated = ref(false)
36
+ watch(showModuleSplash, (open) => {
37
+ if (open && !hasEvaluated.value) {
38
+ hasEvaluated.value = true
39
+ evaluate()
40
+ }
41
+ })
42
+
43
+ const activeTab = ref('modules')
44
+
45
+ const healthBadge = computed(() => {
46
+ if (!evaluated.value || summary.value.total === 0)
47
+ return undefined
48
+ if (summary.value.requiredPending > 0)
49
+ return { label: `${summary.value.requiredPending}`, color: 'error' as const, variant: 'subtle' as const }
50
+ if (summary.value.recommendedPending > 0)
51
+ return { label: `${summary.value.recommendedPending}`, color: 'warning' as const, variant: 'subtle' as const }
52
+ return { label: '', icon: 'i-carbon-checkmark', color: 'success' as const, variant: 'subtle' as const }
53
+ })
54
+
55
+ const tabs = computed<TabsItem[]>(() => {
56
+ const items: TabsItem[] = [
57
+ { label: 'Modules', value: 'modules', icon: 'i-carbon-grid' },
58
+ ]
59
+ if (proModules.value.length) {
60
+ items.push({ label: 'Pro', value: 'pro', icon: 'i-carbon-locked' })
61
+ }
62
+ if (evaluated.value && summary.value.total > 0) {
63
+ items.push({
64
+ label: 'Setup',
65
+ value: 'setup',
66
+ icon: 'i-carbon-task-complete',
67
+ badge: healthBadge.value,
68
+ })
69
+ }
70
+ return items
71
+ })
72
+
19
73
  function handleModuleClick(mod: typeof moduleCatalog.value[0]) {
20
- if (!mod.installed || mod.name === props.currentModule)
74
+ if (!mod.installed) {
75
+ const set = new Set(selectedForInstall.value)
76
+ if (set.has(mod.name))
77
+ set.delete(mod.name)
78
+ else
79
+ set.add(mod.name)
80
+ selectedForInstall.value = set
81
+ return
82
+ }
83
+ if (mod.name === props.currentModule)
21
84
  return
22
85
  switchToModule(mod.name)
23
86
  }
@@ -36,12 +99,21 @@ function handleModuleClick(mod: typeof moduleCatalog.value[0]) {
36
99
  </button>
37
100
  </div>
38
101
 
39
- <!-- Modules Section -->
40
- <div class="splash-section">
41
- <div class="splash-section-label">
42
- <span class="splash-section-dot" />
43
- Modules
44
- </div>
102
+ <!-- Tabs -->
103
+ <UTabs
104
+ v-model="activeTab"
105
+ variant="link"
106
+ size="sm"
107
+ :content="false"
108
+ :items="tabs"
109
+ :ui="{
110
+ root: 'splash-tabs',
111
+ trigger: 'splash-tab-trigger',
112
+ }"
113
+ />
114
+
115
+ <!-- Modules tab -->
116
+ <div v-show="activeTab === 'modules'" class="splash-tab-content">
45
117
  <div class="splash-grid">
46
118
  <button
47
119
  v-for="mod of coreModules"
@@ -50,28 +122,43 @@ function handleModuleClick(mod: typeof moduleCatalog.value[0]) {
50
122
  class="splash-module"
51
123
  :class="{
52
124
  'is-current': mod.name === currentModule,
53
- 'is-unavailable': !mod.installed,
125
+ 'is-unavailable': !mod.installed && !selectedForInstall.has(mod.name) && mod.name !== currentModule,
126
+ 'is-selected-install': selectedForInstall.has(mod.name),
54
127
  'is-switchable': mod.installed && mod.name !== currentModule && isConnected,
55
128
  }"
56
- :disabled="!mod.installed || mod.name === currentModule"
129
+ :disabled="mod.name === currentModule"
57
130
  @click="handleModuleClick(mod)"
58
131
  >
59
132
  <div class="splash-module-icon">
60
133
  <UIcon :name="mod.icon" class="text-base" />
61
134
  </div>
62
135
  <span class="splash-module-title">{{ mod.title }}</span>
63
- <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>
136
+ <DevtoolsChecklistBadge
137
+ v-if="mod.installed && evaluated && getModuleResultByName(mod.name)"
138
+ :required-pending="getModuleResultByName(mod.name)!.requiredPending"
139
+ :recommended-pending="getModuleResultByName(mod.name)!.recommendedPending"
140
+ />
141
+ <span v-else-if="mod.name === currentModule" class="splash-current-badge">Current</span>
142
+ <span v-else-if="!mod.installed" class="splash-not-installed">{{ selectedForInstall.has(mod.name) ? 'Selected' : 'Not installed' }}</span>
65
143
  </button>
66
144
  </div>
145
+
146
+ <!-- Install command -->
147
+ <Transition name="install-bar">
148
+ <div v-if="installCommand" class="splash-install">
149
+ <div class="splash-install-code">
150
+ <code>{{ installCommand }}</code>
151
+ </div>
152
+ <button type="button" class="splash-install-copy" @click="copy()">
153
+ <UIcon :name="copied ? 'carbon:checkmark' : 'carbon:copy'" class="w-3.5 h-3.5" />
154
+ {{ copied ? 'Copied' : 'Copy' }}
155
+ </button>
156
+ </div>
157
+ </Transition>
67
158
  </div>
68
159
 
69
- <!-- Pro Section -->
70
- <div v-if="proModules.length" class="splash-section splash-pro-area">
71
- <div class="splash-section-label">
72
- <span class="splash-section-dot splash-section-dot--pro" />
73
- Pro
74
- </div>
160
+ <!-- Pro tab -->
161
+ <div v-show="activeTab === 'pro'" class="splash-tab-content">
75
162
  <div class="splash-grid">
76
163
  <button
77
164
  v-for="mod of proModules"
@@ -95,7 +182,6 @@ function handleModuleClick(mod: typeof moduleCatalog.value[0]) {
95
182
  </UBadge>
96
183
  </button>
97
184
  </div>
98
- <!-- Pro status / CTA -->
99
185
  <div class="splash-pro-status">
100
186
  <div class="splash-pro-status-inner">
101
187
  <UIcon name="carbon:locked" class="w-3.5 h-3.5 opacity-50" />
@@ -108,6 +194,21 @@ function handleModuleClick(mod: typeof moduleCatalog.value[0]) {
108
194
  </div>
109
195
  </div>
110
196
 
197
+ <!-- Setup tab -->
198
+ <div v-show="activeTab === 'setup'" class="splash-tab-content">
199
+ <div v-if="evaluated && summary.total > 0" class="splash-health-header">
200
+ <span class="splash-health-bar">
201
+ <span
202
+ class="splash-health-bar-fill"
203
+ :class="summary.requiredPending > 0 ? 'is-danger' : summary.recommendedPending > 0 ? 'is-warning' : 'is-complete'"
204
+ :style="{ width: `${summary.total > 0 ? (summary.passed / summary.total) * 100 : 0}%` }"
205
+ />
206
+ </span>
207
+ <span class="splash-health-count">{{ summary.passed }}/{{ summary.total }} complete</span>
208
+ </div>
209
+ <DevtoolsSetupChecklist :current-module="currentModule" />
210
+ </div>
211
+
111
212
  <!-- Footer -->
112
213
  <div class="splash-footer">
113
214
  <a href="https://nuxtseo.com" target="_blank" rel="noopener" class="splash-link">
@@ -135,11 +236,12 @@ function handleModuleClick(mod: typeof moduleCatalog.value[0]) {
135
236
 
136
237
  .splash-panel {
137
238
  width: min(480px, calc(100vw - 2rem));
239
+ max-height: calc(100vh - 4rem);
240
+ overflow-y: auto;
138
241
  border-radius: var(--radius-lg);
139
242
  border: 1px solid var(--color-border);
140
243
  background: var(--color-surface);
141
244
  box-shadow: 0 24px 48px oklch(0% 0 0 / 0.2);
142
- overflow: hidden;
143
245
  }
144
246
 
145
247
  .dark .splash-panel {
@@ -172,36 +274,19 @@ function handleModuleClick(mod: typeof moduleCatalog.value[0]) {
172
274
  color: var(--color-text);
173
275
  }
174
276
 
175
- /* Sections */
176
- .splash-section {
177
- padding: 0.5rem 0.75rem 0.625rem;
178
- }
179
-
180
- .splash-section + .splash-section {
181
- border-top: 1px solid var(--color-border);
182
- }
183
-
184
- .splash-section-label {
185
- display: flex;
186
- align-items: center;
187
- gap: 0.375rem;
188
- font-size: 0.625rem;
189
- font-weight: 600;
190
- text-transform: uppercase;
191
- letter-spacing: 0.05em;
192
- color: var(--color-text-muted);
193
- padding: 0.25rem 0.375rem 0.375rem;
277
+ /* Tabs */
278
+ .splash-tabs {
279
+ padding: 0 0.75rem;
280
+ border-bottom: 1px solid var(--color-border);
194
281
  }
195
282
 
196
- .splash-section-dot {
197
- width: 0.3125rem;
198
- height: 0.3125rem;
199
- border-radius: 50%;
200
- background: var(--color-text-subtle);
283
+ .splash-tab-trigger {
284
+ font-size: 0.6875rem !important;
201
285
  }
202
286
 
203
- .splash-section-dot--pro {
204
- background: var(--seo-green);
287
+ /* Tab content */
288
+ .splash-tab-content {
289
+ padding: 0.5rem 0.75rem 0.625rem;
205
290
  }
206
291
 
207
292
  /* Grid */
@@ -241,7 +326,29 @@ function handleModuleClick(mod: typeof moduleCatalog.value[0]) {
241
326
  }
242
327
 
243
328
  .splash-module.is-unavailable {
244
- opacity: 0.5;
329
+ opacity: 0.4;
330
+ cursor: pointer;
331
+ }
332
+
333
+ .splash-module.is-unavailable:hover {
334
+ opacity: 0.7;
335
+ background: var(--color-surface-elevated);
336
+ }
337
+
338
+ .splash-module.is-selected-install {
339
+ opacity: 1;
340
+ background: oklch(from var(--seo-green) l c h / 0.08);
341
+ cursor: pointer;
342
+ }
343
+
344
+ .splash-module.is-selected-install .splash-module-icon {
345
+ background: oklch(from var(--seo-green) l c h / 0.15);
346
+ color: var(--seo-green);
347
+ }
348
+
349
+ .splash-module.is-selected-install .splash-not-installed {
350
+ color: var(--seo-green);
351
+ font-weight: 600;
245
352
  }
246
353
 
247
354
  .splash-module-icon {
@@ -288,6 +395,8 @@ function handleModuleClick(mod: typeof moduleCatalog.value[0]) {
288
395
  .splash-pro-badge {
289
396
  font-size: 9px !important;
290
397
  flex-shrink: 0;
398
+ color: oklch(65% 0.25 290) !important;
399
+ border-color: oklch(65% 0.25 290 / 0.3) !important;
291
400
  }
292
401
 
293
402
  /* Pro status / CTA */
@@ -295,10 +404,11 @@ function handleModuleClick(mod: typeof moduleCatalog.value[0]) {
295
404
  display: flex;
296
405
  align-items: center;
297
406
  justify-content: space-between;
298
- margin: 0.375rem 0.25rem 0;
407
+ margin: 0.375rem 0 0;
299
408
  padding: 0.4rem 0.625rem;
300
409
  border-radius: var(--radius-md);
301
- background: var(--color-surface-sunken);
410
+ background: oklch(65% 0.25 290 / 0.06);
411
+ border: 1px solid oklch(65% 0.25 290 / 0.12);
302
412
  font-size: 0.6875rem;
303
413
  color: var(--color-text-muted);
304
414
  }
@@ -315,7 +425,7 @@ function handleModuleClick(mod: typeof moduleCatalog.value[0]) {
315
425
  gap: 0.25rem;
316
426
  font-size: 0.6875rem;
317
427
  font-weight: 500;
318
- color: var(--seo-green);
428
+ color: oklch(65% 0.25 290);
319
429
  text-decoration: none;
320
430
  transition: opacity 100ms;
321
431
  }
@@ -324,6 +434,115 @@ function handleModuleClick(mod: typeof moduleCatalog.value[0]) {
324
434
  opacity: 0.8;
325
435
  }
326
436
 
437
+ /* Setup Health header */
438
+ .splash-health-header {
439
+ display: flex;
440
+ align-items: center;
441
+ gap: 0.5rem;
442
+ margin-bottom: 0.5rem;
443
+ }
444
+
445
+ .splash-health-bar {
446
+ flex: 1;
447
+ height: 0.25rem;
448
+ border-radius: 2px;
449
+ background: var(--color-surface-sunken);
450
+ overflow: hidden;
451
+ }
452
+
453
+ .splash-health-bar-fill {
454
+ display: block;
455
+ height: 100%;
456
+ border-radius: 2px;
457
+ transition: width 400ms cubic-bezier(0.22, 1, 0.36, 1);
458
+ }
459
+
460
+ .splash-health-bar-fill.is-complete {
461
+ background: oklch(55% 0.15 145);
462
+ }
463
+
464
+ .dark .splash-health-bar-fill.is-complete {
465
+ background: oklch(65% 0.18 145);
466
+ }
467
+
468
+ .splash-health-bar-fill.is-warning {
469
+ background: oklch(65% 0.18 85);
470
+ }
471
+
472
+ .splash-health-bar-fill.is-danger {
473
+ background: oklch(60% 0.2 25);
474
+ }
475
+
476
+ .splash-health-count {
477
+ font-size: 0.5625rem;
478
+ font-family: var(--font-mono, monospace);
479
+ font-weight: 600;
480
+ color: var(--color-text-subtle);
481
+ font-variant-numeric: tabular-nums;
482
+ white-space: nowrap;
483
+ }
484
+
485
+ /* Install bar */
486
+ .splash-install {
487
+ display: flex;
488
+ align-items: center;
489
+ gap: 0.5rem;
490
+ margin-top: 0.5rem;
491
+ padding: 0.5rem 0.625rem;
492
+ border-radius: var(--radius-md);
493
+ background: var(--color-surface-sunken);
494
+ border: 1px solid oklch(from var(--seo-green) l c h / 0.2);
495
+ }
496
+
497
+ .splash-install-code {
498
+ flex: 1;
499
+ min-width: 0;
500
+ overflow-x: auto;
501
+ }
502
+
503
+ .splash-install-code code {
504
+ font-size: 0.6875rem;
505
+ font-family: var(--font-mono, monospace);
506
+ color: var(--color-text);
507
+ white-space: nowrap;
508
+ }
509
+
510
+ .splash-install-copy {
511
+ display: flex;
512
+ align-items: center;
513
+ gap: 0.25rem;
514
+ flex-shrink: 0;
515
+ padding: 0.25rem 0.5rem;
516
+ border-radius: var(--radius-sm);
517
+ font-size: 0.625rem;
518
+ font-weight: 600;
519
+ color: var(--seo-green);
520
+ background: oklch(from var(--seo-green) l c h / 0.1);
521
+ cursor: pointer;
522
+ transition: background 100ms;
523
+ }
524
+
525
+ .splash-install-copy:hover {
526
+ background: oklch(from var(--seo-green) l c h / 0.18);
527
+ }
528
+
529
+ .install-bar-enter-active,
530
+ .install-bar-leave-active {
531
+ transition: opacity 150ms ease, max-height 150ms ease;
532
+ overflow: hidden;
533
+ }
534
+
535
+ .install-bar-enter-from,
536
+ .install-bar-leave-to {
537
+ opacity: 0;
538
+ max-height: 0;
539
+ }
540
+
541
+ .install-bar-enter-to,
542
+ .install-bar-leave-from {
543
+ max-height: 4rem;
544
+ }
545
+
327
546
  /* Footer */
328
547
  .splash-footer {
329
548
  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,60 @@
1
+ <script setup lang="ts">
2
+ import { useSetupChecklist } from '../composables/checklist'
3
+
4
+ const { currentModule } = defineProps<{
5
+ currentModule?: string
6
+ }>()
7
+
8
+ const { results, loading, evaluated, evaluate } = useSetupChecklist()
9
+
10
+ // Evaluate on mount if not already done
11
+ if (!evaluated.value)
12
+ evaluate()
13
+ </script>
14
+
15
+ <template>
16
+ <div class="setup-checklist">
17
+ <!-- Loading state -->
18
+ <DevtoolsLoading v-if="loading" />
19
+
20
+ <!-- Per-module sections -->
21
+ <template v-else-if="evaluated">
22
+ <DevtoolsSection
23
+ v-for="result of results"
24
+ :key="result.moduleSlug"
25
+ :icon="result.moduleIcon"
26
+ :text="result.moduleLabel"
27
+ :open="result.requiredPending > 0 || result.moduleSlug === currentModule"
28
+ :padding="false"
29
+ >
30
+ <template #actions>
31
+ <DevtoolsChecklistBadge
32
+ :required-pending="result.requiredPending"
33
+ :recommended-pending="result.recommendedPending"
34
+ />
35
+ </template>
36
+ <div class="setup-checklist-items">
37
+ <DevtoolsChecklistItem
38
+ v-for="item of result.items"
39
+ :key="item.id"
40
+ :item="item"
41
+ />
42
+ </div>
43
+ </DevtoolsSection>
44
+ </template>
45
+ </div>
46
+ </template>
47
+
48
+ <style scoped>
49
+ .setup-checklist {
50
+ display: flex;
51
+ flex-direction: column;
52
+ gap: 0.375rem;
53
+ }
54
+
55
+ .setup-checklist-items {
56
+ display: flex;
57
+ flex-direction: column;
58
+ padding: 0.125rem 0.25rem;
59
+ }
60
+ </style>