@movk/nuxt-docs 1.16.2 → 1.17.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 (32) hide show
  1. package/README.md +18 -16
  2. package/app/app.config.ts +1 -1
  3. package/app/assets/css/main.css +1 -1
  4. package/app/components/DocsAsideRightBottom.vue +2 -2
  5. package/app/components/OgImage/OgImageDocs.takumi.vue +6 -10
  6. package/app/components/header/Header.vue +4 -4
  7. package/app/components/theme-picker/ThemePicker.vue +6 -6
  8. package/app/composables/useTheme.ts +6 -6
  9. package/app/pages/docs/[...slug].vue +8 -1
  10. package/app/plugins/theme.ts +1 -1
  11. package/modules/ai-chat/index.ts +1 -1
  12. package/modules/ai-chat/runtime/components/AiChat.vue +3 -3
  13. package/modules/ai-chat/runtime/components/AiChatFloatingInput.vue +2 -2
  14. package/modules/ai-chat/runtime/components/AiChatPanel.vue +32 -21
  15. package/modules/ai-chat/runtime/composables/useModels.ts +1 -1
  16. package/modules/ai-chat/runtime/server/api/ai-chat.ts +3 -2
  17. package/modules/module.ts +2 -11
  18. package/modules/skills/index.ts +144 -0
  19. package/modules/skills/runtime/server/routes/skills-files.ts +49 -0
  20. package/modules/skills/runtime/server/routes/skills-index.ts +8 -0
  21. package/nuxt.config.ts +9 -5
  22. package/nuxt.schema.ts +1 -1
  23. package/package.json +14 -13
  24. package/server/api/github/commits.json.get.ts +3 -3
  25. package/server/api/github/last-commit.json.get.ts +5 -5
  26. package/server/api/github/releases.json.get.ts +2 -2
  27. package/modules/runtime/public/noto-sans-sc-400-normal.woff2 +0 -0
  28. package/modules/runtime/public/noto-sans-sc-500-normal.woff2 +0 -0
  29. package/modules/runtime/public/noto-sans-sc-latin-400-normal.woff +0 -0
  30. package/modules/runtime/public/noto-sans-sc-latin-500-normal.woff2 +0 -0
  31. /package/modules/{runtime/components → components}/prose/Mermaid.vue +0 -0
  32. /package/modules/{runtime/components → components}/prose/Pre.vue +0 -0
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- [![Movk Nuxt Docs OG](https://docs.mhaibaraai.cn/_og/s/c_Docs,title_~546w5Luj5YyWIE51eHQg5paH5qGj5Li76aKY,description_~5Z-65LqOIE51eHQgNCDnmoTnjrDku6PmlofmoaPkuLvpopjvvIzpm4bmiJDnu4Tku7boh6rliqjljJbmlofmoaPjgIFBSSDogYrlpKnliqnmiYvjgIFNQ1AgU2VydmVyIOWSjOWujOaVtOeahOW8gOWPkeiAheS9k-mqjOS8mOWMluOAgg.png)](https://docs.mhaibaraai.cn/)
1
+ [![Movk Nuxt Docs OG](https://docs.mhaibaraai.cn/_og/s/o_njw78u.png)](https://docs.mhaibaraai.cn/)
2
2
  [![Movk Nuxt Docs](https://docs.mhaibaraai.cn/og-image.png)](https://docs.mhaibaraai.cn/)
3
3
 
4
4
  > 基于 Nuxt 4 的现代文档主题,集成组件自动化文档、AI 聊天助手、MCP Server 和完整的开发者体验优化
@@ -30,6 +30,23 @@
30
30
  - **LLM 优化** - 通过 `nuxt-llms` 模块自动生成 `llms.txt` 和 `llms-full.txt`,为 AI 工具提供优化的文档索引
31
31
  - **流式响应** - 支持 AI 响应流式输出和代码高亮,配合 `shiki-stream` 实现实时语法高亮渲染
32
32
 
33
+ ### AI 助手 Skill
34
+
35
+ Agent Skills 是一种开放格式,允许 AI 代理(Claude Code、Cursor、Windsurf 等)自动发现并加载文档站的专属工作流。Movk Nuxt Docs 将 `skills/` 目录下的所有技能自动发布到 `/.well-known/skills/` 端点。
36
+
37
+ **内置技能:**
38
+
39
+ - `create-docs` - 为任意项目生成基于 Movk Nuxt Docs 的完整文档网站
40
+ - `review-docs` - 审查文档质量,检查清晰度、SEO 和技术正确性
41
+
42
+ **一键安装到 AI 工具:**
43
+
44
+ ```bash
45
+ npx skills add https://docs.mhaibaraai.cn
46
+ ```
47
+
48
+ 详见 [Agent Skills 文档](https://docs.mhaibaraai.cn/docs/getting-started/skills)。
49
+
33
50
  ### 🧩 自动化文档生成
34
51
 
35
52
  - **组件元数据自动提取** - 基于 `nuxt-component-meta` 自动提取 Vue 组件的 Props、Slots、Emits 定义
@@ -79,21 +96,6 @@ pnpm dev
79
96
 
80
97
  访问 `http://localhost:3000` 查看你的文档网站。
81
98
 
82
- ### AI 助手 Skill
83
-
84
- 为你的 AI 助手(Cursor、Claude Code 等)添加 Movk Nuxt Docs 专业知识,加速文档编写:
85
-
86
- ```bash
87
- npx skills add mhaibaraai/movk-nuxt-docs
88
- ```
89
-
90
- 此 Skill 为 AI 助手提供 Movk Nuxt Docs 专业知识,帮助你更高效地编写文档:
91
-
92
- - 📝 MDC 组件用法和现成模板
93
- - 🎨 中文文档写作规范和内容结构模式
94
- - 🔧 nuxt.config.ts 和 app.config.ts 配置参考
95
- - 📚 入门页、功能介绍页等常用页面模板
96
-
97
99
  ### 作为 Layer 使用
98
100
 
99
101
  在现有 Nuxt 项目中使用 Movk Nuxt Docs 作为 layer:
package/app/app.config.ts CHANGED
@@ -11,7 +11,7 @@ export default defineAppConfig({
11
11
  radius: 0.25,
12
12
  blackAsPrimary: false,
13
13
  icons: 'lucide',
14
- font: 'Public Sans'
14
+ font: 'Alibaba PuHuiTi'
15
15
  },
16
16
  ui: {
17
17
  colors: {
@@ -4,7 +4,7 @@
4
4
  @theme static {
5
5
  --container-8xl: 90rem;
6
6
 
7
- --font-sans: 'Public Sans', sans-serif;
7
+ --font-sans: 'Alibaba PuHuiTi', sans-serif;
8
8
 
9
9
  --color-green-50: #EFFDF5;
10
10
  --color-green-100: #D9FBE8;
@@ -13,9 +13,9 @@ const showExplainWithAi = computed(() => {
13
13
  <template>
14
14
  <UButton
15
15
  v-if="showExplainWithAi"
16
- :icon="aiChat.icons.explain"
16
+ :icon="aiChat.icons?.explain ?? ''"
17
17
  target="_blank"
18
- :label="aiChat.texts.explainWithAi"
18
+ :label="aiChat.texts?.explainWithAi ?? ''"
19
19
  size="sm"
20
20
  variant="ghost"
21
21
  color="neutral"
@@ -1,17 +1,13 @@
1
1
  <script setup lang="ts">
2
- withDefaults(defineProps<{
2
+ defineProps<{
3
3
  title?: string
4
4
  description?: string
5
5
  siteName?: string
6
- }>(), {
7
- title: '文档',
8
- description: '使用 Nuxt Content 构建的文档站点',
9
- siteName: 'Movk Nuxt Docs'
10
- })
6
+ }>()
11
7
  </script>
12
8
 
13
9
  <template>
14
- <div style="font-family: 'Noto Sans SC', sans-serif;" class="w-full h-full flex flex-col justify-center items-center relative p-10 lg:p-15 bg-white text-neutral-900 dark:bg-neutral-900 dark:text-neutral-50">
10
+ <div style="font-family: 'Alibaba PuHuiTi', sans-serif;" class="w-full h-full flex flex-col justify-center items-center relative p-10 lg:p-15 bg-white text-neutral-900 dark:bg-neutral-900 dark:text-neutral-50">
15
11
  <div
16
12
  class="absolute top-0 left-0 right-0 bottom-0"
17
13
  :style="{
@@ -65,14 +61,14 @@ withDefaults(defineProps<{
65
61
  fill="url(#nsLine1)"
66
62
  />
67
63
  </svg>
68
- <span class="text-[32px] font-medium lg:text-[42px] text-green-500">
64
+ <span class="text-[32px] font-bold lg:text-[42px] text-green-500">
69
65
  {{ siteName }}
70
66
  </span>
71
67
  </div>
72
68
 
73
69
  <div class="items-center justify-center w-full">
74
70
  <h1
75
- class="text-[48px] lg:text-[72px] leading-tight text-pretty font-normal text-slate-900 max-w-175 lg:max-w-250"
71
+ class="text-[48px] lg:text-[72px] leading-tight text-pretty font-medium text-slate-800 max-w-175 lg:max-w-250"
76
72
  style="display: block; line-clamp: 3; text-overflow: ellipsis; text-wrap: balance;"
77
73
  >
78
74
  {{ title }}
@@ -81,7 +77,7 @@ withDefaults(defineProps<{
81
77
 
82
78
  <p
83
79
  v-if="description"
84
- class="text-slate-500 text-[24px] lg:text-[32px] opacity-70 max-w-162.5 lg:max-w-225 leading-normal"
80
+ class="text-slate-700 text-[24px] lg:text-[32px] font-normal opacity-70 max-w-162.5 lg:max-w-225 leading-normal"
85
81
  >
86
82
  {{ description }}
87
83
  </p>
@@ -4,7 +4,7 @@ import type { ButtonProps } from '@nuxt/ui'
4
4
  const route = useRoute()
5
5
  const { header, github } = useAppConfig()
6
6
 
7
- const links = computed<ButtonProps[]>(() => github && github.url
7
+ const links = computed<ButtonProps[]>(() => (github && github.url
8
8
  ? [
9
9
  {
10
10
  'icon': 'i-simple-icons-github',
@@ -12,13 +12,13 @@ const links = computed<ButtonProps[]>(() => github && github.url
12
12
  'target': '_blank',
13
13
  'aria-label': 'GitHub'
14
14
  },
15
- ...header?.links || []
15
+ ...(header?.links || [])
16
16
  ]
17
- : header.links)
17
+ : header?.links || []) as ButtonProps[])
18
18
  </script>
19
19
 
20
20
  <template>
21
- <UHeader :ui="{ left: 'min-w-0' }" class="flex flex-col" aria-label="Site Header">
21
+ <UHeader :ui="{ left: 'min-w-0', right: 'gap-0.5' }" class="flex flex-col" aria-label="Site Header">
22
22
  <template #left>
23
23
  <HeaderLogo />
24
24
  </template>
@@ -49,7 +49,7 @@ const {
49
49
  Primary
50
50
 
51
51
  <UButton
52
- to="/docs/getting-started/theme/css-variables#colors"
52
+ to="https://ui.nuxt.com/docs/getting-started/theme/css-variables#colors"
53
53
  size="xs"
54
54
  color="neutral"
55
55
  variant="link"
@@ -86,7 +86,7 @@ const {
86
86
  Neutral
87
87
 
88
88
  <UButton
89
- to="/docs/getting-started/theme/css-variables#text"
89
+ to="https://ui.nuxt.com/docs/getting-started/theme/css-variables#text"
90
90
  size="xs"
91
91
  color="neutral"
92
92
  variant="link"
@@ -113,7 +113,7 @@ const {
113
113
  Radius
114
114
 
115
115
  <UButton
116
- to="/docs/getting-started/theme/css-variables#radius"
116
+ to="https://ui.nuxt.com/docs/getting-started/theme/css-variables#radius"
117
117
  size="xs"
118
118
  color="neutral"
119
119
  variant="link"
@@ -140,7 +140,7 @@ const {
140
140
  Font
141
141
 
142
142
  <UButton
143
- to="/docs/getting-started/integrations/fonts"
143
+ to="https://ui.nuxt.com/docs/getting-started/integrations/fonts"
144
144
  size="xs"
145
145
  color="neutral"
146
146
  variant="link"
@@ -168,7 +168,7 @@ const {
168
168
  Icons
169
169
 
170
170
  <UButton
171
- to="/docs/getting-started/integrations/icons"
171
+ to="https://ui.nuxt.com/docs/getting-started/integrations/icons"
172
172
  size="xs"
173
173
  color="neutral"
174
174
  variant="link"
@@ -196,7 +196,7 @@ const {
196
196
  Color Mode
197
197
 
198
198
  <UButton
199
- to="/docs/getting-started/integrations/color-mode"
199
+ to="https://ui.nuxt.com/docs/getting-started/integrations/color-mode"
200
200
  size="xs"
201
201
  color="neutral"
202
202
  variant="link"
@@ -9,7 +9,7 @@ export function useTheme() {
9
9
  const site = useSiteConfig()
10
10
 
11
11
  const radius = useLocalStorage(`${site.name}-ui-radius`, 0.25)
12
- const font = useLocalStorage(`${site.name}-ui-font`, 'Public Sans')
12
+ const font = useLocalStorage(`${site.name}-ui-font`, 'Alibaba PuHuiTi')
13
13
  const _iconSet = useLocalStorage(`${site.name}-ui-icons`, 'lucide')
14
14
  const blackAsPrimary = useLocalStorage(`${site.name}-ui-black-as-primary`, false)
15
15
 
@@ -38,7 +38,7 @@ export function useTheme() {
38
38
  })
39
39
 
40
40
  const radiuses = [0, 0.125, 0.25, 0.375, 0.5]
41
- const fonts = ['Public Sans', 'DM Sans', 'Geist', 'Inter', 'Poppins', 'Outfit', 'Raleway']
41
+ const fonts = ['Alibaba PuHuiTi', 'Public Sans', 'DM Sans', 'Geist', 'Inter', 'Poppins', 'Outfit', 'Raleway']
42
42
 
43
43
  const icons = [{
44
44
  label: 'Lucide',
@@ -83,7 +83,7 @@ export function useTheme() {
83
83
 
84
84
  const link = computed(() => {
85
85
  const name = font.value
86
- if (name === 'Public Sans') return []
86
+ if (name === 'Alibaba PuHuiTi' || !fonts.includes(name)) return []
87
87
  return [{
88
88
  rel: 'stylesheet' as const,
89
89
  href: `https://fonts.googleapis.com/css2?family=${encodeURIComponent(name)}:wght@400;500;600;700&display=swap`,
@@ -100,7 +100,7 @@ export function useTheme() {
100
100
  const hasCSSChanges = computed(() => {
101
101
  return radius.value !== 0.25
102
102
  || blackAsPrimary.value
103
- || font.value !== 'Public Sans'
103
+ || font.value !== 'Alibaba PuHuiTi'
104
104
  })
105
105
 
106
106
  const hasAppConfigChanges = computed(() => {
@@ -115,7 +115,7 @@ export function useTheme() {
115
115
  '@import "@nuxt/ui";'
116
116
  ]
117
117
 
118
- if (font.value !== 'Public Sans') {
118
+ if (font.value !== 'Alibaba PuHuiTi') {
119
119
  lines.push('', '@theme {', ` --font-sans: '${font.value}', sans-serif;`, '}')
120
120
  }
121
121
 
@@ -172,7 +172,7 @@ export function useTheme() {
172
172
  window.localStorage.removeItem(`${site.name}-ui-neutral`)
173
173
 
174
174
  radius.value = 0.25
175
- font.value = 'Public Sans'
175
+ font.value = 'Alibaba PuHuiTi'
176
176
  _iconSet.value = 'lucide'
177
177
  appConfig.ui.icons = themeIcons.lucide as any
178
178
  blackAsPrimary.value = false
@@ -8,6 +8,7 @@ definePageMeta({
8
8
  heroBackground: 'opacity-30'
9
9
  })
10
10
 
11
+ const { isOpen } = useAIChat()
11
12
  const route = useRoute()
12
13
  const appConfig = useAppConfig()
13
14
  const { toc, github } = appConfig
@@ -110,7 +111,13 @@ defineOgImage('Docs', {
110
111
  </script>
111
112
 
112
113
  <template>
113
- <UPage v-if="page">
114
+ <UPage
115
+ v-if="page"
116
+ :ui="isOpen ? {
117
+ center: 'lg:col-span-10',
118
+ right: 'lg:col-span-0 hidden'
119
+ } : undefined"
120
+ >
114
121
  <UPageHeader :title="title">
115
122
  <template #headline>
116
123
  <UBreadcrumb :items="breadcrumb" />
@@ -67,7 +67,7 @@ export default defineNuxtPlugin({
67
67
  `if (localStorage.getItem('${site.name}-ui-font')) {`,
68
68
  `var font = localStorage.getItem('${site.name}-ui-font');`,
69
69
  `document.getElementById('${site.name}-ui-font').innerHTML = ':root { --font-sans: \\'' + font + '\\', sans-serif; }';`,
70
- `if (font !== 'Public Sans') {`,
70
+ `if (font !== 'Alibaba PuHuiTi' && ['Alibaba PuHuiTi', 'Public Sans', 'DM Sans', 'Geist', 'Inter', 'Poppins', 'Outfit', 'Raleway'].includes(font)) {`,
71
71
  `var lnk = document.createElement('link');`,
72
72
  `lnk.rel = 'stylesheet';`,
73
73
  `lnk.href = 'https://fonts.googleapis.com/css2?family=' + encodeURIComponent(font) + ':wght@400;500;600;700&display=swap';`,
@@ -31,7 +31,7 @@ export interface AiChatModuleOptions {
31
31
  models?: string[]
32
32
  }
33
33
 
34
- const log = logger.withTag('movk-nuxt-docs')
34
+ const log = logger.withTag('@movk/nuxt-docs')
35
35
 
36
36
  export default defineNuxtModule<AiChatModuleOptions>({
37
37
  meta: {
@@ -4,12 +4,12 @@ const { toggleChat } = useAIChat()
4
4
  </script>
5
5
 
6
6
  <template>
7
- <UTooltip :text="aiChat.texts.trigger">
7
+ <UTooltip :text="aiChat.texts?.trigger ?? ''">
8
8
  <UButton
9
- :icon="aiChat.icons.trigger"
9
+ :icon="aiChat.icons?.trigger ?? ''"
10
10
  variant="ghost"
11
11
  class="rounded-full"
12
- :aria-label="aiChat.texts.trigger"
12
+ :aria-label="aiChat.texts?.trigger ?? ''"
13
13
  @click="toggleChat"
14
14
  />
15
15
  </UTooltip>
@@ -12,7 +12,7 @@ const inputRef = ref<{ inputRef: HTMLInputElement } | null>(null)
12
12
 
13
13
  const isDocsRoute = computed(() => route.meta.layout === 'docs')
14
14
  const isFloatingInputEnabled = computed(() => aiChat.floatingInput !== false)
15
- const focusInputShortcut = computed(() => aiChat.shortcuts.focusInput)
15
+ const focusInputShortcut = computed(() => aiChat.shortcuts?.focusInput ?? 'meta_i')
16
16
 
17
17
  const shortcutDisplayKeys = computed(() => {
18
18
  const shortcut = focusInputShortcut.value
@@ -72,7 +72,7 @@ defineShortcuts(shortcuts)
72
72
  <UInput
73
73
  ref="inputRef"
74
74
  v-model="input"
75
- :placeholder="aiChat.texts.placeholder"
75
+ :placeholder="aiChat.texts?.placeholder ?? ''"
76
76
  size="lg"
77
77
  maxlength="1000"
78
78
  :ui="{
@@ -4,7 +4,7 @@ import type { FaqCategory, FaqQuestions, ToolPart, ToolState } from '../types'
4
4
  import { Chat } from '@ai-sdk/vue'
5
5
  import { DefaultChatTransport, getToolName, isReasoningUIPart, isTextUIPart, isToolUIPart } from 'ai'
6
6
  import { computed } from 'vue'
7
- import { isReasoningStreaming, isToolStreaming } from '@nuxt/ui/utils/ai'
7
+ import { isPartStreaming, isToolStreaming } from '@nuxt/ui/utils/ai'
8
8
  import { useModels } from '../composables/useModels'
9
9
  import { splitByCase, upperFirst } from 'scule'
10
10
  import AiChatPreStream from './AiChatPreStream.vue'
@@ -28,7 +28,7 @@ let _skipSync = false
28
28
  const chat = new Chat({
29
29
  messages: messages.value,
30
30
  transport: new DefaultChatTransport({
31
- api: config.public.aiChat.apiPath,
31
+ api: (config.app?.baseURL.replace(/\/$/, '') || '') + config.public.aiChat.apiPath,
32
32
  body: () => ({ model: model.value })
33
33
  }),
34
34
  onError: (error: Error) => {
@@ -159,30 +159,30 @@ const faqQuestions = computed<FaqCategory[]>(() => {
159
159
  <USidebar
160
160
  v-model:open="isOpen"
161
161
  side="right"
162
- :title="aiChat.texts.title"
162
+ :title="aiChat.texts?.title ?? ''"
163
163
  rail
164
164
  :style="{ '--sidebar-width': '24rem' }"
165
165
  :ui="{ footer: 'p-0', actions: 'gap-0.5' }"
166
166
  >
167
167
  <template #actions>
168
- <UTooltip v-if="canClear" :text="aiChat.texts.clearChat">
168
+ <UTooltip v-if="canClear" :text="aiChat.texts?.clearChat ?? ''">
169
169
  <UButton
170
- :icon="aiChat.icons.clearChat"
170
+ :icon="aiChat.icons?.clearChat ?? ''"
171
171
  color="neutral"
172
172
  variant="ghost"
173
- :aria-label="aiChat.texts.clearChat"
173
+ :aria-label="aiChat.texts?.clearChat ?? ''"
174
174
  @click="clearMessages"
175
175
  />
176
176
  </UTooltip>
177
177
  </template>
178
178
 
179
179
  <template #close>
180
- <UTooltip :text="aiChat.texts.close">
180
+ <UTooltip :text="aiChat.texts?.close ?? ''">
181
181
  <UButton
182
- :icon="aiChat.icons.close"
182
+ :icon="aiChat.icons?.close ?? ''"
183
183
  color="neutral"
184
184
  variant="ghost"
185
- :aria-label="aiChat.texts.close"
185
+ :aria-label="aiChat.texts?.close ?? ''"
186
186
  @click="isOpen = false"
187
187
  />
188
188
  </UTooltip>
@@ -215,13 +215,17 @@ const faqQuestions = computed<FaqCategory[]>(() => {
215
215
  class="px-0 gap-2"
216
216
  :user="{ ui: { container: 'max-w-full' } }"
217
217
  >
218
+ <template #indicator>
219
+ <UChatTool icon="i-lucide-brain" text="Thinking..." streaming />
220
+ </template>
221
+
218
222
  <template #content="{ message }">
219
223
  <template v-for="(part, index) in message.parts" :key="`${message.id}-${part.type}-${index}`">
220
224
  <UChatReasoning
221
225
  v-if="isReasoningUIPart(part)"
222
226
  :text="part.text"
223
- :streaming="isReasoningStreaming(message, index, chat)"
224
- :icon="aiChat.icons.reasoning"
227
+ :streaming="isPartStreaming(part)"
228
+ :icon="aiChat.icons?.reasoning ?? ''"
225
229
  >
226
230
  <MDCCached
227
231
  :value="part.text"
@@ -230,14 +234,21 @@ const faqQuestions = computed<FaqCategory[]>(() => {
230
234
  class="*:first:mt-0 *:last:mb-0"
231
235
  />
232
236
  </UChatReasoning>
233
- <MDCCached
234
- v-else-if="isTextUIPart(part) && part.text.length > 0"
235
- :value="part.text"
236
- :cache-key="`${message.id}-${index}`"
237
- :components="components"
238
- :parser-options="{ highlight: false }"
239
- class="*:first:mt-0 *:last:mb-0"
240
- />
237
+
238
+ <template v-else-if="isTextUIPart(part) && part.text.length > 0">
239
+ <MDCCached
240
+ v-if="message.role === 'assistant'"
241
+ :value="part.text"
242
+ :cache-key="`${message.id}-${index}`"
243
+ :components="components"
244
+ :parser-options="{ highlight: false }"
245
+ class="*:first:mt-0 *:last:mb-0"
246
+ />
247
+ <p v-else-if="message.role === 'user'" class="whitespace-pre-wrap text-sm/6">
248
+ {{ part.text }}
249
+ </p>
250
+ </template>
251
+
241
252
  <UChatTool
242
253
  v-else-if="isToolUIPart(part)"
243
254
  :text="getToolText(part)"
@@ -262,7 +273,7 @@ const faqQuestions = computed<FaqCategory[]>(() => {
262
273
  <UChatPrompt
263
274
  v-model="input"
264
275
  :error="chat.error"
265
- :placeholder="aiChat.texts.placeholder"
276
+ :placeholder="aiChat.texts?.placeholder ?? ''"
266
277
  variant="naked"
267
278
  size="sm"
268
279
  autofocus
@@ -275,7 +286,7 @@ const faqQuestions = computed<FaqCategory[]>(() => {
275
286
  <AiChatModelSelect v-model="model" />
276
287
 
277
288
  <div class="flex gap-1 justify-between items-center px-1 text-xs text-muted">
278
- <span>{{ aiChat.texts.lineBreak }}</span>
289
+ <span>{{ aiChat.texts?.lineBreak ?? '' }}</span>
279
290
  <UKbd value="shift" />
280
291
  <UKbd value="enter" />
281
292
  </div>
@@ -3,7 +3,7 @@ export function useModels() {
3
3
  const model = useCookie<string>('model', { default: () => config.public.aiChat.model })
4
4
 
5
5
  const { aiChat } = useAppConfig()
6
- const providerIcons = computed(() => aiChat.icons.providers || {})
6
+ const providerIcons = computed(() => (aiChat.icons?.providers ?? {}) as Record<string, string>)
7
7
 
8
8
  function getModelIcon(modelId: string): string {
9
9
  const provider = modelId.split('/')[0] || ''
@@ -23,7 +23,7 @@ function getMainAgentSystemPrompt(siteName: string) {
23
23
  - 在适用时引用具体的组件名称、props 或 API。
24
24
  - 如果问题不明确,请要求澄清而不是猜测。
25
25
  - 当发现多个相关项目时,使用要点清楚地列出它们。
26
- - 要策略性地使用工具:先广泛搜索,然后根据需要获取具体信息。
26
+ - 您最多需要 5 次工具调用才能找到答案,因此要有策略:从广泛开始,然后在需要时具体化。
27
27
  - 以对话方式格式化回复,而不是文档章节形式`
28
28
  }
29
29
 
@@ -42,6 +42,7 @@ export default defineEventHandler(async (event) => {
42
42
  const siteConfig = getSiteConfig(event)
43
43
  const siteName = siteConfig.name || 'Documentation'
44
44
 
45
+ const baseURL = config.app?.baseURL?.replace(/\/$/, '') || ''
45
46
  const mcpPath = config.aiChat.mcpPath
46
47
  const isExternalUrl = mcpPath.startsWith('http://') || mcpPath.startsWith('https://')
47
48
 
@@ -50,7 +51,7 @@ export default defineEventHandler(async (event) => {
50
51
  try {
51
52
  const mcpUrl = isExternalUrl
52
53
  ? mcpPath
53
- : `${getRequestURL(event).origin}${mcpPath}`
54
+ : `${getRequestURL(event).origin}${baseURL}${mcpPath}`
54
55
 
55
56
  httpClient = await createMCPClient({
56
57
  transport: { type: 'http', url: mcpUrl }
package/modules/module.ts CHANGED
@@ -22,7 +22,7 @@ export interface ModuleOptions {
22
22
  mermaid?: boolean
23
23
  }
24
24
 
25
- const log = logger.withTag('movk-nuxt-docs')
25
+ const log = logger.withTag('@movk/nuxt-docs')
26
26
 
27
27
  export default defineNuxtModule<ModuleOptions>({
28
28
  meta: {
@@ -63,7 +63,7 @@ export default defineNuxtModule<ModuleOptions>({
63
63
 
64
64
  if (mermaidAvailable) {
65
65
  addComponentsDir({
66
- path: resolve('./runtime/components/prose'),
66
+ path: resolve('./components/prose'),
67
67
  pathPrefix: false,
68
68
  prefix: 'Prose',
69
69
  global: true
@@ -153,7 +153,6 @@ export default defineNuxtModule<ModuleOptions>({
153
153
  })
154
154
 
155
155
  const layerPath = resolve('..')
156
-
157
156
  // @ts-ignore - component-meta is not typed
158
157
  nuxt.hook('component-meta:extend', (options: any) => {
159
158
  const userInclude = (nuxt.options.componentMeta && typeof nuxt.options.componentMeta === 'object')
@@ -165,14 +164,6 @@ export default defineNuxtModule<ModuleOptions>({
165
164
  ...createComponentMetaExcludeFilters(resolve, dir, layerPath, userInclude)
166
165
  ]
167
166
  })
168
-
169
- nuxt.hook('nitro:config', (nitroConfig) => {
170
- nitroConfig.publicAssets ||= []
171
- nitroConfig.publicAssets.push({
172
- dir: resolve('./runtime/public'),
173
- maxAge: 60 * 60 * 24 * 30
174
- })
175
- })
176
167
  }
177
168
  })
178
169
 
@@ -0,0 +1,144 @@
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('@movk/nuxt-docs')
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
+ } catch {
67
+ return null
68
+ }
69
+ }
70
+
71
+ function validateSkillName(name: string, dirName: string): boolean {
72
+ if (name.length > MAX_NAME_LENGTH) {
73
+ log.warn(`Skill "${name}" exceeds ${MAX_NAME_LENGTH} character limit`)
74
+ return false
75
+ }
76
+ if (!SKILL_NAME_REGEX.test(name) || name.includes('--')) {
77
+ log.warn(`Skill name "${name}" does not match the Agent Skills naming spec`)
78
+ return false
79
+ }
80
+ if (name !== dirName) {
81
+ log.warn(`Skill name "${name}" does not match directory name "${dirName}"`)
82
+ return false
83
+ }
84
+ return true
85
+ }
86
+
87
+ async function listFilesRecursively(dir: string, base: string = ''): Promise<string[]> {
88
+ const files: string[] = []
89
+ const entries = await readdir(dir, { withFileTypes: true })
90
+ for (const entry of entries) {
91
+ const relPath = base ? `${base}/${entry.name}` : entry.name
92
+ if (entry.isDirectory()) {
93
+ files.push(...await listFilesRecursively(join(dir, entry.name), relPath))
94
+ } else {
95
+ files.push(relPath)
96
+ }
97
+ }
98
+ return files
99
+ }
100
+
101
+ async function scanSkills(skillsDir: string): Promise<SkillEntry[]> {
102
+ const catalog: SkillEntry[] = []
103
+ const entries = await readdir(skillsDir, { withFileTypes: true })
104
+
105
+ for (const entry of entries) {
106
+ if (!entry.isDirectory()) continue
107
+
108
+ const skillDir = join(skillsDir, entry.name)
109
+ const skillMdPath = join(skillDir, 'SKILL.md')
110
+
111
+ if (!existsSync(skillMdPath)) continue
112
+
113
+ const content = await readFile(skillMdPath, 'utf-8')
114
+ const frontmatter = parseFrontmatter(content)
115
+
116
+ if (!frontmatter?.description) {
117
+ log.warn(`Skipping skill "${entry.name}": missing description in SKILL.md frontmatter`)
118
+ continue
119
+ }
120
+
121
+ const name = frontmatter.name || entry.name
122
+ if (!validateSkillName(name, entry.name)) continue
123
+
124
+ const allFiles = await listFilesRecursively(skillDir)
125
+ const files = allFiles.filter(f => !f.split('/').some(s => s.startsWith('.')))
126
+ const sortedFiles = ['SKILL.md', ...files.filter(f => f !== 'SKILL.md')]
127
+
128
+ catalog.push({
129
+ name,
130
+ description: frontmatter.description,
131
+ files: sortedFiles
132
+ })
133
+ }
134
+
135
+ return catalog
136
+ }
137
+
138
+ declare module 'nuxt/schema' {
139
+ interface RuntimeConfig {
140
+ skills: {
141
+ catalog: SkillEntry[]
142
+ }
143
+ }
144
+ }
@@ -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
@@ -2,6 +2,7 @@ import { defineNuxtConfig } from 'nuxt/config'
2
2
  import pkg from './package.json'
3
3
  import { createResolver, useNuxt } from '@nuxt/kit'
4
4
  import { join } from 'pathe'
5
+ import { createAlibabaPuHuiTiProvider } from './providers/alibaba-puhuiti'
5
6
 
6
7
  const { resolve } = createResolver(import.meta.url)
7
8
 
@@ -95,6 +96,7 @@ export default defineNuxtConfig({
95
96
 
96
97
  nitro: {
97
98
  prerender: {
99
+ routes: ['/', '/sitemap.xml'],
98
100
  crawlLinks: true,
99
101
  failOnError: false,
100
102
  autoSubfolderIndex: false
@@ -119,8 +121,7 @@ export default defineNuxtConfig({
119
121
  cfg.optimizeDeps.include.push(
120
122
  'tailwindcss/colors',
121
123
  '@movk/nuxt-docs > @movk/core',
122
- '@movk/nuxt-docs > prettier',
123
- '@movk/nuxt-docs > reka-ui'
124
+ '@movk/nuxt-docs > prettier'
124
125
  )
125
126
 
126
127
  // AI Chat static deps — only pre-bundle when the feature is actually enabled.
@@ -167,9 +168,11 @@ export default defineNuxtConfig({
167
168
  },
168
169
 
169
170
  fonts: {
171
+ providers: {
172
+ 'alibaba-puhuiti': createAlibabaPuHuiTiProvider('https://cdn.mhaibaraai.cn/fonts')
173
+ },
170
174
  families: [
171
- { name: 'Public Sans', global: true },
172
- { name: 'Noto Sans SC', global: true, provider: 'local' }
175
+ { name: 'Alibaba PuHuiTi', provider: 'alibaba-puhuiti', global: true }
173
176
  ]
174
177
  },
175
178
 
@@ -183,7 +186,8 @@ export default defineNuxtConfig({
183
186
  clientBundle: {
184
187
  scan: true,
185
188
  includeCustomCollections: true
186
- }
189
+ },
190
+ provider: 'iconify'
187
191
  },
188
192
 
189
193
  llms: {
package/nuxt.schema.ts CHANGED
@@ -33,7 +33,7 @@ export default defineNuxtSchema({
33
33
  title: '字体',
34
34
  description: '全局字体名称',
35
35
  icon: 'i-lucide-type',
36
- default: 'Public Sans'
36
+ default: 'Alibaba PuHuiTi'
37
37
  })
38
38
  }
39
39
  }),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@movk/nuxt-docs",
3
3
  "type": "module",
4
- "version": "1.16.2",
4
+ "version": "1.17.0",
5
5
  "private": false,
6
6
  "description": "Modern Nuxt 4 documentation theme with auto-generated component docs, AI chat assistant, MCP server, and complete developer experience optimization.",
7
7
  "author": "YiXuan <mhaibaraai@gmail.com>",
@@ -39,20 +39,20 @@
39
39
  "README.md"
40
40
  ],
41
41
  "dependencies": {
42
- "@ai-sdk/gateway": "^3.0.83",
43
- "@ai-sdk/mcp": "^1.0.30",
44
- "@ai-sdk/vue": "^3.0.141",
45
- "@iconify-json/lucide": "^1.2.100",
46
- "@iconify-json/simple-icons": "^1.2.75",
42
+ "@ai-sdk/gateway": "^3.0.88",
43
+ "@ai-sdk/mcp": "^1.0.32",
44
+ "@ai-sdk/vue": "^3.0.146",
45
+ "@iconify-json/lucide": "^1.2.101",
46
+ "@iconify-json/simple-icons": "^1.2.76",
47
47
  "@iconify-json/vscode-icons": "^1.2.45",
48
48
  "@movk/core": "^1.2.2",
49
49
  "@nuxt/a11y": "^1.0.0-alpha.1",
50
50
  "@nuxt/content": "^3.12.0",
51
51
  "@nuxt/image": "^2.0.0",
52
52
  "@nuxt/kit": "^4.4.2",
53
- "@nuxt/ui": "^4.6.0",
54
- "@nuxtjs/mcp-toolkit": "^0.12.0",
55
- "@nuxtjs/mdc": "^0.21.0",
53
+ "@nuxt/ui": "^4.6.1",
54
+ "@nuxtjs/mcp-toolkit": "^0.13.3",
55
+ "@nuxtjs/mdc": "^0.21.1",
56
56
  "@nuxtjs/robots": "^6.0.6",
57
57
  "@octokit/rest": "^22.0.1",
58
58
  "@shikijs/core": "^4.0.2",
@@ -63,26 +63,27 @@
63
63
  "@takumi-rs/wasm": "^0.73.1",
64
64
  "@vueuse/core": "^14.2.1",
65
65
  "@vueuse/nuxt": "^14.2.1",
66
- "ai": "^6.0.141",
67
- "defu": "^6.1.4",
66
+ "ai": "^6.0.146",
67
+ "defu": "^6.1.6",
68
68
  "exsolve": "^1.0.8",
69
69
  "git-url-parse": "^16.1.0",
70
70
  "motion-v": "^2.2.0",
71
71
  "nuxt-component-meta": "^0.17.2",
72
72
  "nuxt-llms": "^0.2.0",
73
- "nuxt-og-image": "^6.3.0",
73
+ "nuxt-og-image": "^6.3.2",
74
74
  "nuxt-site-config": "^4.0.7",
75
75
  "ohash": "^2.0.11",
76
76
  "pathe": "^2.0.3",
77
77
  "pkg-types": "^2.3.0",
78
78
  "prettier": "^3.8.1",
79
- "reka-ui": "^2.9.2",
80
79
  "scule": "^1.3.0",
81
80
  "shiki-stream": "^0.1.4",
82
81
  "shiki-transformer-color-highlight": "^1.1.0",
83
82
  "tailwindcss": "^4.2.2",
84
83
  "ufo": "^1.6.3",
84
+ "unifont": "^0.7.4",
85
85
  "vue-component-meta": "^3.2.6",
86
+ "yaml": "^2.8.3",
86
87
  "zod": "^4.3.6"
87
88
  }
88
89
  }
@@ -32,9 +32,9 @@ export default defineCachedEventHandler(async (event) => {
32
32
  const allCommits = await Promise.all(
33
33
  paths.map(path =>
34
34
  octokit.rest.repos.listCommits({
35
- sha: github.branch,
36
- owner: github.owner,
37
- repo: github.name,
35
+ sha: github.branch!,
36
+ owner: github.owner!,
37
+ repo: github.name!,
38
38
  path,
39
39
  since: github.since,
40
40
  per_page: github.per_page || 100,
@@ -29,9 +29,9 @@ export default defineCachedEventHandler(async (event) => {
29
29
 
30
30
  try {
31
31
  const commits = await octokit.rest.repos.listCommits({
32
- sha: github.branch,
33
- owner: github.owner,
34
- repo: github.name,
32
+ sha: github.branch!,
33
+ owner: github.owner!,
34
+ repo: github.name!,
35
35
  path,
36
36
  per_page: 1
37
37
  }).then(res => res.data).catch(() => [])
@@ -58,8 +58,8 @@ export default defineCachedEventHandler(async (event) => {
58
58
  const prMatch = commit.commit.message.match(/#(\d+)/)
59
59
  if (prMatch?.[1]) {
60
60
  const prData = await octokit.rest.pulls.get({
61
- owner: github.owner,
62
- repo: github.name,
61
+ owner: github.owner!,
62
+ repo: github.name!,
63
63
  pull_number: Number.parseInt(prMatch[1])
64
64
  }).then(res => res.data).catch(() => null)
65
65
 
@@ -17,8 +17,8 @@ export default defineCachedEventHandler(async () => {
17
17
  const octokit = new Octokit({ auth: process.env.NUXT_GITHUB_TOKEN })
18
18
 
19
19
  const releases = await octokit.rest.repos.listReleases({
20
- owner: github.owner,
21
- repo: github.name
20
+ owner: github.owner!,
21
+ repo: github.name!
22
22
  }).then(res => res.data).catch(() => [])
23
23
 
24
24
  return releases