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 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
- useSeoMeta({
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
- ogDescription: description,
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
- useSeoMeta({
18
+ useSeo({
19
19
  title,
20
20
  description,
21
- ogTitle: title,
22
- ogDescription: description,
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,
@@ -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
- collections[`landing_${code}`] = defineCollection({
27
- type: 'page',
28
- source: {
29
- cwd,
30
- include: `${code}/index.md`,
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
- landing: defineCollection({
55
+ docs: defineCollection({
49
56
  type: 'page',
50
57
  source: {
51
58
  cwd,
52
- include: 'index.md',
59
+ include: hasDocsFolder ? 'docs/**' : '**',
60
+ prefix: hasDocsFolder ? '/docs' : undefined,
61
+ exclude: ['index.md'],
53
62
  },
63
+ schema: createDocsSchema(),
54
64
  }),
55
- docs: defineCollection({
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
- schema: createDocsSchema(),
63
- }),
75
+ })
64
76
  }
65
77
  }
66
78
 
@@ -67,7 +67,7 @@ export default defineNuxtModule<AssistantModuleOptions>({
67
67
  )
68
68
 
69
69
  if (!hasApiKey) {
70
- log.warn('[Docus] AI assistant disabled: AI_GATEWAY_API_KEY not found')
70
+ log.warn('AI assistant disabled: AI_GATEWAY_API_KEY not found')
71
71
  return
72
72
  }
73
73
 
@@ -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 bottom-6 left-1/2 -translate-x-1/2 z-50 px-4"
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 @submit.prevent="handleSubmit">
69
- <UInput
70
- ref="inputRef"
71
- v-model="input"
72
- :placeholder="placeholder"
73
- size="lg"
74
- maxlength="1000"
75
- :ui="{
76
- root: 'group w-72 focus-within:w-96 transition-all duration-300 ease-out hover:scale-105 focus-within:scale-105',
77
- base: 'bg-default shadow-lg rounded-xl',
78
- trailing: 'pe-2',
79
- }"
80
- @keydown.enter.exact.prevent="handleSubmit"
81
- >
82
- <template #trailing>
83
- <div class="flex items-center gap-2">
84
- <div class="hidden sm:flex group-focus-within:hidden items-center gap-1">
85
- <UKbd
86
- v-for="key in shortcutDisplayKeys"
87
- :key="key"
88
- :value="key"
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
- <UButton
93
- type="submit"
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>
@@ -263,7 +263,7 @@ onMounted(() => {
263
263
  maxlength="1000"
264
264
  :ui="{
265
265
  root: 'shadow-none!',
266
- body: '*:p-0! *:rounded-none! *:text-sm!',
266
+ body: '*:p-0! *:rounded-none! *:text-base!',
267
267
  }"
268
268
  @submit="handleSubmit"
269
269
  >
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?.site?.name || meta.name || gitInfo?.name || ''
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
@@ -29,6 +29,8 @@ export default defineNuxtModule({
29
29
  },
30
30
  })
31
31
 
32
- nuxt.options.css.unshift(cssTemplate.dst)
32
+ if (Array.isArray(nuxt.options.css)) {
33
+ nuxt.options.css.unshift(cssTemplate.dst)
34
+ }
33
35
  },
34
36
  })
@@ -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
- extendPages((pages) => {
23
- const landingTemplate = resolve('../app/templates/landing.vue')
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
- if (isI18nEnabled) {
26
- pages.push({
27
- name: 'lang-index',
28
- path: '/:lang?',
29
- file: landingTemplate,
30
- })
31
- }
32
- else {
33
- pages.push({
34
- name: 'index',
35
- path: '/',
36
- file: landingTemplate,
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
- .map(id => id.replace(/^@vercel\/oidc$/, 'docus > @vercel/oidc'))
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.5.0",
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.32",
26
- "@ai-sdk/mcp": "^1.0.18",
27
- "@ai-sdk/vue": "3.0.69",
28
- "@iconify-json/lucide": "^1.2.88",
29
- "@iconify-json/simple-icons": "^1.2.69",
30
- "@iconify-json/vscode-icons": "^1.2.40",
31
- "@nuxt/content": "^3.11.0",
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.0",
33
+ "@nuxt/kit": "^4.3.1",
34
34
  "@nuxt/ui": "^4.4.0",
35
- "@nuxtjs/i18n": "^10.2.1",
36
- "@nuxtjs/mcp-toolkit": "^0.6.2",
37
- "@nuxtjs/mdc": "^0.20.0",
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.0",
40
- "ai": "6.0.69",
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.2.0",
45
- "motion-v": "^1.10.2",
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: Array<{ loc: string }> = []
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
- urls.push({
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: Array<{ loc: string }>, siteUrl: string): string {
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
- return ` <url>\n <loc>${escapeXml(loc)}</loc>\n </url>`
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
+ }