@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 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({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
17
+ throw createError({ status: 404, statusText: 'Page not found', fatal: true })
18
18
  }
19
19
 
20
20
  const navigation = inject<Ref<ContentNavigationItem[]>>('navigation')
@@ -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({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
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({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
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/v4'
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(asSeoCollection({
40
+ landing: defineCollection({
42
41
  type: 'page',
43
42
  source: {
44
43
  cwd,
45
44
  include: 'index.md'
46
45
  }
47
- })),
48
- docs: defineCollection(asSeoCollection({
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(asSeoCollection({
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
  })
@@ -1,5 +1,5 @@
1
1
  import { tool, stepCountIs, generateText } from 'ai'
2
- import { z } from 'zod/v4'
2
+ import { z } from 'zod'
3
3
 
4
4
  function getSubAgentSystemPrompt(siteName: string) {
5
5
  return `您是 ${siteName} 的文档搜索代理。您的工作是从文档中查找并检索相关信息。
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 = nuxt.options?.site?.name || meta.name || gitInfo?.name || ''
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
- ({ filePath }: { filePath: string }) =>
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
@@ -44,6 +44,8 @@ export default defineNuxtModule({
44
44
  }
45
45
  })
46
46
 
47
- nuxt.options.css.unshift(cssTemplate.dst)
47
+ if (Array.isArray(nuxt.options.css)) {
48
+ nuxt.options.css.unshift(cssTemplate.dst)
49
+ }
48
50
  }
49
51
  })
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' as const,
98
- events: 'no-schema' as const,
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.8.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.19",
32
- "@ai-sdk/mcp": "^1.0.11",
33
- "@ai-sdk/vue": "^3.0.45",
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.2.2",
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.45",
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.2.2",
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.5"
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
- statusMessage: 'Example not found!',
14
- statusCode: 404
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
- statusCode: 400,
14
- statusMessage: 'Path is required'
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
- statusCode: 500,
23
- statusMessage: 'GitHub configuration is not available'
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
- statusCode: 400,
12
- statusMessage: 'Path is required'
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
- statusCode: 500,
21
- statusMessage: 'GitHub configuration is not available'
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/v4'
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: '1h',
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 content = await $fetch<string>(`/raw${path}.md`, {
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: '1h',
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
@@ -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
- * @see
4
- * https://github.com/nuxt-content/nuxt-llms?tab=readme-ov-file#readme
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
- sections.push(docSet)
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 defineEventHandler(async (event) => {
9
+ export default eventHandler(async (event) => {
7
10
  const slug = getRouterParams(event)['slug.md']
8
11
  if (!slug?.endsWith('.md')) {
9
- throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
12
+ throw createError({ status: 404, statusText: 'Page not found', fatal: true })
10
13
  }
11
14
 
12
- const path = withLeadingSlash(slug.replace('.md', ''))
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({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
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
+ }