docus 5.5.0 → 5.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/app/app.vue CHANGED
@@ -81,8 +81,8 @@ provide('navigation', navigation)
81
81
  :navigation="navigation"
82
82
  />
83
83
  <template v-if="isAssistantEnabled">
84
- <LazyAssistantFloatingInput />
85
84
  <LazyAssistantPanel />
85
+ <LazyAssistantFloatingInput />
86
86
  </template>
87
87
  </ClientOnly>
88
88
  </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
+ }
@@ -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,34 @@ export const flattenNavigation = (items?: ContentNavigationItem[]): ContentNavig
5
5
  ? flattenNavigation(item.children)
6
6
  : [item],
7
7
  ) || []
8
+
9
+ export interface BreadcrumbItem {
10
+ title: string
11
+ path: string
12
+ }
13
+
14
+ /**
15
+ * Find breadcrumb path to a page in the navigation tree
16
+ */
17
+ export function findPageBreadcrumbs(
18
+ navigation: ContentNavigationItem[] | undefined,
19
+ path: string,
20
+ currentPath: BreadcrumbItem[] = [],
21
+ ): BreadcrumbItem[] | undefined {
22
+ if (!navigation) return undefined
23
+
24
+ for (const item of navigation) {
25
+ const itemPath = [...currentPath, { title: item.title, path: item.path }]
26
+
27
+ if (item.path === path) {
28
+ return itemPath
29
+ }
30
+
31
+ if (item.children) {
32
+ const found = findPageBreadcrumbs(item.children, path, itemPath)
33
+ if (found) return found
34
+ }
35
+ }
36
+
37
+ return undefined
38
+ }
@@ -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
 
@@ -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="fixed inset-x-0 z-10 px-4 sm:px-80 bottom-[max(1.5rem,env(safe-area-inset-bottom))]"
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="flex w-full justify-center"
70
+ @submit.prevent="handleSubmit"
71
+ >
72
+ <div class="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
  })
package/nuxt.config.ts CHANGED
@@ -93,4 +93,13 @@ export default defineNuxtConfig({
93
93
  icon: {
94
94
  provider: 'iconify',
95
95
  },
96
+ robots: {
97
+ groups: [
98
+ {
99
+ userAgent: '*',
100
+ allow: '/',
101
+ },
102
+ ],
103
+ sitemap: '/sitemap.xml',
104
+ },
96
105
  })
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.5.1",
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.39",
26
+ "@ai-sdk/mcp": "^1.0.19",
27
+ "@ai-sdk/vue": "3.0.78",
28
+ "@iconify-json/lucide": "^1.2.90",
29
+ "@iconify-json/simple-icons": "^1.2.70",
30
+ "@iconify-json/vscode-icons": "^1.2.41",
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.6.3",
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.78",
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