docus 5.5.0 → 5.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/app.vue +3 -6
- package/app/composables/useSeo.ts +207 -0
- package/app/error.vue +2 -5
- package/app/pages/[[lang]]/[...slug].vue +8 -7
- package/app/templates/landing.vue +4 -10
- package/app/utils/navigation.ts +50 -0
- package/content.config.ts +28 -16
- package/modules/assistant/index.ts +1 -1
- package/modules/assistant/runtime/components/AssistantFloatingInput.vue +39 -34
- package/modules/assistant/runtime/components/AssistantPanel.vue +1 -1
- package/modules/config.ts +1 -1
- package/modules/css.ts +3 -1
- package/modules/routing.ts +21 -17
- package/nuxt.config.ts +18 -6
- package/package.json +16 -16
- package/server/routes/sitemap.xml.ts +24 -5
- package/utils/pages.ts +22 -0
package/app/app.vue
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { ContentNavigationItem, PageCollections } from '@nuxt/content'
|
|
3
3
|
import * as nuxtUiLocales from '@nuxt/ui/locale'
|
|
4
|
+
import { transformNavigation } from './utils/navigation'
|
|
4
5
|
|
|
5
6
|
const { seo } = useAppConfig()
|
|
6
7
|
const site = useSiteConfig()
|
|
@@ -45,11 +46,7 @@ if (isEnabled.value) {
|
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
const { data: navigation } = await useAsyncData(() => `navigation_${collectionName.value}`, () => queryCollectionNavigation(collectionName.value as keyof PageCollections), {
|
|
48
|
-
transform: (data: ContentNavigationItem[]) =>
|
|
49
|
-
const rootResult = data.find(item => item.path === '/docs')?.children || data || []
|
|
50
|
-
|
|
51
|
-
return rootResult.find((item: ContentNavigationItem) => item.path === `/${locale.value}`)?.children || rootResult
|
|
52
|
-
},
|
|
49
|
+
transform: (data: ContentNavigationItem[]) => transformNavigation(data, isEnabled.value, locale.value),
|
|
53
50
|
watch: [locale],
|
|
54
51
|
})
|
|
55
52
|
const { data: files } = useLazyAsyncData(`search_${collectionName.value}`, () => queryCollectionSearchSections(collectionName.value as keyof PageCollections), {
|
|
@@ -81,8 +78,8 @@ provide('navigation', navigation)
|
|
|
81
78
|
:navigation="navigation"
|
|
82
79
|
/>
|
|
83
80
|
<template v-if="isAssistantEnabled">
|
|
84
|
-
<LazyAssistantFloatingInput />
|
|
85
81
|
<LazyAssistantPanel />
|
|
82
|
+
<LazyAssistantFloatingInput />
|
|
86
83
|
</template>
|
|
87
84
|
</ClientOnly>
|
|
88
85
|
</UApp>
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import type { MaybeRefOrGetter } from 'vue'
|
|
2
|
+
import type { BreadcrumbItem } from '../utils/navigation'
|
|
3
|
+
import { joinURL, withoutTrailingSlash } from 'ufo'
|
|
4
|
+
|
|
5
|
+
export interface UseSeoOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Page title
|
|
8
|
+
*/
|
|
9
|
+
title: MaybeRefOrGetter<string | undefined>
|
|
10
|
+
/**
|
|
11
|
+
* Page description
|
|
12
|
+
*/
|
|
13
|
+
description: MaybeRefOrGetter<string | undefined>
|
|
14
|
+
/**
|
|
15
|
+
* Page type for og:type (default: 'article' for docs, 'website' for landing)
|
|
16
|
+
*/
|
|
17
|
+
type?: MaybeRefOrGetter<'website' | 'article'>
|
|
18
|
+
/**
|
|
19
|
+
* Custom OG image URL (absolute)
|
|
20
|
+
*/
|
|
21
|
+
ogImage?: MaybeRefOrGetter<string | undefined>
|
|
22
|
+
/**
|
|
23
|
+
* Published date for article schema
|
|
24
|
+
*/
|
|
25
|
+
publishedAt?: MaybeRefOrGetter<string | undefined>
|
|
26
|
+
/**
|
|
27
|
+
* Modified date for article schema
|
|
28
|
+
*/
|
|
29
|
+
modifiedAt?: MaybeRefOrGetter<string | undefined>
|
|
30
|
+
/**
|
|
31
|
+
* Breadcrumb items for BreadcrumbList schema
|
|
32
|
+
*/
|
|
33
|
+
breadcrumbs?: MaybeRefOrGetter<BreadcrumbItem[] | undefined>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Composable for comprehensive SEO setup including:
|
|
38
|
+
* - Meta tags (title, description, og:*, twitter:*)
|
|
39
|
+
* - Canonical URLs
|
|
40
|
+
* - Hreflang tags for i18n
|
|
41
|
+
* - JSON-LD structured data
|
|
42
|
+
*/
|
|
43
|
+
export function useSeo(options: UseSeoOptions) {
|
|
44
|
+
const route = useRoute()
|
|
45
|
+
const site = useSiteConfig()
|
|
46
|
+
const { locale, locales, isEnabled: isI18nEnabled, switchLocalePath } = useDocusI18n()
|
|
47
|
+
|
|
48
|
+
const title = computed(() => toValue(options.title))
|
|
49
|
+
const description = computed(() => toValue(options.description))
|
|
50
|
+
const type = computed(() => toValue(options.type) || 'article')
|
|
51
|
+
const ogImage = computed(() => toValue(options.ogImage))
|
|
52
|
+
const publishedAt = computed(() => toValue(options.publishedAt))
|
|
53
|
+
const modifiedAt = computed(() => toValue(options.modifiedAt))
|
|
54
|
+
const breadcrumbs = computed(() => toValue(options.breadcrumbs))
|
|
55
|
+
|
|
56
|
+
// Build canonical URL
|
|
57
|
+
const canonicalUrl = computed(() => {
|
|
58
|
+
if (!site.url) return undefined
|
|
59
|
+
return joinURL(site.url, route.path)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// Base URL for building other URLs
|
|
63
|
+
const baseUrl = computed(() => site.url ? withoutTrailingSlash(site.url) : '')
|
|
64
|
+
|
|
65
|
+
// Set meta tags
|
|
66
|
+
useSeoMeta({
|
|
67
|
+
title,
|
|
68
|
+
description,
|
|
69
|
+
ogTitle: title,
|
|
70
|
+
ogDescription: description,
|
|
71
|
+
ogType: type,
|
|
72
|
+
ogUrl: canonicalUrl,
|
|
73
|
+
ogLocale: computed(() => isI18nEnabled.value ? locale.value : undefined),
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// Set canonical link
|
|
77
|
+
useHead({
|
|
78
|
+
link: computed(() => {
|
|
79
|
+
const links: Array<{ rel: string, href?: string, hreflang?: string }> = []
|
|
80
|
+
|
|
81
|
+
// Canonical URL
|
|
82
|
+
if (canonicalUrl.value) {
|
|
83
|
+
links.push({
|
|
84
|
+
rel: 'canonical',
|
|
85
|
+
href: canonicalUrl.value,
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Hreflang tags for i18n
|
|
90
|
+
if (isI18nEnabled.value && baseUrl.value) {
|
|
91
|
+
for (const loc of locales) {
|
|
92
|
+
const localePath = switchLocalePath(loc.code)
|
|
93
|
+
if (localePath) {
|
|
94
|
+
links.push({
|
|
95
|
+
rel: 'alternate',
|
|
96
|
+
hreflang: loc.code,
|
|
97
|
+
href: joinURL(baseUrl.value, localePath),
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// x-default hreflang (points to default locale)
|
|
103
|
+
const defaultLocalePath = switchLocalePath(locales[0]?.code || 'en')
|
|
104
|
+
if (defaultLocalePath) {
|
|
105
|
+
links.push({
|
|
106
|
+
rel: 'alternate',
|
|
107
|
+
hreflang: 'x-default',
|
|
108
|
+
href: joinURL(baseUrl.value, defaultLocalePath),
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return links
|
|
114
|
+
}),
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// Custom OG image handling
|
|
118
|
+
if (ogImage.value) {
|
|
119
|
+
useSeoMeta({
|
|
120
|
+
ogImage: ogImage.value,
|
|
121
|
+
twitterImage: ogImage.value,
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// JSON-LD structured data
|
|
126
|
+
useHead({
|
|
127
|
+
script: computed(() => {
|
|
128
|
+
const scripts: Array<{ type: string, innerHTML: string }> = []
|
|
129
|
+
|
|
130
|
+
if (!baseUrl.value || !title.value) return scripts
|
|
131
|
+
|
|
132
|
+
const pageUrl = joinURL(baseUrl.value, route.path)
|
|
133
|
+
|
|
134
|
+
// Article schema for documentation pages
|
|
135
|
+
if (type.value === 'article') {
|
|
136
|
+
const articleSchema: Record<string, unknown> = {
|
|
137
|
+
'@context': 'https://schema.org',
|
|
138
|
+
'@type': 'Article',
|
|
139
|
+
'headline': title.value,
|
|
140
|
+
'description': description.value,
|
|
141
|
+
'url': pageUrl,
|
|
142
|
+
'mainEntityOfPage': {
|
|
143
|
+
'@type': 'WebPage',
|
|
144
|
+
'@id': pageUrl,
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (publishedAt.value) {
|
|
149
|
+
articleSchema.datePublished = publishedAt.value
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (modifiedAt.value) {
|
|
153
|
+
articleSchema.dateModified = modifiedAt.value
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (site.name) {
|
|
157
|
+
articleSchema.publisher = {
|
|
158
|
+
'@type': 'Organization',
|
|
159
|
+
'name': site.name,
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
scripts.push({
|
|
164
|
+
type: 'application/ld+json',
|
|
165
|
+
innerHTML: JSON.stringify(articleSchema),
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// WebSite schema for landing pages
|
|
170
|
+
if (type.value === 'website') {
|
|
171
|
+
const websiteSchema: Record<string, unknown> = {
|
|
172
|
+
'@context': 'https://schema.org',
|
|
173
|
+
'@type': 'WebSite',
|
|
174
|
+
'name': site.name || title.value,
|
|
175
|
+
'description': description.value,
|
|
176
|
+
'url': baseUrl.value,
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
scripts.push({
|
|
180
|
+
type: 'application/ld+json',
|
|
181
|
+
innerHTML: JSON.stringify(websiteSchema),
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// BreadcrumbList schema for navigation
|
|
186
|
+
if (breadcrumbs.value && breadcrumbs.value.length > 0) {
|
|
187
|
+
const breadcrumbSchema = {
|
|
188
|
+
'@context': 'https://schema.org',
|
|
189
|
+
'@type': 'BreadcrumbList',
|
|
190
|
+
'itemListElement': breadcrumbs.value.map((item, index) => ({
|
|
191
|
+
'@type': 'ListItem',
|
|
192
|
+
'position': index + 1,
|
|
193
|
+
'name': item.title,
|
|
194
|
+
'item': joinURL(baseUrl.value, item.path),
|
|
195
|
+
})),
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
scripts.push({
|
|
199
|
+
type: 'application/ld+json',
|
|
200
|
+
innerHTML: JSON.stringify(breadcrumbSchema),
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return scripts
|
|
205
|
+
}),
|
|
206
|
+
})
|
|
207
|
+
}
|
package/app/error.vue
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import type { NuxtError } from '#app'
|
|
3
3
|
import type { ContentNavigationItem, PageCollections } from '@nuxt/content'
|
|
4
4
|
import * as nuxtUiLocales from '@nuxt/ui/locale'
|
|
5
|
+
import { transformNavigation } from './utils/navigation'
|
|
5
6
|
|
|
6
7
|
const props = defineProps<{
|
|
7
8
|
error: NuxtError
|
|
@@ -47,11 +48,7 @@ if (isEnabled.value) {
|
|
|
47
48
|
const collectionName = computed(() => isEnabled.value ? `docs_${locale.value}` : 'docs')
|
|
48
49
|
|
|
49
50
|
const { data: navigation } = await useAsyncData(`navigation_${collectionName.value}`, () => queryCollectionNavigation(collectionName.value as keyof PageCollections), {
|
|
50
|
-
transform: (data: ContentNavigationItem[]) =>
|
|
51
|
-
const rootResult = data.find(item => item.path === '/docs')?.children || data || []
|
|
52
|
-
|
|
53
|
-
return rootResult.find(item => item.path === `/${locale.value}`)?.children || rootResult
|
|
54
|
-
},
|
|
51
|
+
transform: (data: ContentNavigationItem[]) => transformNavigation(data, isEnabled.value, locale.value),
|
|
55
52
|
watch: [locale],
|
|
56
53
|
})
|
|
57
54
|
const { data: files } = useLazyAsyncData(`search_${collectionName.value}`, () => queryCollectionSearchSections(collectionName.value as keyof PageCollections), {
|
|
@@ -31,14 +31,16 @@ if (!page.value) {
|
|
|
31
31
|
const title = page.value.seo?.title || page.value.title
|
|
32
32
|
const description = page.value.seo?.description || page.value.description
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
const headline = ref(findPageHeadline(navigation?.value, page.value?.path))
|
|
35
|
+
const breadcrumbs = computed(() => findPageBreadcrumbs(navigation?.value, page.value?.path || ''))
|
|
36
|
+
|
|
37
|
+
useSeo({
|
|
35
38
|
title,
|
|
36
|
-
ogTitle: title,
|
|
37
39
|
description,
|
|
38
|
-
|
|
40
|
+
type: 'article',
|
|
41
|
+
modifiedAt: (page.value as unknown as Record<string, unknown>).modifiedAt as string | undefined,
|
|
42
|
+
breadcrumbs,
|
|
39
43
|
})
|
|
40
|
-
|
|
41
|
-
const headline = ref(findPageHeadline(navigation?.value, page.value?.path))
|
|
42
44
|
watch(() => navigation?.value, () => {
|
|
43
45
|
headline.value = findPageHeadline(navigation?.value, page.value?.path) || headline.value
|
|
44
46
|
})
|
|
@@ -99,9 +101,8 @@ addPrerenderPath(`/raw${route.path}.md`)
|
|
|
99
101
|
:value="page"
|
|
100
102
|
/>
|
|
101
103
|
|
|
102
|
-
<USeparator>
|
|
104
|
+
<USeparator v-if="github">
|
|
103
105
|
<div
|
|
104
|
-
v-if="github"
|
|
105
106
|
class="flex items-center gap-2 text-sm text-muted"
|
|
106
107
|
>
|
|
107
108
|
<UButton
|
|
@@ -15,20 +15,14 @@ if (!page.value) {
|
|
|
15
15
|
const title = page.value.seo?.title || page.value.title
|
|
16
16
|
const description = page.value.seo?.description || page.value.description
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
useSeo({
|
|
19
19
|
title,
|
|
20
20
|
description,
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
type: 'website',
|
|
22
|
+
ogImage: page.value?.seo?.ogImage as string | undefined,
|
|
23
23
|
})
|
|
24
24
|
|
|
25
|
-
if (page.value?.seo?.ogImage) {
|
|
26
|
-
useSeoMeta({
|
|
27
|
-
ogImage: page.value.seo.ogImage,
|
|
28
|
-
twitterImage: page.value.seo.ogImage,
|
|
29
|
-
})
|
|
30
|
-
}
|
|
31
|
-
else {
|
|
25
|
+
if (!page.value?.seo?.ogImage) {
|
|
32
26
|
defineOgImageComponent('Landing', {
|
|
33
27
|
title,
|
|
34
28
|
description,
|
package/app/utils/navigation.ts
CHANGED
|
@@ -5,3 +5,53 @@ export const flattenNavigation = (items?: ContentNavigationItem[]): ContentNavig
|
|
|
5
5
|
? flattenNavigation(item.children)
|
|
6
6
|
: [item],
|
|
7
7
|
) || []
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Transform navigation data by stripping locale and docs levels
|
|
11
|
+
*/
|
|
12
|
+
export function transformNavigation(
|
|
13
|
+
data: ContentNavigationItem[],
|
|
14
|
+
isI18nEnabled: boolean,
|
|
15
|
+
locale?: string,
|
|
16
|
+
): ContentNavigationItem[] {
|
|
17
|
+
if (isI18nEnabled && locale) {
|
|
18
|
+
// i18n: first strip locale level, then check for docs level
|
|
19
|
+
const localeResult = data.find(item => item.path === `/${locale}`)?.children || data
|
|
20
|
+
return localeResult.find(item => item.path === `/${locale}/docs`)?.children || localeResult
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
// non-i18n: strip docs level if exists
|
|
24
|
+
return data.find(item => item.path === '/docs')?.children || data
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface BreadcrumbItem {
|
|
29
|
+
title: string
|
|
30
|
+
path: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Find breadcrumb path to a page in the navigation tree
|
|
35
|
+
*/
|
|
36
|
+
export function findPageBreadcrumbs(
|
|
37
|
+
navigation: ContentNavigationItem[] | undefined,
|
|
38
|
+
path: string,
|
|
39
|
+
currentPath: BreadcrumbItem[] = [],
|
|
40
|
+
): BreadcrumbItem[] | undefined {
|
|
41
|
+
if (!navigation) return undefined
|
|
42
|
+
|
|
43
|
+
for (const item of navigation) {
|
|
44
|
+
const itemPath = [...currentPath, { title: item.title, path: item.path }]
|
|
45
|
+
|
|
46
|
+
if (item.path === path) {
|
|
47
|
+
return itemPath
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (item.children) {
|
|
51
|
+
const found = findPageBreadcrumbs(item.children, path, itemPath)
|
|
52
|
+
if (found) return found
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return undefined
|
|
57
|
+
}
|
package/content.config.ts
CHANGED
|
@@ -2,11 +2,15 @@ import type { DefinedCollection } from '@nuxt/content'
|
|
|
2
2
|
import { defineContentConfig, defineCollection, z } from '@nuxt/content'
|
|
3
3
|
import { useNuxt } from '@nuxt/kit'
|
|
4
4
|
import { joinURL } from 'ufo'
|
|
5
|
+
import { landingPageExists, docsFolderExists } from './utils/pages'
|
|
5
6
|
|
|
6
7
|
const { options } = useNuxt()
|
|
7
8
|
const cwd = joinURL(options.rootDir, 'content')
|
|
8
9
|
const locales = options.i18n?.locales
|
|
9
10
|
|
|
11
|
+
const hasLandingPage = landingPageExists(options.rootDir)
|
|
12
|
+
const hasDocsFolder = docsFolderExists(options.rootDir)
|
|
13
|
+
|
|
10
14
|
const createDocsSchema = () => z.object({
|
|
11
15
|
links: z.array(z.object({
|
|
12
16
|
label: z.string(),
|
|
@@ -22,21 +26,24 @@ if (locales && Array.isArray(locales)) {
|
|
|
22
26
|
collections = {}
|
|
23
27
|
for (const locale of locales) {
|
|
24
28
|
const code = (typeof locale === 'string' ? locale : locale.code).replace('-', '_')
|
|
29
|
+
const hasLocaleDocs = docsFolderExists(options.rootDir, code)
|
|
25
30
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
if (!hasLandingPage) {
|
|
32
|
+
collections[`landing_${code}`] = defineCollection({
|
|
33
|
+
type: 'page',
|
|
34
|
+
source: {
|
|
35
|
+
cwd,
|
|
36
|
+
include: `${code}/index.md`,
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
}
|
|
33
40
|
|
|
34
41
|
collections[`docs_${code}`] = defineCollection({
|
|
35
42
|
type: 'page',
|
|
36
43
|
source: {
|
|
37
44
|
cwd,
|
|
38
|
-
include: `${code}/**/*`,
|
|
39
|
-
prefix: `/${code}`,
|
|
45
|
+
include: hasLocaleDocs ? `${code}/docs/**` : `${code}/**/*`,
|
|
46
|
+
prefix: hasLocaleDocs ? `/${code}/docs` : `/${code}`,
|
|
40
47
|
exclude: [`${code}/index.md`],
|
|
41
48
|
},
|
|
42
49
|
schema: createDocsSchema(),
|
|
@@ -45,22 +52,27 @@ if (locales && Array.isArray(locales)) {
|
|
|
45
52
|
}
|
|
46
53
|
else {
|
|
47
54
|
collections = {
|
|
48
|
-
|
|
55
|
+
docs: defineCollection({
|
|
49
56
|
type: 'page',
|
|
50
57
|
source: {
|
|
51
58
|
cwd,
|
|
52
|
-
include: '
|
|
59
|
+
include: hasDocsFolder ? 'docs/**' : '**',
|
|
60
|
+
prefix: hasDocsFolder ? '/docs' : undefined,
|
|
61
|
+
exclude: ['index.md'],
|
|
53
62
|
},
|
|
63
|
+
schema: createDocsSchema(),
|
|
54
64
|
}),
|
|
55
|
-
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Only define landing collection if user doesn't have their own index.vue
|
|
68
|
+
if (!hasLandingPage) {
|
|
69
|
+
collections.landing = defineCollection({
|
|
56
70
|
type: 'page',
|
|
57
71
|
source: {
|
|
58
72
|
cwd,
|
|
59
|
-
include: '
|
|
60
|
-
exclude: ['index.md'],
|
|
73
|
+
include: 'index.md',
|
|
61
74
|
},
|
|
62
|
-
|
|
63
|
-
}),
|
|
75
|
+
})
|
|
64
76
|
}
|
|
65
77
|
}
|
|
66
78
|
|
|
@@ -18,7 +18,7 @@ const placeholder = computed(() => t('assistant.placeholder'))
|
|
|
18
18
|
const shortcutDisplayKeys = computed(() => {
|
|
19
19
|
const shortcut = focusInputShortcut.value
|
|
20
20
|
const parts = shortcut.split('_')
|
|
21
|
-
return parts.map(part => part === 'meta' ? 'meta' : part.toUpperCase())
|
|
21
|
+
return parts.map((part: string) => part === 'meta' ? 'meta' : part.toUpperCase())
|
|
22
22
|
})
|
|
23
23
|
|
|
24
24
|
function handleSubmit() {
|
|
@@ -62,43 +62,48 @@ defineShortcuts(shortcuts)
|
|
|
62
62
|
:animate="{ y: 0, opacity: 1 }"
|
|
63
63
|
:exit="{ y: 100, opacity: 0 }"
|
|
64
64
|
:transition="{ duration: 0.2, ease: 'easeOut' }"
|
|
65
|
-
class="fixed
|
|
65
|
+
class="pointer-events-none fixed inset-x-0 z-10 bottom-[max(1.5rem,env(safe-area-inset-bottom))] px-4 sm:px-80"
|
|
66
66
|
style="will-change: transform"
|
|
67
67
|
>
|
|
68
|
-
<form
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
68
|
+
<form
|
|
69
|
+
class="pointer-events-none flex w-full justify-center"
|
|
70
|
+
@submit.prevent="handleSubmit"
|
|
71
|
+
>
|
|
72
|
+
<div class="pointer-events-auto w-full max-w-96">
|
|
73
|
+
<UInput
|
|
74
|
+
ref="inputRef"
|
|
75
|
+
v-model="input"
|
|
76
|
+
:placeholder="placeholder"
|
|
77
|
+
size="lg"
|
|
78
|
+
maxlength="1000"
|
|
79
|
+
:ui="{
|
|
80
|
+
root: 'group w-full! min-w-0 sm:max-w-96 transition-all duration-300 ease-out [@media(hover:hover)]:hover:scale-105 [@media(hover:hover)]:focus-within:scale-105',
|
|
81
|
+
base: 'bg-default shadow-lg rounded-xl text-base',
|
|
82
|
+
trailing: 'pe-2',
|
|
83
|
+
}"
|
|
84
|
+
@keydown.enter.exact.prevent="handleSubmit"
|
|
85
|
+
>
|
|
86
|
+
<template #trailing>
|
|
87
|
+
<div class="flex items-center gap-2">
|
|
88
|
+
<div class="hidden sm:flex group-focus-within:hidden items-center gap-1">
|
|
89
|
+
<UKbd
|
|
90
|
+
v-for="key in shortcutDisplayKeys"
|
|
91
|
+
:key="key"
|
|
92
|
+
:value="key"
|
|
93
|
+
/>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<UButton
|
|
97
|
+
type="submit"
|
|
98
|
+
icon="i-lucide-arrow-up"
|
|
99
|
+
color="primary"
|
|
100
|
+
size="xs"
|
|
101
|
+
:disabled="!input.trim()"
|
|
89
102
|
/>
|
|
90
103
|
</div>
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
icon="i-lucide-arrow-up"
|
|
95
|
-
color="primary"
|
|
96
|
-
size="xs"
|
|
97
|
-
:disabled="!input.trim()"
|
|
98
|
-
/>
|
|
99
|
-
</div>
|
|
100
|
-
</template>
|
|
101
|
-
</UInput>
|
|
104
|
+
</template>
|
|
105
|
+
</UInput>
|
|
106
|
+
</div>
|
|
102
107
|
</form>
|
|
103
108
|
</motion.div>
|
|
104
109
|
</AnimatePresence>
|
package/modules/config.ts
CHANGED
|
@@ -16,7 +16,7 @@ export default defineNuxtModule({
|
|
|
16
16
|
const url = inferSiteURL()
|
|
17
17
|
const meta = await getPackageJsonMetadata(dir)
|
|
18
18
|
const gitInfo = await getLocalGitInfo(dir) || getGitEnv()
|
|
19
|
-
const siteName = nuxt.options
|
|
19
|
+
const siteName = (typeof nuxt.options.site === 'object' && nuxt.options.site?.name) || meta.name || gitInfo?.name || ''
|
|
20
20
|
|
|
21
21
|
nuxt.options.llms = defu(nuxt.options.llms, {
|
|
22
22
|
domain: url,
|
package/modules/css.ts
CHANGED
package/modules/routing.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { defineNuxtModule, extendPages, createResolver } from '@nuxt/kit'
|
|
2
|
+
import { landingPageExists } from '../utils/pages'
|
|
2
3
|
|
|
3
4
|
export default defineNuxtModule({
|
|
4
5
|
meta: {
|
|
@@ -19,23 +20,26 @@ export default defineNuxtModule({
|
|
|
19
20
|
})
|
|
20
21
|
})
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
// Only add landing if index.vue is not already defined
|
|
24
|
+
if (!landingPageExists(nuxt.options.rootDir)) {
|
|
25
|
+
extendPages((pages) => {
|
|
26
|
+
const landingTemplate = resolve('../app/templates/landing.vue')
|
|
24
27
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
28
|
+
if (isI18nEnabled) {
|
|
29
|
+
pages.push({
|
|
30
|
+
name: 'lang-index',
|
|
31
|
+
path: '/:lang?',
|
|
32
|
+
file: landingTemplate,
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
pages.push({
|
|
37
|
+
name: 'index',
|
|
38
|
+
path: '/',
|
|
39
|
+
file: landingTemplate,
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
}
|
|
40
44
|
},
|
|
41
45
|
})
|
package/nuxt.config.ts
CHANGED
|
@@ -20,14 +20,16 @@ export default defineNuxtConfig({
|
|
|
20
20
|
extendViteConfig((config) => {
|
|
21
21
|
config.optimizeDeps ||= {}
|
|
22
22
|
config.optimizeDeps.include ||= []
|
|
23
|
-
config.optimizeDeps.include.push(
|
|
24
|
-
'@nuxt/content > slugify',
|
|
25
|
-
// Fix @vercel/oidc ESM export issue (transitive dep of @ai-sdk/gateway)
|
|
26
|
-
'@vercel/oidc',
|
|
27
|
-
)
|
|
23
|
+
config.optimizeDeps.include.push('@nuxt/content > slugify')
|
|
28
24
|
config.optimizeDeps.include = config.optimizeDeps.include
|
|
29
25
|
.map(id => id.replace(/^@nuxt\/content > /, 'docus > @nuxt/content > '))
|
|
30
|
-
|
|
26
|
+
|
|
27
|
+
// Fix @vercel/oidc ESM export issue (transitive dep of @ai-sdk/gateway)
|
|
28
|
+
// Only needed when AI assistant is enabled.
|
|
29
|
+
if (process.env.AI_GATEWAY_API_KEY) {
|
|
30
|
+
config.optimizeDeps.include.push('@vercel/oidc')
|
|
31
|
+
config.optimizeDeps.include.map(id => id.replace(/^@vercel\/oidc$/, 'docus > @vercel/oidc'))
|
|
32
|
+
}
|
|
31
33
|
})
|
|
32
34
|
},
|
|
33
35
|
],
|
|
@@ -35,6 +37,7 @@ export default defineNuxtConfig({
|
|
|
35
37
|
enabled: true,
|
|
36
38
|
},
|
|
37
39
|
content: {
|
|
40
|
+
experimental: { sqliteConnector: 'native' },
|
|
38
41
|
build: {
|
|
39
42
|
markdown: {
|
|
40
43
|
highlight: {
|
|
@@ -93,4 +96,13 @@ export default defineNuxtConfig({
|
|
|
93
96
|
icon: {
|
|
94
97
|
provider: 'iconify',
|
|
95
98
|
},
|
|
99
|
+
robots: {
|
|
100
|
+
groups: [
|
|
101
|
+
{
|
|
102
|
+
userAgent: '*',
|
|
103
|
+
allow: '/',
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
sitemap: '/sitemap.xml',
|
|
107
|
+
},
|
|
96
108
|
})
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "docus",
|
|
3
3
|
"description": "Nuxt layer for Docus documentation theme",
|
|
4
|
-
"version": "5.
|
|
4
|
+
"version": "5.6.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./nuxt.config.ts",
|
|
7
7
|
"repository": {
|
|
@@ -22,27 +22,27 @@
|
|
|
22
22
|
"README.md"
|
|
23
23
|
],
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@ai-sdk/gateway": "^3.0.
|
|
26
|
-
"@ai-sdk/mcp": "^1.0.
|
|
27
|
-
"@ai-sdk/vue": "3.0.
|
|
28
|
-
"@iconify-json/lucide": "^1.2.
|
|
29
|
-
"@iconify-json/simple-icons": "^1.2.
|
|
30
|
-
"@iconify-json/vscode-icons": "^1.2.
|
|
31
|
-
"@nuxt/content": "^3.11.
|
|
25
|
+
"@ai-sdk/gateway": "^3.0.46",
|
|
26
|
+
"@ai-sdk/mcp": "^1.0.21",
|
|
27
|
+
"@ai-sdk/vue": "3.0.86",
|
|
28
|
+
"@iconify-json/lucide": "^1.2.91",
|
|
29
|
+
"@iconify-json/simple-icons": "^1.2.71",
|
|
30
|
+
"@iconify-json/vscode-icons": "^1.2.43",
|
|
31
|
+
"@nuxt/content": "^3.11.2",
|
|
32
32
|
"@nuxt/image": "^2.0.0",
|
|
33
|
-
"@nuxt/kit": "^4.3.
|
|
33
|
+
"@nuxt/kit": "^4.3.1",
|
|
34
34
|
"@nuxt/ui": "^4.4.0",
|
|
35
|
-
"@nuxtjs/i18n": "^10.2.
|
|
36
|
-
"@nuxtjs/mcp-toolkit": "^0.
|
|
37
|
-
"@nuxtjs/mdc": "^0.20.
|
|
35
|
+
"@nuxtjs/i18n": "^10.2.3",
|
|
36
|
+
"@nuxtjs/mcp-toolkit": "^0.7.0",
|
|
37
|
+
"@nuxtjs/mdc": "^0.20.1",
|
|
38
38
|
"@nuxtjs/robots": "^5.7.0",
|
|
39
|
-
"@vueuse/core": "^14.2.
|
|
40
|
-
"ai": "6.0.
|
|
39
|
+
"@vueuse/core": "^14.2.1",
|
|
40
|
+
"ai": "6.0.86",
|
|
41
41
|
"defu": "^6.1.4",
|
|
42
42
|
"exsolve": "^1.0.8",
|
|
43
43
|
"git-url-parse": "^16.1.0",
|
|
44
|
-
"minimark": "^0.
|
|
45
|
-
"motion-v": "^1.10.
|
|
44
|
+
"minimark": "^1.0.0",
|
|
45
|
+
"motion-v": "^1.10.3",
|
|
46
46
|
"nuxt-llms": "^0.2.0",
|
|
47
47
|
"nuxt-og-image": "^5.1.13",
|
|
48
48
|
"pkg-types": "^2.3.0",
|
|
@@ -2,6 +2,11 @@ import { queryCollection } from '@nuxt/content/server'
|
|
|
2
2
|
import { getAvailableLocales, getCollectionsToQuery } from '../utils/content'
|
|
3
3
|
import { inferSiteURL } from '../../utils/meta'
|
|
4
4
|
|
|
5
|
+
interface SitemapUrl {
|
|
6
|
+
loc: string
|
|
7
|
+
lastmod?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
5
10
|
export default defineEventHandler(async (event) => {
|
|
6
11
|
const config = useRuntimeConfig(event)
|
|
7
12
|
const siteUrl = inferSiteURL() || ''
|
|
@@ -18,7 +23,7 @@ export default defineEventHandler(async (event) => {
|
|
|
18
23
|
collections.push('landing')
|
|
19
24
|
}
|
|
20
25
|
|
|
21
|
-
const urls:
|
|
26
|
+
const urls: SitemapUrl[] = []
|
|
22
27
|
|
|
23
28
|
for (const collection of collections) {
|
|
24
29
|
try {
|
|
@@ -34,9 +39,16 @@ export default defineEventHandler(async (event) => {
|
|
|
34
39
|
// Skip .navigation files (used for navigation configuration)
|
|
35
40
|
if (pagePath.endsWith('.navigation') || pagePath.includes('/.navigation')) continue
|
|
36
41
|
|
|
37
|
-
|
|
42
|
+
const urlEntry: SitemapUrl = {
|
|
38
43
|
loc: pagePath,
|
|
39
|
-
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Add lastmod if available (modifiedAt from content)
|
|
47
|
+
if (meta.modifiedAt && typeof meta.modifiedAt === 'string') {
|
|
48
|
+
urlEntry.lastmod = meta.modifiedAt.split('T')[0] // Use date part only (YYYY-MM-DD)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
urls.push(urlEntry)
|
|
40
52
|
}
|
|
41
53
|
}
|
|
42
54
|
catch {
|
|
@@ -50,11 +62,18 @@ export default defineEventHandler(async (event) => {
|
|
|
50
62
|
return sitemap
|
|
51
63
|
})
|
|
52
64
|
|
|
53
|
-
function generateSitemap(urls:
|
|
65
|
+
function generateSitemap(urls: SitemapUrl[], siteUrl: string): string {
|
|
54
66
|
const urlEntries = urls
|
|
55
67
|
.map((url) => {
|
|
56
68
|
const loc = siteUrl ? `${siteUrl}${url.loc}` : url.loc
|
|
57
|
-
|
|
69
|
+
let entry = ` <url>\n <loc>${escapeXml(loc)}</loc>`
|
|
70
|
+
|
|
71
|
+
if (url.lastmod) {
|
|
72
|
+
entry += `\n <lastmod>${escapeXml(url.lastmod)}</lastmod>`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
entry += `\n </url>`
|
|
76
|
+
return entry
|
|
58
77
|
})
|
|
59
78
|
.join('\n')
|
|
60
79
|
|
package/utils/pages.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { joinURL } from 'ufo'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Checks if the user has their own index.vue file in the pages folder.
|
|
6
|
+
* When true, the layer should not define the landing collection and / route.
|
|
7
|
+
*/
|
|
8
|
+
export function landingPageExists(rootDir: string): boolean {
|
|
9
|
+
const vueLandingPath = joinURL(rootDir, 'app', 'pages', 'index.vue')
|
|
10
|
+
return existsSync(vueLandingPath)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Checks if a docs folder exists in the content directory.
|
|
15
|
+
* When true, docs should be prefixed with /docs and only include files from the docs folder.
|
|
16
|
+
*/
|
|
17
|
+
export function docsFolderExists(rootDir: string, locale?: string): boolean {
|
|
18
|
+
const docsPath = locale
|
|
19
|
+
? joinURL(rootDir, 'content', locale, 'docs')
|
|
20
|
+
: joinURL(rootDir, 'content', 'docs')
|
|
21
|
+
return existsSync(docsPath)
|
|
22
|
+
}
|