From 7d0494a50935e98d37c7eb9687da971d76a39fd5 Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Wed, 25 Mar 2026 21:29:57 +1100 Subject: [PATCH 1/3] feat: add CLI package and shared extraction layer - Add @tiny-design/cli with 8 commands: list, info, doc, demo, token, icon, doctor, usage - Extract shared extraction logic into @tiny-design/extract (internal package) - Refactor @tiny-design/mcp to use shared extraction package - Add CLI documentation page (en + zh) to docs site - Reorganize Guide sidebar menu into 4 groups: Overview, Getting Started, AI, Resources --- .changeset/add-cli-package.md | 6 + .changeset/config.json | 2 +- .gitignore | 4 + apps/docs/guides/cli.md | 165 +++++++++ apps/docs/guides/cli.zh_CN.md | 165 +++++++++ apps/docs/src/containers/guide/index.tsx | 20 +- apps/docs/src/locale/en_US.ts | 7 + apps/docs/src/locale/types.ts | 7 + apps/docs/src/locale/zh_CN.ts | 7 + apps/docs/src/routers.tsx | 37 +- packages/cli/README.md | 66 ++++ packages/cli/package.json | 37 ++ packages/cli/scripts/extract.ts | 41 +++ packages/cli/src/commands/demo.ts | 72 ++++ packages/cli/src/commands/doc.ts | 48 +++ packages/cli/src/commands/doctor.ts | 154 +++++++++ packages/cli/src/commands/icon.ts | 82 +++++ packages/cli/src/commands/info.ts | 82 +++++ packages/cli/src/commands/list.ts | 77 +++++ packages/cli/src/commands/token.ts | 73 ++++ packages/cli/src/commands/usage.ts | 113 ++++++ packages/cli/src/index.ts | 106 ++++++ packages/cli/src/utils/format.ts | 21 ++ packages/cli/src/utils/match.ts | 79 +++++ packages/cli/src/utils/table.ts | 49 +++ packages/cli/tsconfig.json | 16 + packages/cli/tsup.config.ts | 10 + packages/extract/package.json | 16 + packages/extract/src/extract-components.ts | 322 ++++++++++++++++++ .../scripts => extract/src}/extract-icons.ts | 14 +- .../scripts => extract/src}/extract-tokens.ts | 13 +- packages/extract/src/index.ts | 16 + packages/extract/src/types.ts | 64 ++++ packages/extract/tsconfig.json | 14 + packages/mcp/package.json | 2 +- packages/mcp/scripts/extract-components.ts | 185 ---------- packages/mcp/scripts/extract.ts | 13 +- packages/mcp/src/tools/components.ts | 2 +- packages/mcp/src/tools/icons.ts | 2 +- packages/mcp/src/tools/tokens.ts | 2 +- packages/mcp/src/types.ts | 38 --- pnpm-lock.yaml | 53 ++- 42 files changed, 2030 insertions(+), 272 deletions(-) create mode 100644 .changeset/add-cli-package.md create mode 100644 apps/docs/guides/cli.md create mode 100644 apps/docs/guides/cli.zh_CN.md create mode 100644 packages/cli/README.md create mode 100644 packages/cli/package.json create mode 100644 packages/cli/scripts/extract.ts create mode 100644 packages/cli/src/commands/demo.ts create mode 100644 packages/cli/src/commands/doc.ts create mode 100644 packages/cli/src/commands/doctor.ts create mode 100644 packages/cli/src/commands/icon.ts create mode 100644 packages/cli/src/commands/info.ts create mode 100644 packages/cli/src/commands/list.ts create mode 100644 packages/cli/src/commands/token.ts create mode 100644 packages/cli/src/commands/usage.ts create mode 100644 packages/cli/src/index.ts create mode 100644 packages/cli/src/utils/format.ts create mode 100644 packages/cli/src/utils/match.ts create mode 100644 packages/cli/src/utils/table.ts create mode 100644 packages/cli/tsconfig.json create mode 100644 packages/cli/tsup.config.ts create mode 100644 packages/extract/package.json create mode 100644 packages/extract/src/extract-components.ts rename packages/{mcp/scripts => extract/src}/extract-icons.ts (52%) rename packages/{mcp/scripts => extract/src}/extract-tokens.ts (78%) create mode 100644 packages/extract/src/index.ts create mode 100644 packages/extract/src/types.ts create mode 100644 packages/extract/tsconfig.json delete mode 100644 packages/mcp/scripts/extract-components.ts delete mode 100644 packages/mcp/src/types.ts diff --git a/.changeset/add-cli-package.md b/.changeset/add-cli-package.md new file mode 100644 index 00000000..54af02f0 --- /dev/null +++ b/.changeset/add-cli-package.md @@ -0,0 +1,6 @@ +--- +"@tiny-design/cli": minor +"@tiny-design/mcp": patch +--- + +Add @tiny-design/cli package for querying component metadata, docs, demos, tokens, and icons from the terminal. Extract shared extraction logic into internal @tiny-design/extract package. Add CLI docs page and reorganize Guide menu into grouped sections. diff --git a/.changeset/config.json b/.changeset/config.json index 177f323b..3714b850 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -13,5 +13,5 @@ "access": "public", "baseBranch": "master", "updateInternalDependencies": "patch", - "ignore": ["@tiny-design/docs"] + "ignore": ["@tiny-design/docs", "@tiny-design/extract"] } diff --git a/.gitignore b/.gitignore index 60889c4d..bfcf5c6b 100755 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,10 @@ apps/docs/build .claude/* !.claude/skills/ +# Generated data +packages/mcp/src/data +packages/cli/src/data + # Misc *.tgz *.log diff --git a/apps/docs/guides/cli.md b/apps/docs/guides/cli.md new file mode 100644 index 00000000..ab968299 --- /dev/null +++ b/apps/docs/guides/cli.md @@ -0,0 +1,165 @@ +# CLI + +`@tiny-design/cli` is an official command-line tool that brings Tiny Design component knowledge to your terminal. It ships all metadata locally — every prop, demo, token, and icon — queryable in milliseconds, fully offline. + +## Highlights + +- **Fully offline** — All metadata ships with the package. No network calls, no latency, no API keys. +- **Agent-optimized** — `--format json` on every command. Structured output ready for AI tools. +- **Bilingual** — Every component name and description has both English and Chinese. Switch with `--lang zh`. +- **Smart matching** — Typo `Buttn`? The CLI suggests `Button` using fuzzy matching. + +## Install + +```bash +npm install -g @tiny-design/cli +``` + +## Quick Start + +```bash +tiny-design list # List all 80+ components by category +tiny-design info Button # Component props, types, defaults +tiny-design doc Select # Full markdown documentation +tiny-design demo Button Type # Runnable demo source code +tiny-design token colors # Design token values +tiny-design icon arrow # Search icons by name +tiny-design doctor # Diagnose project issues +tiny-design usage ./src # Scan project for import stats +``` + +## Commands + +### Knowledge Query + +| Command | Description | +|---------|-------------| +| `tiny-design list [category]` | List all components grouped by category. Filter by: Foundation, Layout, Navigation, Data Display, Form, Feedback, Miscellany. | +| `tiny-design info ` | Props table with types, required flags, default values, and descriptions. | +| `tiny-design doc ` | Full markdown documentation for a component. | +| `tiny-design demo [name]` | Demo source code (TSX). Lists available demos if no name is given. | +| `tiny-design token [category]` | Design token values. Categories: colors, typography, spacing, breakpoints, shadows. | +| `tiny-design icon [search]` | List all 240+ icons or search by name. | + +### Project Analysis + +| Command | Description | +|---------|-------------| +| `tiny-design doctor` | Diagnostic checks: package.json, React version, peer deps, TypeScript, duplicate React detection. | +| `tiny-design usage [dir]` | Scan source files for `@tiny-design/react` imports. Shows component usage counts and file locations. | + +### Global Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--format json\|text\|markdown` | Output format | `text` | +| `--lang en\|zh` | Output language | `en` | +| `--detail` | Include extended information | `false` | + +## Examples + +### List components in a category + +```bash +$ tiny-design list Foundation + + Foundation + + Component | Description + ------------+---------------------------------------------- + Button | To trigger an operation. + Image | The Image component is used to display images. + Link | Displays a hyperlink. + Typography | Basic text writing, including headings, ... + + 4 components total +``` + +### Get component props (JSON for AI tools) + +```bash +$ tiny-design info Modal --format json +{ + "name": "Modal", + "category": "Feedback", + "description": "Modal dialogs.", + "props": [ + { + "name": "visible", + "type": "boolean", + "required": false, + "description": "Whether the modal is visible" + }, + ... + ] +} +``` + +### Search icons + +```bash +$ tiny-design icon arrow + + Icons matching "arrow" (16 found) + + • IconArrowRight + • IconArrowUp + • IconArrowLeft + • IconArrowDown + ... +``` + +### View a demo + +```bash +$ tiny-design demo Button Type + + Button / Type + +import React from 'react'; +import { Button, Flex } from '@tiny-design/react'; + +export default function TypeDemo() { + return ( + + + + + + ); +} +``` + +### Diagnose project setup + +```bash +$ tiny-design doctor + + Tiny Design Doctor + + ✓ package.json: Found + ✓ @tiny-design/react: v1.6.1 installed + ✓ React version: v18.3.1 + ✓ TypeScript: v5.4.5 + ✓ Peer dependencies: react-dom found + ✓ Duplicate React: No duplicates found + + All checks passed! +``` + +## Usage with AI Tools + +The CLI is designed to work seamlessly with AI coding assistants. Use `--format json` for structured output: + +```bash +# Get component info as JSON for an AI agent +tiny-design info DatePicker --format json + +# List all icons as JSON +tiny-design icon --format json + +# Get design tokens as JSON +tiny-design token colors --format json +``` + +For a richer AI integration that works inside your editor, check out the [MCP Server](/guide/mcp-server) which provides the same data through the Model Context Protocol. diff --git a/apps/docs/guides/cli.zh_CN.md b/apps/docs/guides/cli.zh_CN.md new file mode 100644 index 00000000..52d1ee71 --- /dev/null +++ b/apps/docs/guides/cli.zh_CN.md @@ -0,0 +1,165 @@ +# CLI + +`@tiny-design/cli` 是官方命令行工具,将 Tiny Design 组件知识带到你的终端。所有元数据随包附带——每个属性、演示、令牌和图标——毫秒级查询,完全离线。 + +## 特性 + +- **完全离线** — 所有元数据随包附带,无需网络请求、无延迟、无需 API 密钥。 +- **AI 友好** — 所有命令支持 `--format json` 输出,结构化数据适配 AI 工具。 +- **双语支持** — 所有组件名称和描述同时提供中英文,使用 `--lang zh` 切换。 +- **智能匹配** — 输错 `Buttn`?CLI 会通过模糊匹配建议 `Button`。 + +## 安装 + +```bash +npm install -g @tiny-design/cli +``` + +## 快速开始 + +```bash +tiny-design list # 列出所有 80+ 个组件(按分类) +tiny-design info Button # 组件属性、类型、默认值 +tiny-design doc Select # 完整的 Markdown 文档 +tiny-design demo Button Type # 可运行的演示源代码 +tiny-design token colors # 设计令牌值 +tiny-design icon arrow # 按名称搜索图标 +tiny-design doctor # 诊断项目问题 +tiny-design usage ./src # 扫描项目中的导入统计 +``` + +## 命令 + +### 知识查询 + +| 命令 | 描述 | +|------|------| +| `tiny-design list [category]` | 按分类列出所有组件。可筛选:基础、布局、导航、数据展示、表单、反馈、其他。 | +| `tiny-design info ` | 属性表,包含类型、是否必填、默认值和描述。 | +| `tiny-design doc ` | 组件的完整 Markdown 文档。 | +| `tiny-design demo [name]` | 演示源代码(TSX)。未指定名称时列出可用演示。 | +| `tiny-design token [category]` | 设计令牌值。分类:colors、typography、spacing、breakpoints、shadows。 | +| `tiny-design icon [search]` | 列出所有 240+ 个图标或按名称搜索。 | + +### 项目分析 + +| 命令 | 描述 | +|------|------| +| `tiny-design doctor` | 诊断检查:package.json、React 版本、对等依赖、TypeScript、重复 React 检测。 | +| `tiny-design usage [dir]` | 扫描源文件中的 `@tiny-design/react` 导入,显示组件使用次数和文件位置。 | + +### 全局参数 + +| 参数 | 描述 | 默认值 | +|------|------|--------| +| `--format json\|text\|markdown` | 输出格式 | `text` | +| `--lang en\|zh` | 输出语言 | `en` | +| `--detail` | 显示扩展信息 | `false` | + +## 示例 + +### 按分类列出组件 + +```bash +$ tiny-design list Foundation --lang zh + + Foundation + + Component | Description + ------------+---------------------------------------------- + Button | 用于触发一个操作。 + Image | Image 组件用于显示图片。 + Link | 显示超链接。 + Typography | 基本的文字排版,包括标题、正文、列表等。 + + 4 components total +``` + +### 获取组件属性(JSON 格式,适合 AI 工具) + +```bash +$ tiny-design info Modal --format json +{ + "name": "Modal", + "category": "Feedback", + "description": "Modal dialogs.", + "props": [ + { + "name": "visible", + "type": "boolean", + "required": false, + "description": "Whether the modal is visible" + }, + ... + ] +} +``` + +### 搜索图标 + +```bash +$ tiny-design icon arrow + + Icons matching "arrow" (16 found) + + • IconArrowRight + • IconArrowUp + • IconArrowLeft + • IconArrowDown + ... +``` + +### 查看演示 + +```bash +$ tiny-design demo Button Type + + Button / Type + +import React from 'react'; +import { Button, Flex } from '@tiny-design/react'; + +export default function TypeDemo() { + return ( + + + + + + ); +} +``` + +### 诊断项目配置 + +```bash +$ tiny-design doctor + + Tiny Design Doctor + + ✓ package.json: Found + ✓ @tiny-design/react: v1.6.1 installed + ✓ React version: v18.3.1 + ✓ TypeScript: v5.4.5 + ✓ Peer dependencies: react-dom found + ✓ Duplicate React: No duplicates found + + All checks passed! +``` + +## 与 AI 工具配合使用 + +CLI 专为 AI 编程助手设计。使用 `--format json` 获取结构化输出: + +```bash +# 以 JSON 格式获取组件信息 +tiny-design info DatePicker --format json + +# 以 JSON 列出所有图标 +tiny-design icon --format json + +# 以 JSON 获取设计令牌 +tiny-design token colors --format json +``` + +如需在编辑器内更丰富的 AI 集成体验,请查看 [MCP Server](/guide/mcp-server),它通过 Model Context Protocol 提供相同的数据。 diff --git a/apps/docs/src/containers/guide/index.tsx b/apps/docs/src/containers/guide/index.tsx index 275ab5ce..660b1ebf 100644 --- a/apps/docs/src/containers/guide/index.tsx +++ b/apps/docs/src/containers/guide/index.tsx @@ -1,6 +1,6 @@ import React, { Suspense, useMemo } from 'react'; import { Route, Routes, Navigate } from 'react-router-dom'; -import { getGuideMenu } from '../../routers'; +import { getGuideMenu, RouterItem } from '../../routers'; import { SidebarMenu } from '../../components/sidebar-menu'; import { Layout, Loader, Divider } from '@tiny-design/react'; import { DocFooter } from '../../components/doc-footer'; @@ -9,9 +9,21 @@ import { useLocaleContext } from '../../context/locale-context'; const { Content } = Layout; +const flattenRouters = (routers: RouterItem[]): RouterItem[] => { + return routers.reduce((res: RouterItem[], router) => { + if (router.children) { + router.children.forEach((child) => res.push(child)); + } else { + res.push(router); + } + return res; + }, []); +}; + const GuidePage = (): React.ReactElement => { const { siteLocale } = useLocaleContext(); const guideMenu = useMemo(() => getGuideMenu(siteLocale), [siteLocale]); + const flattenedRouters = useMemo(() => flattenRouters(guideMenu), [guideMenu]); return ( @@ -26,7 +38,7 @@ const GuidePage = (): React.ReactElement => { }> - {guideMenu.map((menu) => { + {flattenedRouters.map((menu) => { const Component = menu.component; return ( { /> ); })} - } /> + } /> - + diff --git a/apps/docs/src/locale/en_US.ts b/apps/docs/src/locale/en_US.ts index 5220e04c..c4f2212c 100644 --- a/apps/docs/src/locale/en_US.ts +++ b/apps/docs/src/locale/en_US.ts @@ -59,6 +59,13 @@ const en_US: SiteLocale = { changelog: 'Changelog', faq: 'FAQ', mcpServer: 'MCP Server', + cli: 'CLI', + groups: { + overview: 'Overview', + gettingStarted: 'Getting Started', + ai: 'AI', + resources: 'Resources', + }, }, themeMenu: { customiseTheme: 'Customise Theme', diff --git a/apps/docs/src/locale/types.ts b/apps/docs/src/locale/types.ts index b581d29a..bec02aaa 100644 --- a/apps/docs/src/locale/types.ts +++ b/apps/docs/src/locale/types.ts @@ -53,6 +53,13 @@ export type SiteLocale = { changelog: string; faq: string; mcpServer: string; + cli: string; + groups: { + overview: string; + gettingStarted: string; + ai: string; + resources: string; + }; }; themeMenu: { customiseTheme: string; diff --git a/apps/docs/src/locale/zh_CN.ts b/apps/docs/src/locale/zh_CN.ts index f05818e2..cc12b9e1 100644 --- a/apps/docs/src/locale/zh_CN.ts +++ b/apps/docs/src/locale/zh_CN.ts @@ -55,6 +55,13 @@ const zh_CN: SiteLocale = { changelog: '更新日志', faq: '常见问题', mcpServer: 'MCP Server', + cli: 'CLI', + groups: { + overview: '概览', + gettingStarted: '快速上手', + ai: 'AI', + resources: '资源', + }, }, themeMenu: { customiseTheme: '自定义主题', diff --git a/apps/docs/src/routers.tsx b/apps/docs/src/routers.tsx index bbf1f4a3..14cac092 100755 --- a/apps/docs/src/routers.tsx +++ b/apps/docs/src/routers.tsx @@ -52,6 +52,10 @@ const guide = { () => import('../guides/mcp-server.md'), () => import('../guides/mcp-server.zh_CN.md'), ), + cli: ll( + () => import('../guides/cli.md'), + () => import('../guides/cli.zh_CN.md'), + ), themeEditor: ll( () => import('./containers/theme-editor'), () => import('./containers/theme-editor'), @@ -148,12 +152,33 @@ const c = { export const getGuideMenu = (s: SiteLocale): RouterItem[] => { const isZh = s.locale === 'zh_CN'; return [ - { title: s.guideMenu.introduction, route: 'introduction', component: pick(guide.introduction, isZh) }, - { title: s.guideMenu.getStarted, route: 'get-started', component: pick(guide.getStarted, isZh) }, - { title: s.guideMenu.useWithVite, route: 'use-with-vite', component: pick(guide.useWithVite, isZh) }, - { title: s.guideMenu.mcpServer, route: 'mcp-server', component: pick(guide.mcpServer, isZh) }, - { title: s.guideMenu.changelog, route: 'changelog', component: pick(guide.changelog, isZh) }, - { title: s.guideMenu.faq, route: 'faq', component: pick(guide.faq, isZh) }, + { + title: s.guideMenu.groups.overview, + children: [ + { title: s.guideMenu.introduction, route: 'introduction', component: pick(guide.introduction, isZh) }, + ], + }, + { + title: s.guideMenu.groups.gettingStarted, + children: [ + { title: s.guideMenu.getStarted, route: 'get-started', component: pick(guide.getStarted, isZh) }, + { title: s.guideMenu.useWithVite, route: 'use-with-vite', component: pick(guide.useWithVite, isZh) }, + ], + }, + { + title: s.guideMenu.groups.ai, + children: [ + { title: s.guideMenu.mcpServer, route: 'mcp-server', component: pick(guide.mcpServer, isZh) }, + { title: s.guideMenu.cli, route: 'cli', component: pick(guide.cli, isZh) }, + ], + }, + { + title: s.guideMenu.groups.resources, + children: [ + { title: s.guideMenu.changelog, route: 'changelog', component: pick(guide.changelog, isZh) }, + { title: s.guideMenu.faq, route: 'faq', component: pick(guide.faq, isZh) }, + ], + }, ]; }; diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 00000000..a41229fe --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,66 @@ +# @tiny-design/cli + +CLI for the [Tiny Design](https://github.com/wangdicoder/tiny-design) component library. Query component metadata, docs, demos, design tokens, and icons from your terminal — fully offline. + +## Install + +```bash +npm install -g @tiny-design/cli +``` + +## Quick Start + +```bash +tiny-design list # List all 80+ components by category +tiny-design info Button # Component props, types, defaults +tiny-design doc Select # Full markdown documentation +tiny-design demo Button Type # Runnable demo source code +tiny-design token colors # Design token values +tiny-design icon arrow # Search icons by name +tiny-design doctor # Diagnose project issues +tiny-design usage ./src # Scan project for import stats +``` + +## Commands + +### Knowledge Query + +| Command | Description | +|---------|-------------| +| `tiny-design list [category]` | List all components grouped by category | +| `tiny-design info ` | Props table with types, required flags, default values | +| `tiny-design doc ` | Full markdown documentation | +| `tiny-design demo [name]` | Demo source code (TSX) | +| `tiny-design token [category]` | Design token values (colors, typography, spacing, breakpoints, shadows) | +| `tiny-design icon [search]` | List all 240+ icons or search by name | + +### Project Analysis + +| Command | Description | +|---------|-------------| +| `tiny-design doctor` | Diagnose project setup (React version, peer deps, duplicates) | +| `tiny-design usage [dir]` | Scan for `@tiny-design/react` import statistics | + +### Global Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--format json\|text\|markdown` | Output format | `text` | +| `--lang en\|zh` | Output language | `en` | +| `--detail` | Include extended information | `false` | + +## Usage with AI Tools + +Use `--format json` for structured output suitable for AI agents: + +```bash +tiny-design info DatePicker --format json +tiny-design icon --format json +tiny-design token colors --format json +``` + +For editor-integrated AI access, see [@tiny-design/mcp](https://www.npmjs.com/package/@tiny-design/mcp). + +## License + +MIT diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 00000000..857b65cb --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,37 @@ +{ + "name": "@tiny-design/cli", + "version": "1.0.0", + "description": "CLI for the Tiny Design component library", + "license": "MIT", + "keywords": ["tiny-design", "cli", "components", "design-system"], + "repository": { + "type": "git", + "url": "https://github.com/wangdicoder/tiny-design.git", + "directory": "packages/cli" + }, + "author": "Di Wang", + "publishConfig": { + "access": "public" + }, + "bin": { + "tiny-design": "./dist/index.js" + }, + "files": ["dist"], + "type": "module", + "scripts": { + "extract": "node --import tsx scripts/extract.ts", + "build": "pnpm extract && tsup", + "test": "NODE_OPTIONS='--experimental-vm-modules' jest" + }, + "dependencies": { + "chalk": "^5.0.0", + "commander": "^12.0.0" + }, + "devDependencies": { + "@tiny-design/extract": "workspace:*", + "@types/node": "^22.0.0", + "tsup": "^8.0.0", + "tsx": "^4.0.0", + "typescript": "^5.4.0" + } +} diff --git a/packages/cli/scripts/extract.ts b/packages/cli/scripts/extract.ts new file mode 100644 index 00000000..55a91003 --- /dev/null +++ b/packages/cli/scripts/extract.ts @@ -0,0 +1,41 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { extractComponents, extractTokens, extractIcons } from '@tiny-design/extract'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const DATA_DIR = path.resolve(__dirname, '../src/data'); +const REACT_SRC = path.resolve(__dirname, '../../react/src'); +const VARIABLES_PATH = path.resolve(__dirname, '../../tokens/scss/_variables.scss'); +const ICONS_INDEX = path.resolve(__dirname, '../../icons/src/index.ts'); + +function ensureDir(dir: string) { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +function writeJson(filename: string, data: unknown) { + const filePath = path.join(DATA_DIR, filename); + fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); + console.log(` wrote ${filePath}`); +} + +console.log('Extracting Tiny Design data for CLI...'); +ensureDir(DATA_DIR); + +console.log(' extracting components (with docs & defaults)...'); +writeJson( + 'components.json', + extractComponents({ reactSrcPath: REACT_SRC, includeDocs: true, includeDefaults: true }), +); + +console.log(' extracting tokens...'); +writeJson('tokens.json', extractTokens({ variablesPath: VARIABLES_PATH })); + +console.log(' extracting icons...'); +writeJson('icons.json', extractIcons({ iconsIndexPath: ICONS_INDEX })); + +console.log('Done.'); diff --git a/packages/cli/src/commands/demo.ts b/packages/cli/src/commands/demo.ts new file mode 100644 index 00000000..cd74e419 --- /dev/null +++ b/packages/cli/src/commands/demo.ts @@ -0,0 +1,72 @@ +import chalk from 'chalk'; +import type { ComponentDataWithDocs } from '@tiny-design/extract'; +import type { OutputFormat } from '../utils/format.js'; +import { findBestMatch } from '../utils/match.js'; + +export function demoCommand( + name: string, + demoName: string | undefined, + components: ComponentDataWithDocs[], + options: { format: OutputFormat }, +) { + const names = components.map((c) => c.name); + const result = findBestMatch(name, names); + + if (!result.match) { + console.error(`Component "${name}" not found.`); + if (result.suggestion) { + console.error(`Did you mean "${result.suggestion}"?`); + } + process.exit(1); + } + + const component = components.find((c) => c.name === result.match)!; + + if (component.demos.length === 0) { + console.error(`No demos found for ${component.name}.`); + process.exit(1); + } + + // If no demo name specified, list available demos + if (!demoName) { + if (options.format === 'json') { + console.log( + JSON.stringify( + component.demos.map((d) => d.name), + null, + 2, + ), + ); + return; + } + console.log(`\n ${chalk.bold.cyan(component.name)} demos:\n`); + for (const demo of component.demos) { + console.log(` ${chalk.dim('•')} ${demo.name}`); + } + console.log(`\n ${chalk.dim(`Run: tiny-design demo ${component.name} `)}\n`); + return; + } + + // Find specific demo + const demoNames = component.demos.map((d) => d.name); + const demoResult = findBestMatch(demoName, demoNames); + + if (!demoResult.match) { + console.error(`Demo "${demoName}" not found for ${component.name}.`); + if (demoResult.suggestion) { + console.error(`Did you mean "${demoResult.suggestion}"?`); + } + console.error(`Available demos: ${demoNames.join(', ')}`); + process.exit(1); + } + + const demo = component.demos.find((d) => d.name === demoResult.match)!; + + if (options.format === 'json') { + console.log(JSON.stringify(demo, null, 2)); + return; + } + + console.log(`\n ${chalk.bold.cyan(component.name)} ${chalk.dim('/')} ${demo.name}\n`); + console.log(demo.code); +} diff --git a/packages/cli/src/commands/doc.ts b/packages/cli/src/commands/doc.ts new file mode 100644 index 00000000..7471eb84 --- /dev/null +++ b/packages/cli/src/commands/doc.ts @@ -0,0 +1,48 @@ +import chalk from 'chalk'; +import type { ComponentDataWithDocs } from '@tiny-design/extract'; +import type { OutputFormat } from '../utils/format.js'; +import { findBestMatch } from '../utils/match.js'; + +export function docCommand( + name: string, + components: ComponentDataWithDocs[], + options: { format: OutputFormat; lang?: string }, +) { + const names = components.map((c) => c.name); + const result = findBestMatch(name, names); + + if (!result.match) { + console.error(`Component "${name}" not found.`); + if (result.suggestion) { + console.error(`Did you mean "${result.suggestion}"?`); + } + process.exit(1); + } + + const component = components.find((c) => c.name === result.match)!; + const doc = options.lang === 'zh' ? component.doc.zh : component.doc.en; + + if (options.format === 'json') { + console.log( + JSON.stringify( + { + name: component.name, + lang: options.lang || 'en', + content: doc, + }, + null, + 2, + ), + ); + return; + } + + if (!doc) { + console.log( + ` ${chalk.yellow('No documentation found')} for ${component.name} in ${options.lang === 'zh' ? 'Chinese' : 'English'}.`, + ); + return; + } + + console.log(doc); +} diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts new file mode 100644 index 00000000..3610844d --- /dev/null +++ b/packages/cli/src/commands/doctor.ts @@ -0,0 +1,154 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import chalk from 'chalk'; +import type { OutputFormat } from '../utils/format.js'; + +interface CheckResult { + name: string; + status: 'pass' | 'warn' | 'fail'; + message: string; +} + +function checkPackageJson(cwd: string): CheckResult { + const pkgPath = path.join(cwd, 'package.json'); + if (!fs.existsSync(pkgPath)) { + return { name: 'package.json', status: 'fail', message: 'No package.json found' }; + } + return { name: 'package.json', status: 'pass', message: 'Found' }; +} + +function checkTinyDesignInstalled(cwd: string): CheckResult { + const pkgPath = path.join(cwd, 'package.json'); + if (!fs.existsSync(pkgPath)) { + return { + name: '@tiny-design/react', + status: 'fail', + message: 'Cannot check — no package.json', + }; + } + + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + const deps = { ...pkg.dependencies, ...pkg.devDependencies }; + if (!deps['@tiny-design/react']) { + return { + name: '@tiny-design/react', + status: 'fail', + message: 'Not listed in dependencies', + }; + } + + // Check actual installed version + const modulePkgPath = path.join(cwd, 'node_modules/@tiny-design/react/package.json'); + if (fs.existsSync(modulePkgPath)) { + const modulePkg = JSON.parse(fs.readFileSync(modulePkgPath, 'utf-8')); + return { + name: '@tiny-design/react', + status: 'pass', + message: `v${modulePkg.version} installed`, + }; + } + + return { + name: '@tiny-design/react', + status: 'warn', + message: `Listed (${deps['@tiny-design/react']}) but not installed — run your package manager`, + }; +} + +function checkReactVersion(cwd: string): CheckResult { + const modulePkgPath = path.join(cwd, 'node_modules/react/package.json'); + if (!fs.existsSync(modulePkgPath)) { + return { name: 'React version', status: 'warn', message: 'React not found in node_modules' }; + } + + const pkg = JSON.parse(fs.readFileSync(modulePkgPath, 'utf-8')); + const major = parseInt(pkg.version.split('.')[0], 10); + if (major < 18) { + return { + name: 'React version', + status: 'fail', + message: `v${pkg.version} — Tiny Design requires React >=18.0.0`, + }; + } + return { name: 'React version', status: 'pass', message: `v${pkg.version}` }; +} + +function checkTypeScript(cwd: string): CheckResult { + const tsconfigPath = path.join(cwd, 'tsconfig.json'); + if (!fs.existsSync(tsconfigPath)) { + return { name: 'TypeScript', status: 'warn', message: 'No tsconfig.json (optional)' }; + } + + const modulePkgPath = path.join(cwd, 'node_modules/typescript/package.json'); + if (fs.existsSync(modulePkgPath)) { + const pkg = JSON.parse(fs.readFileSync(modulePkgPath, 'utf-8')); + return { name: 'TypeScript', status: 'pass', message: `v${pkg.version}` }; + } + + return { name: 'TypeScript', status: 'warn', message: 'tsconfig.json exists but TypeScript not installed' }; +} + +function checkPeerDeps(cwd: string): CheckResult { + const reactDomPath = path.join(cwd, 'node_modules/react-dom/package.json'); + if (!fs.existsSync(reactDomPath)) { + return { + name: 'Peer dependencies', + status: 'warn', + message: 'react-dom not found — required by @tiny-design/react', + }; + } + return { name: 'Peer dependencies', status: 'pass', message: 'react-dom found' }; +} + +function checkDuplicateReact(cwd: string): CheckResult { + // Check for nested react installations that could cause issues + const nestedReact = path.join( + cwd, + 'node_modules/@tiny-design/react/node_modules/react/package.json', + ); + if (fs.existsSync(nestedReact)) { + return { + name: 'Duplicate React', + status: 'fail', + message: 'Found nested React installation — this will cause hooks errors', + }; + } + return { name: 'Duplicate React', status: 'pass', message: 'No duplicates found' }; +} + +export function doctorCommand(options: { format: OutputFormat }) { + const cwd = process.cwd(); + + const checks: CheckResult[] = [ + checkPackageJson(cwd), + checkTinyDesignInstalled(cwd), + checkReactVersion(cwd), + checkTypeScript(cwd), + checkPeerDeps(cwd), + checkDuplicateReact(cwd), + ]; + + if (options.format === 'json') { + console.log(JSON.stringify(checks, null, 2)); + return; + } + + const icons = { pass: chalk.green('✓'), warn: chalk.yellow('⚠'), fail: chalk.red('✗') }; + + console.log(`\n ${chalk.bold.cyan('Tiny Design Doctor')}\n`); + for (const check of checks) { + console.log(` ${icons[check.status]} ${chalk.bold(check.name)}: ${check.message}`); + } + + const fails = checks.filter((c) => c.status === 'fail').length; + const warns = checks.filter((c) => c.status === 'warn').length; + console.log(); + if (fails > 0) { + console.log(` ${chalk.red(`${fails} issue(s) found.`)}`); + } else if (warns > 0) { + console.log(` ${chalk.yellow(`${warns} warning(s), no critical issues.`)}`); + } else { + console.log(` ${chalk.green('All checks passed!')}`); + } + console.log(); +} diff --git a/packages/cli/src/commands/icon.ts b/packages/cli/src/commands/icon.ts new file mode 100644 index 00000000..f1b12a15 --- /dev/null +++ b/packages/cli/src/commands/icon.ts @@ -0,0 +1,82 @@ +import chalk from 'chalk'; +import type { IconData } from '@tiny-design/extract'; +import type { OutputFormat } from '../utils/format.js'; +import { findBestMatch } from '../utils/match.js'; + +export function iconCommand( + search: string | undefined, + icons: IconData, + options: { format: OutputFormat; detail?: boolean }, +) { + if (!search) { + // List all icons + if (options.format === 'json') { + console.log(JSON.stringify({ count: icons.icons.length, icons: icons.icons }, null, 2)); + return; + } + + console.log(`\n ${chalk.bold.cyan('Icons')} ${chalk.dim(`(${icons.icons.length} total)`)}\n`); + + // Display in columns + const colWidth = 28; + const termWidth = process.stdout.columns || 80; + const cols = Math.max(1, Math.floor(termWidth / colWidth)); + + for (let i = 0; i < icons.icons.length; i += cols) { + const row = icons.icons + .slice(i, i + cols) + .map((name) => name.padEnd(colWidth)) + .join(''); + console.log(` ${row}`); + } + console.log(); + return; + } + + // Search icons + const term = search.toLowerCase(); + const matches = icons.icons.filter((name) => name.toLowerCase().includes(term)); + + if (matches.length === 0) { + // Try fuzzy match + const result = findBestMatch(search, icons.icons); + console.error(`No icons matching "${search}".`); + if (result.suggestion) { + console.error(`Did you mean "${result.suggestion}"?`); + } + process.exit(1); + } + + if (options.format === 'json') { + console.log( + JSON.stringify( + { + query: search, + count: matches.length, + icons: matches, + props: icons.props, + }, + null, + 2, + ), + ); + return; + } + + console.log( + `\n ${chalk.bold.cyan('Icons matching')} "${search}" ${chalk.dim(`(${matches.length} found)`)}\n`, + ); + + for (const name of matches) { + console.log(` ${chalk.dim('•')} ${name}`); + } + + if (options.detail && matches.length > 0) { + console.log(`\n ${chalk.dim('Usage:')}`); + console.log( + ` ${chalk.dim(`import { ${matches[0]} } from '@tiny-design/icons';`)}`, + ); + console.log(` ${chalk.dim(`<${matches[0]} size={24} color="#6e41bf" />`)}`); + } + console.log(); +} diff --git a/packages/cli/src/commands/info.ts b/packages/cli/src/commands/info.ts new file mode 100644 index 00000000..6124f80a --- /dev/null +++ b/packages/cli/src/commands/info.ts @@ -0,0 +1,82 @@ +import chalk from 'chalk'; +import type { ComponentDataWithDocs } from '@tiny-design/extract'; +import type { OutputFormat } from '../utils/format.js'; +import { findBestMatch } from '../utils/match.js'; +import { renderTable } from '../utils/table.js'; + +export function infoCommand( + name: string, + components: ComponentDataWithDocs[], + options: { format: OutputFormat; lang?: string; detail?: boolean }, +) { + const names = components.map((c) => c.name); + const result = findBestMatch(name, names); + + if (!result.match) { + console.error(`Component "${name}" not found.`); + if (result.suggestion) { + console.error(`Did you mean "${result.suggestion}"?`); + } + process.exit(1); + } + + const component = components.find((c) => c.name === result.match)!; + + if (options.format === 'json') { + const data = { + name: component.name, + category: component.category, + description: options.lang === 'zh' ? component.descriptionZh : component.description, + props: component.props, + }; + console.log(JSON.stringify(data, null, 2)); + return; + } + + const desc = options.lang === 'zh' ? component.descriptionZh : component.description; + + if (options.format === 'markdown') { + console.log(`# ${component.name}\n`); + console.log(`${desc}\n`); + console.log(`**Category:** ${component.category}\n`); + console.log('## Props\n'); + console.log('| Property | Type | Required | Default | Description |'); + console.log('|----------|------|----------|---------|-------------|'); + for (const p of component.props) { + console.log( + `| ${p.name} | \`${p.type}\` | ${p.required ? 'Yes' : 'No'} | ${p.default || '-'} | ${p.description} |`, + ); + } + return; + } + + // Text format + console.log(); + console.log(` ${chalk.bold.cyan(component.name)} ${chalk.dim(`[${component.category}]`)}`); + console.log(` ${desc}`); + console.log(); + + const rows = component.props.map((p) => ({ + name: p.name, + type: p.type, + required: p.required ? chalk.yellow('Yes') : 'No', + default: p.default || '-', + description: p.description, + })); + + const columns = [ + { header: 'Property', key: 'name' }, + { header: 'Type', key: 'type', width: 40 }, + { header: 'Required', key: 'required' }, + { header: 'Default', key: 'default' }, + { header: 'Description', key: 'description', width: 50 }, + ]; + + console.log( + renderTable(columns, rows) + .split('\n') + .map((l) => ` ${l}`) + .join('\n'), + ); + console.log(); +} diff --git a/packages/cli/src/commands/list.ts b/packages/cli/src/commands/list.ts new file mode 100644 index 00000000..7c0f56dd --- /dev/null +++ b/packages/cli/src/commands/list.ts @@ -0,0 +1,77 @@ +import chalk from 'chalk'; +import type { ComponentDataWithDocs } from '@tiny-design/extract'; +import type { OutputFormat } from '../utils/format.js'; +import { renderTable } from '../utils/table.js'; + +export function listCommand( + components: ComponentDataWithDocs[], + options: { format: OutputFormat; category?: string; detail?: boolean; lang?: string }, +) { + let filtered = components; + if (options.category) { + const cat = options.category.toLowerCase(); + filtered = components.filter((c) => c.category.toLowerCase() === cat); + if (filtered.length === 0) { + const categories = [...new Set(components.map((c) => c.category))]; + console.error( + `Category "${options.category}" not found. Available: ${categories.join(', ')}`, + ); + process.exit(1); + } + } + + if (options.format === 'json') { + const data = filtered.map(({ name, category, description, descriptionZh }) => ({ + name, + category, + description: options.lang === 'zh' ? descriptionZh : description, + })); + console.log(JSON.stringify(data, null, 2)); + return; + } + + // Group by category + const groups = new Map(); + for (const c of filtered) { + const list = groups.get(c.category) ?? []; + list.push(c); + groups.set(c.category, list); + } + + if (options.format === 'markdown') { + for (const [category, items] of groups) { + console.log(`## ${category}\n`); + console.log('| Component | Description |'); + console.log('|-----------|-------------|'); + for (const c of items) { + const desc = options.lang === 'zh' ? c.descriptionZh : c.description; + console.log(`| ${c.name} | ${desc} |`); + } + console.log(); + } + return; + } + + // Text format + for (const [category, items] of groups) { + console.log(chalk.bold.cyan(`\n ${category}`)); + console.log(); + const rows = items.map((c) => ({ + name: c.name, + description: options.lang === 'zh' ? c.descriptionZh : c.description, + ...(options.detail ? { props: String(c.props.length) } : {}), + })); + const columns = [ + { header: 'Component', key: 'name' }, + { header: 'Description', key: 'description', width: 60 }, + ...(options.detail ? [{ header: 'Props', key: 'props' }] : []), + ]; + console.log( + renderTable(columns, rows) + .split('\n') + .map((l) => ` ${l}`) + .join('\n'), + ); + } + console.log(`\n ${chalk.dim(`${filtered.length} components total`)}\n`); +} diff --git a/packages/cli/src/commands/token.ts b/packages/cli/src/commands/token.ts new file mode 100644 index 00000000..ba798696 --- /dev/null +++ b/packages/cli/src/commands/token.ts @@ -0,0 +1,73 @@ +import chalk from 'chalk'; +import type { TokenData } from '@tiny-design/extract'; +import type { OutputFormat } from '../utils/format.js'; +import { renderTable } from '../utils/table.js'; + +export function tokenCommand( + category: string | undefined, + tokens: TokenData, + options: { format: OutputFormat; detail?: boolean }, +) { + const categories = Object.keys(tokens); + + // If no category, list categories with counts + if (!category) { + if (options.format === 'json') { + const data = categories.map((cat) => ({ + category: cat, + count: Object.keys(tokens[cat]).length, + })); + console.log(JSON.stringify(data, null, 2)); + return; + } + + console.log(`\n ${chalk.bold.cyan('Token Categories')}\n`); + for (const cat of categories) { + const count = Object.keys(tokens[cat]).length; + console.log(` ${chalk.dim('•')} ${cat} ${chalk.dim(`(${count} tokens)`)}`); + } + console.log(`\n ${chalk.dim('Run: tiny-design token ')}\n`); + return; + } + + // Find category (case-insensitive) + const match = categories.find((c) => c.toLowerCase() === category.toLowerCase()); + if (!match) { + console.error(`Category "${category}" not found. Available: ${categories.join(', ')}`); + process.exit(1); + } + + const entries = tokens[match]; + + if (options.format === 'json') { + console.log(JSON.stringify(entries, null, 2)); + return; + } + + if (options.format === 'markdown') { + console.log(`## ${match}\n`); + console.log('| Variable | Value |'); + console.log('|----------|-------|'); + for (const [, entry] of Object.entries(entries)) { + console.log(`| \`${entry.variable}\` | \`${entry.value}\` |`); + } + return; + } + + console.log(`\n ${chalk.bold.cyan(match)}\n`); + const rows = Object.entries(entries).map(([, entry]) => ({ + variable: entry.variable, + value: entry.value, + })); + const columns = [ + { header: 'Variable', key: 'variable', width: 40 }, + { header: 'Value', key: 'value', width: 50 }, + ]; + console.log( + renderTable(columns, rows) + .split('\n') + .map((l) => ` ${l}`) + .join('\n'), + ); + console.log(); +} diff --git a/packages/cli/src/commands/usage.ts b/packages/cli/src/commands/usage.ts new file mode 100644 index 00000000..8ae32962 --- /dev/null +++ b/packages/cli/src/commands/usage.ts @@ -0,0 +1,113 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import chalk from 'chalk'; +import type { OutputFormat } from '../utils/format.js'; +import { renderTable } from '../utils/table.js'; + +interface UsageEntry { + component: string; + count: number; + files: string[]; +} + +function scanDir(dir: string, results: Map) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue; + + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + scanDir(fullPath, results); + } else if (/\.(tsx?|jsx?)$/.test(entry.name)) { + scanFile(fullPath, results); + } + } +} + +function scanFile(filePath: string, results: Map) { + const content = fs.readFileSync(filePath, 'utf-8'); + + // Match: import { Button, Modal } from '@tiny-design/react'; + const importRegex = /import\s*\{([^}]+)\}\s*from\s*['"]@tiny-design\/react['"]/g; + let match: RegExpExecArray | null; + + while ((match = importRegex.exec(content)) !== null) { + const names = match[1].split(',').map((n) => n.trim().split(/\s+as\s+/)[0].trim()); + for (const name of names) { + if (!name) continue; + const files = results.get(name) ?? []; + files.push(filePath); + results.set(name, files); + } + } +} + +export function usageCommand(dir: string, options: { format: OutputFormat; detail?: boolean }) { + const targetDir = path.resolve(dir); + + if (!fs.existsSync(targetDir)) { + console.error(`Directory "${targetDir}" not found.`); + process.exit(1); + } + + const results = new Map(); + scanDir(targetDir, results); + + if (results.size === 0) { + if (options.format === 'json') { + console.log(JSON.stringify([], null, 2)); + } else { + console.log(`\n ${chalk.yellow('No @tiny-design/react imports found')} in ${targetDir}\n`); + } + return; + } + + const entries: UsageEntry[] = [...results.entries()] + .map(([component, files]) => ({ + component, + count: files.length, + files: [...new Set(files)], + })) + .sort((a, b) => b.count - a.count); + + if (options.format === 'json') { + console.log(JSON.stringify(entries, null, 2)); + return; + } + + if (options.format === 'markdown') { + console.log('## @tiny-design/react Usage\n'); + console.log(`Scanned: \`${targetDir}\`\n`); + console.log('| Component | Imports |'); + console.log('|-----------|---------|'); + for (const e of entries) { + console.log(`| ${e.component} | ${e.count} |`); + } + return; + } + + // Text format + console.log(`\n ${chalk.bold.cyan('@tiny-design/react Usage')}`); + console.log(` ${chalk.dim(`Scanned: ${targetDir}`)}\n`); + + const rows = entries.map((e) => ({ + component: e.component, + imports: String(e.count), + ...(options.detail ? { files: e.files.join(', ') } : {}), + })); + + const columns = [ + { header: 'Component', key: 'component' }, + { header: 'Imports', key: 'imports' }, + ...(options.detail ? [{ header: 'Files', key: 'files', width: 60 }] : []), + ]; + + console.log( + renderTable(columns, rows) + .split('\n') + .map((l) => ` ${l}`) + .join('\n'), + ); + console.log(`\n ${chalk.dim(`${entries.length} components, ${entries.reduce((s, e) => s + e.count, 0)} total imports`)}\n`); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 00000000..51b3048e --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,106 @@ +// Note: shebang is added by tsup via banner config — do NOT add #!/usr/bin/env node here +import { Command } from 'commander'; +import type { ComponentDataWithDocs, TokenData, IconData } from '@tiny-design/extract'; +import { listCommand } from './commands/list.js'; +import { infoCommand } from './commands/info.js'; +import { docCommand } from './commands/doc.js'; +import { demoCommand } from './commands/demo.js'; +import { tokenCommand } from './commands/token.js'; +import { iconCommand } from './commands/icon.js'; +import { doctorCommand } from './commands/doctor.js'; +import { usageCommand } from './commands/usage.js'; +import type { OutputFormat } from './utils/format.js'; +import componentsData from './data/components.json'; +import tokensData from './data/tokens.json'; +import iconsData from './data/icons.json'; +import pkg from '../package.json'; + +const components = componentsData as ComponentDataWithDocs[]; +const tokens = tokensData as TokenData; +const icons = iconsData as IconData; + +const program = new Command(); + +program + .name('tiny-design') + .version(pkg.version) + .description('CLI for the Tiny Design component library') + .option('--format ', 'output format: json, text, markdown', 'text') + .option('--lang ', 'language: en, zh', 'en') + .option('--detail', 'show extended information', false); + +function getOpts() { + const opts = program.opts(); + return { + format: opts.format as OutputFormat, + lang: opts.lang as string, + detail: opts.detail as boolean, + }; +} + +program + .command('list') + .description('List all components, optionally filtered by category') + .argument('[category]', 'filter by category') + .action((category?: string) => { + const opts = getOpts(); + listCommand(components, { ...opts, category }); + }); + +program + .command('info') + .description('Show component props and API details') + .argument('', 'component name') + .action((component: string) => { + infoCommand(component, components, getOpts()); + }); + +program + .command('doc') + .description('Show full markdown documentation for a component') + .argument('', 'component name') + .action((component: string) => { + docCommand(component, components, getOpts()); + }); + +program + .command('demo') + .description('Show demo source code for a component') + .argument('', 'component name') + .argument('[name]', 'specific demo name') + .action((component: string, name?: string) => { + demoCommand(component, name, components, getOpts()); + }); + +program + .command('token') + .description('Show design token values by category') + .argument('[category]', 'token category: colors, typography, spacing, breakpoints, shadows') + .action((category?: string) => { + tokenCommand(category, tokens, getOpts()); + }); + +program + .command('icon') + .description('List or search icons') + .argument('[search]', 'search term to filter icons') + .action((search?: string) => { + iconCommand(search, icons, getOpts()); + }); + +program + .command('doctor') + .description('Diagnose project setup issues') + .action(() => { + doctorCommand(getOpts()); + }); + +program + .command('usage') + .description('Scan project for @tiny-design/react import statistics') + .argument('[dir]', 'directory to scan', '.') + .action((dir: string) => { + usageCommand(dir, getOpts()); + }); + +program.parse(); diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts new file mode 100644 index 00000000..a81634ab --- /dev/null +++ b/packages/cli/src/utils/format.ts @@ -0,0 +1,21 @@ +/** + * Output formatting for json/text/markdown modes. + */ + +export type OutputFormat = 'json' | 'text' | 'markdown'; + +export function output(data: unknown, format: OutputFormat): void { + switch (format) { + case 'json': + console.log(JSON.stringify(data, null, 2)); + break; + case 'text': + case 'markdown': + if (typeof data === 'string') { + console.log(data); + } else { + console.log(JSON.stringify(data, null, 2)); + } + break; + } +} diff --git a/packages/cli/src/utils/match.ts b/packages/cli/src/utils/match.ts new file mode 100644 index 00000000..840e999a --- /dev/null +++ b/packages/cli/src/utils/match.ts @@ -0,0 +1,79 @@ +/** + * Fuzzy component name matching with Levenshtein distance. + */ + +function levenshtein(a: string, b: string): number { + const m = a.length; + const n = b.length; + const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0)); + + for (let i = 0; i <= m; i++) dp[i][0] = i; + for (let j = 0; j <= n; j++) dp[0][j] = j; + + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i][j] = + a[i - 1] === b[j - 1] + ? dp[i - 1][j - 1] + : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); + } + } + + return dp[m][n]; +} + +function kebabToPascal(str: string): string { + return str + .split('-') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(''); +} + +export interface MatchResult { + match: string | null; + suggestion?: string; +} + +/** + * Find the best matching name from a list. + * Priority: exact match > case-insensitive > kebab-to-pascal > substring > Levenshtein + */ +export function findBestMatch(input: string, candidates: string[]): MatchResult { + // Exact match + const exact = candidates.find((c) => c === input); + if (exact) return { match: exact }; + + // Case-insensitive + const lower = input.toLowerCase(); + const caseMatch = candidates.find((c) => c.toLowerCase() === lower); + if (caseMatch) return { match: caseMatch }; + + // Kebab-case to PascalCase + if (input.includes('-')) { + const pascal = kebabToPascal(input); + const kebabMatch = candidates.find((c) => c.toLowerCase() === pascal.toLowerCase()); + if (kebabMatch) return { match: kebabMatch }; + } + + // Substring match + const substringMatches = candidates.filter((c) => c.toLowerCase().includes(lower)); + if (substringMatches.length === 1) return { match: substringMatches[0] }; + + // Levenshtein distance + let bestDist = Infinity; + let bestCandidate = ''; + for (const c of candidates) { + const dist = levenshtein(lower, c.toLowerCase()); + if (dist < bestDist) { + bestDist = dist; + bestCandidate = c; + } + } + + // Only suggest if distance is reasonable (less than half the input length) + if (bestDist <= Math.max(2, Math.floor(input.length / 2))) { + return { match: null, suggestion: bestCandidate }; + } + + return { match: null }; +} diff --git a/packages/cli/src/utils/table.ts b/packages/cli/src/utils/table.ts new file mode 100644 index 00000000..5a6b035a --- /dev/null +++ b/packages/cli/src/utils/table.ts @@ -0,0 +1,49 @@ +/** + * Simple terminal table rendering. + */ + +export interface Column { + header: string; + key: string; + width?: number; +} + +export function renderTable(columns: Column[], rows: Record[]): string { + // Calculate column widths + // Collapse multiline values to single line + const normalized = rows.map((row) => { + const result: Record = {}; + for (const key in row) { + result[key] = row[key].replace(/\n/g, ' ').replace(/\s+/g, ' ').trim(); + } + return result; + }); + + const widths = columns.map((col) => { + const maxContent = Math.max( + col.header.length, + ...normalized.map((row) => (row[col.key] || '').length), + ); + return col.width ? Math.min(col.width, maxContent) : maxContent; + }); + + const separator = widths.map((w) => '-'.repeat(w + 2)).join('+'); + const headerLine = columns + .map((col, i) => ` ${col.header.padEnd(widths[i])} `) + .join('|'); + + const lines = [headerLine, separator]; + + for (const row of normalized) { + const line = columns + .map((col, i) => { + const val = row[col.key] || ''; + const truncated = val.length > widths[i] ? val.slice(0, widths[i] - 1) + '…' : val; + return ` ${truncated.padEnd(widths[i])} `; + }) + .join('|'); + lines.push(line); + } + + return lines.join('\n'); +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 00000000..39206639 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": false, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": ".", + "resolveJsonModule": true + }, + "include": ["src/**/*", "scripts/**/*"], + "exclude": ["node_modules", "dist", "__tests__"] +} diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts new file mode 100644 index 00000000..2738179c --- /dev/null +++ b/packages/cli/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + outDir: 'dist', + clean: true, + banner: { js: '#!/usr/bin/env node' }, + loader: { '.json': 'json' }, +}); diff --git a/packages/extract/package.json b/packages/extract/package.json new file mode 100644 index 00000000..c9aa52fd --- /dev/null +++ b/packages/extract/package.json @@ -0,0 +1,16 @@ +{ + "name": "@tiny-design/extract", + "version": "0.0.0", + "private": true, + "description": "Shared metadata extraction for Tiny Design tooling", + "license": "MIT", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "ts-morph": "^25.0.0" + } +} diff --git a/packages/extract/src/extract-components.ts b/packages/extract/src/extract-components.ts new file mode 100644 index 00000000..54ae684f --- /dev/null +++ b/packages/extract/src/extract-components.ts @@ -0,0 +1,322 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { Project, type InterfaceDeclaration } from 'ts-morph'; +import type { + ComponentData, + ComponentDataWithDocs, + ComponentProp, + ExtractComponentsOptions, +} from './types.js'; + +// Category mapping from routers.tsx +export const CATEGORY_MAP: Record = { + button: 'Foundation', icon: 'Foundation', image: 'Foundation', + link: 'Foundation', typography: 'Foundation', + + 'aspect-ratio': 'Layout', divider: 'Layout', flex: 'Layout', + grid: 'Layout', layout: 'Layout', space: 'Layout', split: 'Layout', + waterfall: 'Layout', + + anchor: 'Navigation', breadcrumb: 'Navigation', dropdown: 'Navigation', + menu: 'Navigation', pagination: 'Navigation', 'speed-dial': 'Navigation', + steps: 'Navigation', + + avatar: 'Data Display', badge: 'Data Display', calendar: 'Data Display', + card: 'Data Display', carousel: 'Data Display', collapse: 'Data Display', + countdown: 'Data Display', empty: 'Data Display', descriptions: 'Data Display', + flip: 'Data Display', list: 'Data Display', marquee: 'Data Display', + popover: 'Data Display', progress: 'Data Display', 'scroll-number': 'Data Display', + statistic: 'Data Display', table: 'Data Display', tag: 'Data Display', + 'text-loop': 'Data Display', timeline: 'Data Display', tooltip: 'Data Display', + tree: 'Data Display', + + form: 'Form', 'auto-complete': 'Form', cascader: 'Form', checkbox: 'Form', + 'color-picker': 'Form', 'date-picker': 'Form', input: 'Form', + 'input-number': 'Form', 'input-password': 'Form', 'input-otp': 'Form', + 'native-select': 'Form', radio: 'Form', rate: 'Form', segmented: 'Form', + select: 'Form', slider: 'Form', 'split-button': 'Form', switch: 'Form', + tabs: 'Form', textarea: 'Form', 'time-picker': 'Form', transfer: 'Form', + upload: 'Form', + + alert: 'Feedback', drawer: 'Feedback', loader: 'Feedback', + overlay: 'Feedback', 'loading-bar': 'Feedback', message: 'Feedback', + modal: 'Feedback', notification: 'Feedback', 'pop-confirm': 'Feedback', + result: 'Feedback', 'scroll-indicator': 'Feedback', skeleton: 'Feedback', + 'strength-indicator': 'Feedback', + + 'back-top': 'Miscellany', 'config-provider': 'Miscellany', + 'copy-to-clipboard': 'Miscellany', keyboard: 'Miscellany', + sticky: 'Miscellany', +}; + +export function dirNameToComponentName(dirName: string): string { + return dirName + .split('-') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(''); +} + +export function getDescription(componentDir: string, filename = 'index.md'): string { + const mdPath = path.join(componentDir, filename); + if (!fs.existsSync(mdPath)) return ''; + + const content = fs.readFileSync(mdPath, 'utf-8'); + const lines = content.split('\n'); + + let foundHeading = false; + for (const line of lines) { + if (line.startsWith('# ')) { + foundHeading = true; + continue; + } + if (foundHeading && line.trim() !== '' && !line.startsWith('import ')) { + return line.trim(); + } + } + return ''; +} + +export function getDemos(componentDir: string): { name: string; code: string }[] { + const demoDir = path.join(componentDir, 'demo'); + if (!fs.existsSync(demoDir)) return []; + + return fs + .readdirSync(demoDir) + .filter((f) => f.endsWith('.tsx')) + .map((f) => ({ + name: path.basename(f, '.tsx'), + code: fs.readFileSync(path.join(demoDir, f), 'utf-8'), + })); +} + +function resolveTypeText(typeText: string, iface: InterfaceDeclaration): string { + if (/^[A-Z][A-Za-z0-9]*$/.test(typeText)) { + const sourceFile = iface.getSourceFile(); + const typeAlias = sourceFile.getTypeAlias(typeText); + if (typeAlias) { + const aliasTypeNode = typeAlias.getTypeNode(); + if (aliasTypeNode) { + return aliasTypeNode.getText(); + } + } + } + return typeText; +} + +function extractPropsFromInterface(iface: InterfaceDeclaration): ComponentProp[] { + const props: ComponentProp[] = []; + + for (const prop of iface.getProperties()) { + const name = prop.getName(); + const typeNode = prop.getTypeNode(); + const rawTypeText = typeNode ? typeNode.getText() : prop.getType().getText(prop); + const typeText = resolveTypeText(rawTypeText, iface); + const isOptional = prop.hasQuestionToken(); + + const jsDocs = prop.getJsDocs(); + const description = jsDocs.length > 0 ? jsDocs[0].getDescription().trim() : ''; + + props.push({ + name, + type: typeText, + required: !isOptional, + description, + }); + } + + return props; +} + +/** + * Parse the markdown API table to extract default values for props. + * Tables use the format: | Property | Description | Type | Default | + * Pipe characters in cells are encoded as | + */ +function parseApiTableDefaults(mdPath: string): Record { + const defaults: Record = {}; + if (!fs.existsSync(mdPath)) return defaults; + + const content = fs.readFileSync(mdPath, 'utf-8'); + const lines = content.split('\n'); + + let inApiSection = false; + let headerParsed = false; + + for (const line of lines) { + if (line.startsWith('## API')) { + inApiSection = true; + headerParsed = false; + continue; + } + if (inApiSection && line.startsWith('## ')) { + break; // Next section + } + if (!inApiSection) continue; + + // Skip the header row and separator + if (line.includes('Property') && line.includes('Description')) { + headerParsed = true; + continue; + } + if (line.match(/^\|\s*-+/)) continue; + + if (headerParsed && line.startsWith('|')) { + // Split by | but handle | escape + const raw = line.replace(/|/g, '\u0000'); + const cells = raw.split('|').map((c) => c.replace(/\u0000/g, '|').trim()); + // cells[0] is empty (before first |), cells[1]=Property, cells[4]=Default + if (cells.length >= 5) { + const propName = cells[1].trim(); + const defaultVal = cells[4].trim(); + if (propName && defaultVal && defaultVal !== '-') { + defaults[propName] = defaultVal; + } + } + } + } + + return defaults; +} + +/** + * Read full markdown doc, stripping import lines and JSX components. + */ +function readDocMarkdown(mdPath: string): string { + if (!fs.existsSync(mdPath)) return ''; + + const content = fs.readFileSync(mdPath, 'utf-8'); + const lines = content.split('\n'); + const result: string[] = []; + let skipDepth = 0; + let inCodeBlock = false; + let preamble = true; // Track whether we're still in the file preamble (before first heading) + + for (const line of lines) { + // Track code blocks to avoid stripping content inside them + if (line.startsWith('```')) { + inCodeBlock = !inCodeBlock; + } + + // Only skip import lines in the preamble (before first heading), not inside code blocks + if (preamble && !inCodeBlock && line.startsWith('import ')) continue; + if (line.startsWith('#')) preamble = false; + + // Inside code blocks, emit as-is + if (inCodeBlock) { + result.push(line); + continue; + } + + // Track JSX block elements to skip + if (line.match(/^\s*<(Layout|Column|Demo|DemoBlock)\b/)) { + skipDepth++; + continue; + } + if (line.match(/^\s*<\/(Layout|Column|Demo)>/)) { + skipDepth--; + continue; + } + // Skip self-closing DemoBlock tags + if (line.match(/^\s*/)) continue; + + if (skipDepth > 0) { + // Still include markdown content inside JSX blocks (headings, paragraphs) + if (line.startsWith('#') || (line.trim() !== '' && !line.trim().startsWith('<'))) { + result.push(line); + } + continue; + } + + result.push(line); + } + + return result.join('\n').trim(); +} + +const BASE_PROPS: ComponentProp[] = [ + { name: 'style', type: 'CSSProperties', required: false, description: 'Inline styles' }, + { name: 'className', type: 'string', required: false, description: 'CSS class name' }, + { + name: 'prefixCls', + type: 'string', + required: false, + description: 'CSS class prefix (default: "ty")', + }, +]; + +export function extractComponents(options: ExtractComponentsOptions): ComponentData[]; +export function extractComponents( + options: ExtractComponentsOptions & { includeDocs: true }, +): ComponentDataWithDocs[]; +export function extractComponents( + options: ExtractComponentsOptions, +): (ComponentData | ComponentDataWithDocs)[] { + const { reactSrcPath, includeDocs = false, includeDefaults = false } = options; + + const project = new Project({ + skipAddingFilesFromTsConfig: true, + skipFileDependencyResolution: true, + }); + + const components: (ComponentData | ComponentDataWithDocs)[] = []; + const dirs = fs.readdirSync(reactSrcPath, { withFileTypes: true }); + + for (const dir of dirs) { + if (!dir.isDirectory()) continue; + if (dir.name.startsWith('_')) continue; + + const dirName = dir.name; + const componentDir = path.join(reactSrcPath, dirName); + const typesPath = path.join(componentDir, 'types.ts'); + if (!fs.existsSync(typesPath)) continue; + + const category = CATEGORY_MAP[dirName]; + if (!category) continue; + + const sourceFile = project.addSourceFileAtPath(typesPath); + const interfaces = sourceFile.getInterfaces(); + + const componentName = dirNameToComponentName(dirName); + const mainInterface = + interfaces.find((i) => i.getName() === `${componentName}Props`) ?? + interfaces.find((i) => i.isExported() && i.getName().endsWith('Props')); + + if (!mainInterface) continue; + + const props = extractPropsFromInterface(mainInterface); + + // Merge default values from API table + if (includeDefaults) { + const defaults = parseApiTableDefaults(path.join(componentDir, 'index.md')); + for (const prop of props) { + if (defaults[prop.name]) { + prop.default = defaults[prop.name]; + } + } + } + + const propNames = new Set(props.map((p) => p.name)); + const allProps = [...props, ...BASE_PROPS.filter((p) => !propNames.has(p.name))]; + + const component: ComponentData = { + name: componentName, + dirName, + category, + description: getDescription(componentDir), + descriptionZh: getDescription(componentDir, 'index.zh_CN.md'), + props: allProps, + demos: getDemos(componentDir), + }; + + if (includeDocs) { + (component as ComponentDataWithDocs).doc = { + en: readDocMarkdown(path.join(componentDir, 'index.md')), + zh: readDocMarkdown(path.join(componentDir, 'index.zh_CN.md')), + }; + } + + components.push(component); + } + + return components.sort((a, b) => a.name.localeCompare(b.name)); +} diff --git a/packages/mcp/scripts/extract-icons.ts b/packages/extract/src/extract-icons.ts similarity index 52% rename from packages/mcp/scripts/extract-icons.ts rename to packages/extract/src/extract-icons.ts index 0ebb24fb..5e049eba 100644 --- a/packages/mcp/scripts/extract-icons.ts +++ b/packages/extract/src/extract-icons.ts @@ -1,15 +1,8 @@ import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import type { IconData } from '../src/types'; +import type { IconData, ExtractIconsOptions } from './types.js'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const ICONS_INDEX = path.resolve(__dirname, '../../icons/src/index.ts'); - -export function extractIcons(): IconData { - const content = fs.readFileSync(ICONS_INDEX, 'utf-8'); +export function extractIcons(options: ExtractIconsOptions): IconData { + const content = fs.readFileSync(options.iconsIndexPath, 'utf-8'); const icons: string[] = []; const exportRegex = /export\s*\{\s*(\w+)\s*\}/g; @@ -17,7 +10,6 @@ export function extractIcons(): IconData { while ((match = exportRegex.exec(content)) !== null) { const name = match[1]; - // Skip the IconProps type export if (name !== 'IconProps') { icons.push(name); } diff --git a/packages/mcp/scripts/extract-tokens.ts b/packages/extract/src/extract-tokens.ts similarity index 78% rename from packages/mcp/scripts/extract-tokens.ts rename to packages/extract/src/extract-tokens.ts index 88cd28a2..e2698c05 100644 --- a/packages/mcp/scripts/extract-tokens.ts +++ b/packages/extract/src/extract-tokens.ts @@ -1,12 +1,5 @@ import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import type { TokenData } from '../src/types'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const VARIABLES_PATH = path.resolve(__dirname, '../../tokens/scss/_variables.scss'); +import type { TokenData, ExtractTokensOptions } from './types.js'; // Map variable name prefixes to categories const CATEGORY_RULES: Array<{ test: (name: string) => boolean; category: string }> = [ @@ -31,8 +24,8 @@ function categorize(name: string): string | null { return null; } -export function extractTokens(): TokenData { - const content = fs.readFileSync(VARIABLES_PATH, 'utf-8'); +export function extractTokens(options: ExtractTokensOptions): TokenData { + const content = fs.readFileSync(options.variablesPath, 'utf-8'); const result: TokenData = { colors: {}, typography: {}, diff --git a/packages/extract/src/index.ts b/packages/extract/src/index.ts new file mode 100644 index 00000000..ef3ee5fd --- /dev/null +++ b/packages/extract/src/index.ts @@ -0,0 +1,16 @@ +export type { + ComponentProp, + ComponentDemo, + ComponentData, + ComponentDataWithDocs, + TokenEntry, + TokenData, + IconData, + ExtractComponentsOptions, + ExtractTokensOptions, + ExtractIconsOptions, +} from './types.js'; + +export { extractComponents, CATEGORY_MAP, dirNameToComponentName } from './extract-components.js'; +export { extractTokens } from './extract-tokens.js'; +export { extractIcons } from './extract-icons.js'; diff --git a/packages/extract/src/types.ts b/packages/extract/src/types.ts new file mode 100644 index 00000000..ccf21836 --- /dev/null +++ b/packages/extract/src/types.ts @@ -0,0 +1,64 @@ +export interface ComponentProp { + name: string; + type: string; + required: boolean; + description: string; + default?: string; +} + +export interface ComponentDemo { + name: string; + code: string; +} + +export interface ComponentData { + name: string; + dirName: string; + category: string; + description: string; + descriptionZh: string; + props: ComponentProp[]; + demos: ComponentDemo[]; +} + +export interface ComponentDataWithDocs extends ComponentData { + doc: { en: string; zh: string }; +} + +export interface TokenEntry { + variable: string; + value: string; +} + +export interface TokenData { + [category: string]: { + [name: string]: TokenEntry; + }; +} + +export interface IconData { + props: { + size: { type: string; default: string }; + color: { type: string; default: string }; + }; + icons: string[]; +} + +export interface ExtractComponentsOptions { + /** Path to the react package src directory */ + reactSrcPath: string; + /** Include full markdown docs (en + zh) */ + includeDocs?: boolean; + /** Parse default values from API tables in markdown */ + includeDefaults?: boolean; +} + +export interface ExtractTokensOptions { + /** Path to the tokens SCSS variables file */ + variablesPath: string; +} + +export interface ExtractIconsOptions { + /** Path to the icons index.ts file */ + iconsIndexPath: string; +} diff --git a/packages/extract/tsconfig.json b/packages/extract/tsconfig.json new file mode 100644 index 00000000..f3067e13 --- /dev/null +++ b/packages/extract/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": false, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/packages/mcp/package.json b/packages/mcp/package.json index aba07c44..e975addb 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -27,11 +27,11 @@ "@modelcontextprotocol/sdk": "^1.12.1" }, "devDependencies": { + "@tiny-design/extract": "workspace:*", "@types/jest": "^29.0.0", "@types/node": "^22.0.0", "jest": "^29.0.0", "ts-jest": "^29.0.0", - "ts-morph": "^25.0.0", "tsup": "^8.0.0", "tsx": "^4.0.0", "typescript": "^5.4.0" diff --git a/packages/mcp/scripts/extract-components.ts b/packages/mcp/scripts/extract-components.ts deleted file mode 100644 index 584dffea..00000000 --- a/packages/mcp/scripts/extract-components.ts +++ /dev/null @@ -1,185 +0,0 @@ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { Project, type InterfaceDeclaration } from 'ts-morph'; -import type { ComponentData, ComponentProp } from '../src/types'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const REACT_SRC = path.resolve(__dirname, '../../react/src'); - -// Category mapping from routers.tsx -const CATEGORY_MAP: Record = { - button: 'Foundation', icon: 'Foundation', image: 'Foundation', - link: 'Foundation', typography: 'Foundation', - - 'aspect-ratio': 'Layout', divider: 'Layout', flex: 'Layout', - grid: 'Layout', layout: 'Layout', space: 'Layout', split: 'Layout', - waterfall: 'Layout', - - anchor: 'Navigation', breadcrumb: 'Navigation', dropdown: 'Navigation', - menu: 'Navigation', pagination: 'Navigation', 'speed-dial': 'Navigation', - steps: 'Navigation', - - avatar: 'Data Display', badge: 'Data Display', calendar: 'Data Display', - card: 'Data Display', carousel: 'Data Display', collapse: 'Data Display', - countdown: 'Data Display', empty: 'Data Display', descriptions: 'Data Display', - flip: 'Data Display', list: 'Data Display', marquee: 'Data Display', - popover: 'Data Display', progress: 'Data Display', 'scroll-number': 'Data Display', - statistic: 'Data Display', table: 'Data Display', tag: 'Data Display', - 'text-loop': 'Data Display', timeline: 'Data Display', tooltip: 'Data Display', - tree: 'Data Display', - - form: 'Form', 'auto-complete': 'Form', cascader: 'Form', checkbox: 'Form', - 'color-picker': 'Form', 'date-picker': 'Form', input: 'Form', - 'input-number': 'Form', 'input-password': 'Form', 'input-otp': 'Form', - 'native-select': 'Form', radio: 'Form', rate: 'Form', segmented: 'Form', - select: 'Form', slider: 'Form', 'split-button': 'Form', switch: 'Form', - tabs: 'Form', textarea: 'Form', 'time-picker': 'Form', transfer: 'Form', - upload: 'Form', - - alert: 'Feedback', drawer: 'Feedback', loader: 'Feedback', - overlay: 'Feedback', 'loading-bar': 'Feedback', message: 'Feedback', - modal: 'Feedback', notification: 'Feedback', 'pop-confirm': 'Feedback', - result: 'Feedback', 'scroll-indicator': 'Feedback', skeleton: 'Feedback', - 'strength-indicator': 'Feedback', - - 'back-top': 'Miscellany', 'config-provider': 'Miscellany', - 'copy-to-clipboard': 'Miscellany', keyboard: 'Miscellany', - sticky: 'Miscellany', -}; - -function getDescription(componentDir: string): string { - const mdPath = path.join(componentDir, 'index.md'); - if (!fs.existsSync(mdPath)) return ''; - - const content = fs.readFileSync(mdPath, 'utf-8'); - const lines = content.split('\n'); - - let foundHeading = false; - for (const line of lines) { - if (line.startsWith('# ')) { - foundHeading = true; - continue; - } - if (foundHeading && line.trim() !== '' && !line.startsWith('import ')) { - return line.trim(); - } - } - return ''; -} - -function getDemos(componentDir: string): { name: string; code: string }[] { - const demoDir = path.join(componentDir, 'demo'); - if (!fs.existsSync(demoDir)) return []; - - return fs - .readdirSync(demoDir) - .filter((f) => f.endsWith('.tsx')) - .map((f) => ({ - name: path.basename(f, '.tsx'), - code: fs.readFileSync(path.join(demoDir, f), 'utf-8'), - })); -} - -function resolveTypeText(typeText: string, iface: InterfaceDeclaration): string { - // If the type is a simple identifier (a type alias reference), try to resolve it - // from the same source file so we get the actual union/type text - if (/^[A-Z][A-Za-z0-9]*$/.test(typeText)) { - const sourceFile = iface.getSourceFile(); - const typeAlias = sourceFile.getTypeAlias(typeText); - if (typeAlias) { - const aliasTypeNode = typeAlias.getTypeNode(); - if (aliasTypeNode) { - return aliasTypeNode.getText(); - } - } - } - return typeText; -} - -function extractPropsFromInterface(iface: InterfaceDeclaration): ComponentProp[] { - const props: ComponentProp[] = []; - - for (const prop of iface.getProperties()) { - const name = prop.getName(); - const typeNode = prop.getTypeNode(); - const rawTypeText = typeNode ? typeNode.getText() : prop.getType().getText(prop); - const typeText = resolveTypeText(rawTypeText, iface); - const isOptional = prop.hasQuestionToken(); - - const jsDocs = prop.getJsDocs(); - const description = jsDocs.length > 0 ? jsDocs[0].getDescription().trim() : ''; - - props.push({ - name, - type: typeText, - required: !isOptional, - description, - }); - } - - return props; -} - -function dirNameToComponentName(dirName: string): string { - return dirName - .split('-') - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(''); -} - -export function extractComponents(): ComponentData[] { - const project = new Project({ - skipAddingFilesFromTsConfig: true, - skipFileDependencyResolution: true, - }); - - const components: ComponentData[] = []; - const dirs = fs.readdirSync(REACT_SRC, { withFileTypes: true }); - - for (const dir of dirs) { - if (!dir.isDirectory()) continue; - if (dir.name.startsWith('_')) continue; - - const dirName = dir.name; - const typesPath = path.join(REACT_SRC, dirName, 'types.ts'); - if (!fs.existsSync(typesPath)) continue; - - const category = CATEGORY_MAP[dirName]; - if (!category) continue; - - const sourceFile = project.addSourceFileAtPath(typesPath); - const interfaces = sourceFile.getInterfaces(); - - const componentName = dirNameToComponentName(dirName); - // Try exact match first, then fall back to the first exported interface in the file - const mainInterface = - interfaces.find((i) => i.getName() === `${componentName}Props`) ?? - interfaces.find((i) => i.isExported() && i.getName().endsWith('Props')); - - if (!mainInterface) continue; - - const props = extractPropsFromInterface(mainInterface); - - const baseProps: ComponentProp[] = [ - { name: 'style', type: 'CSSProperties', required: false, description: 'Inline styles' }, - { name: 'className', type: 'string', required: false, description: 'CSS class name' }, - { name: 'prefixCls', type: 'string', required: false, description: 'CSS class prefix (default: "ty")' }, - ]; - - const propNames = new Set(props.map((p) => p.name)); - const allProps = [...props, ...baseProps.filter((p) => !propNames.has(p.name))]; - - components.push({ - name: componentName, - category, - description: getDescription(path.join(REACT_SRC, dirName)), - props: allProps, - demos: getDemos(path.join(REACT_SRC, dirName)), - }); - } - - return components.sort((a, b) => a.name.localeCompare(b.name)); -} diff --git a/packages/mcp/scripts/extract.ts b/packages/mcp/scripts/extract.ts index df0e0b80..f22332cc 100644 --- a/packages/mcp/scripts/extract.ts +++ b/packages/mcp/scripts/extract.ts @@ -1,14 +1,15 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { extractComponents } from './extract-components'; -import { extractTokens } from './extract-tokens'; -import { extractIcons } from './extract-icons'; +import { extractComponents, extractTokens, extractIcons } from '@tiny-design/extract'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const DATA_DIR = path.resolve(__dirname, '../src/data'); +const REACT_SRC = path.resolve(__dirname, '../../react/src'); +const VARIABLES_PATH = path.resolve(__dirname, '../../tokens/scss/_variables.scss'); +const ICONS_INDEX = path.resolve(__dirname, '../../icons/src/index.ts'); function ensureDir(dir: string) { if (!fs.existsSync(dir)) { @@ -26,12 +27,12 @@ console.log('Extracting Tiny Design data...'); ensureDir(DATA_DIR); console.log(' extracting components...'); -writeJson('components.json', extractComponents()); +writeJson('components.json', extractComponents({ reactSrcPath: REACT_SRC })); console.log(' extracting tokens...'); -writeJson('tokens.json', extractTokens()); +writeJson('tokens.json', extractTokens({ variablesPath: VARIABLES_PATH })); console.log(' extracting icons...'); -writeJson('icons.json', extractIcons()); +writeJson('icons.json', extractIcons({ iconsIndexPath: ICONS_INDEX })); console.log('Done.'); diff --git a/packages/mcp/src/tools/components.ts b/packages/mcp/src/tools/components.ts index ec78cd79..e45e20c9 100644 --- a/packages/mcp/src/tools/components.ts +++ b/packages/mcp/src/tools/components.ts @@ -1,4 +1,4 @@ -import type { ComponentData } from '../types.js'; +import type { ComponentData } from '@tiny-design/extract'; import componentsData from '../data/components.json'; const components = componentsData as ComponentData[]; diff --git a/packages/mcp/src/tools/icons.ts b/packages/mcp/src/tools/icons.ts index f8f76379..fab1f289 100644 --- a/packages/mcp/src/tools/icons.ts +++ b/packages/mcp/src/tools/icons.ts @@ -1,4 +1,4 @@ -import type { IconData } from '../types.js'; +import type { IconData } from '@tiny-design/extract'; import iconsData from '../data/icons.json'; const data = iconsData as IconData; diff --git a/packages/mcp/src/tools/tokens.ts b/packages/mcp/src/tools/tokens.ts index 5c797653..7109efbb 100644 --- a/packages/mcp/src/tools/tokens.ts +++ b/packages/mcp/src/tools/tokens.ts @@ -1,4 +1,4 @@ -import type { TokenData } from '../types.js'; +import type { TokenData } from '@tiny-design/extract'; import tokensData from '../data/tokens.json'; const tokens = tokensData as TokenData; diff --git a/packages/mcp/src/types.ts b/packages/mcp/src/types.ts deleted file mode 100644 index 6edeb96b..00000000 --- a/packages/mcp/src/types.ts +++ /dev/null @@ -1,38 +0,0 @@ -export interface ComponentProp { - name: string; - type: string; - required: boolean; - description: string; -} - -export interface ComponentDemo { - name: string; - code: string; -} - -export interface ComponentData { - name: string; - category: string; - description: string; - props: ComponentProp[]; - demos: ComponentDemo[]; -} - -export interface TokenEntry { - variable: string; - value: string; -} - -export interface TokenData { - [category: string]: { - [name: string]: TokenEntry; - }; -} - -export interface IconData { - props: { - size: { type: string; default: string }; - color: { type: string; default: string }; - }; - icons: string[]; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab8f59a8..5498e4f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,6 +121,37 @@ importers: specifier: ^7.3.1 version: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.2) + packages/cli: + dependencies: + chalk: + specifier: ^5.0.0 + version: 5.6.2 + commander: + specifier: ^12.0.0 + version: 12.1.0 + devDependencies: + '@tiny-design/extract': + specifier: workspace:* + version: link:../extract + '@types/node': + specifier: ^22.0.0 + version: 22.19.15 + tsup: + specifier: ^8.0.0 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + tsx: + specifier: ^4.0.0 + version: 4.21.0 + typescript: + specifier: ^5.4.0 + version: 5.9.3 + + packages/extract: + dependencies: + ts-morph: + specifier: ^25.0.0 + version: 25.0.1 + packages/icons: devDependencies: '@testing-library/jest-dom': @@ -137,7 +168,7 @@ importers: version: 18.3.28 jest: specifier: ^29.0.0 - version: 29.7.0(@types/node@22.19.15) + version: 29.7.0(@types/node@25.4.0) jest-environment-jsdom: specifier: ^29.0.0 version: 29.7.0 @@ -152,7 +183,7 @@ importers: version: 3.0.2 ts-jest: specifier: ^29.0.0 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(esbuild@0.27.3)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.19.15))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@25.4.0))(typescript@5.9.3) tsdown: specifier: ^0.21.1 version: 0.21.2(typescript@5.9.3) @@ -166,6 +197,9 @@ importers: specifier: ^1.12.1 version: 1.27.1(zod@4.3.6) devDependencies: + '@tiny-design/extract': + specifier: workspace:* + version: link:../extract '@types/jest': specifier: ^29.0.0 version: 29.5.14 @@ -178,9 +212,6 @@ importers: ts-jest: specifier: ^29.0.0 version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(esbuild@0.27.3)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.19.15))(typescript@5.9.3) - ts-morph: - specifier: ^25.0.0 - version: 25.0.1 tsup: specifier: ^8.0.0 version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) @@ -2018,6 +2049,10 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + commander@13.1.0: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} @@ -6919,6 +6954,7 @@ snapshots: '@types/node@25.4.0': dependencies: undici-types: 7.18.2 + optional: true '@types/prismjs@1.26.6': {} @@ -7626,6 +7662,8 @@ snapshots: comma-separated-tokens@2.0.3: {} + commander@12.1.0: {} + commander@13.1.0: {} commander@2.20.3: {} @@ -9386,7 +9424,7 @@ snapshots: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 '@types/jsdom': 20.0.1 - '@types/node': 25.4.0 + '@types/node': 22.19.15 jest-mock: 29.7.0 jest-util: 29.7.0 jsdom: 20.0.3 @@ -12025,7 +12063,8 @@ snapshots: undici-types@6.21.0: {} - undici-types@7.18.2: {} + undici-types@7.18.2: + optional: true unified@11.0.5: dependencies: From 1bfb047e242609d36fb700e0069667da45d63a17 Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Wed, 25 Mar 2026 21:31:07 +1100 Subject: [PATCH 2/3] chore: update change log --- packages/react/CHANGELOG.md | 248 ------------------------------------ 1 file changed, 248 deletions(-) diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index 826a65ad..44fd87ef 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -76,251 +76,3 @@ ### Patch Changes - Fix conditional hook call in Heading component - [#50](https://github.com/wangdicoder/tiny-design/pull/50) [`582bc46`](https://github.com/wangdicoder/tiny-design/commit/582bc46828a5a8032ee9fe4d98ead5f0d547f61e) - -## [1.0.9](https://github.com/wangdicoder/tiny-ui/compare/v1.0.7...v1.0.8) (2026-03-12) - -### Refactors - -- move withSpin HOC from icons to react package ([64a835d](https://github.com/wangdicoder/tiny-ui/commit/64a835d)) - -## [1.0.6](https://github.com/wangdicoder/tiny-ui/compare/v1.0.5...v1.0.6) (2026-03-12) - -### Bug Fixes - -- replace deprecated `` with named icon components in demos ([e51fca5](https://github.com/wangdicoder/tiny-ui/commit/e51fca5)) - -## [1.0.5](https://github.com/wangdicoder/tiny-ui/compare/v1.0.4...v1.0.5) (2026-03-12) - -### Features - -- add withSpin HOC for spinning icon variants ([0129863](https://github.com/wangdicoder/tiny-ui/commit/0129863)) - -## [1.0.4](https://github.com/wangdicoder/tiny-ui/compare/v1.0.3...v1.0.4) (2026-03-12) - -### Features - -- extract `@tiny-design/icons` package with tree-shakeable SVG icons ([2378f8d](https://github.com/wangdicoder/tiny-ui/commit/2378f8d)) - -### Refactors - -- migrate to pnpm monorepo with turborepo ([52e6c47](https://github.com/wangdicoder/tiny-ui/commit/52e6c47)) -- consume @tiny-design/tokens from react package ([a66e078](https://github.com/wangdicoder/tiny-ui/commit/a66e078)) -- rename npm scope from @tiny-ui to @tiny-design ([1f7c1bc](https://github.com/wangdicoder/tiny-ui/commit/1f7c1bc)) - -## [1.0.3](https://github.com/wangdicoder/tiny-ui/compare/v1.0.2...v1.0.3) (2026-03-11) - -### Bug Fixes - -- make light mode the default and use data-tiny-theme attribute ([bdd69b4](https://github.com/wangdicoder/tiny-ui/commit/bdd69b4)) -- unit tests ([4f78b3c](https://github.com/wangdicoder/tiny-ui/commit/4f78b3c)) - -## [1.0.2](https://github.com/wangdicoder/tiny-ui/compare/v1.0.1...v1.0.2) (2026-03-11) - -### Bug Fixes - -- CSSTransition issue ([5275a17](https://github.com/wangdicoder/tiny-ui/commit/5275a17)) - -### Refactors - -- reuse Pagination component in Table and List ([444b9a0](https://github.com/wangdicoder/tiny-ui/commit/444b9a0)) -- unify component file structure so index.tsx only re-exports ([60bed2d](https://github.com/wangdicoder/tiny-ui/commit/60bed2d)) - -## [1.0.1](https://github.com/wangdicoder/tiny-ui/compare/v1.0.0...v1.0.1) (2026-03-10) - -### Bug Fixes - -- `` - prevent custom props from leaking to DOM element ([#41](https://github.com/wangdicoder/tiny-ui/issues/41)) ([4b5103a](https://github.com/wangdicoder/tiny-ui/commit/4b5103a)) -- `` - accept single Col element in RowProps ([#36](https://github.com/wangdicoder/tiny-ui/issues/36)) ([6edf326](https://github.com/wangdicoder/tiny-ui/commit/6edf326)) -- CSSTransition issue ([5275a17](https://github.com/wangdicoder/tiny-ui/commit/5275a17)) - -## [1.0.0](https://github.com/wangdicoder/tiny-ui/compare/v0.0.94...v1.0.0) (2026-03-10) - -### Features - -- `` - add Flex component for lightweight flexbox layouts ([a5029cb](https://github.com/wangdicoder/tiny-ui/commit/a5029cb)) -- `` - enhance with scroll container, link registration, and line type ([ad06647](https://github.com/wangdicoder/tiny-ui/commit/ad06647)) -- `` - rewrite with filtering, keyboard nav, and Popup ([3f1f674](https://github.com/wangdicoder/tiny-ui/commit/3f1f674)) -- `` - add new Speed Dial component -- `