nuxtseo-layer-devtools 0.5.0 → 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.
@@ -0,0 +1,65 @@
1
+ <script setup lang="ts">
2
+ const { requiredPending = 0, recommendedPending = 0 } = defineProps<{
3
+ requiredPending?: number
4
+ recommendedPending?: number
5
+ }>()
6
+ </script>
7
+
8
+ <template>
9
+ <span
10
+ v-if="requiredPending > 0 || recommendedPending > 0"
11
+ class="checklist-badge"
12
+ :class="requiredPending > 0 ? 'checklist-badge--required' : 'checklist-badge--recommended'"
13
+ >
14
+ {{ requiredPending + recommendedPending }}
15
+ </span>
16
+ <span v-else class="checklist-badge checklist-badge--complete">
17
+ <UIcon name="carbon:checkmark" class="w-2.5 h-2.5" />
18
+ </span>
19
+ </template>
20
+
21
+ <style scoped>
22
+ .checklist-badge {
23
+ display: inline-flex;
24
+ align-items: center;
25
+ justify-content: center;
26
+ min-width: 1rem;
27
+ height: 1rem;
28
+ padding: 0 0.25rem;
29
+ border-radius: var(--radius-full, 9999px);
30
+ font-size: 0.5625rem;
31
+ font-weight: 700;
32
+ line-height: 1;
33
+ flex-shrink: 0;
34
+ }
35
+
36
+ .checklist-badge--required {
37
+ background: oklch(65% 0.18 25 / 0.15);
38
+ color: oklch(55% 0.18 25);
39
+ }
40
+
41
+ .dark .checklist-badge--required {
42
+ background: oklch(45% 0.14 25 / 0.2);
43
+ color: oklch(72% 0.14 25);
44
+ }
45
+
46
+ .checklist-badge--recommended {
47
+ background: oklch(80% 0.12 85 / 0.15);
48
+ color: oklch(50% 0.15 85);
49
+ }
50
+
51
+ .dark .checklist-badge--recommended {
52
+ background: oklch(50% 0.12 85 / 0.2);
53
+ color: oklch(75% 0.12 85);
54
+ }
55
+
56
+ .checklist-badge--complete {
57
+ background: oklch(75% 0.15 145 / 0.12);
58
+ color: oklch(50% 0.15 145);
59
+ }
60
+
61
+ .dark .checklist-badge--complete {
62
+ background: oklch(50% 0.15 145 / 0.15);
63
+ color: oklch(75% 0.18 145);
64
+ }
65
+ </style>
@@ -0,0 +1,171 @@
1
+ <script setup lang="ts">
2
+ import type { ChecklistItemResult } from '../composables/checklist'
3
+
4
+ const { item } = defineProps<{
5
+ item: ChecklistItemResult
6
+ }>()
7
+ </script>
8
+
9
+ <template>
10
+ <div class="checklist-item" :class="item.passed ? 'is-passed' : `is-pending-${item.level}`">
11
+ <div class="checklist-item-status">
12
+ <UIcon
13
+ :name="item.passed ? 'carbon:checkmark-filled' : item.level === 'required' ? 'carbon:warning-alt-filled' : 'carbon:circle-dash'"
14
+ class="checklist-item-icon"
15
+ />
16
+ </div>
17
+ <div class="checklist-item-content">
18
+ <div class="checklist-item-header">
19
+ <span class="checklist-item-label">{{ item.label }}</span>
20
+ <UBadge
21
+ v-if="!item.passed"
22
+ size="xs"
23
+ :color="item.level === 'required' ? 'error' : 'warning'"
24
+ variant="subtle"
25
+ class="checklist-item-level"
26
+ >
27
+ {{ item.level === 'required' ? 'Required' : 'Tip' }}
28
+ </UBadge>
29
+ </div>
30
+ <div class="checklist-item-description">
31
+ {{ item.description }}
32
+ </div>
33
+ <div v-if="item.detail" class="checklist-item-detail">
34
+ {{ item.detail }}
35
+ </div>
36
+ </div>
37
+ <a
38
+ :href="item.docsUrl"
39
+ target="_blank"
40
+ rel="noopener"
41
+ class="checklist-item-docs"
42
+ :class="item.passed ? 'is-subtle' : ''"
43
+ >
44
+ <UIcon name="carbon:arrow-up-right" class="w-3 h-3" />
45
+ </a>
46
+ </div>
47
+ </template>
48
+
49
+ <style scoped>
50
+ .checklist-item {
51
+ display: flex;
52
+ align-items: flex-start;
53
+ gap: 0.5rem;
54
+ padding: 0.4375rem 0.5rem;
55
+ border-radius: var(--radius-md);
56
+ transition: background 100ms;
57
+ }
58
+
59
+ .checklist-item:hover {
60
+ background: var(--color-surface-elevated);
61
+ }
62
+
63
+ .checklist-item.is-passed {
64
+ opacity: 0.55;
65
+ }
66
+
67
+ .checklist-item.is-passed:hover {
68
+ opacity: 0.8;
69
+ }
70
+
71
+ .checklist-item-status {
72
+ flex-shrink: 0;
73
+ padding-top: 0.125rem;
74
+ }
75
+
76
+ .checklist-item-icon {
77
+ font-size: 0.8125rem;
78
+ }
79
+
80
+ .checklist-item.is-passed .checklist-item-icon {
81
+ color: oklch(50% 0.15 145);
82
+ }
83
+
84
+ .dark .checklist-item.is-passed .checklist-item-icon {
85
+ color: oklch(70% 0.18 145);
86
+ }
87
+
88
+ .checklist-item.is-pending-required .checklist-item-icon {
89
+ color: oklch(55% 0.2 25);
90
+ }
91
+
92
+ .dark .checklist-item.is-pending-required .checklist-item-icon {
93
+ color: oklch(70% 0.16 25);
94
+ }
95
+
96
+ .checklist-item.is-pending-recommended .checklist-item-icon {
97
+ color: oklch(52% 0.15 85);
98
+ }
99
+
100
+ .dark .checklist-item.is-pending-recommended .checklist-item-icon {
101
+ color: oklch(72% 0.12 85);
102
+ }
103
+
104
+ .checklist-item-content {
105
+ flex: 1;
106
+ min-width: 0;
107
+ }
108
+
109
+ .checklist-item-header {
110
+ display: flex;
111
+ align-items: center;
112
+ gap: 0.375rem;
113
+ }
114
+
115
+ .checklist-item-label {
116
+ font-size: 0.6875rem;
117
+ font-weight: 600;
118
+ color: var(--color-text);
119
+ }
120
+
121
+ .checklist-item-level {
122
+ font-size: 0.5rem !important;
123
+ padding: 0 0.25rem !important;
124
+ line-height: 1.4 !important;
125
+ }
126
+
127
+ .checklist-item-description {
128
+ font-size: 0.625rem;
129
+ color: var(--color-text-muted);
130
+ margin-top: 0.0625rem;
131
+ line-height: 1.35;
132
+ }
133
+
134
+ .checklist-item-detail {
135
+ display: inline-block;
136
+ font-size: 0.5625rem;
137
+ font-family: var(--font-mono, monospace);
138
+ color: var(--color-text-subtle);
139
+ margin-top: 0.1875rem;
140
+ padding: 0.0625rem 0.3125rem;
141
+ background: var(--color-surface-sunken);
142
+ border-radius: var(--radius-sm);
143
+ }
144
+
145
+ .checklist-item-docs {
146
+ display: flex;
147
+ align-items: center;
148
+ justify-content: center;
149
+ flex-shrink: 0;
150
+ width: 1.25rem;
151
+ height: 1.25rem;
152
+ margin-top: 0.0625rem;
153
+ border-radius: var(--radius-sm);
154
+ color: var(--color-text-muted);
155
+ text-decoration: none;
156
+ transition: color 100ms, background 100ms;
157
+ }
158
+
159
+ .checklist-item-docs:hover {
160
+ color: var(--seo-green);
161
+ background: oklch(from var(--seo-green) l c h / 0.08);
162
+ }
163
+
164
+ .checklist-item-docs.is-subtle {
165
+ opacity: 0;
166
+ }
167
+
168
+ .checklist-item:hover .checklist-item-docs.is-subtle {
169
+ opacity: 1;
170
+ }
171
+ </style>
@@ -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 { useSetupChecklist } from '../composables/checklist'
4
5
  import { fetchInstalledModules, findModuleByName, showModuleSplash } from '../composables/modules'
