@movk/nuxt-docs 1.8.1 → 1.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 +4 -0
- package/app/composables/useToolCall.ts +3 -1
- package/app/pages/docs/[...slug].vue +1 -1
- package/app/pages/index.vue +1 -1
- package/app/templates/releases.vue +1 -1
- package/content.config.ts +7 -8
- package/modules/ai-chat/runtime/server/utils/docs_agent.ts +1 -1
- package/modules/config.ts +14 -35
- package/modules/css.ts +3 -1
- package/nuxt.config.ts +3 -6
- package/package.json +9 -9
- package/server/api/component-example.get.ts +2 -2
- package/server/api/github/commits.get.ts +4 -4
- package/server/api/github/last-commit.get.ts +4 -4
- package/server/mcp/tools/get-example.ts +19 -0
- package/server/mcp/tools/get-page.ts +75 -6
- package/server/mcp/tools/list-examples.ts +14 -0
- package/server/mcp/tools/list-pages.ts +1 -1
- package/server/plugins/llms.ts +10 -15
- package/server/routes/raw/[...slug].md.get.ts +28 -8
- package/server/utils/transformMDC.ts +559 -0
- package/utils/component-meta.ts +97 -0
- /package/app/components/content/prose/{ProsePre.vue → ProsePre.global.vue} +0 -0
package/README.md
CHANGED
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
|
|
21
21
|
### 🤖 AI 增强体验
|
|
22
22
|
|
|
23
|
+
<div style="padding: 40px 0; display: flex; justify-content: center;">
|
|
24
|
+
<img src="https://docs.mhaibaraai.cn/ai/AiChat.png" alt="AiChat" width="400">
|
|
25
|
+
</div>
|
|
26
|
+
|
|
23
27
|
- **AI 聊天助手** - 内置智能文档助手,基于 Vercel AI SDK 支持多种 LLM 模型(Mistral、Qwen、OpenRouter)
|
|
24
28
|
- **MCP Server 支持** - 集成 Model Context Protocol 服务器,为 AI 助手提供结构化的文档访问能力
|
|
25
29
|
- **LLM 优化** - 通过 `nuxt-llms` 模块自动生成 `llms.txt` 和 `llms-full.txt`,为 AI 工具提供优化的文档索引
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
export function useToolCall() {
|
|
2
2
|
const tools: Record<string, string | ((args: any) => string)> = {
|
|
3
3
|
'list-pages': '列出所有文档页面',
|
|
4
|
-
'get-page': (args: any) => `检索 ${args?.path || '页面'}
|
|
4
|
+
'get-page': (args: any) => `检索 ${args?.path || '页面'}`,
|
|
5
|
+
'list-examples': '列出所有示例',
|
|
6
|
+
'get-example': (args: any) => `获取示例:${args?.exampleName || '示例'}`
|
|
5
7
|
}
|
|
6
8
|
return {
|
|
7
9
|
tools
|
|
@@ -14,7 +14,7 @@ const { toc, github } = useAppConfig()
|
|
|
14
14
|
const { data: page } = await useAsyncData(`docs-${kebabCase(route.path)}`, () => queryCollection('docs').path(route.path).first())
|
|
15
15
|
|
|
16
16
|
if (!page.value) {
|
|
17
|
-
throw createError({
|
|
17
|
+
throw createError({ status: 404, statusText: 'Page not found', fatal: true })
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
const navigation = inject<Ref<ContentNavigationItem[]>>('navigation')
|
package/app/pages/index.vue
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
const { data: page } = await useAsyncData('landing', () => queryCollection('landing').path('/').first())
|
|
3
3
|
if (!page.value) {
|
|
4
|
-
throw createError({
|
|
4
|
+
throw createError({ status: 404, statusText: 'Page not found', fatal: true })
|
|
5
5
|
}
|
|
6
6
|
|
|
7
7
|
const title = page.value.seo?.title || page.value.title
|
|
@@ -3,7 +3,7 @@ import type { ButtonProps } from '@nuxt/ui'
|
|
|
3
3
|
|
|
4
4
|
const { data: page } = await useAsyncData('releases', () => queryCollection('releases').first())
|
|
5
5
|
if (!page.value) {
|
|
6
|
-
throw createError({
|
|
6
|
+
throw createError({ status: 404, statusText: 'Page not found', fatal: true })
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
const title = page.value.seo?.title || page.value.title
|
package/content.config.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { defineCollection, defineContentConfig } from '@nuxt/content'
|
|
3
3
|
import { useNuxt } from '@nuxt/kit'
|
|
4
|
-
import { asSeoCollection } from '@nuxtjs/seo/content'
|
|
5
4
|
import { joinURL } from 'ufo'
|
|
6
|
-
import { z } from 'zod
|
|
5
|
+
import { z } from 'zod'
|
|
7
6
|
|
|
8
7
|
const { options } = useNuxt()
|
|
9
8
|
const cwd = joinURL(options.rootDir, 'content')
|
|
@@ -38,14 +37,14 @@ const PageHero = z.object({
|
|
|
38
37
|
|
|
39
38
|
export default defineContentConfig({
|
|
40
39
|
collections: {
|
|
41
|
-
landing: defineCollection(
|
|
40
|
+
landing: defineCollection({
|
|
42
41
|
type: 'page',
|
|
43
42
|
source: {
|
|
44
43
|
cwd,
|
|
45
44
|
include: 'index.md'
|
|
46
45
|
}
|
|
47
|
-
})
|
|
48
|
-
docs: defineCollection(
|
|
46
|
+
}),
|
|
47
|
+
docs: defineCollection({
|
|
49
48
|
type: 'page',
|
|
50
49
|
source: {
|
|
51
50
|
cwd,
|
|
@@ -58,8 +57,8 @@ export default defineContentConfig({
|
|
|
58
57
|
title: z.string().optional()
|
|
59
58
|
})
|
|
60
59
|
})
|
|
61
|
-
})
|
|
62
|
-
releases: defineCollection(
|
|
60
|
+
}),
|
|
61
|
+
releases: defineCollection({
|
|
63
62
|
type: 'page',
|
|
64
63
|
source: {
|
|
65
64
|
cwd,
|
|
@@ -71,6 +70,6 @@ export default defineContentConfig({
|
|
|
71
70
|
releases: z.string(),
|
|
72
71
|
hero: PageHero
|
|
73
72
|
})
|
|
74
|
-
})
|
|
73
|
+
})
|
|
75
74
|
}
|
|
76
75
|
})
|
package/modules/config.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createResolver, defineNuxtModule } from '@nuxt/kit'
|
|
2
|
-
import { join } from 'pathe'
|
|
3
2
|
import { defu } from 'defu'
|
|
4
3
|
import { getGitBranch, getGitEnv, getLocalGitInfo } from '../utils/git'
|
|
5
4
|
import { getPackageJsonMetadata, inferSiteURL } from '../utils/meta'
|
|
5
|
+
import { createComponentMetaExcludeFilters } from '../utils/component-meta'
|
|
6
6
|
|
|
7
7
|
export default defineNuxtModule({
|
|
8
8
|
meta: {
|
|
@@ -17,28 +17,25 @@ export default defineNuxtModule({
|
|
|
17
17
|
const url = inferSiteURL()
|
|
18
18
|
const meta = await getPackageJsonMetadata(dir)
|
|
19
19
|
const gitInfo = await getLocalGitInfo(dir) || getGitEnv()
|
|
20
|
-
const siteName =
|
|
20
|
+
const siteName = meta.name || gitInfo?.name || ''
|
|
21
|
+
|
|
22
|
+
nuxt.options.site = defu(nuxt.options.site, {
|
|
23
|
+
url,
|
|
24
|
+
name: siteName,
|
|
25
|
+
debug: false
|
|
26
|
+
})
|
|
21
27
|
|
|
22
28
|
nuxt.options.llms = defu(nuxt.options.llms, {
|
|
23
29
|
domain: url || 'https://example.com',
|
|
24
30
|
title: siteName,
|
|
25
31
|
description: meta.description || '',
|
|
32
|
+
contentRawMarkdown: false as const,
|
|
26
33
|
full: {
|
|
27
34
|
title: siteName,
|
|
28
35
|
description: meta.description || ''
|
|
29
36
|
}
|
|
30
37
|
})
|
|
31
38
|
|
|
32
|
-
nuxt.options.site = defu(nuxt.options.site, {
|
|
33
|
-
url,
|
|
34
|
-
name: siteName,
|
|
35
|
-
debug: false
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
nuxt.options.robots = defu(nuxt.options.robots, {
|
|
39
|
-
sitemap: url ? `${url.replace(/\/$/, '')}/sitemap.xml` : undefined
|
|
40
|
-
})
|
|
41
|
-
|
|
42
39
|
nuxt.options.appConfig.header = defu(nuxt.options.appConfig.header, {
|
|
43
40
|
title: siteName
|
|
44
41
|
})
|
|
@@ -62,34 +59,16 @@ export default defineNuxtModule({
|
|
|
62
59
|
})
|
|
63
60
|
|
|
64
61
|
const layerPath = resolve('..')
|
|
65
|
-
const allowedComponents = [
|
|
66
|
-
resolve('../app/components/content/CommitChangelog.vue'),
|
|
67
|
-
resolve('../app/components/content/ComponentEmits.vue'),
|
|
68
|
-
resolve('../app/components/content/ComponentExample.vue'),
|
|
69
|
-
resolve('../app/components/content/ComponentProps.vue'),
|
|
70
|
-
resolve('../app/components/content/ComponentSlots.vue'),
|
|
71
|
-
resolve('../app/components/content/PageLastCommit.vue'),
|
|
72
|
-
resolve('../app/components/content/Mermaid.vue'),
|
|
73
|
-
resolve('./ai-chat/runtime/components/AiChatToolCall.vue'),
|
|
74
|
-
resolve('./ai-chat/runtime/components/AiChatReasoning.vue'),
|
|
75
|
-
resolve('./ai-chat/runtime/components/AiChatSlideoverFaq.vue'),
|
|
76
|
-
resolve('./ai-chat/runtime/components/AiChatPreStream.vue')
|
|
77
|
-
]
|
|
78
|
-
const userComponentPaths = [
|
|
79
|
-
join(dir, 'app/components'),
|
|
80
|
-
join(dir, 'components'),
|
|
81
|
-
join(dir, 'docs/app/components'),
|
|
82
|
-
join(dir, 'templates/*/app/components')
|
|
83
|
-
]
|
|
84
62
|
|
|
85
63
|
// @ts-ignore - component-meta is not typed
|
|
86
64
|
nuxt.hook('component-meta:extend', (options: any) => {
|
|
65
|
+
const userInclude = (nuxt.options.componentMeta && typeof nuxt.options.componentMeta === 'object')
|
|
66
|
+
? nuxt.options.componentMeta.include || []
|
|
67
|
+
: []
|
|
68
|
+
|
|
87
69
|
options.exclude = [
|
|
88
70
|
...(options.exclude || []),
|
|
89
|
-
(
|
|
90
|
-
filePath.startsWith(layerPath) && !allowedComponents.includes(filePath),
|
|
91
|
-
({ filePath }: { filePath: string }) =>
|
|
92
|
-
userComponentPaths.some(path => filePath.startsWith(path))
|
|
71
|
+
...createComponentMetaExcludeFilters(resolve, dir, layerPath, userInclude)
|
|
93
72
|
]
|
|
94
73
|
})
|
|
95
74
|
}
|
package/modules/css.ts
CHANGED
package/nuxt.config.ts
CHANGED
|
@@ -12,10 +12,10 @@ export default defineNuxtConfig({
|
|
|
12
12
|
'@nuxt/image',
|
|
13
13
|
'@nuxt/a11y',
|
|
14
14
|
'@nuxtjs/mcp-toolkit',
|
|
15
|
-
'@nuxtjs/seo',
|
|
16
15
|
'@vueuse/nuxt',
|
|
17
16
|
'nuxt-component-meta',
|
|
18
17
|
'nuxt-llms',
|
|
18
|
+
'nuxt-og-image',
|
|
19
19
|
'motion-v/nuxt',
|
|
20
20
|
() => {
|
|
21
21
|
extendViteConfig((config) => {
|
|
@@ -94,8 +94,8 @@ export default defineNuxtConfig({
|
|
|
94
94
|
metaFields: {
|
|
95
95
|
type: false,
|
|
96
96
|
props: true,
|
|
97
|
-
slots: 'no-schema'
|
|
98
|
-
events: 'no-schema'
|
|
97
|
+
slots: 'no-schema',
|
|
98
|
+
events: 'no-schema',
|
|
99
99
|
exposed: false
|
|
100
100
|
},
|
|
101
101
|
exclude: [
|
|
@@ -134,8 +134,5 @@ export default defineNuxtConfig({
|
|
|
134
134
|
'Inter:400',
|
|
135
135
|
'Inter:700'
|
|
136
136
|
]
|
|
137
|
-
},
|
|
138
|
-
sitemap: {
|
|
139
|
-
zeroRuntime: true
|
|
140
137
|
}
|
|
141
138
|
})
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@movk/nuxt-docs",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.9.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>",
|
|
@@ -28,9 +28,9 @@
|
|
|
28
28
|
"README.md"
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@ai-sdk/gateway": "^3.0.
|
|
32
|
-
"@ai-sdk/mcp": "^1.0.
|
|
33
|
-
"@ai-sdk/vue": "^3.0.
|
|
31
|
+
"@ai-sdk/gateway": "^3.0.22",
|
|
32
|
+
"@ai-sdk/mcp": "^1.0.13",
|
|
33
|
+
"@ai-sdk/vue": "^3.0.48",
|
|
34
34
|
"@iconify-json/lucide": "^1.2.86",
|
|
35
35
|
"@iconify-json/ph": "^1.2.2",
|
|
36
36
|
"@iconify-json/simple-icons": "^1.2.67",
|
|
@@ -40,26 +40,26 @@
|
|
|
40
40
|
"@nuxt/a11y": "^1.0.0-alpha.1",
|
|
41
41
|
"@nuxt/content": "^3.11.0",
|
|
42
42
|
"@nuxt/image": "^2.0.0",
|
|
43
|
-
"@nuxt/kit": "^4.
|
|
43
|
+
"@nuxt/kit": "^4.3.0",
|
|
44
44
|
"@nuxt/ui": "^4.4.0",
|
|
45
45
|
"@nuxtjs/mcp-toolkit": "^0.6.2",
|
|
46
|
-
"@nuxtjs/seo": "^3.3.0",
|
|
47
46
|
"@octokit/rest": "^22.0.1",
|
|
48
47
|
"@openrouter/ai-sdk-provider": "^2.0.0",
|
|
49
48
|
"@vercel/analytics": "^1.6.1",
|
|
50
49
|
"@vercel/speed-insights": "^1.3.1",
|
|
51
50
|
"@vueuse/core": "^14.1.0",
|
|
52
51
|
"@vueuse/nuxt": "^14.1.0",
|
|
53
|
-
"ai": "^6.0.
|
|
52
|
+
"ai": "^6.0.48",
|
|
54
53
|
"defu": "^6.1.4",
|
|
55
54
|
"dompurify": "^3.3.1",
|
|
56
55
|
"exsolve": "^1.0.8",
|
|
57
56
|
"git-url-parse": "^16.1.0",
|
|
58
57
|
"mermaid": "^11.12.2",
|
|
59
58
|
"motion-v": "^1.9.0",
|
|
60
|
-
"nuxt": "^4.
|
|
59
|
+
"nuxt": "^4.3.0",
|
|
61
60
|
"nuxt-component-meta": "^0.17.1",
|
|
62
61
|
"nuxt-llms": "^0.2.0",
|
|
62
|
+
"nuxt-og-image": "^5.1.13",
|
|
63
63
|
"ohash": "^2.0.11",
|
|
64
64
|
"pathe": "^2.0.3",
|
|
65
65
|
"pkg-types": "^2.3.0",
|
|
@@ -70,6 +70,6 @@
|
|
|
70
70
|
"shiki-transformer-color-highlight": "^1.0.0",
|
|
71
71
|
"tailwindcss": "^4.1.18",
|
|
72
72
|
"ufo": "^1.6.3",
|
|
73
|
-
"zod": "^4.3.
|
|
73
|
+
"zod": "^4.3.6"
|
|
74
74
|
}
|
|
75
75
|
}
|
|
@@ -10,8 +10,8 @@ export default defineEventHandler((event) => {
|
|
|
10
10
|
const component = components[pascalCase(componentName)]
|
|
11
11
|
if (!component) {
|
|
12
12
|
throw createError({
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
statusText: 'Example not found!',
|
|
14
|
+
status: 404
|
|
15
15
|
})
|
|
16
16
|
}
|
|
17
17
|
return component
|
|
@@ -10,8 +10,8 @@ export default defineCachedEventHandler(async (event) => {
|
|
|
10
10
|
|
|
11
11
|
if (!paths.length || !paths[0]) {
|
|
12
12
|
throw createError({
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
status: 400,
|
|
14
|
+
statusText: 'Path is required'
|
|
15
15
|
})
|
|
16
16
|
}
|
|
17
17
|
|
|
@@ -19,8 +19,8 @@ export default defineCachedEventHandler(async (event) => {
|
|
|
19
19
|
|
|
20
20
|
if (!github || typeof github === 'boolean') {
|
|
21
21
|
throw createError({
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
status: 500,
|
|
23
|
+
statusText: 'GitHub configuration is not available'
|
|
24
24
|
})
|
|
25
25
|
}
|
|
26
26
|
|
|
@@ -8,8 +8,8 @@ export default defineCachedEventHandler(async (event) => {
|
|
|
8
8
|
const { path } = getQuery(event) as { path: string }
|
|
9
9
|
if (!path) {
|
|
10
10
|
throw createError({
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
status: 400,
|
|
12
|
+
statusText: 'Path is required'
|
|
13
13
|
})
|
|
14
14
|
}
|
|
15
15
|
|
|
@@ -17,8 +17,8 @@ export default defineCachedEventHandler(async (event) => {
|
|
|
17
17
|
|
|
18
18
|
if (!github || typeof github === 'boolean') {
|
|
19
19
|
throw createError({
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
status: 500,
|
|
21
|
+
statusText: 'GitHub configuration is not available'
|
|
22
22
|
})
|
|
23
23
|
}
|
|
24
24
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
export default defineMcpTool({
|
|
4
|
+
description: '检索特定的 UI 示例实现代码和详细信息',
|
|
5
|
+
inputSchema: {
|
|
6
|
+
exampleName: z.string().describe('示例名称(PascalCase)')
|
|
7
|
+
},
|
|
8
|
+
cache: '30m',
|
|
9
|
+
async handler({ exampleName }) {
|
|
10
|
+
try {
|
|
11
|
+
const result = await $fetch<{ code: string }>(`/api/component-example/${exampleName}.json`)
|
|
12
|
+
return {
|
|
13
|
+
content: [{ type: 'text' as const, text: result.code }]
|
|
14
|
+
}
|
|
15
|
+
} catch {
|
|
16
|
+
return errorResult(`示例 '${exampleName}' 未找到。使用 list_examples 工具查看所有可用示例。`)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
})
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { z } from 'zod
|
|
1
|
+
import { z } from 'zod'
|
|
2
2
|
import { queryCollection } from '@nuxt/content/server'
|
|
3
3
|
import { inferSiteURL } from '../../../utils/meta'
|
|
4
4
|
|
|
5
5
|
export default defineMcpTool({
|
|
6
|
-
description:
|
|
6
|
+
description: `检索特定文档页面的完整内容和详细信息,使用 \`sections\` 参数仅获取特定的 h2 部分以减少响应大小。'
|
|
7
7
|
|
|
8
8
|
何时使用:当你知道文档页面的确切路径时使用。常见用例:
|
|
9
9
|
- 用户请求特定页面:「显示入门指南」→ /docs/getting-started
|
|
@@ -15,10 +15,11 @@ export default defineMcpTool({
|
|
|
15
15
|
|
|
16
16
|
工作流程:此工具返回完整的页面内容,包括标题、描述和完整的 markdown。当你需要从特定文档页面提供详细答案或代码示例时使用。`,
|
|
17
17
|
inputSchema: {
|
|
18
|
-
path: z.string().describe('从 list-pages 获取或用户提供的页面路径(例如 /docs/getting-started/installation)')
|
|
18
|
+
path: z.string().describe('从 list-pages 获取或用户提供的页面路径(例如 /docs/getting-started/installation)'),
|
|
19
|
+
sections: z.array(z.string()).optional().describe('要返回的特定 h2 部分标题(例如 ["Usage","API"])。如果省略,则返回完整文档。')
|
|
19
20
|
},
|
|
20
|
-
cache: '
|
|
21
|
-
handler: async ({ path }) => {
|
|
21
|
+
cache: '30m',
|
|
22
|
+
handler: async ({ path, sections }) => {
|
|
22
23
|
const event = useEvent()
|
|
23
24
|
const siteUrl = import.meta.dev ? 'http://localhost:3000' : inferSiteURL()
|
|
24
25
|
|
|
@@ -35,10 +36,17 @@ export default defineMcpTool({
|
|
|
35
36
|
}
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
const
|
|
39
|
+
const fullContent = await $fetch<string>(`/raw${path}.md`, {
|
|
39
40
|
baseURL: siteUrl
|
|
40
41
|
})
|
|
41
42
|
|
|
43
|
+
let content = fullContent
|
|
44
|
+
|
|
45
|
+
// If sections are specified, extract only those sections
|
|
46
|
+
if (sections && sections.length > 0) {
|
|
47
|
+
content = extractSections(fullContent, sections)
|
|
48
|
+
}
|
|
49
|
+
|
|
42
50
|
const result = {
|
|
43
51
|
title: page.title,
|
|
44
52
|
path: page.path,
|
|
@@ -58,3 +66,64 @@ export default defineMcpTool({
|
|
|
58
66
|
}
|
|
59
67
|
}
|
|
60
68
|
})
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Extract specific sections from markdown content based on h2 headings
|
|
72
|
+
*/
|
|
73
|
+
function extractSections(markdown: string, sectionTitles: string[]): string {
|
|
74
|
+
const lines = markdown.split('\n')
|
|
75
|
+
const result: string[] = []
|
|
76
|
+
|
|
77
|
+
// Normalize section titles for matching
|
|
78
|
+
const normalizedTitles = sectionTitles.map(t => t.toLowerCase().trim())
|
|
79
|
+
|
|
80
|
+
// Always include title (h1) and description (first blockquote)
|
|
81
|
+
let inHeader = true
|
|
82
|
+
for (const line of lines) {
|
|
83
|
+
if (inHeader) {
|
|
84
|
+
result.push(line)
|
|
85
|
+
// Stop after the description blockquote
|
|
86
|
+
if (line.startsWith('>') && result.length > 1) {
|
|
87
|
+
result.push('')
|
|
88
|
+
inHeader = false
|
|
89
|
+
}
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
break
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Find and extract requested sections
|
|
96
|
+
let currentSection: string | null = null
|
|
97
|
+
let sectionContent: string[] = []
|
|
98
|
+
|
|
99
|
+
for (let i = 0; i < lines.length; i++) {
|
|
100
|
+
const line = lines[i]
|
|
101
|
+
if (!line) continue
|
|
102
|
+
|
|
103
|
+
// Check for h2 heading
|
|
104
|
+
if (line.startsWith('## ')) {
|
|
105
|
+
// Save previous section if it was requested
|
|
106
|
+
if (currentSection && normalizedTitles.includes(currentSection.toLowerCase())) {
|
|
107
|
+
result.push(...sectionContent)
|
|
108
|
+
result.push('')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Start new section
|
|
112
|
+
currentSection = line.replace('## ', '').trim()
|
|
113
|
+
sectionContent = [line]
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Add line to current section
|
|
118
|
+
if (currentSection) {
|
|
119
|
+
sectionContent.push(line)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Don't forget the last section
|
|
124
|
+
if (currentSection && normalizedTitles.includes(currentSection.toLowerCase())) {
|
|
125
|
+
result.push(...sectionContent)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return result.join('\n').trim()
|
|
129
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// @ts-expect-error - no types available
|
|
2
|
+
import components from '#component-example/nitro'
|
|
3
|
+
|
|
4
|
+
export default defineMcpTool({
|
|
5
|
+
description: '列出所有可用的 UI 示例和代码演示',
|
|
6
|
+
cache: '1h',
|
|
7
|
+
handler() {
|
|
8
|
+
const examples = Object.entries<{ pascalName: string }>(components).map(([_key, value]) => {
|
|
9
|
+
return value.pascalName
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
return jsonResult(examples)
|
|
13
|
+
}
|
|
14
|
+
})
|
|
@@ -19,7 +19,7 @@ export default defineMcpTool({
|
|
|
19
19
|
- path:用于 get-page 的确切路径
|
|
20
20
|
- description:页面内容的简要摘要
|
|
21
21
|
- url:完整 URL 供参考`,
|
|
22
|
-
cache: '
|
|
22
|
+
cache: '30m',
|
|
23
23
|
handler: async () => {
|
|
24
24
|
const event = useEvent()
|
|
25
25
|
const siteUrl = import.meta.dev ? 'http://localhost:3000' : getRequestURL(event).origin
|
package/server/plugins/llms.ts
CHANGED
|
@@ -1,24 +1,19 @@
|
|
|
1
|
+
import type { H3Event } from 'h3'
|
|
2
|
+
import type { PageCollectionItemBase } from '@nuxt/content'
|
|
3
|
+
|
|
1
4
|
export default defineNitroPlugin((nitroApp) => {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
*/
|
|
6
|
-
nitroApp.hooks.hook('llms:generate', (_, { sections, domain }) => {
|
|
7
|
-
// Transform links except for "Documentation Sets"
|
|
8
|
-
sections.forEach((section) => {
|
|
9
|
-
if (section.title !== 'Documentation Sets') {
|
|
10
|
-
section.links = section.links?.map(link => ({
|
|
11
|
-
...link,
|
|
12
|
-
href: `${link.href.replace(new RegExp(`^${domain}`), `${domain}/raw`)}.md`
|
|
13
|
-
}))
|
|
14
|
-
}
|
|
15
|
-
})
|
|
5
|
+
nitroApp.hooks.hook('content:llms:generate:document', async (event: H3Event, doc: PageCollectionItemBase) => {
|
|
6
|
+
await transformMDC(event, doc as any)
|
|
7
|
+
})
|
|
16
8
|
|
|
9
|
+
nitroApp.hooks.hook('llms:generate', (_, { sections }) => {
|
|
17
10
|
// Move "Documentation Sets" to the end
|
|
18
11
|
const docSetIdx = sections.findIndex(s => s.title === 'Documentation Sets')
|
|
19
12
|
if (docSetIdx !== -1) {
|
|
20
13
|
const [docSet] = sections.splice(docSetIdx, 1)
|
|
21
|
-
|
|
14
|
+
if (docSet) {
|
|
15
|
+
sections.push(docSet)
|
|
16
|
+
}
|
|
22
17
|
}
|
|
23
18
|
})
|
|
24
19
|
})
|
|
@@ -1,21 +1,41 @@
|
|
|
1
|
-
import type { Collections } from '@nuxt/content'
|
|
2
|
-
import { queryCollection } from '@nuxt/content/server'
|
|
3
|
-
import { stringify } from 'minimark/stringify'
|
|
4
1
|
import { withLeadingSlash } from 'ufo'
|
|
2
|
+
import { stringify } from 'minimark/stringify'
|
|
3
|
+
import { queryCollection } from '@nuxt/content/server'
|
|
4
|
+
import type { Collections, PageCollectionItemBase } from '@nuxt/content'
|
|
5
|
+
import { getRouterParams, eventHandler, createError, setHeader } from 'h3'
|
|
6
|
+
import collections from '#content/manifest'
|
|
7
|
+
import { transformMDC } from '../../utils/transformMDC'
|
|
5
8
|
|
|
6
|
-
export default
|
|
9
|
+
export default eventHandler(async (event) => {
|
|
7
10
|
const slug = getRouterParams(event)['slug.md']
|
|
8
11
|
if (!slug?.endsWith('.md')) {
|
|
9
|
-
throw createError({
|
|
12
|
+
throw createError({ status: 404, statusText: 'Page not found', fatal: true })
|
|
10
13
|
}
|
|
11
14
|
|
|
12
|
-
|
|
15
|
+
let path = withLeadingSlash(slug.replace('.md', ''))
|
|
16
|
+
if (path.endsWith('/index')) {
|
|
17
|
+
path = path.substring(0, path.length - 6)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const _collections = Object.entries(collections as unknown as Record<string, { type: string }>)
|
|
21
|
+
.filter(([_key, value]) => value.type === 'page')
|
|
22
|
+
.map(([key]) => key) as string[]
|
|
23
|
+
|
|
24
|
+
let page: PageCollectionItemBase | null = null
|
|
25
|
+
for (const collection of _collections) {
|
|
26
|
+
page = await queryCollection(event, collection as keyof Collections).path(path).first() as PageCollectionItemBase | null
|
|
27
|
+
if (page) {
|
|
28
|
+
break
|
|
29
|
+
}
|
|
30
|
+
}
|
|
13
31
|
|
|
14
|
-
const page = await queryCollection(event, 'docs' as keyof Collections).path(path).first()
|
|
15
32
|
if (!page) {
|
|
16
|
-
throw createError({
|
|
33
|
+
throw createError({ status: 404, statusText: 'Page not found', fatal: true })
|
|
17
34
|
}
|
|
18
35
|
|
|
36
|
+
// Transform MDC components to standard elements for LLM consumption
|
|
37
|
+
await transformMDC(event, page as any)
|
|
38
|
+
|
|
19
39
|
// Add title and description to the top of the page if missing
|
|
20
40
|
if (page.body.value[0]?.[0] !== 'h1') {
|
|
21
41
|
page.body.value.unshift(['blockquote', {}, page.description])
|
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
import type { H3Event } from 'h3'
|
|
2
|
+
import { camelCase, kebabCase, upperFirst } from 'scule'
|
|
3
|
+
import { visit } from '@nuxt/content/runtime'
|
|
4
|
+
// @ts-expect-error - no types available
|
|
5
|
+
import components from '#component-example/nitro'
|
|
6
|
+
|
|
7
|
+
type Document = {
|
|
8
|
+
title: string
|
|
9
|
+
body: any
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function replaceNodeWithPre(node: any[], language: string, code: string, filename?: string) {
|
|
13
|
+
node[0] = 'pre'
|
|
14
|
+
node[1] = { language, code }
|
|
15
|
+
if (filename) node[1].filename = filename
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function visitAndReplace(doc: Document, type: string, handler: (node: any[]) => void) {
|
|
19
|
+
visit(doc.body, (node) => {
|
|
20
|
+
if (Array.isArray(node) && node[0] === type) {
|
|
21
|
+
handler(node)
|
|
22
|
+
}
|
|
23
|
+
return true
|
|
24
|
+
}, node => node)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function transformMDC(event: H3Event, doc: Document): Promise<Document> {
|
|
28
|
+
// Transform commit-changelog to changelog content
|
|
29
|
+
const changelogNodes: any[][] = []
|
|
30
|
+
visitAndReplace(doc, 'commit-changelog', (node) => {
|
|
31
|
+
changelogNodes.push(node)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
if (changelogNodes.length) {
|
|
35
|
+
const { github } = useAppConfig() as { github: Record<string, any> }
|
|
36
|
+
|
|
37
|
+
await Promise.all(changelogNodes.map(async (node) => {
|
|
38
|
+
const attrs = node[1] || {}
|
|
39
|
+
const basePath = attrs['commit-path'] || github?.commitPath || 'src'
|
|
40
|
+
const filePrefix = attrs.prefix ? `${attrs.prefix}/` : ''
|
|
41
|
+
const fileExtension = attrs.suffix || github?.suffix || 'vue'
|
|
42
|
+
const fileName = attrs.name || doc.title || ''
|
|
43
|
+
const casing = attrs.casing || github?.casing || 'auto'
|
|
44
|
+
|
|
45
|
+
const transformedName = (() => {
|
|
46
|
+
switch (casing) {
|
|
47
|
+
case 'kebab': return kebabCase(fileName)
|
|
48
|
+
case 'camel': return camelCase(fileName)
|
|
49
|
+
case 'pascal': return upperFirst(camelCase(fileName))
|
|
50
|
+
case 'auto':
|
|
51
|
+
default:
|
|
52
|
+
return fileExtension === 'vue'
|
|
53
|
+
? upperFirst(camelCase(fileName))
|
|
54
|
+
: camelCase(fileName)
|
|
55
|
+
}
|
|
56
|
+
})()
|
|
57
|
+
|
|
58
|
+
const filePath = `${basePath}/${filePrefix}${transformedName}.${fileExtension}`
|
|
59
|
+
const githubUrl = github?.url || ''
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const commits = await $fetch<Array<{ sha: string, message: string }>>('/api/github/commits', {
|
|
63
|
+
query: { path: filePath, author: attrs.author }
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
if (!commits?.length) {
|
|
67
|
+
node[0] = 'p'
|
|
68
|
+
node[1] = {}
|
|
69
|
+
node[2] = 'No recent changes.'
|
|
70
|
+
node.length = 3
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const lines = commits.map((commit) => {
|
|
75
|
+
const shortSha = commit.sha.slice(0, 5)
|
|
76
|
+
const message = commit.message.replace(/\(.*?\)/, '').replace(/#(\d+)/g, `[#$1](${githubUrl}/issues/$1)`)
|
|
77
|
+
return `- [\`${shortSha}\`](${githubUrl}/commit/${commit.sha}) — ${message}`
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
node[0] = 'p'
|
|
81
|
+
node[1] = {}
|
|
82
|
+
node[2] = lines.join('\n')
|
|
83
|
+
node.length = 3
|
|
84
|
+
} catch {
|
|
85
|
+
node[0] = 'p'
|
|
86
|
+
node[1] = {}
|
|
87
|
+
node[2] = githubUrl
|
|
88
|
+
? `See the [releases page](${githubUrl}/releases) for the latest changes.`
|
|
89
|
+
: 'No recent changes.'
|
|
90
|
+
node.length = 3
|
|
91
|
+
}
|
|
92
|
+
}))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Transform component-props, component-slots, component-emits to markdown tables
|
|
96
|
+
const DEFAULT_IGNORE_PROPS = [
|
|
97
|
+
'activeClass', 'inactiveClass', 'exactActiveClass', 'ariaCurrentValue',
|
|
98
|
+
'href', 'rel', 'noRel', 'prefetch', 'prefetchOn', 'noPrefetch',
|
|
99
|
+
'prefetchedClass', 'replace', 'exact', 'exactQuery', 'exactHash',
|
|
100
|
+
'external', 'onClick', 'viewTransition', 'enterKeyHint',
|
|
101
|
+
'form', 'formaction', 'formenctype', 'formmethod', 'formnovalidate', 'formtarget'
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
const metaNodes: { node: any[], type: 'props' | 'slots' | 'emits' }[] = []
|
|
105
|
+
for (const type of ['component-props', 'component-slots', 'component-emits'] as const) {
|
|
106
|
+
visitAndReplace(doc, type, (node) => {
|
|
107
|
+
metaNodes.push({ node, type: type.replace('component-', '') as 'props' | 'slots' | 'emits' })
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (metaNodes.length) {
|
|
112
|
+
await Promise.all(metaNodes.map(async ({ node, type }) => {
|
|
113
|
+
const attrs = node[1] || {}
|
|
114
|
+
const slug = attrs.slug || doc.title || ''
|
|
115
|
+
const camelName = camelCase(slug)
|
|
116
|
+
const componentName = type === 'props' && attrs.prose
|
|
117
|
+
? `Prose${upperFirst(camelName)}`
|
|
118
|
+
: upperFirst(camelName)
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const meta = await $fetch<{ meta: { props?: any[], slots?: any[], events?: any[] } }>(`/api/component-meta/${componentName}.json`)
|
|
122
|
+
|
|
123
|
+
let markdown = ''
|
|
124
|
+
|
|
125
|
+
if (type === 'props') {
|
|
126
|
+
const ignoreList = attrs.ignore ? String(attrs.ignore).split(',').map((s: string) => s.trim()) : DEFAULT_IGNORE_PROPS
|
|
127
|
+
const props = (meta?.meta?.props || [])
|
|
128
|
+
.filter((p: any) => !ignoreList.includes(p.name))
|
|
129
|
+
.sort((a: any, b: any) => {
|
|
130
|
+
if (a.name === 'as') return -1
|
|
131
|
+
if (b.name === 'as') return 1
|
|
132
|
+
if (a.name === 'ui') return 1
|
|
133
|
+
if (b.name === 'ui') return -1
|
|
134
|
+
return 0
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
if (props.length) {
|
|
138
|
+
markdown = '| Prop | Default | Type |\n| --- | --- | --- |\n'
|
|
139
|
+
markdown += props.map((p: any) => {
|
|
140
|
+
const def = p.default?.replace(' as never', '').replace(/^"(.*)"$/, '\'$1\'') || ''
|
|
141
|
+
const desc = p.description ? ` - ${p.description}` : ''
|
|
142
|
+
return `| \`${p.name}\` | \`${def || '-'}\` | \`${p.type || '-'}\`${desc} |`
|
|
143
|
+
}).join('\n')
|
|
144
|
+
} else {
|
|
145
|
+
markdown = 'No props available.'
|
|
146
|
+
}
|
|
147
|
+
} else if (type === 'slots') {
|
|
148
|
+
const slots = meta?.meta?.slots || []
|
|
149
|
+
if (slots.length) {
|
|
150
|
+
markdown = '| Slot | Type |\n| --- | --- |\n'
|
|
151
|
+
markdown += slots.map((s: any) => {
|
|
152
|
+
const desc = s.description ? ` - ${s.description}` : ''
|
|
153
|
+
return `| \`${s.name}\` | \`${s.type || '-'}\`${desc} |`
|
|
154
|
+
}).join('\n')
|
|
155
|
+
} else {
|
|
156
|
+
markdown = 'No slots available.'
|
|
157
|
+
}
|
|
158
|
+
} else {
|
|
159
|
+
const events = meta?.meta?.events || []
|
|
160
|
+
if (events.length) {
|
|
161
|
+
markdown = '| Event | Type |\n| --- | --- |\n'
|
|
162
|
+
markdown += events.map((e: any) => `| \`${e.name}\` | \`${e.type || '-'}\` |`).join('\n')
|
|
163
|
+
} else {
|
|
164
|
+
markdown = 'No events available.'
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
node[0] = 'p'
|
|
169
|
+
node[1] = {}
|
|
170
|
+
node[2] = markdown
|
|
171
|
+
node.length = 3
|
|
172
|
+
} catch {
|
|
173
|
+
node[0] = 'p'
|
|
174
|
+
node[1] = {}
|
|
175
|
+
node[2] = `Component metadata not available for \`${componentName}\`.`
|
|
176
|
+
node.length = 3
|
|
177
|
+
}
|
|
178
|
+
}))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Transform component-example to code block
|
|
182
|
+
visitAndReplace(doc, 'component-example', (node) => {
|
|
183
|
+
const camelName = camelCase(node[1]['name'])
|
|
184
|
+
const name = camelName.charAt(0).toUpperCase() + camelName.slice(1)
|
|
185
|
+
const code = components[name].code
|
|
186
|
+
replaceNodeWithPre(node, 'vue', code, `${name}.vue`)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
// Transform callout components (tip, note, warning, caution, callout) to blockquotes
|
|
190
|
+
const calloutTypes = ['tip', 'note', 'warning', 'caution', 'callout']
|
|
191
|
+
const calloutLabels: Record<string, string> = {
|
|
192
|
+
tip: 'TIP',
|
|
193
|
+
note: 'NOTE',
|
|
194
|
+
warning: 'WARNING',
|
|
195
|
+
caution: 'CAUTION',
|
|
196
|
+
callout: 'NOTE'
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
for (const calloutType of calloutTypes) {
|
|
200
|
+
visitAndReplace(doc, calloutType, (node) => {
|
|
201
|
+
const attrs = node[1] || {}
|
|
202
|
+
const content = node.slice(2)
|
|
203
|
+
const label = calloutLabels[calloutType]
|
|
204
|
+
|
|
205
|
+
// Build the blockquote content
|
|
206
|
+
let blockquoteText = `> [!${label}]`
|
|
207
|
+
|
|
208
|
+
// Add link if present
|
|
209
|
+
if (attrs.to) {
|
|
210
|
+
blockquoteText += `\n> See: ${attrs.to}`
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Extract text content from children
|
|
214
|
+
const extractText = (children: any[]): string => {
|
|
215
|
+
return children.map((child) => {
|
|
216
|
+
if (typeof child === 'string') return child
|
|
217
|
+
if (Array.isArray(child)) {
|
|
218
|
+
const tag = child[0]
|
|
219
|
+
const childAttrs = child[1] || {}
|
|
220
|
+
const childContent = child.slice(2)
|
|
221
|
+
if (tag === 'code') return `\`${extractText(childContent)}\``
|
|
222
|
+
if (tag === 'a') return `[${extractText(childContent)}](${childAttrs.href || ''})`
|
|
223
|
+
if (tag === 'pre') {
|
|
224
|
+
const lang = childAttrs.language || ''
|
|
225
|
+
const code = childAttrs.code || extractText(childContent)
|
|
226
|
+
return `\n\`\`\`${lang}\n${code}\n\`\`\`\n`
|
|
227
|
+
}
|
|
228
|
+
return extractText(childContent)
|
|
229
|
+
}
|
|
230
|
+
return ''
|
|
231
|
+
}).join('')
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (content.length > 0) {
|
|
235
|
+
const textContent = extractText(content)
|
|
236
|
+
if (textContent.trim()) {
|
|
237
|
+
blockquoteText += `\n> ${textContent.trim().split('\n').join('\n> ')}`
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
node[0] = 'p'
|
|
242
|
+
node[1] = {}
|
|
243
|
+
node[2] = blockquoteText
|
|
244
|
+
node.length = 3
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Transform framework-only - extract content from both slots and label them
|
|
249
|
+
visitAndReplace(doc, 'framework-only', (node) => {
|
|
250
|
+
const children = node.slice(2)
|
|
251
|
+
let nuxtContent = ''
|
|
252
|
+
let vueContent = ''
|
|
253
|
+
|
|
254
|
+
// Helper to extract text from AST nodes
|
|
255
|
+
const extractContent = (nodes: any[]): string => {
|
|
256
|
+
return nodes.map((n: any) => {
|
|
257
|
+
if (typeof n === 'string') return n
|
|
258
|
+
if (Array.isArray(n)) {
|
|
259
|
+
const tag = n[0]
|
|
260
|
+
const attrs = n[1] || {}
|
|
261
|
+
const content = n.slice(2)
|
|
262
|
+
if (tag === 'pre') {
|
|
263
|
+
const lang = attrs.language || ''
|
|
264
|
+
const code = attrs.code || ''
|
|
265
|
+
return `\n\`\`\`${lang}\n${code}\n\`\`\`\n`
|
|
266
|
+
}
|
|
267
|
+
return extractContent(content)
|
|
268
|
+
}
|
|
269
|
+
return ''
|
|
270
|
+
}).join('')
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
for (const child of children) {
|
|
274
|
+
if (Array.isArray(child) && child[0] === 'template') {
|
|
275
|
+
const slotAttr = child[1]?.['v-slot:nuxt'] !== undefined
|
|
276
|
+
? 'nuxt'
|
|
277
|
+
: child[1]?.['v-slot:vue'] !== undefined ? 'vue' : null
|
|
278
|
+
if (slotAttr === 'nuxt') {
|
|
279
|
+
nuxtContent = extractContent(child.slice(2))
|
|
280
|
+
} else if (slotAttr === 'vue') {
|
|
281
|
+
vueContent = extractContent(child.slice(2))
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
let output = ''
|
|
287
|
+
if (nuxtContent.trim()) {
|
|
288
|
+
output += '**Nuxt:**\n' + nuxtContent.trim()
|
|
289
|
+
}
|
|
290
|
+
if (vueContent.trim()) {
|
|
291
|
+
if (output) output += '\n\n'
|
|
292
|
+
output += '**Vue:**\n' + vueContent.trim()
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
node[0] = 'p'
|
|
296
|
+
node[1] = {}
|
|
297
|
+
node[2] = output || ''
|
|
298
|
+
node.length = 3
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
// Transform badge to inline text
|
|
302
|
+
visitAndReplace(doc, 'badge', (node) => {
|
|
303
|
+
const attrs = node[1] || {}
|
|
304
|
+
const label = attrs.label || ''
|
|
305
|
+
node[0] = 'code'
|
|
306
|
+
node[1] = {}
|
|
307
|
+
node[2] = label
|
|
308
|
+
node.length = 3
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
// Transform card components to markdown sections
|
|
312
|
+
visitAndReplace(doc, 'card', (node) => {
|
|
313
|
+
const attrs = node[1] || {}
|
|
314
|
+
const content = node.slice(2)
|
|
315
|
+
const title = attrs.title || ''
|
|
316
|
+
|
|
317
|
+
// Extract text content from children
|
|
318
|
+
const extractText = (children: any[]): string => {
|
|
319
|
+
return children.map((child) => {
|
|
320
|
+
if (typeof child === 'string') return child
|
|
321
|
+
if (Array.isArray(child)) {
|
|
322
|
+
const tag = child[0]
|
|
323
|
+
const childContent = child.slice(2)
|
|
324
|
+
if (tag === 'code') return `\`${extractText(childContent)}\``
|
|
325
|
+
if (tag === 'a') return `[${extractText(childContent)}](${child[1]?.href || ''})`
|
|
326
|
+
return extractText(childContent)
|
|
327
|
+
}
|
|
328
|
+
return ''
|
|
329
|
+
}).join('')
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
let cardText = title ? `**${title}**` : ''
|
|
333
|
+
if (content.length > 0) {
|
|
334
|
+
const textContent = extractText(content)
|
|
335
|
+
if (textContent.trim()) {
|
|
336
|
+
cardText += cardText ? `\n${textContent.trim()}` : textContent.trim()
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
node[0] = 'p'
|
|
341
|
+
node[1] = {}
|
|
342
|
+
node[2] = cardText
|
|
343
|
+
node.length = 3
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
// Transform accordion-item to Q&A format
|
|
347
|
+
visitAndReplace(doc, 'accordion-item', (node) => {
|
|
348
|
+
const attrs = node[1] || {}
|
|
349
|
+
const content = node.slice(2)
|
|
350
|
+
const label = attrs.label || ''
|
|
351
|
+
|
|
352
|
+
// Extract text content from children
|
|
353
|
+
const extractText = (children: any[]): string => {
|
|
354
|
+
return children.map((child) => {
|
|
355
|
+
if (typeof child === 'string') return child
|
|
356
|
+
if (Array.isArray(child)) {
|
|
357
|
+
const tag = child[0]
|
|
358
|
+
const childContent = child.slice(2)
|
|
359
|
+
if (tag === 'code') return `\`${extractText(childContent)}\``
|
|
360
|
+
if (tag === 'a') return `[${extractText(childContent)}](${child[1]?.href || ''})`
|
|
361
|
+
if (tag === 'p') return extractText(childContent)
|
|
362
|
+
return extractText(childContent)
|
|
363
|
+
}
|
|
364
|
+
return ''
|
|
365
|
+
}).join('')
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
let itemText = label ? `**Q: ${label}**` : ''
|
|
369
|
+
if (content.length > 0) {
|
|
370
|
+
const textContent = extractText(content)
|
|
371
|
+
if (textContent.trim()) {
|
|
372
|
+
itemText += `\n\nA: ${textContent.trim()}`
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
node[0] = 'p'
|
|
377
|
+
node[1] = {}
|
|
378
|
+
node[2] = itemText
|
|
379
|
+
node.length = 3
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
// Remove wrapper elements by extracting children content
|
|
383
|
+
const wrapperTypes = ['card-group', 'accordion', 'steps', 'code-group', 'code-collapse', 'tabs']
|
|
384
|
+
for (const wrapperType of wrapperTypes) {
|
|
385
|
+
visitAndReplace(doc, wrapperType, (node) => {
|
|
386
|
+
const children = node.slice(2)
|
|
387
|
+
|
|
388
|
+
// Extract text from transformed children (they should be paragraphs now)
|
|
389
|
+
const extractFromChildren = (nodes: any[]): string => {
|
|
390
|
+
return nodes.map((child: any) => {
|
|
391
|
+
if (typeof child === 'string') return child
|
|
392
|
+
if (Array.isArray(child)) {
|
|
393
|
+
const tag = child[0]
|
|
394
|
+
const attrs = child[1] || {}
|
|
395
|
+
const content = child.slice(2)
|
|
396
|
+
// Handle pre/code blocks
|
|
397
|
+
if (tag === 'pre') {
|
|
398
|
+
const lang = attrs.language || ''
|
|
399
|
+
const code = attrs.code || ''
|
|
400
|
+
return `\n\`\`\`${lang}\n${code}\n\`\`\`\n`
|
|
401
|
+
}
|
|
402
|
+
// Handle paragraphs and other text
|
|
403
|
+
if (tag === 'p') {
|
|
404
|
+
const text = content.map((c: any) => typeof c === 'string' ? c : '').join('')
|
|
405
|
+
return text + '\n\n'
|
|
406
|
+
}
|
|
407
|
+
return extractFromChildren(content)
|
|
408
|
+
}
|
|
409
|
+
return ''
|
|
410
|
+
}).join('')
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const extracted = extractFromChildren(children).trim()
|
|
414
|
+
node[0] = 'p'
|
|
415
|
+
node[1] = {}
|
|
416
|
+
node[2] = extracted
|
|
417
|
+
node.length = 3
|
|
418
|
+
})
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Transform field-group to remove wrapper (fields already handled)
|
|
422
|
+
const fieldWrappers = ['field-group', 'collapsible']
|
|
423
|
+
for (const wrapperType of fieldWrappers) {
|
|
424
|
+
visitAndReplace(doc, wrapperType, (node) => {
|
|
425
|
+
const children = node.slice(2)
|
|
426
|
+
const extractFromChildren = (nodes: any[]): string => {
|
|
427
|
+
return nodes.map((child: any) => {
|
|
428
|
+
if (typeof child === 'string') return child
|
|
429
|
+
if (Array.isArray(child)) {
|
|
430
|
+
const tag = child[0]
|
|
431
|
+
const attrs = child[1] || {}
|
|
432
|
+
const content = child.slice(2)
|
|
433
|
+
if (tag === 'pre') {
|
|
434
|
+
const lang = attrs.language || ''
|
|
435
|
+
const code = attrs.code || ''
|
|
436
|
+
return `\n\`\`\`${lang}\n${code}\n\`\`\`\n`
|
|
437
|
+
}
|
|
438
|
+
if (tag === 'p') {
|
|
439
|
+
const text = content.map((c: any) => typeof c === 'string' ? c : '').join('')
|
|
440
|
+
return text + '\n\n'
|
|
441
|
+
}
|
|
442
|
+
return extractFromChildren(content)
|
|
443
|
+
}
|
|
444
|
+
return ''
|
|
445
|
+
}).join('')
|
|
446
|
+
}
|
|
447
|
+
const extracted = extractFromChildren(children).trim()
|
|
448
|
+
node[0] = 'p'
|
|
449
|
+
node[1] = {}
|
|
450
|
+
node[2] = extracted
|
|
451
|
+
node.length = 3
|
|
452
|
+
})
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Transform field to a definition format
|
|
456
|
+
visitAndReplace(doc, 'field', (node) => {
|
|
457
|
+
const attrs = node[1] || {}
|
|
458
|
+
const content = node.slice(2)
|
|
459
|
+
const name = attrs.name || ''
|
|
460
|
+
const type = attrs.type || ''
|
|
461
|
+
const required = attrs.required === 'true' || attrs[':required'] === 'true'
|
|
462
|
+
|
|
463
|
+
const extractText = (nodes: any[]): string => {
|
|
464
|
+
return nodes.map((child: any) => {
|
|
465
|
+
if (typeof child === 'string') return child
|
|
466
|
+
if (Array.isArray(child)) {
|
|
467
|
+
const content = child.slice(2)
|
|
468
|
+
return extractText(content)
|
|
469
|
+
}
|
|
470
|
+
return ''
|
|
471
|
+
}).join('')
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
let fieldText = `**${name}**`
|
|
475
|
+
if (type) fieldText += ` (\`${type}\`)`
|
|
476
|
+
if (required) fieldText += ' *required*'
|
|
477
|
+
const desc = extractText(content).trim()
|
|
478
|
+
if (desc) fieldText += `: ${desc}`
|
|
479
|
+
|
|
480
|
+
node[0] = 'p'
|
|
481
|
+
node[1] = {}
|
|
482
|
+
node[2] = fieldText
|
|
483
|
+
node.length = 3
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
// Transform code-preview to extract the Vue code as a code block
|
|
487
|
+
visitAndReplace(doc, 'code-preview', (node) => {
|
|
488
|
+
const children = node.slice(2)
|
|
489
|
+
|
|
490
|
+
const extractVueCode = (nodes: any[]): string => {
|
|
491
|
+
return nodes.map((child: any) => {
|
|
492
|
+
if (typeof child === 'string') return child
|
|
493
|
+
if (Array.isArray(child)) {
|
|
494
|
+
const tag = child[0]
|
|
495
|
+
const attrs = child[1] || {}
|
|
496
|
+
const content = child.slice(2)
|
|
497
|
+
// Build the opening tag
|
|
498
|
+
let tagStr = `<${tag}`
|
|
499
|
+
for (const [key, val] of Object.entries(attrs)) {
|
|
500
|
+
if (key.startsWith(':') || key.startsWith('v-')) {
|
|
501
|
+
tagStr += ` ${key}=${val}`
|
|
502
|
+
} else if (typeof val === 'string') {
|
|
503
|
+
tagStr += ` ${key}=${val}`
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
const innerContent = extractVueCode(content)
|
|
507
|
+
if (innerContent.trim()) {
|
|
508
|
+
tagStr += `>\n${innerContent}</${tag}>`
|
|
509
|
+
} else {
|
|
510
|
+
tagStr += ' />'
|
|
511
|
+
}
|
|
512
|
+
return tagStr
|
|
513
|
+
}
|
|
514
|
+
return ''
|
|
515
|
+
}).join('\n')
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const vueCode = extractVueCode(children).trim()
|
|
519
|
+
node[0] = 'pre'
|
|
520
|
+
node[1] = { language: 'vue', code: `<template>\n ${vueCode.split('\n').join('\n ')}\n</template>` }
|
|
521
|
+
node.length = 2
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
// Transform icons-theme and icons-theme-select to placeholder
|
|
525
|
+
visitAndReplace(doc, 'icons-theme', (node) => {
|
|
526
|
+
node[0] = 'p'
|
|
527
|
+
node[1] = {}
|
|
528
|
+
node[2] = '*See the interactive theme picker on the documentation website.*'
|
|
529
|
+
node.length = 3
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
visitAndReplace(doc, 'icons-theme-select', (node) => {
|
|
533
|
+
node[0] = 'p'
|
|
534
|
+
node[1] = {}
|
|
535
|
+
node[2] = ''
|
|
536
|
+
node.length = 3
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
// Transform supported-languages to placeholder
|
|
540
|
+
visitAndReplace(doc, 'supported-languages', (node) => {
|
|
541
|
+
node[0] = 'p'
|
|
542
|
+
node[1] = {}
|
|
543
|
+
node[2] = '*See the full list of supported languages on the documentation website.*'
|
|
544
|
+
node.length = 3
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
// Transform u-button to markdown link
|
|
548
|
+
visitAndReplace(doc, 'u-button', (node) => {
|
|
549
|
+
const attrs = node[1] || {}
|
|
550
|
+
const label = attrs.label || ''
|
|
551
|
+
const to = attrs.to || ''
|
|
552
|
+
node[0] = 'p'
|
|
553
|
+
node[1] = {}
|
|
554
|
+
node[2] = to ? `[${label}](${to})` : label
|
|
555
|
+
node.length = 3
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
return doc
|
|
559
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { Resolver } from '@nuxt/kit'
|
|
2
|
+
import { join } from 'pathe'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 匹配组件是否符合用户定义的 include 模式
|
|
6
|
+
* @param filePath 组件文件路径
|
|
7
|
+
* @param pascalName 组件 PascalCase 名称
|
|
8
|
+
* @param includePatterns 包含模式数组(字符串 glob、正则表达式或函数)
|
|
9
|
+
* @returns 是否匹配
|
|
10
|
+
*/
|
|
11
|
+
function matchesUserInclude(
|
|
12
|
+
filePath: string,
|
|
13
|
+
pascalName: string,
|
|
14
|
+
includePatterns: Array<string | RegExp | ((component: { filePath: string, pascalName: string }) => boolean)>
|
|
15
|
+
): boolean {
|
|
16
|
+
return includePatterns.some((pattern) => {
|
|
17
|
+
if (typeof pattern === 'string') {
|
|
18
|
+
// 简单的 glob 支持: ** 匹配任意路径,* 匹配非路径分隔符
|
|
19
|
+
const regexPattern = pattern
|
|
20
|
+
.replace(/\*\*/g, '{{DOUBLE_STAR}}')
|
|
21
|
+
.replace(/\*/g, '[^/]*')
|
|
22
|
+
.replace(/\{\{DOUBLE_STAR\}\}/g, '.*')
|
|
23
|
+
const regex = new RegExp(regexPattern)
|
|
24
|
+
return regex.test(filePath) || filePath.includes(pattern)
|
|
25
|
+
}
|
|
26
|
+
if (pattern instanceof RegExp) {
|
|
27
|
+
return pattern.test(filePath) || pattern.test(pascalName)
|
|
28
|
+
}
|
|
29
|
+
if (typeof pattern === 'function') {
|
|
30
|
+
return pattern({ filePath, pascalName })
|
|
31
|
+
}
|
|
32
|
+
return false
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 检查组件是否为用户组件
|
|
38
|
+
* @param filePath 组件文件路径
|
|
39
|
+
* @param userComponentPaths 用户组件路径数组
|
|
40
|
+
* @returns 是否为用户组件
|
|
41
|
+
*/
|
|
42
|
+
export function isUserComponent(filePath: string, userComponentPaths: string[]): boolean {
|
|
43
|
+
return userComponentPaths.some(path => filePath.startsWith(path))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 创建 component-meta exclude 过滤器
|
|
48
|
+
* @param layerPath layer 路径
|
|
49
|
+
* @param allowedComponents 允许的组件列表
|
|
50
|
+
* @param userComponentPaths 用户组件路径数组
|
|
51
|
+
* @param userInclude 用户定义的 include 模式
|
|
52
|
+
* @returns exclude 过滤器数组
|
|
53
|
+
*/
|
|
54
|
+
export function createComponentMetaExcludeFilters(
|
|
55
|
+
resolve: Resolver['resolve'],
|
|
56
|
+
dir: string,
|
|
57
|
+
layerPath: string,
|
|
58
|
+
userInclude: Array<string | RegExp | ((component: { filePath: string, pascalName: string }) => boolean)>
|
|
59
|
+
) {
|
|
60
|
+
const allowedComponents = [
|
|
61
|
+
resolve('../app/components/content/CommitChangelog.vue'),
|
|
62
|
+
resolve('../app/components/content/ComponentEmits.vue'),
|
|
63
|
+
resolve('../app/components/content/ComponentExample.vue'),
|
|
64
|
+
resolve('../app/components/content/ComponentProps.vue'),
|
|
65
|
+
resolve('../app/components/content/ComponentSlots.vue'),
|
|
66
|
+
resolve('../app/components/content/PageLastCommit.vue'),
|
|
67
|
+
resolve('../app/components/content/Mermaid.vue'),
|
|
68
|
+
resolve('./ai-chat/runtime/components/AiChatToolCall.vue'),
|
|
69
|
+
resolve('./ai-chat/runtime/components/AiChatReasoning.vue'),
|
|
70
|
+
resolve('./ai-chat/runtime/components/AiChatSlideoverFaq.vue'),
|
|
71
|
+
resolve('./ai-chat/runtime/components/AiChatPreStream.vue')
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
const userComponentPaths = [
|
|
75
|
+
join(dir, 'app/components'),
|
|
76
|
+
join(dir, 'components'),
|
|
77
|
+
join(dir, 'docs/app/components'),
|
|
78
|
+
join(dir, 'templates/*/app/components')
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
return [
|
|
82
|
+
// 排除 layer 中不在白名单的组件
|
|
83
|
+
({ filePath }: { filePath: string }) =>
|
|
84
|
+
filePath.startsWith(layerPath) && !allowedComponents.includes(filePath),
|
|
85
|
+
// 排除用户组件中不符合 include 规则的组件
|
|
86
|
+
({ filePath, pascalName }: { filePath: string, pascalName: string }) => {
|
|
87
|
+
const isUser = isUserComponent(filePath, userComponentPaths)
|
|
88
|
+
if (!isUser) return false
|
|
89
|
+
|
|
90
|
+
// 如果没有指定 include,排除所有用户组件
|
|
91
|
+
if (userInclude.length === 0) return true
|
|
92
|
+
|
|
93
|
+
// 如果指定了 include,排除不匹配的组件
|
|
94
|
+
return !matchesUserInclude(filePath, pascalName, userInclude)
|
|
95
|
+
}
|
|
96
|
+
]
|
|
97
|
+
}
|
|
File without changes
|