docus 5.8.0 → 5.9.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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- [![docus](https://docus.dev/__og-image__/static/og.png)](https://docus.dev)
1
+ [![docus](https://docus.dev/_og/s/c_Landing,title_Write+beautiful+docs+with+Markdown,description_Ship+fast+flexible+and+SEO-optimized+documentation+with+beautiful+design+out+of+the+box.+Docus+brings+together+the+best+of+the+Nuxt+ecosystem.+Powered+by+Nuxt+UI.,p_Ii9lbiI.png)](https://docus.dev)
2
2
 
3
3
  # Docus
4
4
 
package/app/app.config.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export default defineAppConfig({
2
2
  docus: {
3
3
  locale: 'en',
4
+ colorMode: '',
4
5
  },
5
6
  ui: {
6
7
  colors: {
package/app/app.vue CHANGED
@@ -2,9 +2,12 @@
2
2
  import type { ContentNavigationItem, PageCollections } from '@nuxt/content'
3
3
  import * as nuxtUiLocales from '@nuxt/ui/locale'
4
4
  import { transformNavigation } from './utils/navigation'
5
+ import { useDocusColorMode } from './composables/useDocusColorMode'
5
6
  import { useSubNavigation } from './composables/useSubNavigation'
6
7
 
7
- const { seo } = useAppConfig()
8
+ const appConfig = useAppConfig()
9
+ const { seo } = appConfig
10
+ const { forced: forcedColorMode } = useDocusColorMode()
8
11
  const site = useSiteConfig()
9
12
  const { locale, locales, isEnabled, switchLocalePath } = useDocusI18n()
10
13
  const { isEnabled: isAssistantEnabled, panelWidth: assistantPanelWidth, shouldPushContent } = useAssistant()
@@ -79,6 +82,7 @@ const { subNavigationMode } = useSubNavigation(navigation)
79
82
  <LazyUContentSearch
80
83
  :files="files"
81
84
  :navigation="navigation"
85
+ :color-mode="!forcedColorMode"
82
86
  />
83
87
  <template v-if="isAssistantEnabled">
84
88
  <LazyAssistantPanel />
@@ -0,0 +1,43 @@
1
+ <script lang="ts" setup>
2
+ const { title, description, headline } = defineProps<{ title?: string, description?: string, headline?: string }>()
3
+
4
+ const appConfig = useAppConfig()
5
+ const { name: siteName } = useSiteConfig()
6
+ const primaryColor = appConfig.ui?.colors?.primary ?? 'emerald'
7
+ </script>
8
+
9
+ <template>
10
+ <div class="w-full h-full flex flex-col justify-between bg-neutral-950 px-[80px] py-[60px]">
11
+ <!-- Radial glow top-right: wide soft layer -->
12
+ <div class="absolute top-0 right-0 w-[700px] h-[700px] bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.10)_0%,rgba(255,255,255,0.04)_40%,transparent_70%)]" />
13
+ <!-- Radial glow top-right: tight bright core -->
14
+ <div class="absolute top-0 right-0 w-[350px] h-[350px] bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.22)_0%,rgba(255,255,255,0.08)_35%,transparent_65%)]" />
15
+
16
+ <div class="flex-1 flex flex-col justify-center">
17
+ <p
18
+ v-if="headline"
19
+ :class="`uppercase text-[22px] font-bold m-0 mb-5 tracking-[0.05em] text-${primaryColor}-500`"
20
+ >
21
+ {{ headline }}
22
+ </p>
23
+ <h1
24
+ v-if="title"
25
+ class="m-0 mb-6 text-[50px] font-bold text-white leading-[1.1] w-full max-w-[900px] wrap-break-word"
26
+ >
27
+ {{ title?.slice(0, 60) }}
28
+ </h1>
29
+ <p
30
+ v-if="description"
31
+ class="m-0 text-[28px] text-neutral-400 leading-[1.4] w-full max-w-[900px] wrap-break-word"
32
+ >
33
+ {{ description?.slice(0, 200) }}
34
+ </p>
35
+ </div>
36
+
37
+ <div class="flex">
38
+ <div class="text-white text-[18px] font-normal rounded-lg px-5 py-2">
39
+ {{ siteName }}
40
+ </div>
41
+ </div>
42
+ </div>
43
+ </template>
@@ -0,0 +1,67 @@
1
+ <script lang="ts" setup>
2
+ const { title, description } = defineProps<{ title?: string, description?: string }>()
3
+
4
+ const appConfig = useAppConfig()
5
+ const { name: siteName } = useSiteConfig()
6
+ const primaryColor = appConfig.ui?.colors?.primary ?? 'emerald'
7
+ const logoPath = appConfig.header?.logo?.dark || appConfig.header?.logo?.light
8
+
9
+ const logoSvg = await fetchLogoSvg(logoPath)
10
+
11
+ async function fetchLogoSvg(path?: string): Promise<string> {
12
+ if (!path) return ''
13
+ try {
14
+ const { url: siteUrl } = useSiteConfig()
15
+ const url = path.startsWith('http') ? path : `${siteUrl}${path}`
16
+ const svg = await $fetch<string>(url, { responseType: 'text' })
17
+ return svg.replace('<svg', '<svg width="48" height="48"')
18
+ }
19
+ catch {
20
+ return ''
21
+ }
22
+ }
23
+ </script>
24
+
25
+ <template>
26
+ <div class="w-full h-full flex flex-col justify-between bg-neutral-950 px-[80px] py-[60px]">
27
+ <!-- Radial glow top-right: wide soft layer -->
28
+ <div class="absolute top-0 right-0 w-[700px] h-[700px] bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.10)_0%,rgba(255,255,255,0.04)_40%,transparent_70%)]" />
29
+ <!-- Radial glow top-right: tight bright core -->
30
+ <div class="absolute top-0 right-0 w-[350px] h-[350px] bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.22)_0%,rgba(255,255,255,0.08)_35%,transparent_65%)]" />
31
+
32
+ <div class="flex-1 flex flex-col justify-center w-full">
33
+ <div
34
+ v-if="logoSvg"
35
+ class="flex justify-center mb-8"
36
+ >
37
+ <!-- eslint-disable-next-line vue/no-v-html -->
38
+ <div
39
+ class="w-[48px] h-[48px]"
40
+ v-html="logoSvg"
41
+ />
42
+ </div>
43
+ <div
44
+ v-if="title"
45
+ class="flex justify-center mb-6"
46
+ >
47
+ <h1 class="m-0 text-[50px] font-bold text-white leading-[1.1] text-center wrap-break-word">
48
+ {{ title?.slice(0, 60) }}
49
+ </h1>
50
+ </div>
51
+ <div
52
+ v-if="description"
53
+ class="flex justify-center"
54
+ >
55
+ <p class="m-0 text-[28px] text-neutral-400 leading-[1.4] text-center wrap-break-word">
56
+ {{ description?.slice(0, 200) }}
57
+ </p>
58
+ </div>
59
+ </div>
60
+
61
+ <div class="flex">
62
+ <div :class="`text-[18px] font-normal rounded-lg px-5 py-2 text-${primaryColor}-500`">
63
+ {{ siteName }}
64
+ </div>
65
+ </div>
66
+ </div>
67
+ </template>
@@ -1,5 +1,8 @@
1
1
  <script setup lang="ts">
2
+ import { useDocusColorMode } from '../../composables/useDocusColorMode'
3
+
2
4
  const appConfig = useAppConfig()
5
+ const { forced: forcedColorMode } = useDocusColorMode()
3
6
 
4
7
  interface FooterLink {
5
8
  'icon': string
@@ -44,5 +47,5 @@ const links = computed<FooterLink[]>(() => {
44
47
  v-bind="{ color: 'neutral', variant: 'ghost', ...link }"
45
48
  />
46
49
  </template>
47
- <UColorModeButton />
50
+ <UColorModeButton v-if="!forcedColorMode" />
48
51
  </template>
@@ -1,8 +1,10 @@
1
1
  <script setup lang="ts">
2
+ import { useDocusColorMode } from '../../composables/useDocusColorMode'
2
3
  import { useDocusI18n } from '../../composables/useDocusI18n'
3
- import { useSubNavigation } from '~/composables/useSubNavigation'
4
+ import { useSubNavigation } from '../../composables/useSubNavigation'
4
5
 
5
6
  const appConfig = useAppConfig()
7
+ const { forced: forcedColorMode } = useDocusColorMode()
6
8
  const site = useSiteConfig()
7
9
 
8
10
  const { isEnabled: isAssistantEnabled } = useAssistant()
@@ -58,7 +60,7 @@ const links = computed(() => appConfig.github && appConfig.github.url
58
60
 
59
61
  <UContentSearchButton class="lg:hidden" />
60
62
 
61
- <ClientOnly>
63
+ <ClientOnly v-if="!forcedColorMode">
62
64
  <UColorModeButton />
63
65
 
64
66
  <template #fallback>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { useSubNavigation } from '~/composables/useSubNavigation'
2
+ import { useSubNavigation } from '../../composables/useSubNavigation'
3
3
 
4
4
  const { sections } = useSubNavigation()
5
5
  </script>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { useSubNavigation } from '~/composables/useSubNavigation'
2
+ import { useSubNavigation } from '../../composables/useSubNavigation'
3
3
 
4
4
  const { subNavigationMode, sections } = useSubNavigation()
5
5
  </script>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { useSubNavigation } from '~/composables/useSubNavigation'
2
+ import { useSubNavigation } from '../../composables/useSubNavigation'
3
3
  import type { ContentTocLink } from '@nuxt/ui'
4
4
 
5
5
  defineProps<{
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { useSubNavigation } from '~/composables/useSubNavigation'
2
+ import { useSubNavigation } from '../../composables/useSubNavigation'
3
3
  import type { DocsCollectionItem } from '@nuxt/content'
4
4
 
5
5
  const props = defineProps<{
@@ -0,0 +1,7 @@
1
+ export function useDocusColorMode() {
2
+ const appConfig = useAppConfig()
3
+ const forced = (appConfig.docus as { colorMode?: string })?.colorMode
4
+ return {
5
+ forced: forced === 'light' || forced === 'dark' ? forced : undefined,
6
+ }
7
+ }
package/app/error.vue CHANGED
@@ -3,11 +3,13 @@ import type { NuxtError } from '#app'
3
3
  import type { ContentNavigationItem, PageCollections } from '@nuxt/content'
4
4
  import * as nuxtUiLocales from '@nuxt/ui/locale'
5
5
  import { transformNavigation } from './utils/navigation'
6
+ import { useDocusColorMode } from './composables/useDocusColorMode'
6
7
 
7
8
  const props = defineProps<{
8
9
  error: NuxtError
9
10
  }>()
10
11
 
12
+ const { forced: forcedColorMode } = useDocusColorMode()
11
13
  const { locale, locales, isEnabled, t, switchLocalePath } = useDocusI18n()
12
14
 
13
15
  const nuxtUiLocale = computed(() => nuxtUiLocales[locale.value as keyof typeof nuxtUiLocales] || nuxtUiLocales.en)
@@ -70,6 +72,7 @@ provide('navigation', navigation)
70
72
  <LazyUContentSearch
71
73
  :files="files"
72
74
  :navigation="navigation"
75
+ :color-mode="!forcedColorMode"
73
76
  />
74
77
  </ClientOnly>
75
78
  </UApp>
@@ -0,0 +1,8 @@
1
+ import { useDocusColorMode } from '../composables/useDocusColorMode'
2
+
3
+ export default defineNuxtRouteMiddleware((to) => {
4
+ const { forced } = useDocusColorMode()
5
+ if (forced) {
6
+ to.meta.colorMode = forced
7
+ }
8
+ })
@@ -45,8 +45,10 @@ watch(() => navigation?.value, () => {
45
45
  headline.value = findPageHeadline(navigation?.value, page.value?.path) || headline.value
46
46
  })
47
47
 
48
- defineOgImageComponent('Docs', {
48
+ defineOgImage('Docs', {
49
49
  headline: headline.value,
50
+ title: title?.slice(0, 60),
51
+ description: formatOgDescription(title, description),
50
52
  })
51
53
 
52
54
  const github = computed(() => appConfig.github ? appConfig.github : null)
@@ -115,17 +117,19 @@ addPrerenderPath(`/raw${route.path}.md`)
115
117
  >
116
118
  {{ t('docs.edit') }}
117
119
  </UButton>
118
- <span>{{ t('common.or') }}</span>
119
- <UButton
120
- variant="link"
121
- color="neutral"
122
- :to="`${github.url}/issues/new/choose`"
123
- target="_blank"
124
- icon="i-lucide-alert-circle"
125
- :ui="{ leadingIcon: 'size-4' }"
126
- >
127
- {{ t('docs.report') }}
128
- </UButton>
120
+ <template v-if="github?.url">
121
+ <span>{{ t('common.or') }}</span>
122
+ <UButton
123
+ variant="link"
124
+ color="neutral"
125
+ :to="`${github.url}/issues/new/choose`"
126
+ target="_blank"
127
+ icon="i-lucide-alert-circle"
128
+ :ui="{ leadingIcon: 'size-4' }"
129
+ >
130
+ {{ t('docs.report') }}
131
+ </UButton>
132
+ </template>
129
133
  </div>
130
134
  </USeparator>
131
135
  <UContentSurround :surround="surround" />
@@ -23,9 +23,9 @@ useSeo({
23
23
  })
24
24
 
25
25
  if (!page.value?.seo?.ogImage) {
26
- defineOgImageComponent('Landing', {
27
- title,
28
- description,
26
+ defineOgImage('Landing', {
27
+ title: title?.slice(0, 60),
28
+ description: formatOgDescription(title, description),
29
29
  })
30
30
  }
31
31
  </script>
@@ -0,0 +1,23 @@
1
+ // nuxt-og-image caps the encoded URL segment at 200 chars.
2
+ // Fixed overhead (component name, param keys, path encoding) is ~50 chars,
3
+ // leaving a ~150-char budget for title + description combined.
4
+ const OG_BUDGET = 150
5
+
6
+ /**
7
+ * Trims description to fit within the nuxt-og-image 200-char URL segment limit,
8
+ * accounting for the title length and trying to cut at the last sentence boundary.
9
+ */
10
+ export function formatOgDescription(title: string | undefined, description: string | undefined): string | undefined {
11
+ if (!description) return undefined
12
+
13
+ const titleLen = Math.min(title?.length ?? 0, 60)
14
+ const maxLen = OG_BUDGET - titleLen
15
+ if (maxLen <= 0) return undefined
16
+
17
+ const cleaned = description.replace(/,/g, '')
18
+ if (cleaned.length <= maxLen) return cleaned
19
+
20
+ const truncated = cleaned.slice(0, maxLen)
21
+ const lastDot = truncated.lastIndexOf('.')
22
+ return lastDot > 0 ? truncated.slice(0, lastDot + 1) : truncated
23
+ }
@@ -35,13 +35,20 @@ export default defineNuxtConfig({
35
35
  })
36
36
  ```
37
37
 
38
- 4. Set up your API key as an environment variable:
38
+ 4. Authenticate to AI Gateway in one of two ways:
39
+
40
+ - **`AI_GATEWAY_API_KEY`** — Set it in the Vercel project env UI (and locally in `.env` if you want).
41
+ - **OIDC** — On Vercel, `VERCEL_OIDC_TOKEN` is injected automatically; you do **not** add it (or an API key) in the dashboard. For local builds, run `vercel env pull` on a linked project so `.env` contains the token:
39
42
 
40
43
  ```bash
44
+ # Option A — API key (dashboard + optional local .env)
41
45
  AI_GATEWAY_API_KEY=your-gateway-key
46
+
47
+ # Option B — local only, after vercel env pull (not set manually on Vercel)
48
+ VERCEL_OIDC_TOKEN=...
42
49
  ```
43
50
 
44
- > **Note:** The module will only be enabled if `AI_GATEWAY_API_KEY` is detected. If no key is found, the module is disabled and a message is logged to the console.
51
+ > **Note:** The module enables when `AI_GATEWAY_API_KEY` or `VERCEL_OIDC_TOKEN` is present at build time. On Vercel, OIDC covers that without you creating env vars in the UI. If neither is available at build, the module stays disabled and a warning is logged.
45
52
 
46
53
  ## Usage
47
54
 
@@ -199,7 +206,7 @@ Composable for syntax highlighting code blocks with Shiki.
199
206
  - Nuxt 4.x
200
207
  - Nuxt UI 3.x (for `USlideover`, `UButton`, `UTextarea`, `UChatMessages`, etc.)
201
208
  - An MCP server running (path configurable via `mcpServer`)
202
- - `AI_GATEWAY_API_KEY` environment variable
209
+ - `AI_GATEWAY_API_KEY` or `VERCEL_OIDC_TOKEN` at build time
203
210
 
204
211
  ## Customization
205
212
 
@@ -33,12 +33,14 @@ export default defineNuxtModule<AssistantModuleOptions>({
33
33
  model: 'google/gemini-3-flash',
34
34
  },
35
35
  setup(options, nuxt) {
36
- const hasApiKey = !!process.env.AI_GATEWAY_API_KEY
36
+ const hasAiGatewayAuth = !!(
37
+ process.env.AI_GATEWAY_API_KEY || process.env.VERCEL_OIDC_TOKEN
38
+ )
37
39
 
38
40
  const { resolve } = createResolver(import.meta.url)
39
41
 
40
42
  nuxt.options.runtimeConfig.public.assistant = {
41
- enabled: hasApiKey,
43
+ enabled: hasAiGatewayAuth,
42
44
  apiPath: options.apiPath!,
43
45
  }
44
46
 
@@ -60,14 +62,14 @@ export default defineNuxtModule<AssistantModuleOptions>({
60
62
  components.forEach(name =>
61
63
  addComponent({
62
64
  name,
63
- filePath: hasApiKey
65
+ filePath: hasAiGatewayAuth
64
66
  ? resolve(`./runtime/components/${name}.vue`)
65
67
  : resolve('./runtime/components/AssistantChatDisabled.vue'),
66
68
  }),
67
69
  )
68
70
 
69
- if (!hasApiKey) {
70
- log.warn('AI assistant disabled: AI_GATEWAY_API_KEY not found')
71
+ if (!hasAiGatewayAuth) {
72
+ log.warn('AI assistant disabled: neither AI_GATEWAY_API_KEY nor VERCEL_OIDC_TOKEN found')
71
73
  return
72
74
  }
73
75
 
@@ -37,6 +37,11 @@ function getSystemPrompt(siteName: string) {
37
37
  - Be concise, helpful, and direct
38
38
  - Guide users like a friendly expert would
39
39
 
40
+ **Links and exploration:**
41
+ - Tool results include a \`url\` for each page — prefer markdown links \`[label](url)\` so users can open the doc in one click
42
+ - When it helps, add extra links (related pages, “read more”, side topics) — make the answer easy to dig into, not a wall of text
43
+ - Stick to URLs from tool results (\`url\` / \`path\`) so links stay valid
44
+
40
45
  **FORMATTING RULES (CRITICAL):**
41
46
  - NEVER use markdown headings (#, ##, ###, etc.)
42
47
  - Use **bold text** for emphasis and section labels
package/modules/config.ts CHANGED
@@ -58,13 +58,18 @@ export default defineNuxtModule({
58
58
  branch: getGitBranch(),
59
59
  })
60
60
 
61
+ const forcedColorMode = (nuxt.options.appConfig.docus as Record<string, unknown>)?.colorMode as string | undefined
62
+ if (forcedColorMode === 'light' || forcedColorMode === 'dark') {
63
+ nuxt.options.colorMode = defu({ preference: forcedColorMode, fallback: forcedColorMode }, nuxt.options.colorMode || {}) as typeof nuxt.options.colorMode
64
+ }
65
+
61
66
  /*
62
67
  ** I18N
63
68
  */
64
- const typedNuxtOptions = nuxt.options as typeof nuxt.options & { i18n?: DocusI18nOptions }
69
+ const typedNuxtOptions = nuxt.options as typeof nuxt.options & { i18n?: false | DocusI18nOptions }
65
70
  const i18nOptions = typedNuxtOptions.i18n
66
71
 
67
- if (i18nOptions?.locales) {
72
+ if (i18nOptions && typeof i18nOptions === 'object' && i18nOptions.locales) {
68
73
  const { resolve } = createResolver(import.meta.url)
69
74
 
70
75
  // Filter locales to only include existing ones
@@ -0,0 +1,146 @@
1
+ import { addServerHandler, createResolver, defineNuxtModule, logger } from '@nuxt/kit'
2
+ import { existsSync } from 'node:fs'
3
+ import { readdir, readFile } from 'node:fs/promises'
4
+ import { join } from 'node:path'
5
+ import { parse as parseYaml } from 'yaml'
6
+
7
+ interface SkillEntry {
8
+ name: string
9
+ description: string
10
+ files: string[]
11
+ }
12
+
13
+ const SKILL_NAME_REGEX = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/
14
+ const MAX_NAME_LENGTH = 64
15
+
16
+ const log = logger.withTag('Docus')
17
+
18
+ export default defineNuxtModule({
19
+ meta: {
20
+ name: 'skills',
21
+ },
22
+ async setup(_options, nuxt) {
23
+ const skillsDir = join(nuxt.options.rootDir, 'skills')
24
+ if (!existsSync(skillsDir)) return
25
+
26
+ const catalog = await scanSkills(skillsDir)
27
+ if (!catalog.length) return
28
+
29
+ log.info(`Found ${catalog.length} agent skill${catalog.length > 1 ? 's' : ''}: ${catalog.map(s => s.name).join(', ')}`)
30
+
31
+ nuxt.options.runtimeConfig.skills = { catalog }
32
+
33
+ const { resolve } = createResolver(import.meta.url)
34
+
35
+ nuxt.hook('nitro:config', (nitroConfig) => {
36
+ nitroConfig.serverAssets ||= []
37
+ nitroConfig.serverAssets.push({ baseName: 'skills', dir: skillsDir })
38
+
39
+ nitroConfig.prerender ||= {}
40
+ nitroConfig.prerender.routes ||= []
41
+ nitroConfig.prerender.routes.push('/.well-known/skills/index.json')
42
+ for (const skill of catalog) {
43
+ for (const file of skill.files) {
44
+ nitroConfig.prerender.routes.push(`/.well-known/skills/${skill.name}/${file}`)
45
+ }
46
+ }
47
+ })
48
+
49
+ addServerHandler({
50
+ route: '/.well-known/skills/index.json',
51
+ handler: resolve('./runtime/server/routes/skills-index'),
52
+ })
53
+
54
+ addServerHandler({
55
+ route: '/.well-known/skills/**',
56
+ handler: resolve('./runtime/server/routes/skills-files'),
57
+ })
58
+ },
59
+ })
60
+
61
+ function parseFrontmatter(content: string): { name?: string, description?: string } | null {
62
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
63
+ if (!match?.[1]) return null
64
+ try {
65
+ return parseYaml(match[1])
66
+ }
67
+ catch {
68
+ return null
69
+ }
70
+ }
71
+
72
+ function validateSkillName(name: string, dirName: string): boolean {
73
+ if (name.length > MAX_NAME_LENGTH) {
74
+ log.warn(`Skill "${name}" exceeds ${MAX_NAME_LENGTH} character limit`)
75
+ return false
76
+ }
77
+ if (!SKILL_NAME_REGEX.test(name) || name.includes('--')) {
78
+ log.warn(`Skill name "${name}" does not match the Agent Skills naming spec`)
79
+ return false
80
+ }
81
+ if (name !== dirName) {
82
+ log.warn(`Skill name "${name}" does not match directory name "${dirName}"`)
83
+ return false
84
+ }
85
+ return true
86
+ }
87
+
88
+ async function listFilesRecursively(dir: string, base: string = ''): Promise<string[]> {
89
+ const files: string[] = []
90
+ const entries = await readdir(dir, { withFileTypes: true })
91
+ for (const entry of entries) {
92
+ const relPath = base ? `${base}/${entry.name}` : entry.name
93
+ if (entry.isDirectory()) {
94
+ files.push(...await listFilesRecursively(join(dir, entry.name), relPath))
95
+ }
96
+ else {
97
+ files.push(relPath)
98
+ }
99
+ }
100
+ return files
101
+ }
102
+
103
+ async function scanSkills(skillsDir: string): Promise<SkillEntry[]> {
104
+ const catalog: SkillEntry[] = []
105
+ const entries = await readdir(skillsDir, { withFileTypes: true })
106
+
107
+ for (const entry of entries) {
108
+ if (!entry.isDirectory()) continue
109
+
110
+ const skillDir = join(skillsDir, entry.name)
111
+ const skillMdPath = join(skillDir, 'SKILL.md')
112
+
113
+ if (!existsSync(skillMdPath)) continue
114
+
115
+ const content = await readFile(skillMdPath, 'utf-8')
116
+ const frontmatter = parseFrontmatter(content)
117
+
118
+ if (!frontmatter?.description) {
119
+ log.warn(`Skipping skill "${entry.name}": missing description in SKILL.md frontmatter`)
120
+ continue
121
+ }
122
+
123
+ const name = frontmatter.name || entry.name
124
+ if (!validateSkillName(name, entry.name)) continue
125
+
126
+ const allFiles = await listFilesRecursively(skillDir)
127
+ const files = allFiles.filter(f => !f.split('/').some(s => s.startsWith('.')))
128
+ const sortedFiles = ['SKILL.md', ...files.filter(f => f !== 'SKILL.md')]
129
+
130
+ catalog.push({
131
+ name,
132
+ description: frontmatter.description,
133
+ files: sortedFiles,
134
+ })
135
+ }
136
+
137
+ return catalog
138
+ }
139
+
140
+ declare module 'nuxt/schema' {
141
+ interface RuntimeConfig {
142
+ skills: {
143
+ catalog: SkillEntry[]
144
+ }
145
+ }
146
+ }
@@ -0,0 +1,49 @@
1
+ const CONTENT_TYPES: Record<string, string> = {
2
+ '.md': 'text/markdown; charset=utf-8',
3
+ '.json': 'application/json; charset=utf-8',
4
+ '.yaml': 'text/yaml; charset=utf-8',
5
+ '.yml': 'text/yaml; charset=utf-8',
6
+ '.txt': 'text/plain; charset=utf-8',
7
+ '.py': 'text/plain; charset=utf-8',
8
+ '.sh': 'text/plain; charset=utf-8',
9
+ '.js': 'text/javascript; charset=utf-8',
10
+ '.ts': 'text/plain; charset=utf-8',
11
+ }
12
+
13
+ function getContentType(path: string): string {
14
+ const ext = path.slice(path.lastIndexOf('.'))
15
+ return CONTENT_TYPES[ext] || 'application/octet-stream'
16
+ }
17
+
18
+ export default defineEventHandler(async (event) => {
19
+ const url = getRequestURL(event)
20
+ const prefix = '/.well-known/skills/'
21
+ const idx = url.pathname.indexOf(prefix)
22
+ if (idx === -1) {
23
+ throw createError({ statusCode: 404, statusMessage: 'Not Found' })
24
+ }
25
+
26
+ const filePath = decodeURIComponent(url.pathname.slice(idx + prefix.length))
27
+
28
+ if (!filePath || filePath.includes('..')) {
29
+ throw createError({ statusCode: 400, statusMessage: 'Bad Request' })
30
+ }
31
+
32
+ const { skills } = useRuntimeConfig(event)
33
+ const skillName = filePath.split('/')[0]
34
+ if (!skills.catalog.some((s: { name: string }) => s.name === skillName)) {
35
+ throw createError({ statusCode: 404, statusMessage: 'Not Found' })
36
+ }
37
+
38
+ const storage = useStorage('assets:skills')
39
+ const content = await storage.getItemRaw<string>(filePath)
40
+
41
+ if (!content) {
42
+ throw createError({ statusCode: 404, statusMessage: 'Not Found' })
43
+ }
44
+
45
+ setResponseHeader(event, 'content-type', getContentType(filePath))
46
+ setResponseHeader(event, 'cache-control', 'public, max-age=3600')
47
+
48
+ return content
49
+ })
@@ -0,0 +1,8 @@
1
+ export default defineEventHandler((event) => {
2
+ const { skills } = useRuntimeConfig(event)
3
+
4
+ setResponseHeader(event, 'content-type', 'application/json')
5
+ setResponseHeader(event, 'cache-control', 'public, max-age=3600')
6
+
7
+ return { skills: skills.catalog }
8
+ })
package/nuxt.config.ts CHANGED
@@ -10,6 +10,7 @@ export default defineNuxtConfig({
10
10
  resolve('./modules/config'),
11
11
  resolve('./modules/routing'),
12
12
  resolve('./modules/markdown-rewrite'),
13
+ resolve('./modules/skills'),
13
14
  resolve('./modules/css'),
14
15
  () => {
15
16
  const nuxt = useNuxt()
@@ -38,9 +39,11 @@ export default defineNuxtConfig({
38
39
 
39
40
  // Fix @vercel/oidc ESM export issue (transitive dep of @ai-sdk/gateway)
40
41
  // Only needed when AI assistant is enabled.
41
- if (process.env.AI_GATEWAY_API_KEY) {
42
+ if (process.env.AI_GATEWAY_API_KEY || process.env.VERCEL_OIDC_TOKEN) {
42
43
  config.optimizeDeps.include.push('@vercel/oidc')
43
- config.optimizeDeps.include.map(id => id.replace(/^@vercel\/oidc$/, 'docus > @vercel/oidc'))
44
+ config.optimizeDeps.include = config.optimizeDeps.include.map(id =>
45
+ id.replace(/^@vercel\/oidc$/, 'docus > @vercel/oidc'),
46
+ )
44
47
  }
45
48
  })
46
49
  },
@@ -118,6 +121,9 @@ export default defineNuxtConfig({
118
121
  },
119
122
  provider: 'iconify',
120
123
  },
124
+ ogImage: {
125
+ zeroRuntime: true,
126
+ },
121
127
  robots: {
122
128
  groups: [
123
129
  {
package/nuxt.schema.ts CHANGED
@@ -277,6 +277,28 @@ export default defineNuxtSchema({
277
277
  }),
278
278
  },
279
279
  }),
280
+ docus: group({
281
+ title: 'Docus',
282
+ description: 'Docus configuration.',
283
+ icon: 'i-lucide-settings',
284
+ fields: {
285
+ locale: field({
286
+ type: 'string',
287
+ title: 'Locale',
288
+ description: 'Default locale for single-language documentation.',
289
+ icon: 'i-lucide-languages',
290
+ default: 'en',
291
+ }),
292
+ colorMode: field({
293
+ type: 'string',
294
+ title: 'Color Mode',
295
+ description: 'Force a specific color mode. Leave empty for system preference with toggle.',
296
+ icon: 'i-lucide-monitor',
297
+ default: '',
298
+ required: ['', 'light', 'dark'],
299
+ }),
300
+ },
301
+ }),
280
302
  assistant: group({
281
303
  title: 'Assistant',
282
304
  description: 'Assistant configuration.',
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.8.0",
4
+ "version": "5.9.0",
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",
7
7
  "repository": {
@@ -22,39 +22,41 @@
22
22
  "README.md"
23
23
  ],
24
24
  "dependencies": {
25
- "@ai-sdk/gateway": "^3.0.66",
26
- "@ai-sdk/mcp": "^1.0.25",
27
- "@ai-sdk/vue": "3.0.116",
28
- "@iconify-json/lucide": "^1.2.96",
29
- "@iconify-json/simple-icons": "^1.2.73",
25
+ "@ai-sdk/gateway": "^3.0.83",
26
+ "@ai-sdk/mcp": "^1.0.30",
27
+ "@ai-sdk/vue": "3.0.141",
28
+ "@iconify-json/lucide": "^1.2.100",
29
+ "@iconify-json/simple-icons": "^1.2.75",
30
30
  "@iconify-json/vscode-icons": "^1.2.45",
31
31
  "@nuxt/content": "^3.12.0",
32
32
  "@nuxt/image": "^2.0.0",
33
- "@nuxt/kit": "^4.3.1",
34
- "@nuxt/ui": "^4.5.1",
35
- "@nuxtjs/i18n": "^10.2.3",
36
- "@nuxtjs/mcp-toolkit": "^0.7.0",
37
- "@nuxtjs/mdc": "^0.20.2",
38
- "@nuxtjs/robots": "^5.7.1",
33
+ "@nuxt/kit": "^4.4.2",
34
+ "@nuxt/ui": "^4.6.0",
35
+ "@nuxtjs/i18n": "^10.2.4",
36
+ "@nuxtjs/mcp-toolkit": "^0.13.2",
37
+ "@nuxtjs/mdc": "^0.21.0",
38
+ "@nuxtjs/robots": "^6.0.6",
39
+ "@shikijs/core": "^4.0.2",
40
+ "@shikijs/engine-javascript": "^4.0.2",
41
+ "@shikijs/langs": "^4.0.2",
42
+ "@shikijs/themes": "^4.0.2",
43
+ "@takumi-rs/core": "^0.73.1",
39
44
  "@vueuse/core": "^14.2.1",
40
- "ai": "6.0.116",
45
+ "ai": "6.0.141",
41
46
  "defu": "^6.1.4",
42
47
  "exsolve": "^1.0.8",
43
48
  "git-url-parse": "^16.1.0",
44
- "motion-v": "^1.10.3",
49
+ "motion-v": "^2.2.0",
45
50
  "nuxt-llms": "^0.2.0",
46
- "nuxt-og-image": "^5.1.13",
51
+ "nuxt-og-image": "^6.3.1",
47
52
  "pkg-types": "^2.3.0",
48
53
  "scule": "^1.3.0",
49
- "@shikijs/core": "^3.22.0",
50
- "@shikijs/engine-javascript": "^3.22.0",
51
- "@shikijs/langs": "^3.22.0",
52
- "@shikijs/themes": "^3.22.0",
53
54
  "shiki-stream": "^0.1.4",
54
- "tailwindcss": "^4.2.1",
55
+ "tailwindcss": "^4.2.2",
55
56
  "ufo": "^1.6.3",
57
+ "yaml": "^2.7.1",
56
58
  "zod": "^4.3.6",
57
- "zod-to-json-schema": "^3.25.1"
59
+ "zod-to-json-schema": "^3.25.2"
58
60
  },
59
61
  "peerDependencies": {
60
62
  "better-sqlite3": "12.x",
@@ -17,9 +17,19 @@ WHEN NOT TO USE: If you don't know the exact path and need to search/explore, us
17
17
 
18
18
  WORKFLOW: This tool returns the complete page content including title, description, and full markdown. Use this when you need to provide detailed answers or code examples from specific documentation pages.
19
19
  `,
20
+ annotations: {
21
+ readOnlyHint: true,
22
+ destructiveHint: false,
23
+ idempotentHint: true,
24
+ openWorldHint: false,
25
+ },
20
26
  inputSchema: {
21
27
  path: z.string().describe('The page path from list-pages or provided by the user (e.g., /en/getting-started/installation)'),
22
28
  },
29
+ inputExamples: [
30
+ { path: '/en/getting-started/installation' },
31
+ { path: '/getting-started/introduction' },
32
+ ],
23
33
  cache: '1h',
24
34
  handler: async ({ path }) => {
25
35
  const event = useEvent()
@@ -38,21 +48,22 @@ WORKFLOW: This tool returns the complete page content including title, descripti
38
48
  .first()
39
49
 
40
50
  if (!page) {
41
- return errorResult('Page not found')
51
+ throw createError({ statusCode: 404, message: 'Page not found' })
42
52
  }
43
53
 
44
54
  const content = await event.$fetch<string>(`/raw${path}.md`)
45
55
 
46
- return jsonResult({
56
+ return {
47
57
  title: page.title,
48
58
  path: page.path,
49
59
  description: page.description,
50
60
  content,
51
61
  url: `${siteUrl}${page.path}`,
52
- })
62
+ }
53
63
  }
54
- catch {
55
- return errorResult('Failed to get page')
64
+ catch (error) {
65
+ if ((error as { statusCode?: number }).statusCode === 404) throw error
66
+ throw createError({ statusCode: 500, message: 'Failed to get page' })
56
67
  }
57
68
  },
58
69
  })
@@ -23,9 +23,19 @@ OUTPUT: Returns a structured list with:
23
23
  - path: Exact path for use with get-page
24
24
  - description: Brief summary of page content
25
25
  - url: Full URL for reference`,
26
+ annotations: {
27
+ readOnlyHint: true,
28
+ destructiveHint: false,
29
+ idempotentHint: true,
30
+ openWorldHint: false,
31
+ },
26
32
  inputSchema: {
27
- locale: z.string().optional().describe('The locale to filter pages by'),
33
+ locale: z.string().optional().describe('The locale to filter pages by (e.g., "en", "fr")'),
28
34
  },
35
+ inputExamples: [
36
+ { locale: 'en' },
37
+ {},
38
+ ],
29
39
  cache: '1h',
30
40
  handler: async ({ locale }) => {
31
41
  const event = useEvent()
@@ -52,10 +62,10 @@ OUTPUT: Returns a structured list with:
52
62
  }),
53
63
  )
54
64
 
55
- return jsonResult(allPages.flat())
65
+ return allPages.flat()
56
66
  }
57
67
  catch {
58
- return errorResult('Failed to list pages')
68
+ throw createError({ statusCode: 500, message: 'Failed to list pages' })
59
69
  }
60
70
  },
61
71
  })
@@ -1,76 +0,0 @@
1
- <script lang="ts" setup>
2
- const props = withDefaults(defineProps<{ title?: string, description?: string, headline?: string }>(), {
3
- title: 'title',
4
- description: 'description',
5
- })
6
-
7
- const title = (props.title || '').slice(0, 60)
8
- const description = (props.description || '').slice(0, 200)
9
- </script>
10
-
11
- <template>
12
- <div class="w-full h-full flex flex-col justify-center bg-neutral-900">
13
- <svg
14
- class="absolute right-0 top-0 opacity-50"
15
- width="629"
16
- height="593"
17
- viewBox="0 0 629 593"
18
- fill="none"
19
- xmlns="http://www.w3.org/2000/svg"
20
- >
21
- <g filter="url(#filter0_f_199_94966)">
22
- <path
23
- d="M628.5 -578L639.334 -94.4223L806.598 -548.281L659.827 -87.387L965.396 -462.344L676.925 -74.0787L1087.69 -329.501L688.776 -55.9396L1160.22 -164.149L694.095 -34.9354L1175.13 15.7948L692.306 -13.3422L1130.8 190.83L683.602 6.50012L1032.04 341.989L668.927 22.4412L889.557 452.891L649.872 32.7537L718.78 511.519L628.5 36.32L538.22 511.519L607.128 32.7537L367.443 452.891L588.073 22.4412L224.955 341.989L573.398 6.50012L126.198 190.83L564.694 -13.3422L81.8734 15.7948L562.905 -34.9354L96.7839 -164.149L568.224 -55.9396L169.314 -329.501L580.075 -74.0787L291.604 -462.344L597.173 -87.387L450.402 -548.281L617.666 -94.4223L628.5 -578Z"
24
- fill="white"
25
- />
26
- </g>
27
- <defs>
28
- <filter
29
- id="filter0_f_199_94966"
30
- x="0.873535"
31
- y="-659"
32
- width="1255.25"
33
- height="1251.52"
34
- filterUnits="userSpaceOnUse"
35
- color-interpolation-filters="sRGB"
36
- >
37
- <feFlood
38
- flood-opacity="0"
39
- result="BackgroundImageFix"
40
- />
41
- <feBlend
42
- mode="normal"
43
- in="SourceGraphic"
44
- in2="BackgroundImageFix"
45
- result="shape"
46
- />
47
- <feGaussianBlur
48
- stdDeviation="40.5"
49
- result="effect1_foregroundBlur_199_94966"
50
- />
51
- </filter>
52
- </defs>
53
- </svg>
54
-
55
- <div class="pl-[100px]">
56
- <p
57
- v-if="headline"
58
- class="uppercase text-[24px] text-emerald-500 mb-4 font-semibold"
59
- >
60
- {{ headline }}
61
- </p>
62
- <h1
63
- v-if="title"
64
- class="m-0 text-[75px] font-semibold mb-4 text-white flex items-center"
65
- >
66
- <span>{{ title }}</span>
67
- </h1>
68
- <p
69
- v-if="description"
70
- class="text-[32px] text-neutral-300 leading-tight w-[700px]"
71
- >
72
- {{ description }}
73
- </p>
74
- </div>
75
- </div>
76
- </template>
@@ -1,98 +0,0 @@
1
- <script lang="ts" setup>
2
- const props = withDefaults(defineProps<{ title?: string, description?: string }>(), {
3
- title: 'title',
4
- description: 'description',
5
- })
6
-
7
- const appConfig = useAppConfig()
8
- const logoPath = appConfig.header?.logo?.dark || appConfig.header?.logo?.light
9
-
10
- const logoSvg = await fetchLogoSvg(logoPath)
11
-
12
- const title = (props.title || '').slice(0, 60)
13
- const description = (props.description || '').slice(0, 200)
14
-
15
- async function fetchLogoSvg(path?: string): Promise<string> {
16
- if (!path) return ''
17
- try {
18
- // eslint-disable-next-line
19
- // @ts-ignore
20
- const { url: siteUrl } = useSiteConfig()
21
- const url = path.startsWith('http') ? path : `${siteUrl}${path}`
22
- const svg = await $fetch<string>(url, { responseType: 'text' })
23
- return svg.replace('<svg', '<svg width="100%" height="100%"')
24
- }
25
- catch {
26
- return ''
27
- }
28
- }
29
- </script>
30
-
31
- <template>
32
- <div class="w-full h-full flex items-center justify-center bg-neutral-900">
33
- <svg
34
- class="absolute right-0 top-0 opacity-50"
35
- width="629"
36
- height="593"
37
- viewBox="0 0 629 593"
38
- fill="none"
39
- xmlns="http://www.w3.org/2000/svg"
40
- >
41
- <g filter="url(#filter0_f_199_94966)">
42
- <path
43
- d="M628.5 -578L639.334 -94.4223L806.598 -548.281L659.827 -87.387L965.396 -462.344L676.925 -74.0787L1087.69 -329.501L688.776 -55.9396L1160.22 -164.149L694.095 -34.9354L1175.13 15.7948L692.306 -13.3422L1130.8 190.83L683.602 6.50012L1032.04 341.989L668.927 22.4412L889.557 452.891L649.872 32.7537L718.78 511.519L628.5 36.32L538.22 511.519L607.128 32.7537L367.443 452.891L588.073 22.4412L224.955 341.989L573.398 6.50012L126.198 190.83L564.694 -13.3422L81.8734 15.7948L562.905 -34.9354L96.7839 -164.149L568.224 -55.9396L169.314 -329.501L580.075 -74.0787L291.604 -462.344L597.173 -87.387L450.402 -548.281L617.666 -94.4223L628.5 -578Z"
44
- fill="white"
45
- />
46
- </g>
47
- <defs>
48
- <filter
49
- id="filter0_f_199_94966"
50
- x="0.873535"
51
- y="-659"
52
- width="1255.25"
53
- height="1251.52"
54
- filterUnits="userSpaceOnUse"
55
- color-interpolation-filters="sRGB"
56
- >
57
- <feFlood
58
- flood-opacity="0"
59
- result="BackgroundImageFix"
60
- />
61
- <feBlend
62
- mode="normal"
63
- in="SourceGraphic"
64
- in2="BackgroundImageFix"
65
- result="shape"
66
- />
67
- <feGaussianBlur
68
- stdDeviation="40.5"
69
- result="effect1_foregroundBlur_199_94966"
70
- />
71
- </filter>
72
- </defs>
73
- </svg>
74
-
75
- <div
76
- class="flex flex-col items-center justify-center p-8"
77
- >
78
- <div
79
- v-if="logoSvg"
80
- class="flex items-center justify-center mb-10 max-w-[900px]"
81
- style="width: 72px; height: 72px;"
82
- v-html="logoSvg"
83
- />
84
- <h1
85
- v-if="title"
86
- class="m-0 text-5xl font-semibold mb-4 text-white text-center"
87
- >
88
- {{ title }}
89
- </h1>
90
- <p
91
- v-if="description"
92
- class="text-center text-2xl text-neutral-300 leading-tight max-w-[800px]"
93
- >
94
- {{ description }}
95
- </p>
96
- </div>
97
- </div>
98
- </template>