5
6
  import { colorMode } from '../composables/rpc'
6
7
  import { hasProductionUrl, isConnected, isProductionMode, isStandalone, path, previewSource, productionUrl, standaloneUrl } from '../composables/state'
@@ -93,6 +94,13 @@ const standaloneHostname = computed(() => {
93
94
 
94
95
  const showStandaloneSetup = computed(() => !isConnected.value && !isStandalone.value)
95
96
 
97
+ const { evaluated, getModuleResultByName } = useSetupChecklist()
98
+ const moduleChecklistResult = computed(() => {
99
+ if (!evaluated.value || !moduleName)
100
+ return undefined
101
+ return getModuleResultByName(moduleName)
102
+ })
103
+
96
104
  function disconnectStandalone() {
97
105
  standaloneUrl.value = ''
98
106
  }
@@ -130,6 +138,11 @@ function disconnectStandalone() {
130
138
  <span class="text-sm sm:text-base font-semibold tracking-tight text-[var(--color-text)]">
131
139
  {{ title }}
132
140
  </span>
141
+ <DevtoolsChecklistBadge
142
+ v-if="moduleChecklistResult?.totalPending"
143
+ :required-pending="moduleChecklistResult.requiredPending"
144
+ :recommended-pending="moduleChecklistResult.recommendedPending"
145
+ />
133
146
  <UIcon name="carbon:chevron-down" class="w-3 h-3 opacity-50 transition-transform" :class="showModuleSplash ? 'rotate-180' : ''" />
134
147
  </button>
135
148
  <UTooltip v-if="version" :text="hasUpdate ? `Update available: v${latestVersion}` : `v${version}`">
@@ -286,6 +299,20 @@ function disconnectStandalone() {
286
299
  </div>
287
300
  </div>
288
301
 
302
+ <!-- Setup checklist alert -->
303
+ <DevtoolsAlert
304
+ v-if="moduleChecklistResult?.requiredPending"
305
+ variant="warning"
306
+ >
307
+ {{ moduleChecklistResult.requiredPending }} required setup {{ moduleChecklistResult.requiredPending === 1 ? 'step' : 'steps' }} remaining
308
+ <template #action>
309
+ <button type="button" class="checklist-alert-action" @click="showModuleSplash = true">
310
+ View setup
311
+ <UIcon name="carbon:arrow-right" class="w-3 h-3" />
312
+ </button>
313
+ </template>
314
+ </DevtoolsAlert>
315
+
289
316
  <!-- Main Content -->
290
317
  <div class="devtools-main">
291
318
  <main class="devtools-main-content">
@@ -459,4 +486,20 @@ function disconnectStandalone() {
459
486
  padding: 1.5rem 1rem 1rem;
460
487
  max-width: 48rem;
461
488
  }
489
+
490
+ .checklist-alert-action {
491
+ display: flex;
492
+ align-items: center;
493
+ gap: 0.25rem;
494
+ font-size: 0.75rem;
495
+ font-weight: 600;
496
+ color: inherit;
497
+ opacity: 0.8;
498
+ cursor: pointer;
499
+ transition: opacity 100ms;
500
+ }
501
+
502
+ .checklist-alert-action:hover {
503
+ opacity: 1;
504
+ }
462
505
  </style>
@@ -1,6 +1,8 @@
1
1
  <script setup lang="ts">
2
+ import type { TabsItem } from '@nuxt/ui'
2
3
  import { onClickOutside, useClipboard } from '@vueuse/core'
3
- import { computed, ref } from 'vue'
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
 
@@ -27,6 +29,47 @@ const installCommand = computed(() => {
27
29
 
28
30
  const { copy, copied } = useClipboard({ source: installCommand })
29
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
+
30
73
  function handleModuleClick(mod: typeof moduleCatalog.value[0]) {
31
74
  if (!mod.installed) {
32
75
  const set = new Set(selectedForInstall.value)
@@ -56,12 +99,21 @@ function handleModuleClick(mod: typeof moduleCatalog.value[0]) {
56
99
  </button>
57
100
  </div>
58
101
 
59
- <!-- Modules Section -->
60
- <div class="splash-section">
61
- <div class="splash-section-label">
62
- <span class="splash-section-dot" />
63
- Modules
64
- </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">
65
117
  <div class="splash-grid">
66
118
  <button
67
119
  v-for="mod of coreModules"
@@ -81,16 +133,19 @@ function handleModuleClick(mod: typeof moduleCatalog.value[0]) {
81
133
  <UIcon :name="mod.icon" class="text-base" />
82
134
  </div>
83
135
  <span class="splash-module-title">{{ mod.title }}</span>
84
- <span v-if="mod.name === currentModule" class="splash-current-badge">Current</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>
85
142
  <span v-else-if="!mod.installed" class="splash-not-installed">{{ selectedForInstall.has(mod.name) ? 'Selected' : 'Not installed' }}</span>
86
143
  </button>
87
144
  </div>
88
- </div>
89
145
 
90
- <!-- Install command -->
91
- <Transition name="install-bar">
92
- <div v-if="installCommand" class="splash-install-section">
93
- <div class="splash-install">
146
+ <!-- Install command -->
147
+ <Transition name="install-bar">
148
+ <div v-if="installCommand" class="splash-install">
94
149
  <div class="splash-install-code">
95
150
  <code>{{ installCommand }}</code>
96
151
  </div>
@@ -99,15 +154,11 @@ function handleModuleClick(mod: typeof moduleCatalog.value[0]) {
99
154
  {{ copied ? 'Copied' : 'Copy' }}
100
155
  </button>
101
156
  </div>
102
- </div>
103
- </Transition>
157
+ </Transition>
158
+ </div>
104
159
 
105
- <!-- Pro Section -->
106
- <div v-if="proModules.length" class="splash-section splash-pro-area">
107
- <div class="splash-section-label">
108
- <span class="splash-section-dot splash-section-dot--pro" />
109
- Pro
110
- </div>
160
+ <!-- Pro tab -->
161
+ <div v-show="activeTab === 'pro'" class="splash-tab-content">
111
162
  <div class="splash-grid">
112
163
  <button
113
164
  v-for="mod of proModules"
@@ -131,7 +182,6 @@ function handleModuleClick(mod: typeof moduleCatalog.value[0]) {
131
182
  </UBadge>
132
183
  </button>
133
184
  </div>
134
- <!-- Pro status / CTA -->
135
185
  <div class="splash-pro-status">
136
186
  <div class="splash-pro-status-inner">
137
187
  <UIcon name="carbon:locked" class="w-3.5 h-3.5 opacity-50" />
@@ -144,6 +194,21 @@ function handleModuleClick(mod: typeof moduleCatalog.value[0]) {
144
194
  </div>
145
195
  </div>
146
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
+
147
212
  <!-- Footer -->
148
213
  <div class="splash-footer">
149
214
  <a href="https://nuxtseo.com" target="_blank" rel="noopener" class="splash-link">
@@ -171,11 +236,12 @@ function handleModuleClick(mod: typeof moduleCatalog.value[0]) {
171
236
 
172
237
  .splash-panel {
173
238
  width: min(480px, calc(100vw - 2rem));
239
+ max-height: calc(100vh - 4rem);
240
+ overflow-y: auto;
174
241
  border-radius: var(--radius-lg);
175
242
  border: 1px solid var(--color-border);
176
243
  background: var(--color-surface);
177
244
  box-shadow: 0 24px 48px oklch(0% 0 0 / 0.2);
178
- overflow: hidden;
179
245
  }
180
246
 
181
247
  .dark .splash-panel {
@@ -208,36 +274,19 @@ function handleModuleClick(mod: typeof moduleCatalog.value[0]) {
208
274
  color: var(--color-text);
209
275
  }
210
276
 
211
- /* Sections */
212
- .splash-section {
213
- padding: 0.5rem 0.75rem 0.625rem;
214
- }
215
-
216
- .splash-section + .splash-section {
217
- border-top: 1px solid var(--color-border);
218
- }
219
-
220
- .splash-section-label {
221
- display: flex;
222
- align-items: center;
223
- gap: 0.375rem;
224
- font-size: 0.625rem;
225
- font-weight: 600;
226
- text-transform: uppercase;
227
- letter-spacing: 0.05em;
228
- color: var(--color-text-muted);
229
- 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);
230
281
  }
231
282
 
232
- .splash-section-dot {
233
- width: 0.3125rem;
234
- height: 0.3125rem;
235
- border-radius: 50%;
236
- background: var(--color-text-subtle);
283
+ .splash-tab-trigger {
284
+ font-size: 0.6875rem !important;
237
285
  }
238
286
 
239
- .splash-section-dot--pro {
240
- background: oklch(65% 0.25 290);
287
+ /* Tab content */
288
+ .splash-tab-content {
289
+ padding: 0.5rem 0.75rem 0.625rem;
241
290
  }
242
291
 
243
292
  /* Grid */
@@ -355,7 +404,7 @@ function handleModuleClick(mod: typeof moduleCatalog.value[0]) {
355
404
  display: flex;
356
405
  align-items: center;
357
406
  justify-content: space-between;
358
- margin: 0.375rem 0.25rem 0;
407
+ margin: 0.375rem 0 0;
359
408
  padding: 0.4rem 0.625rem;
360
409
  border-radius: var(--radius-md);
361
410
  background: oklch(65% 0.25 290 / 0.06);
@@ -385,16 +434,60 @@ function handleModuleClick(mod: typeof moduleCatalog.value[0]) {
385
434
  opacity: 0.8;
386
435
  }
387
436
 
388
- /* Install bar */
389
- .splash-install-section {
390
- padding: 0.5rem 0.75rem;
391
- border-top: 1px solid var(--color-border);
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);
392
458
  }
393
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 */
394
486
  .splash-install {
395
487
  display: flex;
396
488
  align-items: center;
397
489
  gap: 0.5rem;
490
+ margin-top: 0.5rem;
398
491
  padding: 0.5rem 0.625rem;
399
492
  border-radius: var(--radius-md);
400
493
  background: var(--color-surface-sunken);
@@ -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>
@@ -0,0 +1,486 @@
1
+ import type { ChecklistItemDefinition, NuxtSEOModule } from 'nuxtseo-shared/const'
2
+ import { computed, ref, toValue } from 'vue'
3
+ import { installedModules } from './modules'
4
+ import { appFetch } from './rpc'
5
+
6
+ export interface ChecklistDetectResult {
7
+ passed: boolean
8
+ detail?: string
9
+ }
10
+
11
+ interface ChecklistItemWithDetect extends ChecklistItemDefinition {
12
+ detect: (data: Record<string, any>, ctx: DetectContext) => ChecklistDetectResult
13
+ }
14
+
15
+ interface DetectContext {
16
+ installedModuleSlugs: Set<string>
17
+ debugData: Map<string, Record<string, any>>
18
+ }
19
+
20
+ export interface ChecklistItemResult extends ChecklistItemDefinition {
21
+ passed: boolean
22
+ detail?: string
23
+ }
24
+
25
+ export interface ModuleChecklistResult {
26
+ moduleSlug: NuxtSEOModule['slug']
27
+ moduleLabel: string
28
+ moduleIcon: string
29
+ items: ChecklistItemResult[]
30
+ requiredPending: number
31
+ recommendedPending: number
32
+ totalPending: number
33
+ }
34
+
35
+ export interface ChecklistSummary {
36
+ total: number
37
+ passed: number
38
+ requiredPending: number
39
+ recommendedPending: number
40
+ }
41
+
42
+ // Debug endpoint paths for each module
43
+ const DEBUG_ENDPOINTS: Partial<Record<NuxtSEOModule['slug'], string>> = {
44
+ 'site-config': '/__site-config__/debug',
45
+ 'robots': '/__robots__/debug.json',
46
+ 'sitemap': '/__sitemap__/debug.json',
47
+ 'og-image': '/__nuxt-og-image/debug.json',
48
+ 'schema-org': '/__schema-org__/debug.json',
49
+ }
50
+
51
+ // Module slug used internally by devtools → catalog slug mapping
52
+ const DEVTOOLS_NAME_TO_SLUG: Record<string, NuxtSEOModule['slug']> = {
53
+ 'nuxt-robots': 'robots',
54
+ 'sitemap': 'sitemap',
55
+ 'nuxt-og-image': 'og-image',
56
+ 'nuxt-schema-org': 'schema-org',
57
+ 'nuxt-seo-utils': 'seo-utils',
58
+ 'nuxt-link-checker': 'link-checker',
59
+ 'nuxt-site-config': 'site-config',
60
+ 'nuxt-ai-ready': 'ai-ready',
61
+ 'nuxt-skew-protection': 'skew-protection',
62
+ }
63
+
64
+ const MODULE_META: Record<string, { label: string, icon: string }> = {
65
+ 'site-config': { label: 'Site Config', icon: 'carbon:settings-check' },
66
+ 'robots': { label: 'Robots', icon: 'carbon:bot' },
67
+ 'sitemap': { label: 'Sitemap', icon: 'carbon:load-balancer-application' },
68
+ 'og-image': { label: 'OG Image', icon: 'carbon:image-search' },
69
+ 'seo-utils': { label: 'SEO Utils', icon: 'carbon:tools' },
70
+ 'schema-org': { label: 'Schema.org', icon: 'carbon:chart-relationship' },
71
+ }
72
+
73
+ function isValidUrl(url?: string): boolean {
74
+ if (!url)
75
+ return false
76
+ return !url.includes('localhost') && !url.includes('127.0.0.1') && url.length > 0
77
+ }
78
+
79
+ // Checklist definitions with detection logic per module
80
+ const CHECKLIST_DEFINITIONS: Partial<Record<NuxtSEOModule['slug'], ChecklistItemWithDetect[]>> = {
81
+ 'site-config': [
82
+ {
83
+ id: 'site-url',
84
+ label: 'Site URL configured',
85
+ description: 'A production site URL is needed for canonical URLs, sitemaps, and OG images to work correctly.',
86
+ level: 'required',
87
+ docsUrl: 'https://nuxtseo.com/docs/site-config/getting-started/how-it-works',
88
+ detect: (data) => {
89
+ const url = data?.config?.url || ''
90
+ const passed = isValidUrl(url)
91
+ return { passed, detail: passed ? url : 'Not set or using localhost' }
92
+ },
93
+ },
94
+ {
95
+ id: 'site-name',
96
+ label: 'Site name set',
97
+ description: 'Used for default meta tags, Schema.org, and OG tags across all modules.',
98
+ level: 'required',
99
+ docsUrl: 'https://nuxtseo.com/docs/site-config/getting-started/how-it-works',
100
+ detect: (data) => {
101
+ const name = data?.config?.name || ''
102
+ const passed = !!name && name !== 'My Site'
103
+ return { passed, detail: passed ? name : 'Not configured' }
104
+ },
105
+ },
106
+ {
107
+ id: 'default-locale',
108
+ label: 'Default locale configured',
109
+ description: 'Ensures correct hreflang tags and locale-aware sitemaps when using i18n.',
110
+ level: 'recommended',
111
+ docsUrl: 'https://nuxtseo.com/docs/site-config/guides/setting-site-config',
112
+ detect: (data) => {
113
+ const locale = data?.config?.defaultLocale
114
+ const passed = !!locale
115
+ return { passed, detail: passed ? locale : 'Not set' }
116
+ },
117
+ },
118
+ {
119
+ id: 'trailing-slash',
120
+ label: 'Trailing slash preference set',
121
+ description: 'Prevents duplicate content from inconsistent URL formats. Set explicitly to true or false.',
122
+ level: 'recommended',
123
+ docsUrl: 'https://nuxtseo.com/docs/site-config/guides/setting-site-config',
124
+ detect: (data) => {
125
+ const trailingSlash = data?.config?.trailingSlash
126
+ const passed = typeof trailingSlash === 'boolean'
127
+ return { passed, detail: passed ? (trailingSlash ? 'Enabled' : 'Disabled') : 'Not explicitly set' }
128
+ },
129
+ },
130
+ {
131
+ id: 'robots-installed',
132
+ label: 'Robots module installed',
133
+ description: 'Controls crawling and indexing across all SEO modules. Strongly recommended.',
134
+ level: 'recommended',
135
+ docsUrl: 'https://nuxtseo.com/docs/robots/getting-started/installation',
136
+ detect: (_data, ctx) => {
137
+ const passed = ctx.installedModuleSlugs.has('robots')
138
+ return { passed, detail: passed ? 'Installed' : 'Not installed' }
139
+ },
140
+ },
141
+ ],
142
+ 'robots': [
143
+ {
144
+ id: 'no-validation-errors',
145
+ label: 'No robots.txt validation errors',
146
+ description: 'Your robots.txt should be free of syntax errors that could confuse crawlers.',
147
+ level: 'required',
148
+ docsUrl: 'https://nuxtseo.com/docs/robots/getting-started/installation',
149
+ detect: (data) => {
150
+ const errors = data?.validation?.errors || []
151
+ const passed = errors.length === 0
152
+ return { passed, detail: passed ? 'No errors' : `${errors.length} error(s) found` }
153
+ },
154
+ },
155
+ {
156
+ id: 'ai-directives',
157
+ label: 'AI bot directives configured',
158
+ description: 'Configure how AI crawlers interact with your content using blockAiBots or content signal directives.',
159
+ level: 'recommended',
160
+ docsUrl: 'https://nuxtseo.com/docs/robots/guides/ai-bots',
161
+ detect: (data) => {
162
+ const groups = data?.runtimeConfig?.groups || []
163
+ const hasContentSignal = groups.some((g: any) => g.contentSignal?.length || g.contentUsage?.length)
164
+ const aiAgents = ['gptbot', 'chatgpt-user', 'anthropic-ai', 'claudebot', 'claude-web', 'google-extended', 'ccbot']
165
+ const hasAiAgent = groups.some((g: any) =>
166
+ (g.userAgent || []).some((ua: string) => aiAgents.includes(ua.toLowerCase())),
167
+ )
168
+ const passed = hasContentSignal || hasAiAgent
169
+ return { passed, detail: passed ? (hasContentSignal ? 'Content signals configured' : 'AI agent rules configured') : 'No AI bot directives found' }
170
+ },
171
+ },
172
+ {
173
+ id: 'bot-detection',
174
+ label: 'Bot detection enabled',
175
+ description: 'Classify bots via headers and fingerprinting to reduce server load from non-SEO crawlers.',
176
+ level: 'recommended',
177
+ docsUrl: 'https://nuxtseo.com/docs/robots/guides/bot-detection',
178
+ detect: (data) => {
179
+ const enabled = data?.runtimeConfig?.botDetection
180
+ return { passed: !!enabled, detail: enabled ? 'Enabled' : 'Disabled' }
181
+ },
182
+ },
183
+ {
184
+ id: 'sitemap-reference',
185
+ label: 'Sitemap referenced in robots.txt',
186
+ description: 'Crawlers use the Sitemap directive in robots.txt to discover your sitemap URL.',
187
+ level: 'recommended',
188
+ docsUrl: 'https://nuxtseo.com/docs/robots/guides/robots-txt',
189
+ detect: (data) => {
190
+ const sitemaps = data?.validation?.sitemaps || []
191
+ const passed = sitemaps.length > 0
192
+ return { passed, detail: passed ? `${sitemaps.length} sitemap(s) referenced` : 'No sitemap directive found' }
193
+ },
194
+ },
195
+ ],
196
+ 'sitemap': [
197
+ {
198
+ id: 'site-url-set',
199
+ label: 'Site URL set for sitemaps',
200
+ description: 'Sitemaps require an absolute site URL. Without it, URLs will use localhost in production.',
201
+ level: 'required',
202
+ docsUrl: 'https://nuxtseo.com/docs/sitemap/getting-started/installation',
203
+ detect: (data) => {
204
+ const url = data?.siteConfig?.url || ''
205
+ const passed = isValidUrl(url)
206
+ return { passed, detail: passed ? url : 'Site URL not configured' }
207
+ },
208
+ },
209
+ {
210
+ id: 'has-sources',
211
+ label: 'URL sources configured',
212
+ description: 'Add dynamic URL sources for CMS or database content so all pages appear in your sitemap.',
213
+ level: 'recommended',
214
+ docsUrl: 'https://nuxtseo.com/docs/sitemap/guides/dynamic-urls',
215
+ detect: (data) => {
216
+ const sources = data?.globalSources || []
217
+ const sitemaps = data?.sitemaps || {}
218
+ const userSources = sources.filter((s: any) => s.sourceType === 'user' || (s.context?.name && !s.context.name.startsWith('nuxt:')))
219
+ const sitemapUserSources = Object.values(sitemaps).flatMap((s: any) =>
220
+ (s.sources || []).filter((src: any) => src.sourceType === 'user'),
221
+ )
222
+ const totalUser = userSources.length + sitemapUserSources.length
223
+ const passed = totalUser > 0
224
+ return { passed, detail: passed ? `${totalUser} custom source(s)` : 'Only default app sources detected' }
225
+ },
226
+ },
227
+ {
228
+ id: 'no-source-errors',
229
+ label: 'No sitemap source errors',
230
+ description: 'All configured sitemap sources should resolve successfully.',
231
+ level: 'required',
232
+ docsUrl: 'https://nuxtseo.com/docs/sitemap/guides/dynamic-urls',
233
+ detect: (data) => {
234
+ const sources = data?.globalSources || []
235
+ const sitemaps = data?.sitemaps || {}
236
+ const allSources = [
237
+ ...sources,
238
+ ...Object.values(sitemaps).flatMap((s: any) => s.sources || []),
239
+ ]
240
+ const failures = allSources.filter((s: any) => s._isFailure || s.error)
241
+ const passed = failures.length === 0
242
+ return { passed, detail: passed ? 'All sources OK' : `${failures.length} source(s) failing` }
243
+ },
244
+ },
245
+ {
246
+ id: 'url-warnings',
247
+ label: 'No URL validation warnings',
248
+ description: 'URLs in your sitemap should follow best practices (no whitespace, lowercase, etc).',
249
+ level: 'recommended',
250
+ docsUrl: 'https://nuxtseo.com/docs/sitemap/guides/best-practices',
251
+ detect: (data) => {
252
+ const sources = data?.globalSources || []
253
+ const sitemaps = data?.sitemaps || {}
254
+ const allSources = [
255
+ ...sources,
256
+ ...Object.values(sitemaps).flatMap((s: any) => s.sources || []),
257
+ ]
258
+ const warningCount = allSources.reduce((sum: number, s: any) => sum + (s._urlWarnings?.length || 0), 0)
259
+ const passed = warningCount === 0
260
+ return { passed, detail: passed ? 'No warnings' : `${warningCount} URL warning(s)` }
261
+ },
262
+ },
263
+ ],
264
+ 'og-image': [
265
+ {
266
+ id: 'renderer',
267
+ label: 'Renderer installed',
268
+ description: 'A renderer (Takumi, Satori, or Browser) is required to generate OG images.',
269
+ level: 'required',
270
+ docsUrl: 'https://nuxtseo.com/docs/og-image/getting-started/installation',
271
+ detect: (data) => {
272
+ const compat = data?.compatibility || {}
273
+ const hasTakumi = compat.takumi && compat.takumi !== false
274
+ const hasSatori = compat.satori && compat.satori !== false
275
+ const hasBrowser = compat.browser && compat.browser !== false
276
+ const passed = hasTakumi || hasSatori || hasBrowser
277
+ const renderers = [hasTakumi && 'takumi', hasSatori && 'satori', hasBrowser && 'browser'].filter(Boolean)
278
+ return { passed, detail: passed ? `Available: ${renderers.join(', ')}` : 'No renderer installed' }
279
+ },
280
+ },
281
+ {
282
+ id: 'custom-template',
283
+ label: 'Custom OG template created',
284
+ description: 'Community templates are for development only. Create a custom template for production.',
285
+ level: 'recommended',
286
+ docsUrl: 'https://nuxtseo.com/docs/og-image/guides/templates',
287
+ detect: (data) => {
288
+ const components = data?.componentNames || []
289
+ const appTemplates = components.filter((c: any) => c.category === 'app')
290
+ const communityTemplates = components.filter((c: any) => c.category === 'community')
291
+ const passed = appTemplates.length > 0
292
+ if (passed)
293
+ return { passed, detail: `${appTemplates.length} custom template(s)` }
294
+ if (communityTemplates.length > 0)
295
+ return { passed: false, detail: `${communityTemplates.length} community template(s), eject before production` }
296
+ return { passed: false, detail: 'No templates found' }
297
+ },
298
+ },
299
+ ],
300
+ 'seo-utils': [
301
+ {
302
+ id: 'schema-org-installed',
303
+ label: 'Schema.org module installed',
304
+ description: 'Adds structured data to your pages, improving rich search results. Pairs well with SEO Utils.',
305
+ level: 'recommended',
306
+ docsUrl: 'https://nuxtseo.com/docs/schema-org/getting-started/installation',
307
+ detect: (_data, ctx) => {
308
+ const passed = ctx.installedModuleSlugs.has('schema-org')
309
+ return { passed, detail: passed ? 'Installed' : 'Not installed' }
310
+ },
311
+ },
312
+ {
313
+ id: 'sitemap-installed',
314
+ label: 'Sitemap module installed',
315
+ description: 'Generates XML sitemaps so search engines can discover all your pages efficiently.',
316
+ level: 'recommended',
317
+ docsUrl: 'https://nuxtseo.com/docs/sitemap/getting-started/installation',
318
+ detect: (_data, ctx) => {
319
+ const passed = ctx.installedModuleSlugs.has('sitemap')
320
+ return { passed, detail: passed ? 'Installed' : 'Not installed' }
321
+ },
322
+ },
323
+ ],
324
+ 'schema-org': [
325
+ {
326
+ id: 'identity',
327
+ label: 'Identity configured',
328
+ description: 'Set up your Organization or Person identity for rich Schema.org knowledge graph results.',
329
+ level: 'recommended',
330
+ docsUrl: 'https://nuxtseo.com/docs/schema-org/guides/setup-identity',
331
+ detect: (data) => {
332
+ const config = data?.runtimeConfig || {}
333
+ const identity = config.identity
334
+ if (!identity)
335
+ return { passed: false, detail: 'No identity set' }
336
+ const type = typeof identity === 'string' ? identity : identity['@type'] || 'Unknown'
337
+ const name = typeof identity === 'object' ? (identity.name || '') : ''
338
+ return { passed: true, detail: name ? `${type}: ${name}` : type }
339
+ },
340
+ },
341
+ {
342
+ id: 'robots-companion',
343
+ label: 'Robots module installed',
344
+ description: 'Robots module auto-excludes non-indexable paths from Schema.org output.',
345
+ level: 'recommended',
346
+ docsUrl: 'https://nuxtseo.com/docs/schema-org/getting-started/installation',
347
+ detect: (_data, ctx) => {
348
+ const passed = ctx.installedModuleSlugs.has('robots')
349
+ return { passed, detail: passed ? 'Installed' : 'Not installed' }
350
+ },
351
+ },
352
+ ],
353
+ }
354
+
355
+ const debugCache = ref<Map<string, Record<string, any>>>(new Map())
356
+ const loading = ref(false)
357
+ const evaluated = ref(false)
358
+
359
+ function getInstalledSlugs(): Set<string> {
360
+ const slugs = new Set<string>()
361
+ for (const mod of toValue(installedModules)) {
362
+ const slug = DEVTOOLS_NAME_TO_SLUG[mod.name]
363
+ if (slug)
364
+ slugs.add(slug)
365
+ }
366
+ return slugs
367
+ }
368
+
369
+ async function fetchDebugData(): Promise<void> {
370
+ const fetch = toValue(appFetch)
371
+ if (!fetch)
372
+ return
373
+
374
+ const slugs = getInstalledSlugs()
375
+ const cache = new Map<string, Record<string, any>>()
376
+
377
+ const fetches = Object.entries(DEBUG_ENDPOINTS)
378
+ .filter(([slug]) => slugs.has(slug))
379
+ .map(async ([slug, endpoint]) => {
380
+ const data = await fetch(endpoint!).catch(() => null)
381
+ if (data)
382
+ cache.set(slug, data)
383
+ })
384
+
385
+ await Promise.all(fetches)
386
+ debugCache.value = cache
387
+ }
388
+
389
+ function evaluateModule(slug: NuxtSEOModule['slug'], ctx: DetectContext): ModuleChecklistResult | undefined {
390
+ const definitions = CHECKLIST_DEFINITIONS[slug]
391
+ if (!definitions?.length)
392
+ return undefined
393
+
394
+ const meta = MODULE_META[slug]
395
+ if (!meta)
396
+ return undefined
397
+
398
+ const data = debugCache.value.get(slug) || {}
399
+ const items: ChecklistItemResult[] = definitions.map((def) => {
400
+ const result = def.detect(data, ctx)
401
+ return {
402
+ id: def.id,
403
+ label: def.label,
404
+ description: def.description,
405
+ level: def.level,
406
+ docsUrl: def.docsUrl,
407
+ passed: result.passed,
408
+ detail: result.detail,
409
+ }
410
+ })
411
+
412
+ const requiredPending = items.filter(i => i.level === 'required' && !i.passed).length
413
+ const recommendedPending = items.filter(i => i.level === 'recommended' && !i.passed).length
414
+
415
+ return {
416
+ moduleSlug: slug,
417
+ moduleLabel: meta.label,
418
+ moduleIcon: meta.icon,
419
+ items,
420
+ requiredPending,
421
+ recommendedPending,
422
+ totalPending: requiredPending + recommendedPending,
423
+ }
424
+ }
425
+
426
+ const results = computed<ModuleChecklistResult[]>(() => {
427
+ const slugs = getInstalledSlugs()
428
+ const ctx: DetectContext = { installedModuleSlugs: slugs, debugData: debugCache.value }
429
+ const moduleResults: ModuleChecklistResult[] = []
430
+
431
+ // Evaluate in a consistent order
432
+ const orderedSlugs: NuxtSEOModule['slug'][] = ['site-config', 'robots', 'sitemap', 'og-image', 'schema-org', 'seo-utils']
433
+ for (const slug of orderedSlugs) {
434
+ if (!slugs.has(slug) && slug !== 'site-config')
435
+ continue
436
+ // Site config is always evaluated (it's the foundation)
437
+ const result = evaluateModule(slug, ctx)
438
+ if (result)
439
+ moduleResults.push(result)
440
+ }
441
+
442
+ return moduleResults
443
+ })
444
+
445
+ const summary = computed<ChecklistSummary>(() => {
446
+ let total = 0; let passed = 0; let requiredPending = 0; let recommendedPending = 0
447
+ for (const r of results.value) {
448
+ total += r.items.length
449
+ passed += r.items.filter(i => i.passed).length
450
+ requiredPending += r.requiredPending
451
+ recommendedPending += r.recommendedPending
452
+ }
453
+ return { total, passed, requiredPending, recommendedPending }
454
+ })
455
+
456
+ export function getModuleResult(slug: string): ModuleChecklistResult | undefined {
457
+ return results.value.find(r => r.moduleSlug === slug)
458
+ }
459
+
460
+ export function getModuleResultByName(devtoolsName: string): ModuleChecklistResult | undefined {
461
+ const slug = DEVTOOLS_NAME_TO_SLUG[devtoolsName]
462
+ if (!slug)
463
+ return undefined
464
+ return getModuleResult(slug)
465
+ }
466
+
467
+ export async function evaluate(): Promise<void> {
468
+ if (loading.value)
469
+ return
470
+ loading.value = true
471
+ await fetchDebugData().catch(() => {})
472
+ evaluated.value = true
473
+ loading.value = false
474
+ }
475
+
476
+ export function useSetupChecklist() {
477
+ return {
478
+ results,
479
+ summary,
480
+ loading,
481
+ evaluated,
482
+ evaluate,
483
+ getModuleResult,
484
+ getModuleResultByName,
485
+ }
486
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxtseo-layer-devtools",
3
3
  "type": "module",
4
- "version": "0.5.0",
4
+ "version": "0.5.1",
5
5
  "description": "Shared Nuxt layer for Nuxt SEO devtools clients.",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",
@@ -37,7 +37,7 @@
37
37
  "ofetch": "^1.5.1",
38
38
  "shiki": "^4.0.2",
39
39
  "ufo": "^1.6.3",
40
- "nuxtseo-shared": "0.8.2"
40
+ "nuxtseo-shared": "0.9.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "nuxt": "^4.4.2",