docus 5.10.1 → 5.12.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.
Files changed (34) hide show
  1. package/app/app.config.ts +4 -1
  2. package/app/app.vue +22 -27
  3. package/app/components/app/AppSearch.vue +54 -0
  4. package/app/components/docs/DocsAsideRight.vue +1 -2
  5. package/app/components/docs/DocsAsideRightBottom.vue +2 -2
  6. package/app/components/docs/DocsPageHeaderLinks.vue +11 -6
  7. package/app/composables/useDocusShortcuts.ts +24 -0
  8. package/app/composables/useUIConfig.ts +1 -1
  9. package/app/error.vue +1 -10
  10. package/app/pages/[[lang]]/[...slug].vue +6 -4
  11. package/app/plugins/i18n.ts +1 -1
  12. package/app/types/index.d.ts +22 -0
  13. package/i18n/locales/pl.json +2 -2
  14. package/modules/assistant/index.ts +13 -5
  15. package/modules/assistant/runtime/components/AssistantChat.vue +5 -2
  16. package/modules/assistant/runtime/components/AssistantComark.ts +9 -0
  17. package/modules/assistant/runtime/components/AssistantFloatingInput.vue +9 -10
  18. package/modules/assistant/runtime/components/AssistantIndicator.vue +116 -0
  19. package/modules/assistant/runtime/components/AssistantPanel.vue +268 -258
  20. package/modules/assistant/runtime/components/AssistantPreStream.vue +1 -1
  21. package/modules/assistant/runtime/composables/useAssistant.ts +34 -38
  22. package/modules/assistant/runtime/server/api/search.ts +22 -42
  23. package/modules/config.ts +12 -1
  24. package/modules/css.ts +35 -6
  25. package/modules/markdown-rewrite.ts +1 -1
  26. package/modules/skills/index.ts +4 -2
  27. package/nuxt.schema.ts +28 -0
  28. package/package.json +47 -28
  29. package/server/mcp/tools/get-page.ts +17 -8
  30. package/server/mcp/tools/list-pages.ts +10 -6
  31. package/server/routes/sitemap.xml.ts +7 -4
  32. package/server/utils/content.ts +4 -0
  33. package/modules/assistant/runtime/components/AssistantLoading.vue +0 -164
  34. package/modules/assistant/runtime/components/AssistantMatrix.vue +0 -92
@@ -1,7 +1,7 @@
1
1
  import type { UIMessage } from 'ai'
2
- import { useAppConfig, useRuntimeConfig, useState } from '#imports'
3
- import { useMediaQuery } from '@vueuse/core'
4
- import { computed } from 'vue'
2
+ import { useAppConfig, useRuntimeConfig } from '#imports'
3
+ import { createSharedComposable, useLocalStorage } from '@vueuse/core'
4
+ import { computed, ref, watch } from 'vue'
5
5
  import type { FaqCategory, FaqQuestions, LocalizedFaqQuestions } from '../types'
6
6
 
