diff --git a/.github/workflows/pkg-pr-new.yml b/.github/workflows/pkg-pr-new.yml index daa9984d5..9b58afdb8 100644 --- a/.github/workflows/pkg-pr-new.yml +++ b/.github/workflows/pkg-pr-new.yml @@ -3,11 +3,13 @@ on: push: branches: [main] paths: + - 'packages/mcp-server/**' - 'packages/site-components/**' - 'packages/theme-generator/**' pull_request: branches: [main] paths: + - 'packages/mcp-server/**' - 'packages/site-components/**' - 'packages/theme-generator/**' - 'site/**' @@ -30,7 +32,9 @@ jobs: - run: pnpm --filter '@tdesign/site-components' --filter '@tdesign/theme-generator' run build - - run: pnpm dlx pkg-pr-new publish './packages/site-components' './packages/theme-generator' + - run: pnpm --filter tdesign-mcp-server run build:mcp + + - run: pnpm dlx pkg-pr-new publish './packages/site-components' './packages/theme-generator' './packages/mcp-server' build-tdesign-vue-site: if: github.repository == 'Tencent/tdesign' && github.event_name == 'pull_request' diff --git a/.gitignore b/.gitignore index 0edfaf6b9..96da86aed 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,8 @@ dist results static_site lib +packages/mcp-server/docs ## test -coverage \ No newline at end of file +coverage +packages/mcp-server/scripts/dom/snapshots/**/__snapshots__ \ No newline at end of file diff --git a/package.json b/package.json index 23f78dde1..3e40687fb 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,10 @@ "site": "pnpm run build:components && pnpm run --filter tdesign-site build", "site:preview": "pnpm run build:components && pnpm run --filter tdesign-site site:preview", "auto-release-collection": "pnpm run --filter auto-release-collection start", - "prepare": "husky install" + "prepare": "husky install", + "build:mcp": "pnpm run --filter tdesign-mcp-server build:mcp", + "build:mcp-docs": "pnpm run --filter tdesign-mcp-server build:docs+dom", + "start:mcp": "pnpm run --filter tdesign-mcp-server start:mcp-http" }, "license": "MIT", "dependencies": { diff --git a/packages/mcp-server/.env b/packages/mcp-server/.env new file mode 100644 index 000000000..f9ab63b01 --- /dev/null +++ b/packages/mcp-server/.env @@ -0,0 +1,6 @@ +# ============================================= +# 设为 local,从项目的 docs 文件夹读取 +# 设为 online,从 CDN 上获取文档 +# ============================================= + +DOCS_SOURCE_MODE = 'online' diff --git a/packages/mcp-server/.eslintrc.js b/packages/mcp-server/.eslintrc.js new file mode 100644 index 000000000..80abeded0 --- /dev/null +++ b/packages/mcp-server/.eslintrc.js @@ -0,0 +1,8 @@ +module.exports = { + root: true, + extends: ["plugin:@typescript-eslint/recommended"], + parser: "@typescript-eslint/parser", + plugins: ["@typescript-eslint"], + ignorePatterns: ["dist", "node_modules"], + rules: {}, +}; diff --git a/packages/mcp-server/DEVELOPMENT.md b/packages/mcp-server/DEVELOPMENT.md new file mode 100644 index 000000000..b4cb1782e --- /dev/null +++ b/packages/mcp-server/DEVELOPMENT.md @@ -0,0 +1,82 @@ +# 开发指南 + +## 🏠 项目架构 + +```bash +# 在仓库根目录安装依赖 +pnpm install + +#(可选)更新文档 +pnpm run --filter tdesign-mcp-server build:docs+dom +``` + +- 由于该项目在提取文档时,如果找不到本地仓库的话,会自动启动 clone 其它组件库的流程。 +- 默认会在**当前 tdesign 仓库的同级目录**下查找/克隆各端框架仓库(如 `tdesign-react`)。 +- 在 mono / 沙盒 / CI 环境中,可以通过环境变量 `TD_REPOS_ROOT` 显式指定一个目录用于存放这些仓库(例如工作区内的空目录);未设置时自动 clone 会先创建默认目录。 + +```bash +📄 .env # 环境变量(仅用于本地调试) +📁 packages/mcp-server +├── 📁 src # 核心业务逻辑 +│ ├── 📁 tools +│ └── 📁 prompts +├── 📁 common # 通用工具函数(源码目录) +├── 📁 scripts # 自动化脚本(源码目录) +└── 📁 docs # 已整理的文档(脚本生成,不入库) +``` + +### ⚠️ Apple Silicon 环境说明 + +如果你使用 Apple Silicon(M系列)Mac,需要额外安装 Rosetta 才能运行小程序快照构建脚本(`build:snap:mini`)。 + +- 因为底层依赖的 `miniprogram-simulate` → `miniprogram-compiler` 仅提供了 `x86_64` 架构的二进制文件,需要进行转译。 + +```bash +softwareupdate --install-rosetta --agree-to-license +``` + +## 🕹️ 本地启动 + +| 环境变量 | 说明 | 可选值 | 默认值 | +|:---|:---|:---|:---| +| `DOCS_SOURCE_MODE` | 文档数据来源 | `online`(CDN)
`local`(docs 文件夹) | `online` | +| `TD_REPOS_ROOT` | tdesign 系列仓库(tdesign-react 等)的公共目录 | 任意绝对路径 | 当前 tdesign 仓库的同级目录 | + +### Stdio + +```bash +npm run build:mcp-stdio +``` + +```json +{ + "mcpServers": { + "tdesign-mcp-server": { + "command": "node", + "args": ["your_path_to/tdesign/packages/mcp-server/dist/stdio.js"], + "env": { + "DOCS_SOURCE_MODE": "local" + } + } + } +} +``` +### StreamableHttp + +```bash +npm run build:mcp-http +npm run start:mcp-http +``` + +```json +{ + "mcpServers": { + "tdesign-mcp-server": { + "url": "localhost:9000/mcp", + "env": { + "DOCS_SOURCE_MODE": "local" + } + } + } +} +``` diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md new file mode 100644 index 000000000..5c76b50ef --- /dev/null +++ b/packages/mcp-server/README.md @@ -0,0 +1,35 @@ +# TDesign MCP Server + +## 🎉 功能介绍 + +### 🔨 支持框架 +- 桌面端:`react` / `vue-next` / `vue` +- 移动端:`mobile-react` / `mobile-vue` / `miniprogram` / `uniapp` +- AI 对话:`react-chat` / `vue-next-chat` / `miniprogram-chat` / `uniapp-chat` + +### 🔨 支持工具 +- [x] `search-icon`:搜索可用的图标列表,适用场景:图标查找与名称确认 +- [x] `get-component-docs`:获取组件的文档,适用场景:代码生成和代码转换 +- [x] `get-component-dom`:获取组件的 DOM 结构,适用场景:转换自定义的 CSS 样式 +- [x] `get-component-list`:获取所有可用的组件列表,适用场景:选择合适组件进行组合实现不存在的功能 +- [x] `get-component-changelog`:获取组件的变更日志,适用场景:组件库版本升级 + +## 📦 使用说明 +```json +{ + "mcpServers": { // 或 servers(根据不同的 MCP 客户端决定) + "tdesign-mcp-server": { + "command": "npx", + "args": ["-y", "tdesign-mcp-server@latest"] + } + } +} +``` + +> 💡 如果在 MCP 客户端中连接失败,可以在终端手动输入 `npx tdesign-mcp-server`,并反馈对应的报错信息在 issue。 + +## 🔮 开发指南 + +![Node.js >=18](https://img.shields.io/badge/node-%3E%3D18-brightgree) + +详见[文档](DEVELOPMENT.md) \ No newline at end of file diff --git a/packages/mcp-server/common/config.ts b/packages/mcp-server/common/config.ts new file mode 100644 index 000000000..435969408 --- /dev/null +++ b/packages/mcp-server/common/config.ts @@ -0,0 +1,18 @@ +import { createRequire } from "module"; +import path from "path"; +import { getDirname } from "./toolkit"; + +const __dirname = getDirname(import.meta.url); + +export const isProduction = process.env.NODE_ENV === "production"; + +if (!isProduction) { + // 避免打包时把 dotenv 也打入产物 + const require = createRequire(import.meta.url); // 兼容 cjs + const dotenv = require("dotenv"); + dotenv.config({ path: path.join(__dirname, "../../.env"), quiet: true }); +} + +export const DOCS_SOURCE_MODE = process.env.DOCS_SOURCE_MODE as "online" | "local"; + +export const isLocalMode = DOCS_SOURCE_MODE === "local"; diff --git a/packages/mcp-server/common/framework.ts b/packages/mcp-server/common/framework.ts new file mode 100644 index 000000000..444e35489 --- /dev/null +++ b/packages/mcp-server/common/framework.ts @@ -0,0 +1,50 @@ +export const addTdPrefix = (suffix: string) => `tdesign-${suffix}`; + +export const PLATFORMS = { + web: "web", + mobile: "mobile" +} as const; +export type Platform = (typeof PLATFORMS)[keyof typeof PLATFORMS]; + +export const WEB_FRAMEWORKS = ["react", "react-chat", "vue", "vue-next", "vue-next-chat"] as const; + +export const MOBILE_FRAMEWORKS = [ + "mobile-react", + "mobile-vue", + "miniprogram", + "miniprogram-chat", + "uniapp", + "uniapp-chat" +] as const; + +export const FRAMEWORKS = [...WEB_FRAMEWORKS, ...MOBILE_FRAMEWORKS] as const; +export type Framework = (typeof FRAMEWORKS)[number]; + +// wxss 内容置空,聚焦组件本身 +export const MINIPROGRAM_DEMO_EXTS = [".wxml", ".js", ".json"] as const; + +/* 组件库是否为大仓结构 */ +const MONOREPO: Framework[] = ["react", "vue-next", "miniprogram"] as const; +export const isMonorepo = (fw: Framework) => MONOREPO.includes(fw); + +export const isMiniProgram = (fw: Framework) => ["miniprogram", "miniprogram-chat"].includes(fw); + +export const isUniapp = (fw: Framework) => ["uniapp", "uniapp-chat"].includes(fw); + +export const isChatFramework = (fw: Framework) => fw.includes("chat") || fw === "uniapp-chat"; + +const SPECIAL_CHAT_COMPONENTS = ["attachments"]; +export const isChatComponent = (name: string) => name.startsWith("chat-") || SPECIAL_CHAT_COMPONENTS.includes(name); + +export const getSuffixByFramework = (fw: Framework) => (fw.includes("react") ? "tsx" : "vue"); + +/** uniapp 相关框架的源仓库映射 */ +export const getUniappSourceFramework = (fw: Framework): Framework => (fw === "uniapp-chat" ? "uniapp" : "uniapp"); + +export const getPlatformByFramework = (fw: Framework): Platform => { + if ((MOBILE_FRAMEWORKS as readonly Framework[]).includes(fw)) { + return PLATFORMS.mobile; + } + + return PLATFORMS.web; +}; diff --git a/packages/mcp-server/common/index.ts b/packages/mcp-server/common/index.ts new file mode 100644 index 000000000..a40dc92c5 --- /dev/null +++ b/packages/mcp-server/common/index.ts @@ -0,0 +1,4 @@ +export * from "./framework"; +export * from "./parser"; +export * from "./toolkit"; +export * from "./config"; \ No newline at end of file diff --git a/packages/mcp-server/common/parser.ts b/packages/mcp-server/common/parser.ts new file mode 100644 index 000000000..f0625481e --- /dev/null +++ b/packages/mcp-server/common/parser.ts @@ -0,0 +1,216 @@ +type Rule = [RegExp, string]; +export type RuleMap = Record; + +const MD_LINK_RULE: Rule = [/[,,。.]?\s*\[([^\]]+)\]\([^\)]+\)/g, ""]; +const DANGLING_PUNCT_RULE: Rule = [/[,,]\s*[。.](?!\.)/g, "。"]; +const ATTR_INLINE_STYLE_RULE: Rule = [/\s*:?style="([^"]*)"/g, ""]; +const LINK_RULE: Rule = [/https?[^"]*/g, ""]; +const SPACE_RULE: Rule = [/\s+/g, " "]; +const SPACE_BETWEEN_TAG: Rule = [/\s*(<|>)\s*/g, "$1"]; + +const COMMON_RULE_MAP: RuleMap = { + extraLine: [/^\s*\n\s*\n/gm, "\n"], + blankHeadTail: [/^(\s*\n)+|(\s*\n)+$/g, ""] +}; + +export const API_RULE_MAP: RuleMap = { + // 只提取 ## API 后面的段落 + base: [/^[\s\S]*?^## API\s*\n?/m, ""], + mdLink: MD_LINK_RULE, + danglingPunct: DANGLING_PUNCT_RULE, + ...COMMON_RULE_MAP +}; + +export const DEMO_RULE_MAP: RuleMap = { + meta: [/---\n([\s\S]*?)\n---/, ""], + style: [/\s]*>[\s\S]*?<\/style>/gi, ""], + jsxInlineStyle: [/\s*style={{([^}]*)*}}/g, ""], + attrInlineStyle: ATTR_INLINE_STYLE_RULE, + selfClosingDiv: [/^\s*|>\s*<\/div>)[\n]?/gm, ""], + comment: [//g, ""], + log: [/^\s*console\.log\([\s\S]*?\);\s*$/gm, ""], + ...COMMON_RULE_MAP +}; + +export const MOBILE_DEMO_RULE_MAP: RuleMap = { + styleImport: [/^\s*import\s+['"][^'"]*\.(less|css|scss|sass)['"]\s*;?\s*\n?/gm, ""], + demoBlockImport: [/^\s*import\s+TDemoBlock\s+from\s+['"][^'"]*['"]\s*;?\s*\n?/gm, ""], + demoBlockTag: [/\s*<\/?TDemoBlock[^>]*>\s*\n?/g, ""] +}; + +export const DOM_RULE_MAP: RuleMap = { + outerDiv: [/^
\s*([\s\S]*?)\s*<\/div>$/gm, "$1"], + emptyDiv: [/\n?\s*/g, ""], + shadowRoot: [/#shadow-root \(open\)/g, ""], + attrInlineStyle: ATTR_INLINE_STYLE_RULE, + link: LINK_RULE, + linkTag: [/]*\/?>/g, ""], + svgPathData: [/]*)\sd="[^"]*"([^>]*)>/g, ''], + allNewLines: [/\n/g, ""], + space: SPACE_RULE, + spaceBetweenTag: [/\s*(<|>)\s*/g, "$1"] +}; + +export const MINIPROGRAM_DOM_RULE_MAP: RuleMap = { + // 移除事件绑定属性:bind:tap="xxx"、catch:tap="xxx"、mut-bind:tap="xxx"、bind:collapsed-item-click="xxx" + eventBinding: [/\s*(?:bind|catch|mut-bind|capture-bind|capture-catch):[\w-]+="[^"]*"/g, ""], + attrInlineStyle: ATTR_INLINE_STYLE_RULE, + // 移除无障碍相关属性:ariaRole、ariaLabel、ariaHidden、ariaDescribedby、ariaLabelledby 等 + ariaAttr: [/\s*aria[A-Za-z]*="[^"]*"/g, ""], + // 移除 id 属性:id="xxx" + idAttr: [/\s*id="[^"]*"/g, ""], + // 移除值为空的属性:xxx="" + emptyAttr: [/\s*[\w-]+=""/g, ""], + allNewLines: [/\n/g, ""], + link: LINK_RULE, + space: SPACE_RULE, + spaceBetweenTag: SPACE_BETWEEN_TAG +}; +/** + * UniApp DOM 清洗规则 + * 基于小程序 DOM 产物进行标签替换和属性清洗,生成接近 uniapp H5 的 DOM 结构 + */ +export const UNIAPP_DOM_RULE_MAP: RuleMap = { + // ---- 第一步:替换小程序标签为 HTML 标签 ---- + // wx-view → div(开标签和闭标签都替换) + wxView: [/<(\/?)wx-view(?=[\s>])/g, "<$1div"], + // wx-text → span + wxText: [/<(\/?)wx-text(?=[\s>])/g, "<$1span"], + // wx-label → label + wxLabel: [/<(\/?)wx-label(?=[\s>])/g, "<$1label"], + // wx-button → button + wxButton: [/<(\/?)wx-button(?=[\s>])/g, "<$1button"], + // wx-input → input + wxInput: [/<(\/?)wx-input(?=[\s>])/g, "<$1input"], + // wx-textarea → textarea + wxTextarea: [/<(\/?)wx-textarea(?=[\s>])/g, "<$1textarea"], + // wx-form → form + wxForm: [/<(\/?)wx-form(?=[\s>])/g, "<$1form"], + // wx-image → img + wxImage: [/<(\/?)wx-image(?=[\s>])/g, "<$1img"], + // wx-canvas → canvas + wxCanvas: [/<(\/?)wx-canvas(?=[\s>])/g, "<$1canvas"], + // wx-navigator → a + wxNavigator: [/<(\/?)wx-navigator(?=[\s>])/g, "<$1a"], + // wx-scroll-view → div + wxScrollView: [/<(\/?)wx-scroll-view(?=[\s>])/g, "<$1div"], + // wx-swiper-item → div(先替换 swiper-item,防止被 swiper 规则覆盖) + wxSwiperItem: [/<(\/?)wx-swiper-item(?=[\s>])/g, "<$1div"], + // wx-swiper → div + wxSwiper: [/<(\/?)wx-swiper(?=[\s>])/g, "<$1div"], + // wx-rich-text → div + wxRichText: [/<(\/?)wx-rich-text(?=[\s>])/g, "<$1div"], + // 其他 wx-* 标签兜底替换为 div + wxFallback: [/<(\/?)wx-[\w-]+(?=[\s>])/g, "<$1div"], + + // ---- 第二步:移除小程序特有属性 ---- + // 移除 hover 相关属性:hoverClass、hoverStartTime、hoverStayTime、hoverStopPropagation + hoverAttr: [/\s*hover(?:Class|StartTime|StayTime|StopPropagation)="[^"]*"/g, ""], + // 移除微信小程序特有属性:sendMessage*、showMessageCard、needShowEntrance、openType、sessionFrom、appParameter + wxSpecialAttr: [ + /\s*(?:sendMessage\w*|showMessageCard|needShowEntrance|openType|sessionFrom|appParameter)="[^"]*"/g, + "" + ], + // 移除 data-* 属性(含模板语法) + dataAttr: [/\s*data-[\w-]+="[^"]*"/g, ""], + // 移除 tClass 及其所有变体属性(tClassLoad、tClassText、tClassImage、tClassContent 等外部样式类传递) + tClassAttr: [/\s*tClass\w*="[^"]*"/g, ""], + // 移除 wx-image 相关属性:lazyLoad、mode、showMenuByLongpress、webp、src + wxImageAttr: [/\s*(?:lazyLoad|mode|showMenuByLongpress|webp|src)="[^"]*"/g, ""], + // 移除 wx-input/wx-textarea 相关属性 + wxInputAttr: [ + /\s*(?:placeholderClass|placeholderStyle|cursorSpacing|adjustPosition|confirmType|confirmHold|disableDefaultPadding|enhanced)="[^"]*"/g, + "" + ], + // 移除 wx-scroll-view 相关属性 + wxScrollAttr: [ + /\s*(?:scrollTop|scrollLeft|scrollIntoView|scrollWithAnimation|scrollAnchoring|enablePassive|scrollX|scrollY|enableFlex|refresher\w*)="[^"]*"/g, + "" + ], + // 移除 wx-swiper 相关属性 + wxSwiperAttr: [ + /\s*(?:current|autoplay|circular|vertical|interval|duration|easingFunction|displayMultipleItems|previousMargin|nextMargin|snapToEdge|indicatorDots|indicatorColor|indicatorActiveColor)="[^"]*"/g, + "" + ], + // 移除 nodes 属性(wx-rich-text) + wxNodesAttr: [/\s*nodes="[^"]*"/g, ""], + // 移除 reportSubmit 属性(wx-form) + wxFormAttr: [/\s*reportSubmit="[^"]*"/g, ""], + // 移除 $gdc="xxx"、$wxs:change:xxx="xxx" 等 WXS 相关属性(含有值和无值两种格式) + wxsAttr: [/\s*\$(?:gdc|wxs:[\w:]*)(?:="[^"]*")?/g, ""], + // 移除 tabindex 属性 + tabindexAttr: [/\s*tabindex="[^"]*"/g, ""], + + // ---- 第三步(前置):先移除换行符,确保后续 class 值清洗的 lookbehind 正则能正确匹配 ---- + preAllNewLines: [/\n/g, ""], + + // ---- 第三步:清洗 class 值中的小程序占位 class ---- + // 移除 class 值中独立的 "class" 和 "t-class" / "t-class-*" 占位词(支持开头、中间、末尾位置) + classPlaceholder: [ + /(?<=class=")\s*\bclass\b\s*|\s+\bclass\b(?=\s|")|\s+\bt-class(?:-[\w-]+)?\b(?=\s|")|(?<=class=")\s*\bt-class(?:-[\w-]+)?\b\s*/g, + "" + ], + // 清理 class 值中开头和结尾的多余空格 + classTrailingSpace: [/\s+(?=")/g, ""], + classLeadingSpace: [/class="\s+/g, 'class="'], + // 清理 class 值中多余的连续空格 + classInnerSpace: [/\s{2,}/g, " "], + + // ---- 第四步:复用 MINIPROGRAM_DOM_RULE_MAP 的通用清洗 ---- + eventBinding: MINIPROGRAM_DOM_RULE_MAP.eventBinding, + attrInlineStyle: MINIPROGRAM_DOM_RULE_MAP.attrInlineStyle, + ariaAttr: MINIPROGRAM_DOM_RULE_MAP.ariaAttr, + idAttr: MINIPROGRAM_DOM_RULE_MAP.idAttr, + emptyAttr: MINIPROGRAM_DOM_RULE_MAP.emptyAttr, + allNewLines: MINIPROGRAM_DOM_RULE_MAP.allNewLines, + link: MINIPROGRAM_DOM_RULE_MAP.link, + space: MINIPROGRAM_DOM_RULE_MAP.space, + spaceBetweenTag: MINIPROGRAM_DOM_RULE_MAP.spaceBetweenTag +}; + +export const CHANGELOG_RULE_MAP: RuleMap = { + mdLink: MD_LINK_RULE, + danglingPunct: DANGLING_PUNCT_RULE, + user: [/(? { + return Object.values(cleaningRules).reduce((acc, [regex, replacement]) => { + return acc.replace(regex, replacement); + }, text); +}; + +/** + * 只保留 HTML 标签中的特定属性,移除其他所有属性 + * @param html HTML 字符串 + * @param attrs 要保留的属性名列表 + */ +export const keepOnlyAttrs = (html: string, attrs: string[]) => { + // 匹配开始标签: + return html.replace(/<([a-zA-Z][\w-]*)((?:\s+[^>]*)?)\/?>/g, (match, tagName, attrsStr) => { + if (!attrsStr || !attrsStr.trim()) { + return match; + } + + // Extract all attributes, supporting template syntax with nested quotes like: + // class="{{ Array ["t-chat-actionbar","start", ] }}" + // For double-quoted values: match {{ ... }} blocks (which may contain inner quotes) or non-quote chars + const attrRegex = /\s+([\w:-]+)(?:="((?:\{\{[\s\S]*?\}\}|[^"])*)"|='([^']*)'|=([^\s>]+)|(?=\s|\/?>))/g; + const keptAttrs: string[] = []; + let attrMatch; + + while ((attrMatch = attrRegex.exec(attrsStr)) !== null) { + const attrName = attrMatch[1]; + const attrValue = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? ""; + + if (attrs.includes(attrName)) { + keptAttrs.push(`${attrName}="${attrValue}"`); + } + } + + const isSelfClosing = match.endsWith("/>"); + const attrsOutput = keptAttrs.length > 0 ? " " + keptAttrs.join(" ") : ""; + return `<${tagName}${attrsOutput}${isSelfClosing ? "/>" : ">"}`; + }); +}; diff --git a/packages/mcp-server/common/toolkit.ts b/packages/mcp-server/common/toolkit.ts new file mode 100644 index 000000000..837621108 --- /dev/null +++ b/packages/mcp-server/common/toolkit.ts @@ -0,0 +1,22 @@ +import path from "path"; +import { fileURLToPath } from "url"; + +/** + * 获取当前文件的绝对路径 + * - 调用格式:`getDirname(import.meta.url)` + */ +export const getDirname = (importMetaUrl: string) => { + return path.dirname(fileURLToPath(importMetaUrl)); +}; + +export const compareVersion = (a: string, b: string) => { + const aArr = a.split("."); + const bArr = b.split("."); + for (let i = 0; i < Math.max(aArr.length, bArr.length); i++) { + const aNum = Number(aArr[i] || 0); + const bNum = Number(bArr[i] || 0); + if (aNum > bNum) return 1; + if (aNum < bNum) return -1; + } + return 0; +}; diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json new file mode 100644 index 000000000..1b93a04e8 --- /dev/null +++ b/packages/mcp-server/package.json @@ -0,0 +1,73 @@ +{ + "name": "tdesign-mcp-server", + "description": "MCP server for TDesign, providing component knowledge across multiple frameworks.", + "version": "0.2.4", + "type": "module", + "repository": { + "type": "git", + "url": "https://cnb.cool/tencent/tdesign/tdesign.git", + "directory": "packages/mcp-server" + }, + "scripts": { + "build:mcp": "tsdown", + "build:mcp-http": "BUILD_TARGET=http tsdown", + "build:mcp-stdio": "BUILD_TARGET=stdio tsdown", + "start:mcp-http": "node dist/http.cjs", + "build:docs+dom": "tsx ./scripts/index.ts", + "build:docs": "tsx ./scripts/docs/index.ts", + "build:dom": "npm run build:snap && tsx ./scripts/dom/index.ts", + "build:snap": "npm run build:snap:web && npm run build:snap:web-chat && npm run build:snap:mobile && npm run build:snap:mini", + "build:snap:web": "vitest run -u scripts/dom/snapshots/web", + "build:snap:web-chat": "vitest run -u scripts/dom/snapshots/web-chat", + "build:snap:mobile": "vitest run -u scripts/dom/snapshots/mobile", + "build:snap:mini": "jest --config scripts/dom/snapshots/miniprogram/jest.config.cjs -u" + }, + "bin": { + "tdesign-mcp-server": "dist/stdio.js" + }, + "engines": { + "node": ">=18" + }, + "files": [ + "dist", + "README.md" + ], + "dependencies": { + "@modelcontextprotocol/sdk": "^1.23.0", + "express": "^5.2.1", + "zod": "^4.1.13" + }, + "devDependencies": { + "@babel/core": "^7.26.0", + "@babel/plugin-proposal-decorators": "^7.25.9", + "@babel/plugin-transform-class-properties": "^7.25.9", + "@babel/preset-env": "^7.26.0", + "@babel/preset-typescript": "^7.26.0", + "@tdesign-react/chat": "latest", + "@testing-library/react": "^16.3.0", + "@types/express": "^5.0.6", + "@types/jest": "^27.0.3", + "@types/node": "^22.14.1", + "@types/react": "^18.3.10", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.5.1", + "babel-jest": "^26.6.3", + "dotenv": "^17.2.1", + "happy-dom": "^20.8.4", + "jest": "^26.6.3", + "jsdom": "^26.1.0", + "lodash-es": "^4.17.21", + "miniprogram-simulate": "^1.6.0", + "prettier": "^3.5.3", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tdesign-icons-react": "latest", + "tdesign-mobile-react": "latest", + "tdesign-react": "latest", + "tdesign-web-components": "latest", + "tsdown": "^0.23.0", + "tsx": "^4.21.0", + "typescript": "^5.8.3", + "vitest": "^3.2.2" + } +} diff --git a/packages/mcp-server/prettier.config.js b/packages/mcp-server/prettier.config.js new file mode 100644 index 000000000..d7b66bb79 --- /dev/null +++ b/packages/mcp-server/prettier.config.js @@ -0,0 +1,12 @@ +/** + * @type {import("prettier").Config} + */ +export default { + semi: true, + singleAttributePerLine: true, + singleQuote: false, + jsxBracketSameLine: false, + printWidth: 120, + arrowParens: "always", + trailingComma: "none" +}; diff --git a/packages/mcp-server/scripts/docs/index.ts b/packages/mcp-server/scripts/docs/index.ts new file mode 100644 index 000000000..2877d7fc5 --- /dev/null +++ b/packages/mcp-server/scripts/docs/index.ts @@ -0,0 +1,191 @@ +import { existsSync, promises as fs, readdirSync } from "fs"; +import path from "path"; + +import { + addTdPrefix, + API_RULE_MAP, + cleanText, + DEMO_RULE_MAP, + FRAMEWORKS, + getSuffixByFramework, + isMiniProgram, + isUniapp, + MOBILE_DEMO_RULE_MAP, + MOBILE_FRAMEWORKS, + type Framework +} from "../../common"; + +import { + getComponentDir, + getDemoTemplateDir, + getDocsOutputDir, + getMiniprogramOutputDirName, + getUniappOutputDirName, + TD_COMP_MAP_PATH, + TD_DOCS_OUTPUT_DIR +} from "../utils/path"; + +import { generateMiniProgramDemo, generateUniappDemo } from "./utils/miniprogram"; + +(async function main() { + await extractCompMap(); + await extractAllDocs(); +})(); + +async function extractCompMap() { + const { NON_PASCAL_CASE_NAMES, WEB_COMPONENT_MAP, MOBILE_COMPONENT_MAP, CHAT_COMPONENT_MAP } = await import( + TD_COMP_MAP_PATH + ); + + await fs.mkdir(TD_DOCS_OUTPUT_DIR, { recursive: true }); + + const compMaps = [ + { name: "web-components.json", components: WEB_COMPONENT_MAP, alias: NON_PASCAL_CASE_NAMES }, + { name: "mobile-components.json", components: MOBILE_COMPONENT_MAP, alias: NON_PASCAL_CASE_NAMES }, + { name: "chat-components.json", components: CHAT_COMPONENT_MAP } + ]; + + for (const { name, components, alias } of compMaps) { + const outputPath = path.join(TD_DOCS_OUTPUT_DIR, name); + const data = { + ...(alias && { alias }), + components + }; + await fs.writeFile(outputPath, JSON.stringify(data, null, 2), "utf-8"); + } + + console.log("✓ Extract map successfully"); +} + +async function extractAllDocs() { + for (const framework of FRAMEWORKS) { + console.log(`--------(${framework})--------`); + await extractDocs(framework); + console.log(`✓ Extract docs successfully`); + } +} + +async function extractDocs(framework: Framework) { + const demoTemplateDir = await fs.readdir(getDemoTemplateDir(framework)); + const mdFiles = demoTemplateDir.filter((file) => file.endsWith(".md") && !file.includes("en-US")); + + for (const file of mdFiles) { + const compName = file.split(".")[0]; + const compDir = getComponentDir(framework, compName); + + if (!existsSync(compDir)) { + // console.warn(`✗ Component not found: [${framework}] ${compName}`); + continue; + } + + await generateApi(framework, compName); + await generateDemo(framework, compName); + await generateCompList(framework, compName); + } +} + +/* ---------------------------------------- */ + +async function generateApi(framework: Framework, compName: string) { + const componentPath = getComponentDir(framework, compName); + + // miniprogram 和 uniapp 使用 README.md 作为 API 文档 + const apiDocsName = isMiniProgram(framework) || isUniapp(framework) ? "README" : compName; + const apiPath = `${componentPath}/${apiDocsName}.md`; + + let apiContent = await fs.readFile(apiPath, "utf-8"); + apiContent = cleanText(apiContent, API_RULE_MAP); + + const outputDir = await getDocsOutputDir(framework, compName); + await fs.writeFile(path.join(outputDir, "api.md"), apiContent, "utf-8"); +} + +async function generateDemo(framework: Framework, compName: string) { + if (isMiniProgram(framework)) { + return generateMiniProgramDemo(framework, compName); + } + if (isUniapp(framework)) { + return generateUniappDemo(framework, compName); + } + + const exampleDirName = framework === "mobile-vue" ? "demos" : "_example"; + const exampleDir = path.join(getComponentDir(framework, compName), exampleDirName); + // 取基础 Demo 即可 + const baseFileName = "base"; + + if (!existsSync(exampleDir)) return null; + + const MOBILE_SKIP_FILES = ["mobile", "index", "customized"]; + const allEntries = readdirSync(exampleDir, { withFileTypes: true }); + + // 只保留文件 + let allFiles = allEntries.filter((entry) => entry.isFile()).map((entry) => entry.name); + + const isMobile = (MOBILE_FRAMEWORKS as readonly string[]).includes(framework); + const files = isMobile ? allFiles.filter((file) => !MOBILE_SKIP_FILES.includes(file.split(".")[0])) : allFiles; + + const baseDemo = files.find((file) => file.split(".")[0] === baseFileName) || files[0]; + + let demoContent = await fs.readFile(path.join(exampleDir, baseDemo), "utf-8"); + demoContent = cleanText(demoContent, DEMO_RULE_MAP); + if (isMobile) { + demoContent = cleanText(demoContent, MOBILE_DEMO_RULE_MAP); + } + + const suffix = getSuffixByFramework(framework); + const outputDir = await getDocsOutputDir(framework, compName); + await fs.writeFile(path.join(outputDir, `Demo.${suffix}`), demoContent, "utf-8"); +} + +async function generateCompList(framework: Framework, compName: string) { + const componentDir = getComponentDir(framework, compName); + if (!existsSync(componentDir)) return; // 避免某些库没有实现对应的组件 + + const demoTemplatePath = `${getDemoTemplateDir(framework)}/${compName}.md`; + const demoTemplateContent = await fs.readFile(demoTemplatePath, "utf-8"); + + const metaReg = DEMO_RULE_MAP.meta[0]; + const metaMatch = demoTemplateContent.match(metaReg); + const meta = metaMatch ? metaMatch[1] : ""; + const metaObj = Object.fromEntries( + meta.split("\n").map((line) => { + const [key, value] = line.split(": "); + return [key.trim(), value.trim()]; + }) + ); + + if (metaObj.isComponent !== 'true') return; + + const splineType = metaObj.spline; + const compMeta = { + [compName]: metaObj.description + }; + + // miniprogram 的 AI 组件列表输出到 tdesign-miniprogram-chat/index.json + // uniapp 的 AI 组件列表输出到 tdesign-uniapp-chat/index.json + let compListDirName: string; + if (isMiniProgram(framework)) { + compListDirName = addTdPrefix(getMiniprogramOutputDirName(compName)); + } else if (isUniapp(framework)) { + compListDirName = addTdPrefix(getUniappOutputDirName(compName)); + } else { + compListDirName = addTdPrefix(framework); + } + const compListDir = path.join(TD_DOCS_OUTPUT_DIR, compListDirName); + if (!existsSync(compListDir)) { + await fs.mkdir(compListDir, { recursive: true }); + } + let compListPath = path.join(compListDir, "index.json"); + if (!existsSync(compListPath)) { + await fs.writeFile(compListPath, JSON.stringify({}, null, 2), "utf-8"); + } + + let compListContent = JSON.parse(await fs.readFile(compListPath, "utf-8")); + + compListContent[splineType] = { + ...(compListContent[splineType] || {}), + ...compMeta + }; + + await fs.writeFile(compListPath, JSON.stringify(compListContent, null, 2), "utf-8"); +} diff --git a/packages/mcp-server/scripts/docs/utils/miniprogram.ts b/packages/mcp-server/scripts/docs/utils/miniprogram.ts new file mode 100644 index 000000000..e9d7ab8aa --- /dev/null +++ b/packages/mcp-server/scripts/docs/utils/miniprogram.ts @@ -0,0 +1,237 @@ +import { existsSync, promises as fs, readdirSync } from "fs"; +import path from "path"; + +import { + cleanText, + DEMO_RULE_MAP, + MINIPROGRAM_DEMO_EXTS, + MOBILE_DEMO_RULE_MAP, + type Framework +} from "../../../common"; + +import { getComponentDir, getDocsOutputDir, TD_REPOS_ROOT } from "../../utils/path"; + +/** 匹配 JS/WXML/Vue 中的相对路径引用(../ 和 ./) */ +const REF_PATTERNS = [ + /import\s+.+?\s+from\s+['"](\.\.?\/.+?)['"]/g, // JS import + /require\s*\(\s*['"](\.\.?\/.+?)['"]\s*\)/g, // JS require + /src\s*=\s*["'](\.\.?\/.+?)["']/g // WXML src +]; + +/** 尝试解析的文件扩展名 */ +const TRY_EXTS = [".js", ".ts", ".wxs", ".json"]; + +/** + * 匹配 utils 相关的 import 语句,兼容两种写法: + * 1. default import:`import getNavigationBarHeight from '../utils';` + * 2. named import: `import { getNavigationBarHeight } from '../utils';` + */ +const UTILS_IMPORT_PATTERN = + /import\s+(?:getNavigationBarHeight|\{[^}]*getNavigationBarHeight[^}]*\})\s+from\s*['"][^'"]*utils[^'"]*['"];?\n?/g; + +/** 微信小程序获取导航栏高度函数 */ +const MINIPROGRAM_GET_NAV_HEIGHT_FN = ` +const getNavigationBarHeight = () => { + try { + const systemInfo = wx.getSystemInfoSync(); + const statusBarHeight = systemInfo.statusBarHeight || 0; + const menuButtonInfo = wx.getMenuButtonBoundingClientRect(); + if (menuButtonInfo) { + return menuButtonInfo.bottom + (menuButtonInfo.top - statusBarHeight); + } + return statusBarHeight + 44; + } catch (error) { + console.error('获取导航栏高度失败:', error); + return 44; + } +};\n +`.trim(); + +/** UniApp 获取导航栏高度函数 */ +const UNIAPP_GET_NAV_HEIGHT_FN = ` +const getNavigationBarHeight = () => { + try { + const systemInfo = uni.getWindowInfo ? uni.getWindowInfo() : uni.getSystemInfoSync(); + const statusBarHeight = systemInfo.statusBarHeight || 0; + const menuButtonInfo = uni.getMenuButtonBoundingClientRect?.(); + if (menuButtonInfo) { + return menuButtonInfo.bottom + (menuButtonInfo.top - statusBarHeight); + } + return statusBarHeight + 44; + } catch (error) { + console.error('获取导航栏高度失败:', error); + return 44; + } +}; +`.trim(); + +/** + * 解析源文件路径,找到则返回绝对路径,否则返回 null + */ +function resolveSourceFile(baseDir: string, relPath: string): string | null { + const absPath = path.join(baseDir, relPath); + if (existsSync(absPath)) return absPath; + for (const ext of TRY_EXTS) { + if (existsSync(absPath + ext)) return absPath + ext; + } + return null; +} + +/** + * 内联 getNavigationBarHeight 函数:移除 utils import 语句,将函数定义内联到文件中 + * @param content 文件内容 + * @param isUniapp 是否为 uniapp 框架 + * @returns 处理后的内容 + */ +export function inlineNavigationBarHeightFn(content: string, isUniapp: boolean): string { + // 重置正则 lastIndex,避免 g flag 在多次调用间残留状态 + UTILS_IMPORT_PATTERN.lastIndex = 0; + // 检查是否有 utils import + if (!UTILS_IMPORT_PATTERN.test(content)) return content; + + // 重置正则的 lastIndex + UTILS_IMPORT_PATTERN.lastIndex = 0; + + // 移除 import 语句 + let updatedContent = content.replace(UTILS_IMPORT_PATTERN, ""); + + // 选择对应平台的函数实现 + const fnImpl = isUniapp ? UNIAPP_GET_NAV_HEIGHT_FN : MINIPROGRAM_GET_NAV_HEIGHT_FN; + + // 在文件适当位置插入函数定义 + // 对于 JS 文件:在第一个非 import 语句之前插入 + // 对于 Vue 文件:在