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.
- package/components/DevtoolsChecklistBadge.vue +65 -0
- package/components/DevtoolsChecklistItem.vue +171 -0
- package/components/DevtoolsLayout.vue +43 -0
- package/components/DevtoolsModuleSplash.vue +147 -54
- package/components/DevtoolsSetupChecklist.vue +60 -0
- package/composables/checklist.ts +486 -0
- package/package.json +2 -2
|
@@ -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
|
-
<!--
|
|
60
|
-
<
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
</
|
|
103
|
-
</
|
|
157
|
+
</Transition>
|
|
158
|
+
</div>
|
|
104
159
|
|
|
105
|
-
<!-- Pro
|
|
106
|
-
<div v-
|
|
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
|
-
/*
|
|
212
|
-
.splash-
|
|
213
|
-
padding: 0
|
|
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-
|
|
233
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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
|
|
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
|
-
/*
|
|
389
|
-
.splash-
|
|
390
|
-
|
|
391
|
-
|
|
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.
|
|
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.
|
|
40
|
+
"nuxtseo-shared": "0.9.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"nuxt": "^4.4.2",
|