7
7
  function normalizeFaqQuestions(questions: FaqQuestions): FaqCategory[] {
@@ -19,10 +19,7 @@ function normalizeFaqQuestions(questions: FaqQuestions): FaqCategory[] {
19
19
  return questions as FaqCategory[]
20
20
  }
21
21
 
22
- const PANEL_WIDTH_COMPACT = 360
23
- const PANEL_WIDTH_EXPANDED = 520
24
-
25
- export function useAssistant() {
22
+ export const useAssistant = createSharedComposable(() => {
26
23
  const config = useRuntimeConfig()
27
24
  const appConfig = useAppConfig()
28
25
  const assistantRuntimeConfig = config.public.assistant as { enabled?: boolean } | undefined
@@ -30,26 +27,37 @@ export function useAssistant() {
30
27
  const docusRuntimeConfig = appConfig.docus as { locale?: string } | undefined
31
28
  const isEnabled = computed(() => assistantRuntimeConfig?.enabled ?? false)
32
29
 
33
- const isOpen = useState('assistant-open', () => false)
34
- const isExpanded = useState('assistant-expanded', () => false)
35
- const messages = useState<UIMessage[]>('assistant-messages', () => [])
36
- const pendingMessage = useState<string | undefined>('assistant-pending', () => undefined)
30
+ const storageOpen = useLocalStorage('assistant-open', false)
31
+ const messages = useLocalStorage<UIMessage[]>('assistant-messages', [])
32
+
33
+ const isOpen = ref(false)
34
+ const isStudioExpanded = ref(false)
37
35
 
38
- const isMobile = useMediaQuery('(max-width: 767px)')
39
- const panelWidth = computed(() => isExpanded.value ? PANEL_WIDTH_EXPANDED : PANEL_WIDTH_COMPACT)
40
- const shouldPushContent = computed(() => !isMobile.value && isOpen.value)
36
+ onNuxtReady(() => {
37
+ nextTick(() => {
38
+ isOpen.value = storageOpen.value
39
+ })
40
+
41
+ isStudioExpanded.value = document.body.hasAttribute('data-expand-sidebar')
42
+ const observer = new MutationObserver(() => {
43
+ isStudioExpanded.value = document.body.hasAttribute('data-expand-sidebar')
44
+ })
45
+ observer.observe(document.body, { attributes: true, attributeFilter: ['data-expand-sidebar'] })
46
+ })
47
+
48
+ watch(isOpen, (value) => {
49
+ storageOpen.value = value
50
+ })
41
51
 
42
52
  const faqQuestions = computed<FaqCategory[]>(() => {
43
53
  const faqConfig = assistantConfig?.faqQuestions
44
54
  if (!faqConfig) return []
45
55
 
46
- // Check if it's a localized object (has locale keys like 'en', 'fr')
47
56
  if (!Array.isArray(faqConfig)) {
48
57
  const localizedConfig = faqConfig as LocalizedFaqQuestions
49
58
  const currentLocale = docusRuntimeConfig?.locale || 'en'
50
59
  const defaultLocale = config.public.i18n?.defaultLocale || 'en'
51
60
 
52
- // Try current locale, then default locale, then first available
53
61
  const questions = localizedConfig[currentLocale]
54
62
  || localizedConfig[defaultLocale]
55
63
  || Object.values(localizedConfig)[0]
@@ -61,51 +69,39 @@ export function useAssistant() {
61
69
  })
62
70
 
63
71
  function open(initialMessage?: string, clearPrevious = false) {
72
+ if (isStudioExpanded.value) return
73
+
64
74
  if (clearPrevious) {
65
75
  messages.value = []
66
76
  }
67
77
 
68
78
  if (initialMessage) {
69
- pendingMessage.value = initialMessage
79
+ messages.value = [...messages.value, {
80
+ id: String(Date.now()),
81
+ role: 'user' as const,
82
+ parts: [{ type: 'text' as const, text: initialMessage }],
83
+ }]
70
84
  }
71
85
  isOpen.value = true
72
86
  }
73
87
 
74
- function clearPending() {
75
- pendingMessage.value = undefined
76
- }
77
-
78
88
  function close() {
79
89
  isOpen.value = false
80
90
  }
81
91
 
82
92
  function toggle() {
93
+ if (isStudioExpanded.value) return
83
94
  isOpen.value = !isOpen.value
84
95
  }
85
96
 
86
- function clearMessages() {
87
- messages.value = []
88
- }
89
-
90
- function toggleExpanded() {
91
- isExpanded.value = !isExpanded.value
92
- }
93
-
94
97
  return {
95
98
  isEnabled,
96
99
  isOpen,
97
- isExpanded,
98
- isMobile,
99
- panelWidth,
100
- shouldPushContent,
100
+ isStudioExpanded,
101
101
  messages,
102
- pendingMessage,
103
102
  faqQuestions,
104
103
  open,
105
- clearPending,
106
104
  close,
107
105
  toggle,
108
- toggleExpanded,
109
- clearMessages,
110
106
  }
111
- }
107
+ })
@@ -1,5 +1,5 @@
1
- import { streamText, convertToModelMessages, createUIMessageStream, createUIMessageStreamResponse } from 'ai'
2
- import type { UIMessageStreamWriter, ToolCallPart, ToolSet } from 'ai'
1
+ import { streamText, convertToModelMessages } from 'ai'
2
+ import type { ToolSet } from 'ai'
3
3
  import { createMCPClient } from '@ai-sdk/mcp'
4
4
  import type { H3Event } from 'h3'
5
5
 
@@ -23,11 +23,10 @@ function createLocalFetch(event: H3Event): typeof fetch {
23
23
  }
24
24
 
25
25
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
26
- function stopWhenResponseComplete({ steps }: { steps: any[] }): boolean {
26
+ function stopWhenResponseComplete({ steps }: { steps: { text?: string, toolCalls?: unknown[] }[] }): boolean {
27
27
  const lastStep = steps.at(-1)
28
28
  if (!lastStep) return false
29
29
 
30
- // Primary condition: stop when model gives a text response without tool calls
31
30
  const hasText = Boolean(lastStep.text && lastStep.text.trim().length > 0)
32
31
  const hasNoToolCalls = !lastStep.toolCalls || lastStep.toolCalls.length === 0
33
32
 
@@ -57,7 +56,7 @@ function getSystemPrompt(siteName: string) {
57
56
 
58
57
  **Links and exploration:**
59
58
  - Tool results include a \`url\` for each page — prefer markdown links \`[label](url)\` so users can open the doc in one click
60
- - When it helps, add extra links (related pages, read more”, side topics) — make the answer easy to dig into, not a wall of text
59
+ - When it helps, add extra links (related pages, "read more", side topics) — make the answer easy to dig into, not a wall of text
61
60
  - Stick to URLs from tool results (\`url\` / \`path\`) so links stay valid
62
61
 
63
62
  **FORMATTING RULES (CRITICAL):**
@@ -85,6 +84,9 @@ export default defineEventHandler(async (event) => {
85
84
  const isExternalUrl = mcpServer.startsWith('http://') || mcpServer.startsWith('https://')
86
85
  const baseURL = config.app?.baseURL?.replace(/\/$/, '') || ''
87
86
 
87
+ const abortController = new AbortController()
88
+ event.node.req.on('close', () => abortController.abort())
89
+
88
90
  let transport: Parameters<typeof createMCPClient>[0]['transport']
89
91
  if (isExternalUrl) {
90
92
  transport = {
@@ -109,41 +111,19 @@ export default defineEventHandler(async (event) => {
109
111
  const httpClient = await createMCPClient({ transport })
110
112
  const mcpTools = await httpClient.tools()
111
113
 
112
- const stream = createUIMessageStream({
113
- execute: async ({ writer }: { writer: UIMessageStreamWriter }) => {
114
- const modelMessages = await convertToModelMessages(messages)
115
- const result = streamText({
116
- model: config.assistant.model,
117
- maxOutputTokens: 4000,
118
- maxRetries: 2,
119
- stopWhen: stopWhenResponseComplete,
120
- system: getSystemPrompt(siteName),
121
- messages: modelMessages,
122
- tools: mcpTools as ToolSet,
123
- onStepFinish: ({ toolCalls }: { toolCalls: ToolCallPart[] }) => {
124
- if (toolCalls.length === 0) return
125
- writer.write({
126
- id: toolCalls[0]?.toolCallId,
127
- type: 'data-tool-calls',
128
- data: {
129
- tools: toolCalls.map((tc: ToolCallPart) => {
130
- const args = 'args' in tc ? tc.args : 'input' in tc ? tc.input : {}
131
- return {
132
- toolName: tc.toolName,
133
- toolCallId: tc.toolCallId,
134
- args,
135
- }
136
- }),
137
- },
138
- })
139
- },
140
- })
141
- writer.merge(result.toUIMessageStream())
142
- },
143
- onFinish: async () => {
144
- await httpClient.close()
145
- },
146
- })
147
-
148
- return createUIMessageStreamResponse({ stream })
114
+ const closeMcp = () => event.waitUntil(httpClient.close())
115
+
116
+ return streamText({
117
+ model: config.assistant.model,
118
+ maxOutputTokens: 4000,
119
+ maxRetries: 2,
120
+ abortSignal: abortController.signal,
121
+ stopWhen: stopWhenResponseComplete,
122
+ system: getSystemPrompt(siteName),
123
+ messages: await convertToModelMessages(messages),
124
+ tools: mcpTools as ToolSet,
125
+ onFinish: closeMcp,
126
+ onAbort: closeMcp,
127
+ onError: closeMcp,
128
+ }).toUIMessageStreamResponse()
149
129
  })
package/modules/config.ts CHANGED
@@ -5,10 +5,11 @@ import { join } from 'node:path'
5
5
  import { inferSiteURL, getPackageJsonMetadata } from '../utils/meta'
6
6
  import { getGitBranch, getGitEnv, getLocalGitInfo } from '../utils/git'
7
7
 
8
- const log = logger.withTag('Docus')
8
+ const log = logger.withTag('docus')
9
9
 
10
10
  type I18nLocale = string | { code: string, name?: string }
11
11
  type DocusI18nOptions = { locales?: I18nLocale[], strategy?: string }
12
+ type DocusMcpOptions = { route?: string, enabled?: boolean }
12
13
  type RegisterModuleOptions = {
13
14
  langDir: string
14
15
  locales: Array<{ code: string, name: string, file: string }>
@@ -58,6 +59,16 @@ export default defineNuxtModule({
58
59
  branch: getGitBranch(),
59
60
  })
60
61
 
62
+ /*
63
+ ** MCP route (expose to client so the page header dropdown stays in sync
64
+ ** with the user-configured `mcp.route` from @nuxtjs/mcp-toolkit)
65
+ */
66
+ const mcpOptions = (nuxt.options as typeof nuxt.options & { mcp?: DocusMcpOptions }).mcp
67
+ nuxt.options.runtimeConfig.public.mcp = defu(
68
+ nuxt.options.runtimeConfig.public.mcp as DocusMcpOptions | undefined,
69
+ { route: mcpOptions?.route || '/mcp' },
70
+ )
71
+
61
72
  const forcedColorMode = (nuxt.options.appConfig.docus as Record<string, unknown>)?.colorMode as string | undefined
62
73
  if (forcedColorMode === 'light' || forcedColorMode === 'dark') {
63
74
  nuxt.options.colorMode = defu({ preference: forcedColorMode, fallback: forcedColorMode }, nuxt.options.colorMode || {}) as typeof nuxt.options.colorMode
package/modules/css.ts CHANGED
@@ -1,21 +1,37 @@
1
- import { defineNuxtModule, addTemplate, createResolver } from '@nuxt/kit'
2
- import { joinURL } from 'ufo'
1
+ import { defineNuxtModule, addTemplate, createResolver, logger } from '@nuxt/kit'
2
+ import { existsSync } from 'node:fs'
3
+ import { readFile } from 'node:fs/promises'
4
+ import { resolve } from 'pathe'
3
5
  import { resolveModulePath } from 'exsolve'
4
6
 
7
+ const log = logger.withTag('docus')
8
+
5
9
  export default defineNuxtModule({
6
10
  meta: {
7
- name: 'css',
11
+ name: 'docus-css',
8
12
  },
9
13
  async setup(_options, nuxt) {
10
- const dir = nuxt.options.rootDir
11
14
  const resolver = createResolver(import.meta.url)
12
15
 
13
- const contentDir = joinURL(dir, 'content')
16
+ const contentDir = resolve(nuxt.options.rootDir, 'content')
14
17
  const uiPath = resolveModulePath('@nuxt/ui', { from: import.meta.url, conditions: ['style'] })
15
18
  const tailwindPath = resolveModulePath('tailwindcss', { from: import.meta.url, conditions: ['style'] })
16
19
  const layerDir = resolver.resolve('../app')
17
20
  const assistantDir = resolver.resolve('../modules/assistant')
18
21
 
22
+ let userDocusPath: string | null = resolve(nuxt.options.srcDir, 'app.css')
23
+ if (existsSync(userDocusPath)) {
24
+ const userDocusCss = await readFile(userDocusPath, 'utf-8')
25
+ if (userDocusCss.includes('@import "tailwindcss"')) {
26
+ nuxt.hook('modules:done', () => {
27
+ log.warn('`app.css` contains `@import "tailwindcss";` consider removing it to avoid duplicate css.')
28
+ })
29
+ }
30
+ }
31
+ else {
32
+ userDocusPath = null
33
+ }
34
+
19
35
  const cssTemplate = addTemplate({
20
36
  filename: 'docus.css',
21
37
  getContents: () => {
@@ -25,7 +41,20 @@ export default defineNuxtModule({
25
41
  @source "${contentDir.replace(/\\/g, '/')}/**/*";
26
42
  @source "${layerDir.replace(/\\/g, '/')}/**/*";
27
43
  @source "../../app.config.ts";
28
- @source "${assistantDir.replace(/\\/g, '/')}/**/*";`
44
+ @source "${assistantDir.replace(/\\/g, '/')}/**/*";
45
+
46
+ html.dark .shiki span {
47
+ color: var(--shiki-dark) !important;
48
+ background-color: var(--shiki-dark-bg) !important;
49
+ font-style: var(--shiki-dark-font-style) !important;
50
+ font-weight: var(--shiki-dark-font-weight) !important;
51
+ text-decoration: var(--shiki-dark-text-decoration) !important;
52
+ }
53
+
54
+ :root {
55
+ --ui-container: 90rem;
56
+ }
57
+ ` + (userDocusPath ? `\n@import ${JSON.stringify(userDocusPath)};` : '')
29
58
  },
30
59
  })
31
60
 
@@ -2,7 +2,7 @@ import { defineNuxtModule, logger } from '@nuxt/kit'
2
2
  import { resolve } from 'node:path'
3
3
  import { readFile, writeFile } from 'node:fs/promises'
4
4
 
5
- const log = logger.withTag('Docus')
5
+ const log = logger.withTag('docus')
6
6
 
7
7
  type I18nLocale = string | { code: string }
8
8
  type DocusI18nOptions = { locales?: I18nLocale[] }
@@ -19,7 +19,7 @@ export interface SkillsModuleOptions {
19
19
  const SKILL_NAME_REGEX = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/
20
20
  const MAX_NAME_LENGTH = 64
21
21
 
22
- const log = logger.withTag('Docus')
22
+ const log = logger.withTag('docus')
23
23
 
24
24
  const defaults: Required<SkillsModuleOptions> = {
25
25
  dir: 'skills',
@@ -38,7 +38,9 @@ export default defineNuxtModule<SkillsModuleOptions>({
38
38
  const catalog = await scanSkills(skillsDir)
39
39
  if (!catalog.length) return
40
40
 
41
- log.info(`Found ${catalog.length} agent skill${catalog.length > 1 ? 's' : ''}: ${catalog.map(s => s.name).join(', ')}`)
41
+ nuxt.hook('modules:done', () => {
42
+ log.info(`Found ${catalog.length} agent skill${catalog.length > 1 ? 's' : ''}: ${catalog.map(s => s.name).join(', ')}`)
43
+ })
42
44
 
43
45
  nuxt.options.runtimeConfig.skills = { catalog }
44
46
 
package/nuxt.schema.ts CHANGED
@@ -297,6 +297,34 @@ export default defineNuxtSchema({
297
297
  default: '',
298
298
  required: ['', 'light', 'dark'],
299
299
  }),
300
+ shortcuts: group({
301
+ title: 'Shortcuts',
302
+ description: 'Keyboard shortcuts configuration.',
303
+ icon: 'i-lucide-keyboard',
304
+ fields: {
305
+ toggleColorMode: field({
306
+ type: 'string',
307
+ title: 'Toggle Color Mode',
308
+ description: 'Shortcut to toggle light and dark mode (e.g., d, meta_d). Leave empty to disable.',
309
+ icon: 'i-lucide-keyboard',
310
+ default: 'd',
311
+ }),
312
+ },
313
+ }),
314
+ },
315
+ }),
316
+ search: group({
317
+ title: 'Search',
318
+ description: 'Search configuration.',
319
+ icon: 'i-lucide-search',
320
+ fields: {
321
+ fts: field({
322
+ type: 'boolean',
323
+ title: 'Full-Text Search',
324
+ description: 'Use SQLite FTS5 full-text search instead of Fuse.js. Requires @nuxt/content v3.14+.',
325
+ icon: 'i-lucide-database',
326
+ default: false,
327
+ }),
300
328
  },
301
329
  }),
302
330
  assistant: group({
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.10.1",
4
+ "version": "5.12.0",
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",
7
7
  "repository": {
@@ -23,44 +23,63 @@
23
23
  "README.md"
24
24
  ],
25
25
  "dependencies": {
26
- "@ai-sdk/gateway": "^3.0.104",
27
- "@ai-sdk/mcp": "^1.0.36",
28
- "@ai-sdk/vue": "3.0.168",
29
- "@iconify-json/lucide": "^1.2.102",
30
- "@iconify-json/simple-icons": "^1.2.79",
31
- "@iconify-json/vscode-icons": "^1.2.45",
32
- "@nuxt/content": "^3.13.0",
26
+ "@iconify-json/lucide": "^1.2.111",
27
+ "@iconify-json/simple-icons": "^1.2.85",
28
+ "@iconify-json/vscode-icons": "^1.2.53",
29
+ "@nuxt/content": "^3.14.0",
33
30
  "@nuxt/image": "^2.0.0",
34
- "@nuxt/kit": "^4.4.2",
35
- "@nuxt/ui": "^4.7.1",
36
- "@nuxtjs/i18n": "^10.2.4",
37
- "@nuxtjs/mcp-toolkit": "^0.13.4",
38
- "@nuxtjs/mdc": "^0.21.1",
39
- "@nuxtjs/robots": "^6.0.7",
40
- "@shikijs/core": "^4.0.2",
41
- "@shikijs/engine-javascript": "^4.0.2",
42
- "@shikijs/langs": "^4.0.2",
43
- "@shikijs/themes": "^4.0.2",
44
- "@takumi-rs/core": "^1.0.15",
45
- "@vueuse/core": "^14.2.1",
46
- "ai": "6.0.168",
31
+ "@nuxt/kit": "^4.4.7",
32
+ "@nuxt/ui": "^4.8.1",
33
+ "@nuxtjs/i18n": "^10.4.0",
34
+ "@nuxtjs/mcp-toolkit": "^0.17.2",
35
+ "@nuxtjs/mdc": "^0.22.0",
36
+ "@nuxtjs/robots": "^6.0.9",
37
+ "@shikijs/core": "^4.2.0",
38
+ "@shikijs/engine-javascript": "^4.2.0",
39
+ "@shikijs/langs": "^4.2.0",
40
+ "@shikijs/stream": "^4.2.0",
41
+ "@shikijs/themes": "^4.2.0",
42
+ "@takumi-rs/core": "^1.6.0",
43
+ "@vueuse/core": "^14.3.0",
47
44
  "defu": "^6.1.7",
48
45
  "exsolve": "^1.0.8",
49
46
  "git-url-parse": "^16.1.0",
50
47
  "motion-v": "^2.2.1",
51
48
  "nuxt-llms": "^0.2.0",
52
- "nuxt-og-image": "^6.4.5",
53
- "pkg-types": "^2.3.0",
49
+ "nuxt-og-image": "^6.5.1",
50
+ "pathe": "^2.0.3",
51
+ "pkg-types": "^2.3.1",
54
52
  "scule": "^1.3.0",
55
- "shiki-stream": "^0.1.4",
56
- "tailwindcss": "^4.2.3",
57
- "ufo": "^1.6.3",
58
- "yaml": "^2.8.3",
59
- "zod": "^4.3.6",
53
+ "tailwindcss": "^4.3.0",
54
+ "ufo": "^1.6.4",
55
+ "yaml": "^2.9.0",
56
+ "zod": "^4.4.3",
60
57
  "zod-to-json-schema": "^3.25.2"
61
58
  },
62
59
  "peerDependencies": {
60
+ "@ai-sdk/gateway": "^3.0.120",
61
+ "@ai-sdk/mcp": "^1.0.43",
62
+ "@ai-sdk/vue": "^3.0.191",
63
+ "@comark/nuxt": "^0.3.1",
64
+ "ai": "^6.0.191",
63
65
  "better-sqlite3": "12.x",
64
66
  "nuxt": "4.x"
67
+ },
68
+ "peerDependenciesMeta": {
69
+ "@ai-sdk/gateway": {
70
+ "optional": true
71
+ },
72
+ "@ai-sdk/mcp": {
73
+ "optional": true
74
+ },
75
+ "@ai-sdk/vue": {
76
+ "optional": true
77
+ },
78
+ "@comark/nuxt": {
79
+ "optional": true
80
+ },
81
+ "ai": {
82
+ "optional": true
83
+ }
65
84
  }
66
85
  }
@@ -1,8 +1,9 @@
1
- import { z } from 'zod'
2
- import { queryCollection } from '@nuxt/content/server'
3
1
  import type { Collections } from '@nuxt/content'
4
- import { getAvailableLocales, getCollectionFromPath } from '../../utils/content'
2
+ import { queryCollection } from '@nuxt/content/server'
3
+ import { joinURL } from 'ufo'
4
+ import { z } from 'zod'
5
5
  import { inferSiteURL } from '../../../utils/meta'
6
+ import { getAvailableLocales, getCollectionFromPath, isNavigationPath } from '../../utils/content'
6
7
 
7
8
  export default defineMcpTool({
8
9
  description: `Retrieves the full content and details of a specific documentation page.
@@ -24,7 +25,9 @@ WORKFLOW: This tool returns the complete page content including title, descripti
24
25
  openWorldHint: false,
25
26
  },
26
27
  inputSchema: {
27
- path: z.string().describe('The page path from list-pages or provided by the user (e.g., /en/getting-started/installation)'),
28
+ path: z.string().describe(
29
+ 'The page path from list-pages or provided by the user (e.g., /en/getting-started/installation)',
30
+ ),
28
31
  },
29
32
  inputExamples: [
30
33
  { path: '/en/getting-started/installation' },
@@ -32,12 +35,18 @@ WORKFLOW: This tool returns the complete page content including title, descripti
32
35
  ],
33
36
  cache: '1h',
34
37
  handler: async ({ path }) => {
38
+ if (isNavigationPath(path)) {
39
+ throw createError({ statusCode: 404, message: 'Page not found' })
40
+ }
41
+
35
42
  const event = useEvent()
36
- const config = useRuntimeConfig(event).public
43
+ const config = useRuntimeConfig(event)
44
+ const publicConfig = config.public
37
45
  const siteUrl = getRequestURL(event).origin || inferSiteURL()
46
+ const baseURL = config.app?.baseURL || '/'
38
47
 
39
- const availableLocales = getAvailableLocales(config)
40
- const collectionName = config.i18n?.locales
48
+ const availableLocales = getAvailableLocales(publicConfig)
49
+ const collectionName = publicConfig.i18n?.locales
41
50
  ? getCollectionFromPath(path, availableLocales)
42
51
  : 'docs'
43
52
 
@@ -58,7 +67,7 @@ WORKFLOW: This tool returns the complete page content including title, descripti
58
67
  path: page.path,
59
68
  description: page.description,
60
69
  content,
61
- url: `${siteUrl}${page.path}`,
70
+ url: siteUrl ? joinURL(siteUrl, baseURL, page.path) : joinURL(baseURL, page.path),
62
71
  }
63
72
  }
64
73
  catch (error) {
@@ -1,8 +1,9 @@
1
- import { z } from 'zod'
2
- import { queryCollection } from '@nuxt/content/server'
3
1
  import type { Collections } from '@nuxt/content'
4
- import { getCollectionsToQuery, getAvailableLocales } from '../../utils/content'
2
+ import { queryCollection } from '@nuxt/content/server'
3
+ import { joinURL } from 'ufo'
4
+ import { z } from 'zod'
5
5
  import { inferSiteURL } from '../../../utils/meta'
6
+ import { getAvailableLocales, getCollectionsToQuery } from '../../utils/content'
6
7
 
7
8
  export default defineMcpTool({
8
9
  description: `Lists all available documentation pages with their categories and basic information.
@@ -39,16 +40,19 @@ OUTPUT: Returns a structured list with:
39
40
  cache: '1h',
40
41
  handler: async ({ locale }) => {
41
42
  const event = useEvent()
42
- const config = useRuntimeConfig(event).public
43
+ const config = useRuntimeConfig(event)
44
+ const publicConfig = config.public
43
45
 
44
46
  const siteUrl = getRequestURL(event).origin || inferSiteURL()
45
- const availableLocales = getAvailableLocales(config)
47
+ const baseURL = config.app?.baseURL || '/'
48
+ const availableLocales = getAvailableLocales(publicConfig)
46
49
  const collections = getCollectionsToQuery(locale, availableLocales)
47
50
 
48
51
  try {
49
52
  const allPages = await Promise.all(
50
53
  collections.map(async (collectionName) => {
51
54
  const pages = await queryCollection(event, collectionName as keyof Collections)
55
+ .where('path', 'NOT LIKE', '%.navigation')
52
56
  .select('title', 'path', 'description')
53
57
  .all()
54
58
 
@@ -57,7 +61,7 @@ OUTPUT: Returns a structured list with:
57
61
  path: page.path,
58
62
  description: page.description,
59
63
  locale: collectionName.replace('docs_', ''),
60
- url: `${siteUrl}${page.path}`,
64
+ url: siteUrl ? joinURL(siteUrl, baseURL, page.path) : joinURL(baseURL, page.path),
61
65
  }))
62
66
  }),
63
67
  )
@@ -1,6 +1,6 @@
1
1
  import { queryCollection } from '@nuxt/content/server'
2
- import { getAvailableLocales, getCollectionsToQuery } from '../utils/content'
3
2
  import { inferSiteURL } from '../../utils/meta'
3
+ import { getAvailableLocales, getCollectionsToQuery, isNavigationPath } from '../utils/content'
4
4
 
5
5
  interface SitemapUrl {
6
6
  loc: string
@@ -27,17 +27,20 @@ export default defineEventHandler(async (event) => {
27
27
 
28
28
  for (const collection of collections) {
29
29
  try {
30
- const pages = await (queryCollection as unknown as (event: unknown, collection: string) => { all: () => Promise<Array<Record<string, unknown> & { path?: string }>> })(event, collection).all()
30
+ const pages = await (queryCollection as unknown as (
31
+ event: unknown,
32
+ collection: string,
33
+ ) => { all: () => Promise<Array<Record<string, unknown> & { path?: string }>> })(event, collection).all()
31
34
 
32
35
  for (const page of pages) {
33
- const meta = page as Record<string, unknown>
36
+ const meta = page.meta as Record<string, unknown>
34
37
  const pagePath = page.path || '/'
35
38
 
36
39
  // Skip pages with sitemap: false in frontmatter
37
40
  if (meta.sitemap === false) continue
38
41
 
39
42
  // Skip .navigation files (used for navigation configuration)
40
- if (pagePath.endsWith('.navigation') || pagePath.includes('/.navigation')) continue
43
+ if (isNavigationPath(pagePath)) continue
41
44
 
42
45
  const urlEntry: SitemapUrl = {
43
46
  loc: pagePath,
@@ -25,6 +25,10 @@ export function getCollectionsToQuery(locale: string | undefined, availableLocal
25
25
  : ['docs']
26
26
  }
27
27
 
28
+ export function isNavigationPath(path: string): boolean {
29
+ return path.endsWith('.navigation') || path.includes('/.navigation/')
30
+ }
31
+
28
32
  export function getCollectionFromPath(path: string, availableLocales: string[]): string {
29
33
  const pathSegments = path.split('/').filter(Boolean)
30
34
  const firstSegment = pathSegments[0]