From d2759bf458af2f78c7d2007b6dd958f2884dfd54 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Mon, 29 Dec 2025 18:34:40 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(miniapps):=20create-miniapp=20CLI=20+?= =?UTF-8?q?=20Storybook=20v10=20+=20Vitest=20=E9=9B=86=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 packages/create-miniapp CLI 工具,支持快速创建 Bio 小程序 - 集成 shadcn/ui preset URL 生成 - 自动生成 i18n (zh-CN/zh-TW/en)、vitest、e2e、logo 处理脚本 - 配置 Storybook v10 + @storybook/addon-vitest 实现真实浏览器测试 - 为 forge/teleport 添加 Storybook 配置和自定义组件 stories - 简化 splashScreen schema,复用 app.icon 和 themeColorFrom - 添加 pnpm agent miniapp 命令封装 --- ...13\345\272\217\345\274\200\345\217\221.md" | 42 + miniapps/forge/.storybook/main.ts | 12 + miniapps/forge/.storybook/preview.tsx | 123 ++ miniapps/forge/.storybook/vitest.setup.ts | 7 + miniapps/forge/manifest.json | 4 +- miniapps/forge/package.json | 14 +- miniapps/forge/src/App.tsx | 5 + .../src/components/FireButton.stories.tsx | 53 + miniapps/forge/vitest.config.ts | 47 +- miniapps/teleport/.storybook/main.ts | 12 + miniapps/teleport/.storybook/preview.tsx | 123 ++ miniapps/teleport/.storybook/vitest.setup.ts | 7 + miniapps/teleport/manifest.json | 4 +- miniapps/teleport/package.json | 14 +- miniapps/teleport/src/App.tsx | 7 +- .../src/components/GlowButton.stories.tsx | 50 + miniapps/teleport/vitest.config.ts | 47 +- packages/create-miniapp/package.json | 42 + packages/create-miniapp/src/cli.ts | 135 +++ .../create-miniapp/src/commands/create.ts | 207 ++++ packages/create-miniapp/src/index.ts | 3 + packages/create-miniapp/src/types.ts | 39 + packages/create-miniapp/src/utils/inject.ts | 1066 +++++++++++++++++ packages/create-miniapp/src/utils/prompts.ts | 158 +++ packages/create-miniapp/src/utils/shadcn.ts | 28 + packages/create-miniapp/tsconfig.json | 19 + packages/create-miniapp/tsup.config.ts | 12 + pnpm-lock.yaml | 48 + scripts/agent/commands/miniapp.ts | 131 ++ src/pages/ecosystem/miniapp.tsx | 9 +- src/services/ecosystem/registry.ts | 8 +- src/services/ecosystem/schema.ts | 11 +- src/services/ecosystem/types.ts | 16 +- 33 files changed, 2461 insertions(+), 42 deletions(-) create mode 100644 miniapps/forge/.storybook/main.ts create mode 100644 miniapps/forge/.storybook/preview.tsx create mode 100644 miniapps/forge/.storybook/vitest.setup.ts create mode 100644 miniapps/forge/src/components/FireButton.stories.tsx create mode 100644 miniapps/teleport/.storybook/main.ts create mode 100644 miniapps/teleport/.storybook/preview.tsx create mode 100644 miniapps/teleport/.storybook/vitest.setup.ts create mode 100644 miniapps/teleport/src/components/GlowButton.stories.tsx create mode 100644 packages/create-miniapp/package.json create mode 100644 packages/create-miniapp/src/cli.ts create mode 100644 packages/create-miniapp/src/commands/create.ts create mode 100644 packages/create-miniapp/src/index.ts create mode 100644 packages/create-miniapp/src/types.ts create mode 100644 packages/create-miniapp/src/utils/inject.ts create mode 100644 packages/create-miniapp/src/utils/prompts.ts create mode 100644 packages/create-miniapp/src/utils/shadcn.ts create mode 100644 packages/create-miniapp/tsconfig.json create mode 100644 packages/create-miniapp/tsup.config.ts create mode 100644 scripts/agent/commands/miniapp.ts diff --git "a/docs/white-book/10-\347\224\237\346\200\201\347\257\207/03-\345\260\217\347\250\213\345\272\217\345\274\200\345\217\221.md" "b/docs/white-book/10-\347\224\237\346\200\201\347\257\207/03-\345\260\217\347\250\213\345\272\217\345\274\200\345\217\221.md" index 7f6e748d..64b36d16 100644 --- "a/docs/white-book/10-\347\224\237\346\200\201\347\257\207/03-\345\260\217\347\250\213\345\272\217\345\274\200\345\217\221.md" +++ "b/docs/white-book/10-\347\224\237\346\200\201\347\257\207/03-\345\260\217\347\250\213\345\272\217\345\274\200\345\217\221.md" @@ -177,3 +177,45 @@ body { "permissions": ["bio_requestAccounts"] // 只声明需要的 } ``` + +## 启动屏 (Splash Screen) + +KeyApp 为小程序提供启动屏功能,在小程序加载时显示品牌 Logo 和加载动画。 + +### 配置 + +在 `manifest.json` 中启用: + +```json +{ + "icon": "icon.svg", + "themeColor": "from-blue-800 via-indigo-900 to-purple-950", + "themeColorFrom": "#1e40af", + "splashScreen": true +} +``` + +- **图标**:自动使用 `icon` 字段 +- **背景色**:自动使用 `themeColorFrom` 字段(HEX 格式) +- **超时**:默认 5 秒后自动关闭 + +### 自定义超时 + +```json +{ + "splashScreen": { "timeout": 3000 } +} +``` + +### 关闭启动屏 + +小程序初始化完成后,调用 `bio_closeSplashScreen` 关闭启动屏: + +```typescript +// 在小程序初始化完成后调用 +useEffect(() => { + window.bio?.request({ method: 'bio_closeSplashScreen' }) +}, []) +``` + +> 如果未调用 `closeSplashScreen()`,启动屏会在超时后自动关闭。 diff --git a/miniapps/forge/.storybook/main.ts b/miniapps/forge/.storybook/main.ts new file mode 100644 index 00000000..38735a63 --- /dev/null +++ b/miniapps/forge/.storybook/main.ts @@ -0,0 +1,12 @@ +import type { StorybookConfig } from '@storybook/react-vite' + +const config: StorybookConfig = { + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + addons: ['@storybook/addon-vitest', '@storybook/addon-docs'], + framework: { + name: '@storybook/react-vite', + options: {}, + }, +} + +export default config diff --git a/miniapps/forge/.storybook/preview.tsx b/miniapps/forge/.storybook/preview.tsx new file mode 100644 index 00000000..3f94abad --- /dev/null +++ b/miniapps/forge/.storybook/preview.tsx @@ -0,0 +1,123 @@ +import type { Preview, ReactRenderer } from '@storybook/react-vite' +import type { DecoratorFunction } from 'storybook/internal/types' +import { useEffect } from 'react' +import { I18nextProvider } from 'react-i18next' +import i18n, { languages, defaultLanguage, getLanguageDirection, type LanguageCode } from '../src/i18n' +import '../src/index.css' + +const mobileViewports = { + iPhoneSE: { + name: 'iPhone SE', + styles: { width: '375px', height: '667px' }, + type: 'mobile' as const, + }, + iPhone13: { + name: 'iPhone 13', + styles: { width: '390px', height: '844px' }, + type: 'mobile' as const, + }, + iPhone13ProMax: { + name: 'iPhone 13 Pro Max', + styles: { width: '428px', height: '926px' }, + type: 'mobile' as const, + }, +} + +const preview: Preview = { + parameters: { + viewport: { + viewports: mobileViewports, + defaultViewport: 'iPhone13', + }, + backgrounds: { + disable: true, + }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + }, + globalTypes: { + locale: { + name: 'Locale', + description: 'Language', + defaultValue: defaultLanguage, + toolbar: { + icon: 'globe', + items: Object.entries(languages).map(([code, config]) => ({ + value: code, + title: `${config.name} (${config.dir.toUpperCase()})`, + right: config.dir === 'rtl' ? '←' : '→', + })), + dynamicTitle: true, + }, + }, + theme: { + name: 'Theme', + description: 'Color theme', + defaultValue: 'light', + toolbar: { + icon: 'paintbrush', + items: [ + { value: 'light', title: 'Light', icon: 'sun' }, + { value: 'dark', title: 'Dark', icon: 'moon' }, + ], + dynamicTitle: true, + }, + }, + }, + decorators: [ + // i18n + Theme decorator + ((Story, context) => { + const locale = (context.globals['locale'] || defaultLanguage) as LanguageCode + const theme = context.globals['theme'] || 'light' + const direction = getLanguageDirection(locale) + + useEffect(() => { + if (i18n.language !== locale) { + i18n.changeLanguage(locale) + } + document.documentElement.lang = locale + document.documentElement.dir = direction + + if (theme === 'dark') { + document.documentElement.classList.add('dark') + } else { + document.documentElement.classList.remove('dark') + } + }, [locale, theme, direction]) + + return ( + + + + ) + }) as DecoratorFunction, + + // Container decorator with theme wrapper + ((Story, context) => { + const theme = context.globals['theme'] || 'light' + const direction = getLanguageDirection((context.globals['locale'] || defaultLanguage) as LanguageCode) + const isDark = theme === 'dark' + + return ( +
+
+ +
+
+ ) + }) as DecoratorFunction, + ], +} + +export default preview diff --git a/miniapps/forge/.storybook/vitest.setup.ts b/miniapps/forge/.storybook/vitest.setup.ts new file mode 100644 index 00000000..73b80607 --- /dev/null +++ b/miniapps/forge/.storybook/vitest.setup.ts @@ -0,0 +1,7 @@ +import { beforeAll } from 'vitest' +import { setProjectAnnotations } from '@storybook/react-vite' +import * as previewAnnotations from './preview' + +const annotations = setProjectAnnotations([previewAnnotations]) + +beforeAll(annotations.beforeAll) diff --git a/miniapps/forge/manifest.json b/miniapps/forge/manifest.json index c4e08292..aedfd2af 100644 --- a/miniapps/forge/manifest.json +++ b/miniapps/forge/manifest.json @@ -17,5 +17,7 @@ "publishedAt": "2024-12-15", "updatedAt": "2024-12-28", "beta": true, - "themeColor": "from-red-800 via-orange-900 to-amber-950" + "themeColor": "from-red-800 via-orange-900 to-amber-950", + "themeColorFrom": "#991b1b", + "splashScreen": true } diff --git a/miniapps/forge/package.json b/miniapps/forge/package.json index 4472b9a3..d650f6ea 100644 --- a/miniapps/forge/package.json +++ b/miniapps/forge/package.json @@ -13,8 +13,10 @@ "typecheck": "tsc --noEmit", "typecheck:run": "tsc --noEmit", "test": "vitest", - "test:run": "vitest run", - "test:storybook": "echo 'Miniapp has no storybook'", + "test:run": "vitest run --project=unit", + "test:storybook": "vitest run --project=storybook", + "storybook": "storybook dev -p 6007", + "build-storybook": "storybook build", "e2e": "bun scripts/e2e.ts", "e2e:run": "bun scripts/e2e.ts", "e2e:update": "bun scripts/e2e.ts --update-snapshots", @@ -45,15 +47,23 @@ "@biochain/i18n-tools": "workspace:*", "@biochain/theme-tools": "workspace:*", "@playwright/test": "^1.49.1", + "@storybook/addon-docs": "^10.1.4", + "@storybook/addon-vitest": "^10.1.4", + "@storybook/react": "^10.1.4", + "@storybook/react-vite": "^10.1.4", "@tailwindcss/vite": "^4.1.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^5.0.0", + "@vitest/browser": "^4.0.15", + "@vitest/browser-playwright": "^4.0.15", "eslint-plugin-i18next": "^6.1.3", "jsdom": "^26.1.0", "oxlint": "^1.32.0", + "playwright": "^1.57.0", + "storybook": "^10.1.4", "tailwindcss": "^4.1.0", "typescript": "^5.9.3", "vite": "^7.3.0", diff --git a/miniapps/forge/src/App.tsx b/miniapps/forge/src/App.tsx index f994dc4a..8ee80a35 100644 --- a/miniapps/forge/src/App.tsx +++ b/miniapps/forge/src/App.tsx @@ -61,6 +61,11 @@ export default function App() { const [error, setError] = useState(null) const [pickerOpen, setPickerOpen] = useState<'from' | 'to' | null>(null) + // 关闭启动屏 + useEffect(() => { + window.bio?.request({ method: 'bio_closeSplashScreen' }) + }, []) + useEffect(() => { if (fromAmount && parseFloat(fromAmount) > 0) { const rate = EXCHANGE_RATES[`${fromToken.symbol}-${toToken.symbol}`] || 1 diff --git a/miniapps/forge/src/components/FireButton.stories.tsx b/miniapps/forge/src/components/FireButton.stories.tsx new file mode 100644 index 00000000..76730bc4 --- /dev/null +++ b/miniapps/forge/src/components/FireButton.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { FireButton } from './FireButton' +import { Zap } from 'lucide-react' + +const meta = { + title: 'Components/FireButton', + component: FireButton, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + children: '连接钱包', + }, +} + +export const WithIcon: Story = { + args: { + children: ( + <> + + 开始锻造 + + ), + }, +} + +export const Disabled: Story = { + args: { + children: '处理中...', + disabled: true, + }, +} + +export const Wide: Story = { + args: { + children: '确认交易', + className: 'max-w-xs', + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} diff --git a/miniapps/forge/vitest.config.ts b/miniapps/forge/vitest.config.ts index 40d3f57d..724bad21 100644 --- a/miniapps/forge/vitest.config.ts +++ b/miniapps/forge/vitest.config.ts @@ -1,13 +1,46 @@ import { defineConfig } from 'vitest/config' -import react from '@vitejs/plugin-react' -import tsconfigPaths from 'vite-tsconfig-paths' +import { storybookTest } from '@storybook/addon-vitest/vitest-plugin' +import { playwright } from '@vitest/browser-playwright' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const dirname = path.dirname(fileURLToPath(import.meta.url)) export default defineConfig({ - plugins: [react(), tsconfigPaths()], test: { - globals: true, - environment: 'jsdom', - include: ['src/**/*.test.{ts,tsx}'], - setupFiles: ['./src/test-setup.ts'], + projects: [ + // 单元测试项目 (jsdom) + { + extends: './vite.config.ts', + test: { + name: 'unit', + globals: true, + environment: 'jsdom', + setupFiles: ['./src/test-setup.ts'], + include: ['src/**/*.test.{ts,tsx}'], + exclude: ['src/**/*.stories.test.{ts,tsx}'], + }, + }, + // Storybook 组件测试项目 (真实浏览器) + { + extends: './vite.config.ts', + plugins: [ + storybookTest({ + configDir: path.join(dirname, '.storybook'), + storybookScript: 'pnpm storybook --ci', + }), + ], + test: { + name: 'storybook', + browser: { + enabled: true, + provider: playwright(), + headless: true, + instances: [{ browser: 'chromium' }], + }, + setupFiles: ['./.storybook/vitest.setup.ts'], + }, + }, + ], }, }) diff --git a/miniapps/teleport/.storybook/main.ts b/miniapps/teleport/.storybook/main.ts new file mode 100644 index 00000000..38735a63 --- /dev/null +++ b/miniapps/teleport/.storybook/main.ts @@ -0,0 +1,12 @@ +import type { StorybookConfig } from '@storybook/react-vite' + +const config: StorybookConfig = { + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + addons: ['@storybook/addon-vitest', '@storybook/addon-docs'], + framework: { + name: '@storybook/react-vite', + options: {}, + }, +} + +export default config diff --git a/miniapps/teleport/.storybook/preview.tsx b/miniapps/teleport/.storybook/preview.tsx new file mode 100644 index 00000000..3f94abad --- /dev/null +++ b/miniapps/teleport/.storybook/preview.tsx @@ -0,0 +1,123 @@ +import type { Preview, ReactRenderer } from '@storybook/react-vite' +import type { DecoratorFunction } from 'storybook/internal/types' +import { useEffect } from 'react' +import { I18nextProvider } from 'react-i18next' +import i18n, { languages, defaultLanguage, getLanguageDirection, type LanguageCode } from '../src/i18n' +import '../src/index.css' + +const mobileViewports = { + iPhoneSE: { + name: 'iPhone SE', + styles: { width: '375px', height: '667px' }, + type: 'mobile' as const, + }, + iPhone13: { + name: 'iPhone 13', + styles: { width: '390px', height: '844px' }, + type: 'mobile' as const, + }, + iPhone13ProMax: { + name: 'iPhone 13 Pro Max', + styles: { width: '428px', height: '926px' }, + type: 'mobile' as const, + }, +} + +const preview: Preview = { + parameters: { + viewport: { + viewports: mobileViewports, + defaultViewport: 'iPhone13', + }, + backgrounds: { + disable: true, + }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + }, + globalTypes: { + locale: { + name: 'Locale', + description: 'Language', + defaultValue: defaultLanguage, + toolbar: { + icon: 'globe', + items: Object.entries(languages).map(([code, config]) => ({ + value: code, + title: `${config.name} (${config.dir.toUpperCase()})`, + right: config.dir === 'rtl' ? '←' : '→', + })), + dynamicTitle: true, + }, + }, + theme: { + name: 'Theme', + description: 'Color theme', + defaultValue: 'light', + toolbar: { + icon: 'paintbrush', + items: [ + { value: 'light', title: 'Light', icon: 'sun' }, + { value: 'dark', title: 'Dark', icon: 'moon' }, + ], + dynamicTitle: true, + }, + }, + }, + decorators: [ + // i18n + Theme decorator + ((Story, context) => { + const locale = (context.globals['locale'] || defaultLanguage) as LanguageCode + const theme = context.globals['theme'] || 'light' + const direction = getLanguageDirection(locale) + + useEffect(() => { + if (i18n.language !== locale) { + i18n.changeLanguage(locale) + } + document.documentElement.lang = locale + document.documentElement.dir = direction + + if (theme === 'dark') { + document.documentElement.classList.add('dark') + } else { + document.documentElement.classList.remove('dark') + } + }, [locale, theme, direction]) + + return ( + + + + ) + }) as DecoratorFunction, + + // Container decorator with theme wrapper + ((Story, context) => { + const theme = context.globals['theme'] || 'light' + const direction = getLanguageDirection((context.globals['locale'] || defaultLanguage) as LanguageCode) + const isDark = theme === 'dark' + + return ( +
+
+ +
+
+ ) + }) as DecoratorFunction, + ], +} + +export default preview diff --git a/miniapps/teleport/.storybook/vitest.setup.ts b/miniapps/teleport/.storybook/vitest.setup.ts new file mode 100644 index 00000000..73b80607 --- /dev/null +++ b/miniapps/teleport/.storybook/vitest.setup.ts @@ -0,0 +1,7 @@ +import { beforeAll } from 'vitest' +import { setProjectAnnotations } from '@storybook/react-vite' +import * as previewAnnotations from './preview' + +const annotations = setProjectAnnotations([previewAnnotations]) + +beforeAll(annotations.beforeAll) diff --git a/miniapps/teleport/manifest.json b/miniapps/teleport/manifest.json index d997f637..bd13d1a6 100644 --- a/miniapps/teleport/manifest.json +++ b/miniapps/teleport/manifest.json @@ -17,5 +17,7 @@ "publishedAt": "2024-12-01", "updatedAt": "2024-12-28", "beta": false, - "themeColor": "from-indigo-800 via-purple-900 to-violet-950" + "themeColor": "from-indigo-800 via-purple-900 to-violet-950", + "themeColorFrom": "#3730a3", + "splashScreen": true } diff --git a/miniapps/teleport/package.json b/miniapps/teleport/package.json index f440884e..7ea8e513 100644 --- a/miniapps/teleport/package.json +++ b/miniapps/teleport/package.json @@ -13,8 +13,10 @@ "typecheck": "tsc --noEmit", "typecheck:run": "tsc --noEmit", "test": "vitest", - "test:run": "vitest run", - "test:storybook": "echo 'Miniapp has no storybook'", + "test:run": "vitest run --project=unit", + "test:storybook": "vitest run --project=storybook", + "storybook": "storybook dev -p 6008", + "build-storybook": "storybook build", "e2e": "bun scripts/e2e.ts", "e2e:run": "bun scripts/e2e.ts", "e2e:update": "bun scripts/e2e.ts --update-snapshots", @@ -46,15 +48,23 @@ "@biochain/i18n-tools": "workspace:*", "@biochain/theme-tools": "workspace:*", "@playwright/test": "^1.49.1", + "@storybook/addon-docs": "^10.1.4", + "@storybook/addon-vitest": "^10.1.4", + "@storybook/react": "^10.1.4", + "@storybook/react-vite": "^10.1.4", "@tailwindcss/vite": "^4.1.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^5.0.0", + "@vitest/browser": "^4.0.15", + "@vitest/browser-playwright": "^4.0.15", "eslint-plugin-i18next": "^6.1.3", "jsdom": "^26.1.0", "oxlint": "^1.32.0", + "playwright": "^1.57.0", + "storybook": "^10.1.4", "tailwindcss": "^4.1.0", "typescript": "^5.9.3", "vite": "^7.3.0", diff --git a/miniapps/teleport/src/App.tsx b/miniapps/teleport/src/App.tsx index 8d57b745..cb439bfb 100644 --- a/miniapps/teleport/src/App.tsx +++ b/miniapps/teleport/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback } from 'react' +import { useState, useCallback, useEffect } from 'react' import type { BioAccount } from '@biochain/bio-sdk' import { Button } from '@/components/ui/button' import { Card, CardContent, CardTitle, CardDescription } from '@/components/ui/card' @@ -42,6 +42,11 @@ export default function App() { const [loading, setLoading] = useState(false) const [error, setError] = useState(null) + // 关闭启动屏 + useEffect(() => { + window.bio?.request({ method: 'bio_closeSplashScreen' }) + }, []) + const handleConnect = useCallback(async () => { if (!window.bio) { setError('Bio SDK 未初始化') diff --git a/miniapps/teleport/src/components/GlowButton.stories.tsx b/miniapps/teleport/src/components/GlowButton.stories.tsx new file mode 100644 index 00000000..f87a85b3 --- /dev/null +++ b/miniapps/teleport/src/components/GlowButton.stories.tsx @@ -0,0 +1,50 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { GlowButton } from './GlowButton' +import { Send } from 'lucide-react' + +const meta = { + title: 'Components/GlowButton', + component: GlowButton, + parameters: { + layout: 'centered', + backgrounds: { + default: 'dark', + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + children: '开始传送', + }, +} + +export const WithIcon: Story = { + args: { + children: ( + <> + + 发送资产 + + ), + }, +} + +export const Disabled: Story = { + args: { + children: '处理中...', + disabled: true, + className: 'opacity-50 cursor-not-allowed', + }, +} + +export const Wide: Story = { + args: { + children: '确认转账', + className: 'w-64', + }, +} diff --git a/miniapps/teleport/vitest.config.ts b/miniapps/teleport/vitest.config.ts index 40d3f57d..724bad21 100644 --- a/miniapps/teleport/vitest.config.ts +++ b/miniapps/teleport/vitest.config.ts @@ -1,13 +1,46 @@ import { defineConfig } from 'vitest/config' -import react from '@vitejs/plugin-react' -import tsconfigPaths from 'vite-tsconfig-paths' +import { storybookTest } from '@storybook/addon-vitest/vitest-plugin' +import { playwright } from '@vitest/browser-playwright' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const dirname = path.dirname(fileURLToPath(import.meta.url)) export default defineConfig({ - plugins: [react(), tsconfigPaths()], test: { - globals: true, - environment: 'jsdom', - include: ['src/**/*.test.{ts,tsx}'], - setupFiles: ['./src/test-setup.ts'], + projects: [ + // 单元测试项目 (jsdom) + { + extends: './vite.config.ts', + test: { + name: 'unit', + globals: true, + environment: 'jsdom', + setupFiles: ['./src/test-setup.ts'], + include: ['src/**/*.test.{ts,tsx}'], + exclude: ['src/**/*.stories.test.{ts,tsx}'], + }, + }, + // Storybook 组件测试项目 (真实浏览器) + { + extends: './vite.config.ts', + plugins: [ + storybookTest({ + configDir: path.join(dirname, '.storybook'), + storybookScript: 'pnpm storybook --ci', + }), + ], + test: { + name: 'storybook', + browser: { + enabled: true, + provider: playwright(), + headless: true, + instances: [{ browser: 'chromium' }], + }, + setupFiles: ['./.storybook/vitest.setup.ts'], + }, + }, + ], }, }) diff --git a/packages/create-miniapp/package.json b/packages/create-miniapp/package.json new file mode 100644 index 00000000..70e3967d --- /dev/null +++ b/packages/create-miniapp/package.json @@ -0,0 +1,42 @@ +{ + "name": "@biochain/create-miniapp", + "version": "0.1.0", + "description": "CLI 工具用于快速创建 Bio 生态 miniapp 项目", + "type": "module", + "bin": { + "create-miniapp": "./dist/cli.js" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "templates" + ], + "scripts": { + "dev": "bun src/cli.ts", + "build": "tsup", + "typecheck": "tsc --noEmit", + "typecheck:run": "tsc --noEmit", + "lint": "oxlint .", + "lint:run": "oxlint .", + "test": "vitest", + "test:run": "vitest run" + }, + "dependencies": { + "@inquirer/prompts": "^7.5.0", + "chalk": "^5.4.1", + "execa": "^9.5.2", + "yargs": "^18.0.0" + }, + "devDependencies": { + "@types/node": "^22.15.21", + "@types/yargs": "^17.0.33", + "oxlint": "^1.32.0", + "tsup": "^8.5.0", + "typescript": "^5.9.3", + "vitest": "^4.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/create-miniapp/src/cli.ts b/packages/create-miniapp/src/cli.ts new file mode 100644 index 00000000..b6f9afce --- /dev/null +++ b/packages/create-miniapp/src/cli.ts @@ -0,0 +1,135 @@ +#!/usr/bin/env node +/** + * Create Miniapp CLI + * + * Usage: + * pnpm dlx @biochain/create-miniapp my-app + * pnpm dlx @biochain/create-miniapp my-app --style mira --theme blue + */ + +import yargs from 'yargs' +import { hideBin } from 'yargs/helpers' +import { createMiniapp } from './commands/create' +import type { CreateOptions } from './types' + +const STYLES = ['vega', 'nova', 'maia', 'lyra', 'mira'] as const +const BASE_COLORS = ['neutral', 'stone', 'zinc', 'gray'] as const +const THEMES = ['neutral', 'amber', 'blue', 'cyan', 'emerald', 'fuchsia', 'green', 'indigo', 'lime', 'orange', 'pink'] as const +const ICON_LIBRARIES = ['lucide', 'tabler', 'hugeicons', 'phosphor'] as const +const FONTS = ['inter', 'noto-sans', 'nunito-sans', 'figtree'] as const +const RADII = ['default', 'none', 'small', 'medium', 'large'] as const +const MENU_ACCENTS = ['subtle', 'bold'] as const +const TEMPLATES = ['vite', 'start'] as const + +yargs(hideBin(process.argv)) + .scriptName('create-miniapp') + .usage('$0 [name] [options]') + .command( + '$0 [name]', + '创建新的 Bio 生态 miniapp 项目', + (yargs) => { + return yargs + .positional('name', { + type: 'string', + describe: 'Miniapp 名称', + }) + .option('style', { + type: 'string', + describe: 'UI 风格', + choices: STYLES, + default: 'mira', + }) + .option('base-color', { + type: 'string', + describe: '基础颜色', + choices: BASE_COLORS, + default: 'neutral', + }) + .option('theme', { + type: 'string', + describe: '主题色', + choices: THEMES, + default: 'neutral', + }) + .option('icon-library', { + type: 'string', + describe: '图标库', + choices: ICON_LIBRARIES, + default: 'lucide', + }) + .option('font', { + type: 'string', + describe: '字体', + choices: FONTS, + default: 'inter', + }) + .option('radius', { + type: 'string', + describe: '圆角大小', + choices: RADII, + default: 'default', + }) + .option('menu-accent', { + type: 'string', + describe: '菜单强调风格', + choices: MENU_ACCENTS, + default: 'subtle', + }) + .option('template', { + type: 'string', + describe: '项目模板', + choices: TEMPLATES, + default: 'vite', + }) + .option('output', { + type: 'string', + describe: '输出目录', + default: './miniapps', + }) + .option('skip-shadcn', { + type: 'boolean', + describe: '跳过 shadcn 初始化', + default: false, + }) + .option('skip-install', { + type: 'boolean', + describe: '跳过依赖安装', + default: false, + }) + .option('yes', { + type: 'boolean', + alias: 'y', + describe: '使用默认值,跳过交互式提示', + default: false, + }) + .option('no-splash', { + type: 'boolean', + describe: '禁用启动屏', + default: false, + }) + }, + async (argv) => { + await createMiniapp({ + name: argv.name, + style: argv.style as typeof STYLES[number], + baseColor: argv['base-color'] as typeof BASE_COLORS[number], + theme: argv.theme as typeof THEMES[number], + iconLibrary: argv['icon-library'] as typeof ICON_LIBRARIES[number], + font: argv.font as typeof FONTS[number], + radius: argv.radius as typeof RADII[number], + menuAccent: argv['menu-accent'] as typeof MENU_ACCENTS[number], + template: argv.template as typeof TEMPLATES[number], + output: argv.output as string, + skipShadcn: argv['skip-shadcn'] as boolean, + skipInstall: argv['skip-install'] as boolean, + yes: argv.yes as boolean, + noSplash: argv['no-splash'] as boolean, + }) + } + ) + .help() + .alias('h', 'help') + .version() + .alias('v', 'version') + .strict() + .parse() diff --git a/packages/create-miniapp/src/commands/create.ts b/packages/create-miniapp/src/commands/create.ts new file mode 100644 index 00000000..078f32bb --- /dev/null +++ b/packages/create-miniapp/src/commands/create.ts @@ -0,0 +1,207 @@ +import { resolve } from 'path' +import { existsSync, mkdirSync, readdirSync } from 'fs' +import { execa } from 'execa' +import chalk from 'chalk' +import type { CreateOptions } from '../types' +import { promptMissingOptions } from '../utils/prompts' +import { buildShadcnPresetUrl } from '../utils/shadcn' +import { + generateManifest, + generateOxlintConfig, + generateVitestConfig, + generatePlaywrightConfig, + generateBioTypes, + generateTestSetup, + generateE2ESetup, + generateE2EHelpers, + generateE2ESpec, + generateI18nSetup, + generateI18nTest, + generateAppTest, + generateLogoScript, + generateStorybookConfig, + generateExampleStory, + updatePackageJson, + updateViteConfig, + updateMainTsx, + updateAppTsx, +} from '../utils/inject' + +const log = { + info: (msg: string) => console.log(chalk.cyan('ℹ'), msg), + success: (msg: string) => console.log(chalk.green('✓'), msg), + warn: (msg: string) => console.log(chalk.yellow('⚠'), msg), + error: (msg: string) => console.log(chalk.red('✗'), msg), + step: (step: number, total: number, msg: string) => + console.log(chalk.dim(`[${step}/${total}]`), msg), +} + +function getNextPort(outputDir: string): number { + const basePort = 5180 + if (!existsSync(outputDir)) return basePort + + const dirs = readdirSync(outputDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .length + + return basePort + dirs + 1 +} + +export async function createMiniapp(options: CreateOptions): Promise { + console.log() + console.log(chalk.cyan.bold('╔════════════════════════════════════════╗')) + console.log(chalk.cyan.bold('║ Create Bio Miniapp ║')) + console.log(chalk.cyan.bold('╚════════════════════════════════════════╝')) + console.log() + + try { + // 1. 交互式补全选项 + const finalOptions = await promptMissingOptions(options) + const { name, output, skipShadcn, skipInstall } = finalOptions + + const outputDir = resolve(process.cwd(), output) + const projectDir = resolve(outputDir, name) + const port = getNextPort(outputDir) + + // 检查目录是否已存在 + if (existsSync(projectDir)) { + log.error(`目录 ${projectDir} 已存在`) + process.exit(1) + } + + // 确保输出目录存在 + if (!existsSync(outputDir)) { + mkdirSync(outputDir, { recursive: true }) + } + + const totalSteps = skipShadcn ? 4 : (skipInstall ? 5 : 6) + let currentStep = 0 + + // 2. 执行 shadcn create + if (!skipShadcn) { + currentStep++ + log.step(currentStep, totalSteps, '执行 shadcn create...') + + const presetUrl = buildShadcnPresetUrl(finalOptions) + + console.log(chalk.dim(` Preset: ${presetUrl}`)) + + await execa('pnpm', [ + 'dlx', + 'shadcn@latest', + 'create', + '--preset', + presetUrl, + '--template', + finalOptions.template, + name, + ], { + cwd: outputDir, + stdio: 'inherit', + }) + + log.success('shadcn 项目创建完成') + } else { + // 如果跳过 shadcn,创建空目录 + mkdirSync(projectDir, { recursive: true }) + } + + // 3. 生成 manifest.json + currentStep++ + log.step(currentStep, totalSteps, '生成 manifest.json...') + generateManifest(projectDir, finalOptions) + log.success('manifest.json 生成完成') + + // 4. 注入 Bio 生态配置 + currentStep++ + log.step(currentStep, totalSteps, '注入 Bio 生态配置...') + + // 代码质量 + generateOxlintConfig(projectDir) + + // Vitest 测试 + generateVitestConfig(projectDir) + generateTestSetup(projectDir) + generateAppTest(projectDir, name) + + // E2E 测试 + generatePlaywrightConfig(projectDir, port) + generateE2ESetup(projectDir, name) + generateE2EHelpers(projectDir, name) + generateE2ESpec(projectDir, name) + + // i18n 国际化 (zh-CN, zh-TW, en) + generateI18nSetup(projectDir, name) + generateI18nTest(projectDir) + + // Bio SDK 类型 + generateBioTypes(projectDir) + + // Logo 处理脚本 + generateLogoScript(projectDir) + + // Storybook 配置 + generateStorybookConfig(projectDir) + generateExampleStory(projectDir) + + // 更新项目配置 + updatePackageJson(projectDir, name, port) + updateViteConfig(projectDir, port) + updateMainTsx(projectDir) + updateAppTsx(projectDir, finalOptions) + + log.success('Bio 生态配置注入完成') + + // 5. 显示配置摘要 + currentStep++ + log.step(currentStep, totalSteps, '配置摘要') + + console.log() + console.log(chalk.bold(' 配置:')) + console.log(chalk.dim(` 名称: ${name}`)) + console.log(chalk.dim(` App ID: ${finalOptions.appId}`)) + console.log(chalk.dim(` 风格: ${finalOptions.style}`)) + console.log(chalk.dim(` 主题: ${finalOptions.theme}`)) + console.log(chalk.dim(` 图标库: ${finalOptions.iconLibrary}`)) + console.log(chalk.dim(` 字体: ${finalOptions.font}`)) + console.log(chalk.dim(` 模板: ${finalOptions.template}`)) + console.log(chalk.dim(` 端口: ${port}`)) + console.log() + + // 6. 安装依赖 + if (!skipInstall) { + currentStep++ + log.step(currentStep, totalSteps, '安装依赖...') + + await execa('pnpm', ['install'], { + cwd: projectDir, + stdio: 'inherit', + }) + + log.success('依赖安装完成') + } + + // 完成 + console.log() + console.log(chalk.green.bold('✨ Miniapp 创建成功!')) + console.log() + console.log(chalk.bold(' 开始开发:')) + console.log(chalk.cyan(` cd ${output}/${name}`)) + console.log(chalk.cyan(' pnpm dev')) + console.log() + console.log(chalk.bold(' 其他命令:')) + console.log(chalk.dim(' pnpm build 构建生产版本')) + console.log(chalk.dim(' pnpm test 运行单元测试')) + console.log(chalk.dim(' pnpm storybook 启动 Storybook')) + console.log(chalk.dim(' pnpm e2e 运行 E2E 测试')) + console.log(chalk.dim(' pnpm lint 代码检查')) + console.log(chalk.dim(' pnpm typecheck 类型检查')) + console.log(chalk.dim(' pnpm gen-logo 生成 Logo 多尺寸资源')) + console.log() + } catch (error) { + if (error instanceof Error) { + log.error(error.message) + } + process.exit(1) + } +} diff --git a/packages/create-miniapp/src/index.ts b/packages/create-miniapp/src/index.ts new file mode 100644 index 00000000..40c8c873 --- /dev/null +++ b/packages/create-miniapp/src/index.ts @@ -0,0 +1,3 @@ +export { createMiniapp } from './commands/create' +export { buildShadcnPresetUrl } from './utils/shadcn' +export * from './types' diff --git a/packages/create-miniapp/src/types.ts b/packages/create-miniapp/src/types.ts new file mode 100644 index 00000000..1c0e8221 --- /dev/null +++ b/packages/create-miniapp/src/types.ts @@ -0,0 +1,39 @@ +export const STYLES = ['vega', 'nova', 'maia', 'lyra', 'mira'] as const +export const BASE_COLORS = ['neutral', 'stone', 'zinc', 'gray'] as const +export const THEMES = ['neutral', 'amber', 'blue', 'cyan', 'emerald', 'fuchsia', 'green', 'indigo', 'lime', 'orange', 'pink'] as const +export const ICON_LIBRARIES = ['lucide', 'tabler', 'hugeicons', 'phosphor'] as const +export const FONTS = ['inter', 'noto-sans', 'nunito-sans', 'figtree'] as const +export const RADII = ['default', 'none', 'small', 'medium', 'large'] as const +export const MENU_ACCENTS = ['subtle', 'bold'] as const +export const TEMPLATES = ['vite', 'start'] as const + +export type Style = typeof STYLES[number] +export type BaseColor = typeof BASE_COLORS[number] +export type Theme = typeof THEMES[number] +export type IconLibrary = typeof ICON_LIBRARIES[number] +export type Font = typeof FONTS[number] +export type Radius = typeof RADII[number] +export type MenuAccent = typeof MENU_ACCENTS[number] +export type Template = typeof TEMPLATES[number] + +export interface CreateOptions { + name?: string + style: Style + baseColor: BaseColor + theme: Theme + iconLibrary: IconLibrary + font: Font + radius: Radius + menuAccent: MenuAccent + template: Template + output: string + skipShadcn: boolean + skipInstall: boolean + yes: boolean + noSplash: boolean +} + +export interface FinalOptions extends Omit { + name: string + appId: string +} diff --git a/packages/create-miniapp/src/utils/inject.ts b/packages/create-miniapp/src/utils/inject.ts new file mode 100644 index 00000000..bd263970 --- /dev/null +++ b/packages/create-miniapp/src/utils/inject.ts @@ -0,0 +1,1066 @@ +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs' +import { resolve } from 'path' +import type { FinalOptions } from '../types' + +/** + * 生成 manifest.json + */ +export function generateManifest(projectDir: string, options: FinalOptions): void { + const manifest = { + id: options.appId, + name: options.name, + description: `${options.name} - Bio 生态小程序`, + longDescription: `${options.name} 是一个 Bio 生态小程序。`, + icon: 'icon.svg', + version: '0.1.0', + author: 'Bio Team', + website: `https://${options.name}.dweb.xin`, + category: 'utility', + tags: [], + permissions: ['bio_requestAccounts'], + chains: ['bfmeta'], + officialScore: 0, + communityScore: 0, + screenshots: [], + publishedAt: new Date().toISOString().split('T')[0], + updatedAt: new Date().toISOString().split('T')[0], + beta: true, + themeColor: 'from-blue-800 via-indigo-900 to-purple-950', + themeColorFrom: '#1e40af', + // 启动屏配置 - 自动使用 icon 和 themeColorFrom 作为背景 + ...(options.noSplash ? {} : { splashScreen: true }), + } + + writeFileSync( + resolve(projectDir, 'manifest.json'), + JSON.stringify(manifest, null, 2) + ) +} + +/** + * 生成 .oxlintrc.json + */ +export function generateOxlintConfig(projectDir: string): void { + const config = { + $schema: 'https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json', + plugins: ['react', 'typescript', 'jsx-a11y', 'unicorn'], + jsPlugins: ['eslint-plugin-i18next'], + categories: { + correctness: 'warn', + suspicious: 'warn', + pedantic: 'off', + perf: 'warn', + style: 'off', + restriction: 'off', + nursery: 'off', + }, + rules: { + 'no-unused-vars': 'warn', + 'no-console': 'warn', + eqeqeq: 'error', + 'no-var': 'error', + 'prefer-const': 'warn', + 'react/react-in-jsx-scope': 'off', + 'react/jsx-key': 'error', + 'react/no-direct-mutation-state': 'error', + 'react/no-unescaped-entities': 'warn', + 'react/self-closing-comp': 'warn', + 'react/jsx-no-useless-fragment': 'warn', + 'react/jsx-curly-brace-presence': 'warn', + 'react/no-array-index-key': 'warn', + 'typescript/no-explicit-any': 'error', + 'typescript/prefer-ts-expect-error': 'warn', + 'typescript/no-non-null-assertion': 'warn', + 'jsx-a11y/alt-text': 'warn', + 'jsx-a11y/anchor-is-valid': 'warn', + 'unicorn/no-null': 'off', + 'unicorn/prefer-query-selector': 'off', + 'unicorn/require-module-specifiers': 'off', + 'i18next/no-literal-string': ['warn', { + mode: 'jsx-only', + 'jsx-components': { exclude: ['Trans', 'Icon', 'TablerIcon'] }, + 'jsx-attributes': { + exclude: [ + 'className', 'styleName', 'style', 'type', 'key', 'id', 'name', 'role', 'as', 'asChild', + 'data-testid', 'data-test', 'data-slot', 'data-state', 'data-side', 'data-align', + 'to', 'href', 'src', 'alt', 'target', 'rel', 'method', 'action', + 'variant', 'size', 'color', 'weight', 'sign', 'align', 'justify', 'direction', 'orientation', + ], + }, + callees: { + exclude: [ + 't', 'i18n.t', 'useTranslation', 'Trans', + 'console.*', 'require', 'import', 'Error', 'TypeError', + 'describe', 'it', 'test', 'expect', 'vi.*', + ], + }, + words: { + exclude: ['[A-Z_-]+', '[0-9.]+', '^\\s*$', '^[a-z]+$', '^https?://'], + }, + }], + }, + env: { + browser: true, + es2024: true, + }, + ignorePatterns: [ + 'node_modules', + 'dist', + 'coverage', + '*.config.ts', + '**/*.test.ts', + '**/*.test.tsx', + ], + } + + writeFileSync( + resolve(projectDir, '.oxlintrc.json'), + JSON.stringify(config, null, 2) + ) +} + +/** + * 生成 vitest.config.ts (支持 unit + storybook 双项目) + */ +export function generateVitestConfig(projectDir: string): void { + const content = `import { defineConfig } from 'vitest/config' +import { storybookTest } from '@storybook/addon-vitest/vitest-plugin' +import { playwright } from '@vitest/browser-playwright' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const dirname = path.dirname(fileURLToPath(import.meta.url)) + +export default defineConfig({ + test: { + projects: [ + // 单元测试项目 (jsdom) + { + extends: './vite.config.ts', + test: { + name: 'unit', + globals: true, + environment: 'jsdom', + setupFiles: ['./src/test-setup.ts'], + include: ['src/**/*.test.{ts,tsx}'], + exclude: ['src/**/*.stories.test.{ts,tsx}'], + }, + }, + // Storybook 组件测试项目 (真实浏览器) + { + extends: './vite.config.ts', + plugins: [ + storybookTest({ + configDir: path.join(dirname, '.storybook'), + storybookScript: 'pnpm storybook --ci', + }), + ], + test: { + name: 'storybook', + browser: { + enabled: true, + provider: playwright(), + headless: true, + instances: [{ browser: 'chromium' }], + }, + setupFiles: ['./.storybook/vitest.setup.ts'], + }, + }, + ], + }, +}) +` + writeFileSync(resolve(projectDir, 'vitest.config.ts'), content) +} + +/** + * 生成 playwright.config.ts + */ +export function generatePlaywrightConfig(projectDir: string, port: number): void { + const content = `import { defineConfig, devices } from '@playwright/test' + +const baseURL = process.env.E2E_BASE_URL || 'https://localhost:${port}' + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + + use: { + baseURL, + ignoreHTTPSErrors: true, + trace: 'on-first-retry', + }, + + projects: [ + { + name: 'Mobile Chrome', + use: { ...devices['Pixel 5'] }, + }, + ], + + snapshotPathTemplate: '{testDir}/__screenshots__/{projectName}/{testFilePath}/{arg}{ext}', +}) +` + writeFileSync(resolve(projectDir, 'playwright.config.ts'), content) +} + +/** + * 生成 src/bio.d.ts + */ +export function generateBioTypes(projectDir: string): void { + const srcDir = resolve(projectDir, 'src') + if (!existsSync(srcDir)) { + mkdirSync(srcDir, { recursive: true }) + } + + const content = `/// +` + writeFileSync(resolve(srcDir, 'bio.d.ts'), content) +} + +/** + * 生成 src/test-setup.ts + */ +export function generateTestSetup(projectDir: string): void { + const srcDir = resolve(projectDir, 'src') + if (!existsSync(srcDir)) { + mkdirSync(srcDir, { recursive: true }) + } + + const content = `import '@testing-library/jest-dom/vitest' +` + writeFileSync(resolve(srcDir, 'test-setup.ts'), content) +} + +/** + * 生成 e2e 目录和基础测试 + */ +export function generateE2ESetup(projectDir: string, name: string): void { + const e2eDir = resolve(projectDir, 'e2e') + if (!existsSync(e2eDir)) { + mkdirSync(e2eDir, { recursive: true }) + } + + const content = `import { test, expect } from '@playwright/test' + +test('${name} loads correctly', async ({ page }) => { + await page.goto('/') + await expect(page).toHaveTitle(/${name}/i) +}) +` + writeFileSync(resolve(e2eDir, `${name}.spec.ts`), content) + + const scriptsDir = resolve(projectDir, 'scripts') + if (!existsSync(scriptsDir)) { + mkdirSync(scriptsDir, { recursive: true }) + } + + const e2eScript = `import { execSync } from 'child_process' + +const args = process.argv.slice(2).join(' ') +execSync(\`playwright test \${args}\`, { stdio: 'inherit' }) +` + writeFileSync(resolve(scriptsDir, 'e2e.ts'), e2eScript) +} + +/** + * 更新 package.json 添加依赖和脚本 + */ +export function updatePackageJson(projectDir: string, name: string, port: number): void { + const pkgPath = resolve(projectDir, 'package.json') + if (!existsSync(pkgPath)) return + + const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) + + pkg.name = `@biochain/miniapp-${name}` + pkg.private = true + pkg.description = `${name} - Bio 生态小程序` + + // Storybook 端口: 6007 + (port - 5184) + const storybookPort = 6007 + (port - 5184) + + pkg.scripts = { + ...pkg.scripts, + 'lint': 'oxlint .', + 'lint:run': 'oxlint .', + 'typecheck': 'tsc --noEmit', + 'typecheck:run': 'tsc --noEmit', + 'test': 'vitest', + 'test:run': 'vitest run --project=unit', + 'test:storybook': 'vitest run --project=storybook', + 'storybook': `storybook dev -p ${storybookPort}`, + 'build-storybook': 'storybook build', + 'e2e': 'bun scripts/e2e.ts', + 'e2e:run': 'bun scripts/e2e.ts', + 'e2e:update': 'bun scripts/e2e.ts --update-snapshots', + 'e2e:ui': 'playwright test --ui', + 'e2e:audit': 'bunx @biochain/e2e-tools audit --strict', + 'gen-logo': 'bun scripts/gen-logo.ts', + 'i18n:run': 'bunx @biochain/i18n-tools', + 'theme:run': 'bunx @biochain/theme-tools', + } + + pkg.dependencies = { + ...pkg.dependencies, + '@biochain/bio-sdk': 'workspace:*', + '@biochain/keyapp-sdk': 'workspace:*', + 'i18next': '^25.2.1', + 'react-i18next': '^15.5.2', + } + + pkg.devDependencies = { + ...pkg.devDependencies, + '@biochain/e2e-tools': 'workspace:*', + '@biochain/i18n-tools': 'workspace:*', + '@biochain/theme-tools': 'workspace:*', + '@playwright/test': '^1.49.1', + '@storybook/addon-docs': '^10.1.4', + '@storybook/addon-vitest': '^10.1.4', + '@storybook/react': '^10.1.4', + '@storybook/react-vite': '^10.1.4', + '@testing-library/jest-dom': '^6.6.3', + '@testing-library/react': '^16.0.0', + '@vitest/browser': '^4.0.15', + '@vitest/browser-playwright': '^4.0.15', + 'eslint-plugin-i18next': '^6.1.3', + 'jsdom': '^26.1.0', + 'oxlint': '^1.32.0', + 'playwright': '^1.57.0', + 'sharp': '^0.34.0', + 'storybook': '^10.1.4', + 'vite-plugin-mkcert': '^1.17.9', + 'vite-tsconfig-paths': '^5.1.0', + } + + writeFileSync(pkgPath, JSON.stringify(pkg, null, 2)) +} + +/** + * 更新 vite.config.ts 添加 miniapp 插件 + */ +export function updateViteConfig(projectDir: string, port: number): void { + const configPath = resolve(projectDir, 'vite.config.ts') + if (!existsSync(configPath)) return + + const content = `import { defineConfig, type Plugin } from 'vite' +import react from '@vitejs/plugin-react' +import tsconfigPaths from 'vite-tsconfig-paths' +import tailwindcss from '@tailwindcss/vite' +import mkcert from 'vite-plugin-mkcert' +import { resolve } from 'path' +import { existsSync, readFileSync, readdirSync } from 'fs' + +const E2E_SCREENSHOTS_DIR = resolve(__dirname, '../../e2e/__screenshots__/Desktop-Chrome/miniapp-ui.mock.spec.ts') +const MANIFEST_PATH = resolve(__dirname, 'manifest.json') + +function getShortId(): string { + const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf-8')) + return manifest.id.split('.').pop() || '' +} + +function scanScreenshots(shortId: string): string[] { + if (!existsSync(E2E_SCREENSHOTS_DIR)) return [] + return readdirSync(E2E_SCREENSHOTS_DIR) + .filter(f => f.startsWith(\`\${shortId}-\`) && f.endsWith('.png')) + .slice(0, 2) + .map(f => \`screenshots/\${f}\`) +} + +function miniappPlugin(): Plugin { + const shortId = getShortId() + return { + name: 'miniapp-manifest', + configureServer(server) { + server.middlewares.use('/manifest.json', (_req, res) => { + const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf-8')) + manifest.screenshots = scanScreenshots(shortId) + res.setHeader('Content-Type', 'application/json') + res.setHeader('Access-Control-Allow-Origin', '*') + res.end(JSON.stringify(manifest, null, 2)) + }) + server.middlewares.use('/screenshots', (req, res, next) => { + const filename = req.url?.slice(1) || '' + const filepath = resolve(E2E_SCREENSHOTS_DIR, filename) + if (existsSync(filepath)) { + res.setHeader('Content-Type', 'image/png') + res.setHeader('Access-Control-Allow-Origin', '*') + res.end(readFileSync(filepath)) + return + } + next() + }) + }, + } +} + +export default defineConfig({ + plugins: [ + mkcert(), + react(), + tsconfigPaths(), + tailwindcss(), + miniappPlugin(), + ], + base: './', + build: { + outDir: 'dist', + emptyOutDir: true, + }, + server: { + https: true, + port: ${port}, + fs: { + allow: [resolve(__dirname, '../..')], + }, + }, +}) +` + writeFileSync(configPath, content) +} + +/** + * 更新 main.tsx 添加 bio-sdk 和 i18n 导入 + */ +export function updateMainTsx(projectDir: string): void { + const mainPath = resolve(projectDir, 'src/main.tsx') + if (!existsSync(mainPath)) return + + let content = readFileSync(mainPath, 'utf-8') + + const imports: string[] = [] + + if (!content.includes('@biochain/bio-sdk')) { + imports.push("import '@biochain/bio-sdk'") + } + + if (!content.includes('./i18n')) { + imports.push("import './i18n'") + } + + if (imports.length > 0) { + content = imports.join('\n') + '\n' + content + writeFileSync(mainPath, content) + } +} + +/** + * 更新 App.tsx 添加 closeSplashScreen 调用 + */ +export function updateAppTsx(projectDir: string, options: FinalOptions): void { + if (options.noSplash) return + + const appPath = resolve(projectDir, 'src/App.tsx') + if (!existsSync(appPath)) return + + let content = readFileSync(appPath, 'utf-8') + + // 检查是否已有 useEffect 导入 + if (!content.includes('useEffect')) { + // 添加 useEffect 导入 + content = content.replace( + /import \{ ([^}]+) \} from ['"]react['"]/, + "import { $1, useEffect } from 'react'" + ) + } + + // 在 App 函数开始处添加 closeSplashScreen 调用 + const splashEffect = ` + // 关闭启动屏 + useEffect(() => { + window.bio?.request({ method: 'bio_closeSplashScreen' }) + }, []) +` + + // 找到 function App 或 export default function App 并在其后插入 + if (!content.includes('closeSplashScreen')) { + // 尝试匹配 function App() { 或 export default function App() { + const functionMatch = content.match(/((?:export\s+default\s+)?function\s+App\s*\([^)]*\)\s*\{)/) + if (functionMatch) { + content = content.replace(functionMatch[1], functionMatch[1] + splashEffect) + } + } + + writeFileSync(appPath, content) +} + +/** + * 生成 i18n 国际化配置 + */ +export function generateI18nSetup(projectDir: string, name: string): void { + const i18nDir = resolve(projectDir, 'src/i18n') + const localesDir = resolve(i18nDir, 'locales') + mkdirSync(localesDir, { recursive: true }) + + // src/i18n/index.ts + const indexContent = `import i18n from 'i18next' +import { initReactI18next } from 'react-i18next' + +import en from './locales/en.json' +import zh from './locales/zh.json' +import zhCN from './locales/zh-CN.json' +import zhTW from './locales/zh-TW.json' + +export const languages = { + 'zh-CN': { name: '简体中文', dir: 'ltr' as const }, + 'zh-TW': { name: '中文(繁體)', dir: 'ltr' as const }, + 'en': { name: 'English', dir: 'ltr' as const }, +} as const + +export type LanguageCode = keyof typeof languages + +export const defaultLanguage: LanguageCode = 'zh-CN' + +export function getLanguageDirection(lang: LanguageCode): 'ltr' | 'rtl' { + return languages[lang]?.dir ?? 'ltr' +} + +export function isRTL(lang: LanguageCode): boolean { + return getLanguageDirection(lang) === 'rtl' +} + +i18n.use(initReactI18next).init({ + resources: { + 'en': { translation: en }, + 'zh': { translation: zh }, + 'zh-CN': { translation: zhCN }, + 'zh-TW': { translation: zhTW }, + }, + lng: defaultLanguage, + fallbackLng: { + 'zh-CN': ['zh'], + 'zh-TW': ['zh'], + 'zh-HK': ['zh'], + 'default': ['en'], + }, + interpolation: { + escapeValue: false, + }, + react: { + useSuspense: false, + }, +}) + +export default i18n +` + writeFileSync(resolve(i18nDir, 'index.ts'), indexContent) + + // src/i18n/i18next.d.ts + const typesContent = `import 'i18next' +import type en from './locales/en.json' + +declare module 'i18next' { + interface CustomTypeOptions { + defaultNS: 'translation' + resources: { + translation: typeof en + } + } +} +` + writeFileSync(resolve(i18nDir, 'i18next.d.ts'), typesContent) + + // Locale files + const enLocale = { + app: { + title: name, + subtitle: 'Bio Miniapp', + description: `${name} - A Bio ecosystem miniapp`, + }, + common: { + loading: 'Loading...', + error: 'Error', + retry: 'Retry', + cancel: 'Cancel', + confirm: 'Confirm', + back: 'Back', + next: 'Next', + done: 'Done', + }, + connect: { + button: 'Connect Wallet', + loading: 'Connecting...', + }, + error: { + sdkNotInit: 'Bio SDK not initialized', + connectionFailed: 'Connection failed', + }, + } + + const zhLocale = { + app: { + title: name, + subtitle: 'Bio 小程序', + description: `${name} - Bio 生态小程序`, + }, + common: { + loading: '加载中...', + error: '错误', + retry: '重试', + cancel: '取消', + confirm: '确认', + back: '返回', + next: '下一步', + done: '完成', + }, + connect: { + button: '连接钱包', + loading: '连接中...', + }, + error: { + sdkNotInit: 'Bio SDK 未初始化', + connectionFailed: '连接失败', + }, + } + + const zhCNLocale = { ...zhLocale } + const zhTWLocale = { + app: { + title: name, + subtitle: 'Bio 小程式', + description: `${name} - Bio 生態小程式`, + }, + common: { + loading: '載入中...', + error: '錯誤', + retry: '重試', + cancel: '取消', + confirm: '確認', + back: '返回', + next: '下一步', + done: '完成', + }, + connect: { + button: '連接錢包', + loading: '連接中...', + }, + error: { + sdkNotInit: 'Bio SDK 未初始化', + connectionFailed: '連接失敗', + }, + } + + writeFileSync(resolve(localesDir, 'en.json'), JSON.stringify(enLocale, null, 2)) + writeFileSync(resolve(localesDir, 'zh.json'), JSON.stringify(zhLocale, null, 2)) + writeFileSync(resolve(localesDir, 'zh-CN.json'), JSON.stringify(zhCNLocale, null, 2)) + writeFileSync(resolve(localesDir, 'zh-TW.json'), JSON.stringify(zhTWLocale, null, 2)) +} + +/** + * 生成 i18n 单元测试 + */ +export function generateI18nTest(projectDir: string): void { + const content = `import { describe, it, expect } from 'vitest' +import i18n, { languages, defaultLanguage, getLanguageDirection } from './index' + +describe('i18n configuration', () => { + it('should have default language set to zh-CN', () => { + expect(defaultLanguage).toBe('zh-CN') + }) + + it('should have all required languages', () => { + expect(Object.keys(languages)).toContain('zh-CN') + expect(Object.keys(languages)).toContain('zh-TW') + expect(Object.keys(languages)).toContain('en') + }) + + it('should return correct direction for languages', () => { + expect(getLanguageDirection('zh-CN')).toBe('ltr') + expect(getLanguageDirection('en')).toBe('ltr') + }) + + it('should initialize i18n correctly', () => { + expect(i18n.isInitialized).toBe(true) + expect(i18n.language).toBe(defaultLanguage) + }) +}) +` + writeFileSync(resolve(projectDir, 'src/i18n/index.test.ts'), content) +} + +/** + * 生成 App 组件测试 + */ +export function generateAppTest(projectDir: string, name: string): void { + const content = `import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import App from './App' + +// Mock bio SDK +const mockBio = { + request: vi.fn(), + on: vi.fn(), + off: vi.fn(), + isConnected: vi.fn(() => true), +} + +describe('${name} App', () => { + beforeEach(() => { + vi.clearAllMocks() + ;(window as unknown as { bio: typeof mockBio }).bio = mockBio + }) + + it('should render without crashing', () => { + render() + expect(document.body).toBeInTheDocument() + }) + + it('should call closeSplashScreen on mount', async () => { + render() + expect(mockBio.request).toHaveBeenCalledWith({ + method: 'bio_closeSplashScreen', + }) + }) +}) +` + writeFileSync(resolve(projectDir, 'src/App.test.tsx'), content) +} + +/** + * 生成 E2E 测试辅助工具 + */ +export function generateE2EHelpers(projectDir: string, name: string): void { + const helpersDir = resolve(projectDir, 'e2e/helpers') + mkdirSync(helpersDir, { recursive: true }) + + const content = `/** + * ${name} E2E 测试国际化辅助 + */ + +import type { Page, Locator } from '@playwright/test' + +export const UI_TEXT = { + common: { + loading: /加载中|Loading/i, + error: /错误|Error/i, + retry: /重试|Retry/i, + cancel: /取消|Cancel/i, + confirm: /确认|Confirm/i, + }, + connect: { + button: /连接钱包|Connect Wallet/i, + loading: /连接中|Connecting/i, + }, +} as const + +export const TEST_IDS = { + app: 'app', + connectButton: 'connect-button', + loading: 'loading', +} as const + +export function byTestId(page: Page, testId: string): Locator { + return page.locator(\`[data-testid="\${testId}"]\`) +} + +export function i18nLocator(page: Page, selector: string, text: RegExp): Locator { + return page.locator(\`\${selector}:has-text("\${text.source}")\`) +} + +export async function setLanguage(page: Page, lang: string) { + await page.addInitScript((language) => { + localStorage.setItem('i18nextLng', language) + }, lang) +} + +export const mockBioSDK = \` + window.bio = { + request: async ({ method }) => { + if (method === 'bio_selectAccount') { + return { address: '0x1234...5678', name: 'Test Wallet' } + } + if (method === 'bio_closeSplashScreen') { + return {} + } + return {} + }, + on: () => {}, + off: () => {}, + isConnected: () => true, + } +\` +` + writeFileSync(resolve(helpersDir, 'i18n.ts'), content) +} + +/** + * 生成 Logo 处理脚本 + */ +export function generateLogoScript(projectDir: string): void { + const scriptsDir = resolve(projectDir, 'scripts') + mkdirSync(scriptsDir, { recursive: true }) + + const content = `import sharp from 'sharp' +import path from 'path' +import fs from 'fs/promises' +import { existsSync } from 'fs' + +const SIZES = [64, 128, 256, 512] +const INPUT = process.argv[2] || 'logo.png' +const OUTPUT_DIR = 'public/logos' +const SVG_OUTPUT = 'icon.svg' + +async function main() { + if (!existsSync(INPUT)) { + console.error(\`Error: Input file "\${INPUT}" not found\`) + console.log('Usage: bun scripts/gen-logo.ts [input-image]') + console.log('Example: bun scripts/gen-logo.ts logo.png') + process.exit(1) + } + + await fs.mkdir(OUTPUT_DIR, { recursive: true }) + + // 先 trim 一次,获取裁切后的 buffer + const trimmed = await sharp(INPUT).trim().toBuffer() + const metadata = await sharp(trimmed).metadata() + + console.log(\`Processing: \${INPUT} (\${metadata.width}x\${metadata.height})\`) + + // 生成各尺寸 WebP + for (const size of SIZES) { + const buffer = await sharp(trimmed) + .resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }) + .webp({ quality: 100, lossless: true }) + .toBuffer() + + const outPath = path.join(OUTPUT_DIR, \`logo-\${size}.webp\`) + await fs.writeFile(outPath, buffer) + console.log(\`✓ \${outPath}\`) + } + + // 生成 256x256 PNG 作为主 icon + const pngBuffer = await sharp(trimmed) + .resize(256, 256, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }) + .png() + .toBuffer() + + const pngPath = path.join(OUTPUT_DIR, 'icon.png') + await fs.writeFile(pngPath, pngBuffer) + console.log(\`✓ \${pngPath}\`) + + // 生成 SVG wrapper (嵌入 PNG base64) + const base64 = pngBuffer.toString('base64') + const svg = \` + +\` + + await fs.writeFile(SVG_OUTPUT, svg) + console.log(\`✓ \${SVG_OUTPUT}\`) + + console.log('\\nDone! Update manifest.json icon field if needed.') +} + +main().catch(console.error) +` + writeFileSync(resolve(scriptsDir, 'gen-logo.ts'), content) +} + +/** + * 更新 E2E spec 使用 helpers + */ +export function generateE2ESpec(projectDir: string, name: string): void { + const e2eDir = resolve(projectDir, 'e2e') + mkdirSync(e2eDir, { recursive: true }) + + const content = `import { test, expect } from '@playwright/test' +import { UI_TEXT, mockBioSDK } from './helpers/i18n' + +test.describe('${name} UI', () => { + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }) + }) + + test('01 - initial load', async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + await expect(page).toHaveScreenshot('01-initial.png') + }) + + test('02 - with wallet connected', async ({ page }) => { + await page.addInitScript(mockBioSDK) + await page.goto('/') + await page.waitForLoadState('networkidle') + + const connectButton = page.locator(\`button\`).filter({ hasText: UI_TEXT.connect.button }) + if (await connectButton.isVisible()) { + await connectButton.click() + } + + await expect(page).toHaveScreenshot('02-connected.png') + }) +}) +` + writeFileSync(resolve(e2eDir, `${name}.spec.ts`), content) +} + +/** + * 生成 Storybook 配置 + */ +export function generateStorybookConfig(projectDir: string): void { + const storybookDir = resolve(projectDir, '.storybook') + mkdirSync(storybookDir, { recursive: true }) + + // .storybook/main.ts + const mainContent = `import type { StorybookConfig } from '@storybook/react-vite' + +const config: StorybookConfig = { + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + addons: ['@storybook/addon-vitest', '@storybook/addon-docs'], + framework: { + name: '@storybook/react-vite', + options: {}, + }, +} + +export default config +` + writeFileSync(resolve(storybookDir, 'main.ts'), mainContent) + + // .storybook/preview.tsx + const previewContent = `import type { Preview, ReactRenderer } from '@storybook/react-vite' +import type { DecoratorFunction } from 'storybook/internal/types' +import { useEffect } from 'react' +import { I18nextProvider } from 'react-i18next' +import i18n, { languages, defaultLanguage, getLanguageDirection, type LanguageCode } from '../src/i18n' +import '../src/index.css' + +const mobileViewports = { + iPhoneSE: { + name: 'iPhone SE', + styles: { width: '375px', height: '667px' }, + type: 'mobile' as const, + }, + iPhone13: { + name: 'iPhone 13', + styles: { width: '390px', height: '844px' }, + type: 'mobile' as const, + }, + iPhone13ProMax: { + name: 'iPhone 13 Pro Max', + styles: { width: '428px', height: '926px' }, + type: 'mobile' as const, + }, +} + +const preview: Preview = { + parameters: { + viewport: { + viewports: mobileViewports, + defaultViewport: 'iPhone13', + }, + backgrounds: { + disable: true, + }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + }, + globalTypes: { + locale: { + name: 'Locale', + description: 'Language', + defaultValue: defaultLanguage, + toolbar: { + icon: 'globe', + items: Object.entries(languages).map(([code, config]) => ({ + value: code, + title: \`\${config.name} (\${config.dir.toUpperCase()})\`, + right: config.dir === 'rtl' ? '←' : '→', + })), + dynamicTitle: true, + }, + }, + theme: { + name: 'Theme', + description: 'Color theme', + defaultValue: 'light', + toolbar: { + icon: 'paintbrush', + items: [ + { value: 'light', title: 'Light', icon: 'sun' }, + { value: 'dark', title: 'Dark', icon: 'moon' }, + ], + dynamicTitle: true, + }, + }, + }, + decorators: [ + // i18n + Theme decorator + ((Story, context) => { + const locale = (context.globals['locale'] || defaultLanguage) as LanguageCode + const theme = context.globals['theme'] || 'light' + const direction = getLanguageDirection(locale) + + useEffect(() => { + if (i18n.language !== locale) { + i18n.changeLanguage(locale) + } + document.documentElement.lang = locale + document.documentElement.dir = direction + + if (theme === 'dark') { + document.documentElement.classList.add('dark') + } else { + document.documentElement.classList.remove('dark') + } + }, [locale, theme, direction]) + + return ( + + + + ) + }) as DecoratorFunction, + + // Container decorator with theme wrapper + ((Story, context) => { + const theme = context.globals['theme'] || 'light' + const direction = getLanguageDirection((context.globals['locale'] || defaultLanguage) as LanguageCode) + const isDark = theme === 'dark' + + return ( +
+
+ +
+
+ ) + }) as DecoratorFunction, + ], +} + +export default preview +` + writeFileSync(resolve(storybookDir, 'preview.tsx'), previewContent) + + // .storybook/vitest.setup.ts + const vitestSetupContent = `import { beforeAll } from 'vitest' +import { setProjectAnnotations } from '@storybook/react-vite' +import * as previewAnnotations from './preview' + +const annotations = setProjectAnnotations([previewAnnotations]) + +beforeAll(annotations.beforeAll) +` + writeFileSync(resolve(storybookDir, 'vitest.setup.ts'), vitestSetupContent) +} + +/** + * 生成示例 Story 文件 (仅用于自定义组件,shadcn/ui 组件不需要 story) + */ +export function generateExampleStory(_projectDir: string): void { + // shadcn/ui 组件不需要 story,自定义组件由开发者自行添加 +} diff --git a/packages/create-miniapp/src/utils/prompts.ts b/packages/create-miniapp/src/utils/prompts.ts new file mode 100644 index 00000000..1ac38bea --- /dev/null +++ b/packages/create-miniapp/src/utils/prompts.ts @@ -0,0 +1,158 @@ +import { input, select } from '@inquirer/prompts' +import { + STYLES, + BASE_COLORS, + THEMES, + ICON_LIBRARIES, + FONTS, + RADII, + MENU_ACCENTS, + type CreateOptions, + type FinalOptions, +} from '../types' + +const STYLE_DESCRIPTIONS: Record = { + vega: 'The classic shadcn/ui look. Clean, neutral, and familiar.', + nova: 'Reduced padding and margins for compact layouts.', + maia: 'Soft and rounded, with generous spacing.', + lyra: 'Boxy and sharp. Pairs well with mono fonts.', + mira: 'Compact. Made for dense interfaces.', +} + +const FONT_DESCRIPTIONS: Record = { + inter: 'Modern and clean sans-serif', + 'noto-sans': 'Great for multilingual support', + 'nunito-sans': 'Friendly and rounded', + figtree: 'Contemporary geometric sans', +} + +/** + * 交互式补全缺失的选项 + */ +export async function promptMissingOptions(options: CreateOptions): Promise { + // 如果使用 --yes 选项,跳过所有交互式提示 + if (options.yes) { + const name = options.name + if (!name) { + throw new Error('使用 --yes 选项时必须提供 name 参数') + } + return { + ...options, + name, + appId: `xin.dweb.${name}`, + } + } + + const name = options.name ?? await input({ + message: 'Miniapp 名称:', + validate: (value) => { + if (!value.trim()) return '名称不能为空' + if (!/^[a-z][a-z0-9-]*$/.test(value)) { + return '名称只能包含小写字母、数字和连字符,且必须以字母开头' + } + return true + }, + }) + + const appId = await input({ + message: 'App ID (反向域名格式,如 xin.dweb.my-app):', + default: `xin.dweb.${name}`, + validate: (value) => { + if (!/^[a-z][a-z0-9]*(\.[a-z][a-z0-9-]*)+$/.test(value)) { + return 'App ID 必须是反向域名格式,如 xin.dweb.my-app' + } + return true + }, + }) + + const useDefaults = await select({ + message: '使用默认配置?', + choices: [ + { name: '是 - 使用默认配置 (mira 风格)', value: true }, + { name: '否 - 自定义配置', value: false }, + ], + }) + + if (useDefaults) { + return { + ...options, + name, + appId, + } + } + + const style = await select({ + message: '选择 UI 风格:', + choices: STYLES.map((s) => ({ + name: `${s} - ${STYLE_DESCRIPTIONS[s]}`, + value: s, + })), + default: options.style, + }) + + const baseColor = await select({ + message: '选择基础颜色:', + choices: BASE_COLORS.map((c) => ({ name: c, value: c })), + default: options.baseColor, + }) + + const theme = await select({ + message: '选择主题色:', + choices: THEMES.map((t) => ({ name: t, value: t })), + default: options.theme, + }) + + const iconLibrary = await select({ + message: '选择图标库:', + choices: ICON_LIBRARIES.map((i) => ({ name: i, value: i })), + default: options.iconLibrary, + }) + + const font = await select({ + message: '选择字体:', + choices: FONTS.map((f) => ({ + name: `${f} - ${FONT_DESCRIPTIONS[f]}`, + value: f, + })), + default: options.font, + }) + + const radius = await select({ + message: '选择圆角大小:', + choices: RADII.map((r) => ({ name: r, value: r })), + default: options.radius, + }) + + const menuAccent = await select({ + message: '选择菜单强调风格:', + choices: MENU_ACCENTS.map((m) => ({ name: m, value: m })), + default: options.menuAccent, + }) + + const template = await select({ + message: '选择项目模板:', + choices: [ + { name: 'vite - Vite + React', value: 'vite' as const }, + { name: 'start - TanStack Start', value: 'start' as const }, + ], + default: options.template, + }) + + return { + name, + appId, + style, + baseColor, + theme, + iconLibrary, + font, + radius, + menuAccent, + template, + output: options.output, + skipShadcn: options.skipShadcn, + skipInstall: options.skipInstall, + yes: options.yes, + noSplash: options.noSplash, + } +} diff --git a/packages/create-miniapp/src/utils/shadcn.ts b/packages/create-miniapp/src/utils/shadcn.ts new file mode 100644 index 00000000..a51c35c0 --- /dev/null +++ b/packages/create-miniapp/src/utils/shadcn.ts @@ -0,0 +1,28 @@ +import type { FinalOptions } from '../types' + +/** + * 构建 shadcn preset URL + */ +export function buildShadcnPresetUrl(options: FinalOptions): string { + const params = new URLSearchParams({ + base: 'base', + style: options.style, + baseColor: options.baseColor, + theme: options.theme, + iconLibrary: options.iconLibrary, + font: options.font, + menuAccent: options.menuAccent, + menuColor: 'default', + radius: options.radius, + template: options.template, + }) + return `https://ui.shadcn.com/init?${params.toString()}` +} + +/** + * 构建完整的 shadcn create 命令 + */ +export function buildShadcnCommand(options: FinalOptions): string { + const presetUrl = buildShadcnPresetUrl(options) + return `pnpm dlx shadcn@latest create --preset "${presetUrl}" --template ${options.template} ${options.name}` +} diff --git a/packages/create-miniapp/tsconfig.json b/packages/create-miniapp/tsconfig.json new file mode 100644 index 00000000..3fc0ba88 --- /dev/null +++ b/packages/create-miniapp/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "outDir": "dist", + "declaration": true, + "declarationMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/create-miniapp/tsup.config.ts b/packages/create-miniapp/tsup.config.ts new file mode 100644 index 00000000..96dc058a --- /dev/null +++ b/packages/create-miniapp/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: ['src/cli.ts', 'src/index.ts'], + format: ['esm'], + dts: true, + clean: true, + shims: true, + banner: { + js: '#!/usr/bin/env node', + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2050b53d..cdcdd5b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -388,6 +388,18 @@ importers: '@playwright/test': specifier: ^1.49.1 version: 1.57.0 + '@storybook/addon-docs': + specifier: ^10.1.4 + version: 10.1.10(@types/react@19.2.7)(esbuild@0.27.2)(rollup@4.54.0)(storybook@10.1.10(@testing-library/dom@10.4.0)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)) + '@storybook/addon-vitest': + specifier: ^10.1.4 + version: 10.1.10(@vitest/browser-playwright@4.0.16)(@vitest/browser@4.0.16(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2))(vitest@4.0.16))(@vitest/runner@4.0.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.1.10(@testing-library/dom@10.4.0)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vitest@4.0.16) + '@storybook/react': + specifier: ^10.1.4 + version: 10.1.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.1.10(@testing-library/dom@10.4.0)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3) + '@storybook/react-vite': + specifier: ^10.1.4 + version: 10.1.10(esbuild@0.27.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.54.0)(storybook@10.1.10(@testing-library/dom@10.4.0)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)) '@tailwindcss/vite': specifier: ^4.1.0 version: 4.1.18(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)) @@ -406,6 +418,12 @@ importers: '@vitejs/plugin-react': specifier: ^5.0.0 version: 5.1.2(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)) + '@vitest/browser': + specifier: ^4.0.15 + version: 4.0.16(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2))(vitest@4.0.16) + '@vitest/browser-playwright': + specifier: ^4.0.15 + version: 4.0.16(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(playwright@1.57.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2))(vitest@4.0.16) eslint-plugin-i18next: specifier: ^6.1.3 version: 6.1.3 @@ -415,6 +433,12 @@ importers: oxlint: specifier: ^1.32.0 version: 1.35.0 + playwright: + specifier: ^1.57.0 + version: 1.57.0 + storybook: + specifier: ^10.1.4 + version: 10.1.10(@testing-library/dom@10.4.0)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) tailwindcss: specifier: ^4.1.0 version: 4.1.18 @@ -497,6 +521,18 @@ importers: '@playwright/test': specifier: ^1.49.1 version: 1.57.0 + '@storybook/addon-docs': + specifier: ^10.1.4 + version: 10.1.10(@types/react@19.2.7)(esbuild@0.27.2)(rollup@4.54.0)(storybook@10.1.10(@testing-library/dom@10.4.0)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)) + '@storybook/addon-vitest': + specifier: ^10.1.4 + version: 10.1.10(@vitest/browser-playwright@4.0.16)(@vitest/browser@4.0.16(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2))(vitest@4.0.16))(@vitest/runner@4.0.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.1.10(@testing-library/dom@10.4.0)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vitest@4.0.16) + '@storybook/react': + specifier: ^10.1.4 + version: 10.1.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.1.10(@testing-library/dom@10.4.0)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3) + '@storybook/react-vite': + specifier: ^10.1.4 + version: 10.1.10(esbuild@0.27.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.54.0)(storybook@10.1.10(@testing-library/dom@10.4.0)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)) '@tailwindcss/vite': specifier: ^4.1.0 version: 4.1.18(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)) @@ -515,6 +551,12 @@ importers: '@vitejs/plugin-react': specifier: ^5.0.0 version: 5.1.2(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)) + '@vitest/browser': + specifier: ^4.0.15 + version: 4.0.16(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2))(vitest@4.0.16) + '@vitest/browser-playwright': + specifier: ^4.0.15 + version: 4.0.16(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(playwright@1.57.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2))(vitest@4.0.16) eslint-plugin-i18next: specifier: ^6.1.3 version: 6.1.3 @@ -524,6 +566,12 @@ importers: oxlint: specifier: ^1.32.0 version: 1.35.0 + playwright: + specifier: ^1.57.0 + version: 1.57.0 + storybook: + specifier: ^10.1.4 + version: 10.1.10(@testing-library/dom@10.4.0)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) tailwindcss: specifier: ^4.1.0 version: 4.1.18 diff --git a/scripts/agent/commands/miniapp.ts b/scripts/agent/commands/miniapp.ts new file mode 100644 index 00000000..2ead39b1 --- /dev/null +++ b/scripts/agent/commands/miniapp.ts @@ -0,0 +1,131 @@ +import type { ArgumentsCamelCase, CommandModule } from 'yargs' +import { execSync } from 'child_process' +import { resolve } from 'path' + +interface MiniappCreateArgs { + name?: string + style?: string + 'base-color'?: string + theme?: string + 'icon-library'?: string + font?: string + radius?: string + 'menu-accent'?: string + template?: string + output?: string + 'skip-shadcn'?: boolean + 'skip-install'?: boolean + yes?: boolean + 'no-splash'?: boolean +} + +const createCommand: CommandModule = { + command: 'create [name]', + describe: '创建新的 Bio 生态 miniapp', + builder: { + name: { + type: 'string', + describe: 'Miniapp 名称', + }, + style: { + type: 'string', + describe: 'UI 风格 (vega|nova|maia|lyra|mira)', + default: 'mira', + }, + 'base-color': { + type: 'string', + describe: '基础颜色 (neutral|stone|zinc|gray)', + default: 'neutral', + }, + theme: { + type: 'string', + describe: '主题色', + default: 'neutral', + }, + 'icon-library': { + type: 'string', + describe: '图标库 (lucide|tabler|hugeicons|phosphor)', + default: 'lucide', + }, + font: { + type: 'string', + describe: '字体 (inter|noto-sans|nunito-sans|figtree)', + default: 'inter', + }, + radius: { + type: 'string', + describe: '圆角 (default|none|small|medium|large)', + default: 'default', + }, + 'menu-accent': { + type: 'string', + describe: '菜单强调 (subtle|bold)', + default: 'subtle', + }, + template: { + type: 'string', + describe: '模板 (vite|start)', + default: 'vite', + }, + output: { + type: 'string', + describe: '输出目录', + default: './miniapps', + }, + 'skip-shadcn': { + type: 'boolean', + describe: '跳过 shadcn 初始化', + default: false, + }, + 'skip-install': { + type: 'boolean', + describe: '跳过依赖安装', + default: false, + }, + 'yes': { + type: 'boolean', + alias: 'y', + describe: '使用默认值,跳过交互式提示', + default: false, + }, + 'no-splash': { + type: 'boolean', + describe: '禁用启动屏', + default: false, + }, + }, + handler: (argv: ArgumentsCamelCase) => { + const args: string[] = [] + + if (argv.name) args.push(argv.name) + if (argv.style) args.push('--style', argv.style) + if (argv['base-color']) args.push('--base-color', argv['base-color']) + if (argv.theme) args.push('--theme', argv.theme) + if (argv['icon-library']) args.push('--icon-library', argv['icon-library']) + if (argv.font) args.push('--font', argv.font) + if (argv.radius) args.push('--radius', argv.radius) + if (argv['menu-accent']) args.push('--menu-accent', argv['menu-accent']) + if (argv.template) args.push('--template', argv.template) + if (argv.output) args.push('--output', argv.output) + if (argv['skip-shadcn']) args.push('--skip-shadcn') + if (argv['skip-install']) args.push('--skip-install') + if (argv.yes) args.push('--yes') + if (argv['no-splash']) args.push('--no-splash') + + const cliPath = resolve(__dirname, '../../packages/create-miniapp/src/cli.ts') + + execSync(`bun ${cliPath} ${args.join(' ')}`, { + stdio: 'inherit', + cwd: process.cwd(), + }) + }, +} + +export default { + command: 'miniapp ', + describe: 'Miniapp 管理', + builder: (yargs) => { + return yargs.command(createCommand).demandCommand(1, '请指定子命令') + }, + handler: () => {}, +} satisfies CommandModule diff --git a/src/pages/ecosystem/miniapp.tsx b/src/pages/ecosystem/miniapp.tsx index c32384cf..31dfefaf 100644 --- a/src/pages/ecosystem/miniapp.tsx +++ b/src/pages/ecosystem/miniapp.tsx @@ -318,7 +318,8 @@ export function MiniappPage({ appId, onClose }: MiniappPageProps) { setLoading(false) setSplashVisible(true) - const timeout = app.splashScreen.timeout ?? 5000 + // splashScreen 可以是 true 或 { timeout?: number } + const timeout = (typeof app.splashScreen === 'object' ? app.splashScreen.timeout : undefined) ?? 5000 splashTimeoutRef.current = setTimeout(() => { console.warn('[MiniappPage] Splash screen timeout, auto-closing') closeSplashScreen() @@ -418,13 +419,13 @@ export function MiniappPage({ appId, onClose }: MiniappPageProps) { {splashVisible && app?.splashScreen && (
- {/* 启动屏图标 */} + {/* 启动屏图标 - 使用 app.icon */}
{app.name} { diff --git a/src/services/ecosystem/registry.ts b/src/services/ecosystem/registry.ts index 5404118e..76fcf5b2 100644 --- a/src/services/ecosystem/registry.ts +++ b/src/services/ecosystem/registry.ts @@ -57,12 +57,8 @@ function normalizeAppFromSource(app: MiniappManifest, source: SourceRecord, payl icon: resolve(app.icon), url: resolve(app.url), screenshots: app.screenshots?.map(resolve), - splashScreen: app.splashScreen - ? { - ...app.splashScreen, - icon: app.splashScreen.icon ? resolve(app.splashScreen.icon) : undefined, - } - : undefined, + // splashScreen 现在是 true | { timeout?: number },不再需要解析 icon + splashScreen: app.splashScreen, // 来源元数据 sourceUrl: source.url, sourceName: source.name, diff --git a/src/services/ecosystem/schema.ts b/src/services/ecosystem/schema.ts index 2caf6b7e..444a1cd8 100644 --- a/src/services/ecosystem/schema.ts +++ b/src/services/ecosystem/schema.ts @@ -10,13 +10,12 @@ const MiniappCategorySchema = z.enum([ 'other', ]) -const SplashScreenSchema = z - .object({ - backgroundColor: z.string().optional(), - icon: z.string().optional(), +const SplashScreenSchema = z.union([ + z.literal(true), + z.object({ timeout: z.number().int().positive().optional(), - }) - .passthrough() + }).passthrough(), +]) export const MiniappManifestSchema = z .object({ diff --git a/src/services/ecosystem/types.ts b/src/services/ecosystem/types.ts index 6b639e9a..1c43df52 100644 --- a/src/services/ecosystem/types.ts +++ b/src/services/ecosystem/types.ts @@ -171,12 +171,18 @@ export interface MiniappManifest { * 启动屏配置 * 如果配置了启动屏,小程序需要调用 bio.closeSplashScreen() 来关闭 * 如果未配置,则使用 iframe load 事件自动关闭加载状态 + * + * - 图标自动使用 manifest.icon + * - 背景色自动使用 manifest.themeColor + * + * @example + * // 简写形式 + * "splashScreen": true + * + * // 自定义超时 + * "splashScreen": { "timeout": 3000 } */ - splashScreen?: { - /** 启动屏背景色 (CSS 颜色值) */ - backgroundColor?: string - /** 启动屏图标 URL (默认使用 app.icon) */ - icon?: string + splashScreen?: true | { /** 最大等待时间 (ms),超时后自动关闭启动屏,默认 5000 */ timeout?: number } From 67544594bff903b33581fd1212e507800592bfc7 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Mon, 29 Dec 2025 18:37:47 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(create-miniapp):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=20turbo=20=E6=89=80=E9=9C=80=E7=9A=84=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=E5=8D=A0=E4=BD=8D=E7=AC=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHAT.md | 303 +++++++++++++++++++++++++++ packages/create-miniapp/package.json | 10 +- 2 files changed, 309 insertions(+), 4 deletions(-) diff --git a/CHAT.md b/CHAT.md index 6335178c..b2d846e1 100644 --- a/CHAT.md +++ b/CHAT.md @@ -740,3 +740,306 @@ https://snapdom.dev/ 1. 卡片参考这个[DEMO代码](https://codepen.io/jh3y/pen/EaVNNxa) 我要这里的“防伪效果”. 就是使用每个链的 logo 转化成无色后去做图形平铺, 显示炫光来实现防伪的效果 2. 重力感应要轻微的, touch也可以影响卡片, 二者可以叠加 3. 建议引入 swiper, 达到最好的效果 + +--- + +1. 优化一下 getThemeHueForWallet 相关的一些上层函数, 直接破坏性更新: 强制添加一个 themeHue 属性. 确保wallet默认要有这个属性. 不要随机生成, 利用钱包的助记词去做稳定. 但是在创建、导入的过程中,可以修改,并预览我们的钱包卡片. +2. 在设置页面, 新增一个按钮: 清空应用数据. 点击弹出警告(stackflow 的 Modal). 点击就清理localStorage/sessionStorage/indexedDB的所有数据 + +--- + +对于清理应用数据的功能, 改版:在设置页面, 新增一个入口“存储空间”, 进去后显示一个存储配额的信息:“基于navigator.storage.estimate()” +然后再提供一个清理数据的按钮 + +--- + +因为indexedDB被open后存在占用的问题,导致清理数据会一直卡着. +所以我们需要有一个专门的清理页面 clear.html, 跳转到这个页面(基于baseUri), 然后这个页面执行清理作用. +完成之后在跳回 baseUri + +--- + +首页钱包的左上角的按钮要始终显示, 否则没办法添加钱包. + +--- + +做theme选择的时候,除了几个预设的color.还有一个点要注意: + +1. 预设的color必须和当前已有的wallet的hub做规避.用一种趋避算法来改变我们随机的权重.可以理解成把360中颜色改成一个len=360的数组,这个数组有对应的命中权重。我大概这个意思,具体实现你可以自己思考 + - 注意 ,第一个颜色, 不随机, 是用这个 助记词/密钥 hash出来的颜色 +2. 仍然需要提供一个完整的色条,可以让用户随意拖拽选择颜色,精度可以到0.1 +3. 这个卡片和最终完成后的卡片样式并不完全一样 +4. 我发现恢复钱包,缺少选择链网络这个步骤。应该在完成链网络选择之后,来到这个theme变更器这里,这样就能看到地址了.卡片的样式就能保持一直了 + +--- + +这个页面可以和钱包详情页(`/wallet/`)进行深度融合. 变成一个新版的“钱包详情页”. + +1. 这个页面有一个特性, 就是有两个模式: 一种是 edit-mode, 另一种是 default-mode +2. edit-mode 就是用在 创建/导入 到最后一步, 这一步只能编辑钱包的名称, 只能修改钱包的配色. 并且配色选择器自动展开 +3. default-mode 就是卡片的右上角,点击“设置”按钮进入后的模式, 这时候会看到 卡片, 同时还能看到三个按钮: 编辑、导出助记词、删除 + +--- + +1. 路由要做出修改,因为两个页面进行了融合. +2. 相关的e2e测试要进行整改. +3. 最后检查旧版的文件要清理干净 +4. 因为是重构,不考虑向下兼容,会有大量破坏性更新,要做好全面的测试和检查 + +--- + +1. 这个页面不再需要展示 所有的链地址. 而点击 卡片的链选择器,要和首页一样,出现BottomSheet选择器. 检查这个选择器中的地址显示是不是没有AddressDisplay组件? +2. 这个页面的编辑模式就是通过点击“编辑”按钮来实现的 + +--- + +1. create.tsx 和 recover.tsx 顶部的steps进度条,最后一步渲染成彩虹, 用来代表最后一步 WalletConfig 是在做风格化. 也意味着到这步已经成功了. 激活状态意味着采用缓慢流动 +2. ThemeConfig的确定按钮, 的 --primary-hue 跟着试试变动的 themeHub 一起设置 +3. BUG:ThemeConfig的卡片看不到chain的logo水印 + +--- + +issues: + +1. 目前KeyApp只对接了bio生态的接口,还没对接其它Web3生态的接口 +2. 对于bio生态的对接,只正确对接了bfmeta的, 其它的链有点错误.具体查看 `/Users/kzf/Dev/bioforestChain/legacy-apps/libs/wallet-base/services/wallet/*/` + +tasks: + +1. 先完成bio生态的所有链的对接 +2. 再完成 binance/bitcoin/ethereum/tron 的对接 + +--- + +我想到一个方案,这个效果,如果纯粹使用canvas来实现,其实是很简单的,因为它其实就是绘制一些纹理,然后做滤镜叠力. +并且这些叠加在原生的canvas中都已经支持.把所有的成本控制在一个canvas中. + +前期我们可以用最简单的方案:canvas进行实现。 +等我验收好效果了,我们还可以做两种优化: + +1. offscreenCanvas,这个现代浏览器已经全面支持 +2. CSS PaintingAPI,这个目前只有Chromium内核支持,它比offscreenCanvas更加轻量,并且能完美适配我们要的效果.理论上也会更加省电 + +--- + +很好, 原本的省电模式那些优化项还在吗? +还有什么低成本高收益的优化方案? + +1. 能否引入动态刷新? 就是参数没变化的情况下, raf可以跳过重绘, 你有做吗? +2. BUG: 频繁的WalletConfigActivity会引发创建、销毁, 有时候就创建不出来了? 底层可能有一些冲突问题. + +有了动态刷新,我们就能引入"摩擦力"的理念了. +在移动设备上,我们监听了传感器去同步卡片的效果。 +摩擦力的概念就是:要做到如果一段时间低运动(放在桌上3s传感器仍然会接收到微弱的桌面抖动,但是非常微弱)那么就进入静止阶段. 此时过滤掉轻微的抖动. +直到比较大的抖动,会进入滑动摩擦的阶段,这时候是灵敏的. 和目前完全实时的效果一样. +当然,我们还响应了手势,所以手的触摸也会打破静止阶段. + +这里对于“微弱”的判定,我个人的建议是:取决于我们特效的算法, 你可以参考我们的算法,如果可能的光影运动超过了10pt(只是一个例子), 那么就算打破静止阶段. + +--- + +现在要开发一套基于iframe的小程序. 入口也是在底部tab中, 就叫做“生态”. 图标用 IconBrandMiniprogram + +1. 需要一套小程序开发使用的 sdk, 参考 web3 的 dapp 标准 + - 包含 client-sdk 以及 server-provider, KeyApp 本身就是 server-provider, 提供了授权地址信息、交易签名、发起转账等基本功能 + - dapp 用的是 `window.ethereum`, bio生态的, 则是用 `window.bio` 来作为 client-sdk-api, 同时仍然需要提供一套ts-sdk,来提供类型安全的开发体验 +2. mpay之前有做过类似的能力, 但是基于 dweb(power by dweb-browser) 提供的基座, 你可以参考它的代码, 未来同样要将这个 server-provider 提供给 dweb, 这样 dweb-browser 生态的应用,不需要iframe也可以和KeyApp沟通. +3. 前期需要通过内置的两个小程序来验证技术的可行性 + 1. 同样在这个仓库里面开发,直接通过编译发布到 public 文件夹 + 2. 需要在设置中提供一个“小程序可信源”,这是一种订阅技术。目前就通过一个 public/生态.json 来提供本地这两个小程序。这意味着需要一种基于 json 的订阅标准,可以将多个源混合在一起展示在“生态”页面中 +4. 小程序一:一键传送 + - 本质就是用用bio生态的账号,进行某种认证签名,然后再提供bio生态的另外一个地址, 这个小程序的后端会将前者的资产转移到后者上 + - SDK需要一种的能力,来选择当前地址. + - SDK需要一种“WalletPicker”的能力,来选择“另外一个地址”. + - SDK需要提供签名能力 +5. 小程序二:锻造 + - 本质就是用户用eth的账号向某个地址转账, 然后生成bio生态的token,到他的bio生态的地址中 + +目前首要的任务是把这个小程序的架构相关的任务启动. 写好白皮书、做好任务计划、搭建项目、搭建前端DEMO与各种测试、完善自动化脚本和流程 + +--- + +1. 生态页面的滑动方向有问题, 现在是从左往右滑动切换到“我的”, 手势反了 +2. “发现”页面的“推荐”栏, 横向滑动会导致事件冒泡, 触发“发现/我的”切换 +3. 发现页面的应用,点击后应该是打开应用详情,而不是进入应用 + +--- + +我们的 KeyApp 的vite.config.ts 中要同步启动我们的”所有内置应用“: +dev模式的工作原理是: + +1. 通过findPort技术,找到可用的随机端口 +2. 用这个端口启动我们所有的内置的 miniapps +3. 拦截 public/ecosystem.json, 篡改其中的`miniapps/{APPNAME}`的所有链接 + +同理你可以推理出build模式的工作原理. +我不知道你是否有做完整的build脚本,你可以考虑一下我的方案. + +--- + +1. 关于 ecosystemStore “可信源”, 它的管理方式应该是参考 SSR 的订阅原理, 设置页面可以配置多个可信源头, 然后我们将在本地获取缓存这些源的数据到本地. 注意,目前对于“订阅源”的支持还非常简单, 只是做了非常简单的全部列出, 其实应该只做部分列出, 然后在列出的列表中, 为每一项打分: 推荐分(官方评分)、热门分(社区评分). 二者综合分作为“精选”. 然后还可以提供一个“特定的搜索接口”, 可以用来搜索 “订阅列表” 意外的应用. +2. 直接补齐 bio_signTransaction, 准确来说应该还包含 createTransation, sign只是createTransation的其中一步. 这些是必要的,不可偷懒的 +3. appId 最新命名规范为 `xxx.xx.xx...`, 这必须和官网保持“相对一致”,比如“my-app.domain.com”,那么appId就必须是`com.domain.my-app`,之所以要这样,是避免appId的盗取和覆盖问题. + - 但是要完成这点, 必须去下载 https://github.com/daangn/stackflow/tree/main/extensions/plugin-history-sync 源代码, 我们需要在我们自己的 `packages/plugin-navigation-sync` 中维护自己的版本, 其中的改进就是: 使用`npm:urlpattern-polyfill`替代`npm:url-pattern`,因为后者已经不再维护,还有`npm:react18-use`理论上也可以废除. + - 另外之所以我要自己维护, 目的是为了未来能升级成使用。navigation-api 来作为底层支持 +4. 关于build,统一在KeyApp的vite.config.ts中直接完成工作闭环, 使用插件系统来实现. + +--- + +1. 评分综合分 可以基于日期来进行动态加权,比如第一天是 15/85, 第二天是 30/70, 用+15(一个常量)的方式进行循环,也就是 15,30,45,60,75,90,5,20,35... +2. 远程搜索协议返回的内容可以是`{version:string,data:MiniappManifest[]}`,确保返回数据的结构版本一致. 另外,固定搜索只能用 GET. 配置方法类似浏览器中配置搜索引擎的格式`http://www.bing.com/search?q=%s` +3. appId 校验策略 可以宽容,对于不合法的,做警告并跳过就好. + +--- + +我们已经有PR了, 你可以提交, 然后看看CI是否正常. +然后同时继续以下的工作(根据图片修复): + +1. 08-multi-wallet-picker, walletMiniCard没有看到 chainIcon, 这是预期之中吗? +2. 13-permission-request, 我看到“测试小程序”,图标也是临时的,这是符合预期的吗? 如果是e2e测试, 应该是使用真实的miniapps数据才对,也就是我们内置的miniapp才对 +3. authorize-chain-selector-network 是残留的图片吗? +4. authorize-wallet-selector-main 地址授权中,这里有正确是用WalletSelector吗? +5. 17-miniapp-detail-permissions 显示了页面详情,这里存在markdown内容的渲染,需要支持,但是请使用最保守的支持, 要剔除不安全的外部内容、剔除图片、视频、链接等信息 +6. forge 和 teleport 虽然可以使用我们自己的keyapp的组件库,但是作为miniapp应该尽量充分炫酷, 使用 aceternity ui 的组件优化页面, 当然, 这些本身是“功能小程序”,在满足功能的同时,把界面做炫酷,把动画做炫酷,是非常有意义的. + +工作过程中, 充分利用e2e: 编写测试来获得截图. 查看截图来来获得客观的认知. 基于客观的认知推进工作 + +--- + +1. “生态” 页面,请记住最后tab,应用重启后能默认选中最后的tab +2. 同样的, 当前钱包的 themeHub 也要默认记住, 用用重启后从 localStorage中读取themeHub立刻应用, 然后才是从加载的当前钱包中应用themeHub. + +--- + +# TODO + +--- + +forge 和 teleport 虽然可以使用我们自己的keyapp的组件库,但是作为miniapp应该尽量充分炫酷, 使用 aceternity ui 的组件优化页面, 当然, 这些本身是“功能小程序”,在满足功能的同时,把界面做炫酷,把动画做炫酷,是非常有意义的. + +这两个小程序的原始需求是: + +```md +4. 小程序一:一键传送 + - 本质就是用用bio生态的账号,进行某种认证签名,然后再提供bio生态的另外一个地址, 这个小程序的后端会将前者的资产转移到后者上 + - SDK需要一种的能力,来选择当前地址. + - SDK需要一种“WalletPicker”的能力,来选择“另外一个地址”. + - SDK需要提供签名能力 +5. 小程序二:锻造 + - 本质就是用户用eth的账号向某个地址转账, 然后生成bio生态的token,到他的bio生态的地址中 +``` + +目前的问题: + +1. 现在forge页面是报错的,你做类型检查看一下.一键传送的效果也非常糟糕. +2. 样式的留白、布局排版、字体大小, 都有非常多的改进空间. + +注意事项: + +1. 如果要看效果,直接运行e2e测试来获得截图, 截图不够就补充e2e测试. 然后基于截图去分析 +2. 默认情况下,你只能修改miniapps文件夹下的文件. 对于其它文件的权限是 readonly. 如果有需要, 必须和我请示 + +--- + +我们需要彻底重构“发现/我的”: + +1. “生态/发现” 页面,现在是类似于“IOS”的“负一屏”, 顶部这个带search的bigHeader只属于“发现”页面, 因为是“负一屏”,所以仍然可以左右滑动来切换 +2. “生态/我的” 页面请把它用最严苛的标准去实现IOS桌面的模拟, 最好是IOS-26, 包括长按菜单(右键菜单)的效果. 目前的效果非常粗糙,样式也很一般. +3. “生态/我的” 顶部, 有一个“搜索框”,点击就是直接滑动到“负一屏”,也就是发现页面, 并自动聚焦顶部的搜索输入框 +4. 底部的Tab按钮,现在会跟随“发现/我的”两个页面的切换而切换,如果是“我的”,那么图标换成“IconBrandMiniprogram”,文字还是“生态”不变 + +--- + +我需要你提供一份标准的 miniapp-start-template 项目,把它放在 packages 文件夹下, 并提供cli来做到快速创建一个miniapp的模板, 提供丰富的 cli-options 来实现定制化, 特别是要绑定 shadcnui-create: 例如: `pnpm dlx shadcn@latest create --preset "https://ui.shadcn.com/init?base=base&style=mira&baseColor=neutral&theme=neutral&iconLibrary=hugeicons&font=inter&menuAccent=subtle&menuColor=default&radius=default&template=vite" --template vite` + +可变参数: + +- style: + - Vega: The classic shadcn/ui look. Clean, neutral, and familiar. + - Nova: Reduced padding and margins. for compact layouts. + - Maia: Soft and rounded, with generous spacing. + - Lyra: Boxy and sharp. Pairs well with mono fonts. + - Mira: Compact. Made for dense interfaces. +- baseColor: + - Neutral + - Stone + - Zinc + - Gray +- theme: + - Neutral + - Amber + - Blue + - Cyan + - Emerald + - Fuchsia + - Green + - Indigo + - Lime + - Orange + - Pink +- Icon Library: + - Lucide + - Tabler Icons + - Hugelcons + - Phosphor Icons +- font: + - Inter: Designers love packing quirky glyphs into test phrases. + - Noto Sans: Designers love packing quirky glyphs into test phrases. + - Nunito Sans: Designers love packing quirky glyphs into test phrases. + - Figtree: Designers love packing quirky glyphs into test phrases. +- radius: + - Default + - None + - Small + - Medium + - Large +- Menu Accent + - Subtle + - Bold +- template + - start: TanStack Start + - vite + +完成后, 更新白皮书 + +--- + +基于我们的gen-icon工具,为create-miniapp提供 logo处理功能. 并在项目中提供logo更新脚本. + +默认配置 启动屏幕,启动屏幕不是应用内的,是我们keyapp提供的. 检查keyapp是否提供这个功能,白皮书是否有介绍 +另外,create-miniapp是否默认提供了 zh/en 两种国际化语言? +是否有默认提供storybook+vite可以进行真实DOM测试的实例脚本? +是否有默认提供e2e测试与截图生成、管理、检查标准? +是否有默认提供vitest测试的实例? +是否有默认提供oxlint和配套对应的插件 + +--- + +新开一个worktree进行工作: +这是之前已经完成的一个pr: +```md +现在要开发一套基于iframe的小程序. 入口也是在底部tab中, 就叫做“生态”. + +1. 需要一套小程序开发使用的 sdk, 参考 web3 的 dapp 标准 + - 包含 client-sdk 以及 server-provider, KeyApp 本身就是 server-provider, 提供了授权地址信息、交易签名、发起转账等基本功能 + - dapp 用的是 `window.ethereum`, bio生态的, 则是用 `window.bio` 来作为 client-sdk-api, 同时仍然需要提供一套ts-sdk,来提供类型安全的开发体验 +2. mpay之前有做过类似的能力, 但是基于 dweb(power by dweb-browser) 提供的基座, 你可以参考它的代码, 未来同样要将这个 server-provider 提供给 dweb, 这样 dweb-browser 生态的应用,不需要iframe也可以和KeyApp沟通. +3. 前期需要通过内置的两个小程序来验证技术的可行性 + 1. 同样在这个仓库里面开发,直接通过编译发布到 public 文件夹 + 2. 需要在设置中提供一个“小程序可信源”,这是一种订阅技术。目前就通过一个 public/生态.json 来提供本地这两个小程序。这意味着需要一种基于 json 的订阅标准,可以将多个源混合在一起展示在“生态”页面中 +4. 小程序一:一键传送 + - 本质就是用用bio生态的账号,进行某种认证签名,然后再提供bio生态的另外一个地址, 这个小程序的后端会将前者的资产转移到后者上 + - SDK需要一种的能力,来选择当前地址. + - SDK需要一种“WalletPicker”的能力,来选择“另外一个地址”. + - SDK需要提供签名能力 +5. 小程序二:锻造 + - 本质就是用户用eth的账号向某个地址转账, 然后生成bio生态的token,到他的bio生态的地址中 + +目前首要的任务是把这个小程序的架构相关的任务启动. 写好白皮书、做好任务计划、搭建项目、搭建前端DEMO与各种测试、完善自动化脚本和流程 +``` + +以上pr已经完成,接下来,我们需要开始正式对接这两个小程序的后端. +具体的信息需要阅读文件: (会话 2025年12月29日.pdf)[/Users/kzf/Dev/bioforestChain/KeyApp/.chat/会话 2025年12月29日.pdf] + +我需要你调查会话中的资料,然后生成两篇独立的research文档,也是放在.chat目录下,客观地记录调查结果. +research资料的目的是确保能分别完成这两个小程序的后端功能对接. diff --git a/packages/create-miniapp/package.json b/packages/create-miniapp/package.json index 70e3967d..cd513fe4 100644 --- a/packages/create-miniapp/package.json +++ b/packages/create-miniapp/package.json @@ -19,8 +19,11 @@ "typecheck:run": "tsc --noEmit", "lint": "oxlint .", "lint:run": "oxlint .", - "test": "vitest", - "test:run": "vitest run" + "test": "echo 'CLI has no tests'", + "test:run": "echo 'CLI has no tests'", + "test:storybook": "echo 'CLI has no storybook'", + "i18n:run": "echo 'CLI has no i18n'", + "theme:run": "echo 'CLI has no theme'" }, "dependencies": { "@inquirer/prompts": "^7.5.0", @@ -33,8 +36,7 @@ "@types/yargs": "^17.0.33", "oxlint": "^1.32.0", "tsup": "^8.5.0", - "typescript": "^5.9.3", - "vitest": "^4.0.0" + "typescript": "^5.9.3" }, "publishConfig": { "access": "public" From 84492df675bb5ea8cb8eef2f82028a8edd04c1dc Mon Sep 17 00:00:00 2001 From: Gaubee Date: Mon, 29 Dec 2025 18:39:03 +0800 Subject: [PATCH 3/3] chore: update pnpm-lock.yaml --- pnpm-lock.yaml | 123 ------------------------------------------------- 1 file changed, 123 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cdcdd5b9..636b8799 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -639,9 +639,6 @@ importers: typescript: specifier: ^5.9.3 version: 5.9.3 - vitest: - specifier: ^4.0.0 - version: 4.0.16(@types/node@22.19.3)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@22.19.3)(typescript@5.9.3)) packages/e2e-tools: devDependencies: @@ -10718,20 +10715,6 @@ snapshots: vite: 5.4.21(@types/node@24.10.4)(lightningcss@1.30.2) vue: 3.5.26(typescript@5.9.3) - '@vitest/browser-playwright@4.0.16(msw@2.12.4(@types/node@22.19.3)(typescript@5.9.3))(playwright@1.57.0)(vite@7.3.0(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2))(vitest@4.0.16)': - dependencies: - '@vitest/browser': 4.0.16(msw@2.12.4(@types/node@22.19.3)(typescript@5.9.3))(vite@7.3.0(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2))(vitest@4.0.16) - '@vitest/mocker': 4.0.16(msw@2.12.4(@types/node@22.19.3)(typescript@5.9.3))(vite@7.3.0(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)) - playwright: 1.57.0 - tinyrainbow: 3.0.3 - vitest: 4.0.16(@types/node@22.19.3)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@22.19.3)(typescript@5.9.3)) - transitivePeerDependencies: - - bufferutil - - msw - - utf-8-validate - - vite - optional: true - '@vitest/browser-playwright@4.0.16(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(playwright@1.57.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2))(vitest@4.0.16)': dependencies: '@vitest/browser': 4.0.16(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2))(vitest@4.0.16) @@ -10745,24 +10728,6 @@ snapshots: - utf-8-validate - vite - '@vitest/browser@4.0.16(msw@2.12.4(@types/node@22.19.3)(typescript@5.9.3))(vite@7.3.0(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2))(vitest@4.0.16)': - dependencies: - '@vitest/mocker': 4.0.16(msw@2.12.4(@types/node@22.19.3)(typescript@5.9.3))(vite@7.3.0(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)) - '@vitest/utils': 4.0.16 - magic-string: 0.30.21 - pixelmatch: 7.1.0 - pngjs: 7.0.0 - sirv: 3.0.2 - tinyrainbow: 3.0.3 - vitest: 4.0.16(@types/node@22.19.3)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@22.19.3)(typescript@5.9.3)) - ws: 8.18.3 - transitivePeerDependencies: - - bufferutil - - msw - - utf-8-validate - - vite - optional: true - '@vitest/browser@4.0.16(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2))(vitest@4.0.16)': dependencies: '@vitest/mocker': 4.0.16(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)) @@ -10849,15 +10814,6 @@ snapshots: msw: 2.12.4(@types/node@24.10.4)(typescript@5.9.3) vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2) - '@vitest/mocker@4.0.16(msw@2.12.4(@types/node@22.19.3)(typescript@5.9.3))(vite@7.3.0(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2))': - dependencies: - '@vitest/spy': 4.0.16 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - msw: 2.12.4(@types/node@22.19.3)(typescript@5.9.3) - vite: 7.3.0(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2) - '@vitest/mocker@4.0.16(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2))': dependencies: '@vitest/spy': 4.0.16 @@ -13035,32 +12991,6 @@ snapshots: ms@2.1.3: {} - msw@2.12.4(@types/node@22.19.3)(typescript@5.9.3): - dependencies: - '@inquirer/confirm': 5.1.21(@types/node@22.19.3) - '@mswjs/interceptors': 0.40.0 - '@open-draft/deferred-promise': 2.2.0 - '@types/statuses': 2.0.6 - cookie: 1.1.1 - graphql: 16.12.0 - headers-polyfill: 4.0.3 - is-node-process: 1.2.0 - outvariant: 1.4.3 - path-to-regexp: 6.3.0 - picocolors: 1.1.1 - rettime: 0.7.0 - statuses: 2.0.2 - strict-event-emitter: 0.5.1 - tough-cookie: 6.0.0 - type-fest: 5.3.1 - until-async: 3.0.2 - yargs: 17.7.2 - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - '@types/node' - optional: true - msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3): dependencies: '@inquirer/confirm': 5.1.21(@types/node@24.10.4) @@ -14639,20 +14569,6 @@ snapshots: fsevents: 2.3.3 lightningcss: 1.30.2 - vite@7.3.0(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2): - dependencies: - esbuild: 0.27.2 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.54.0 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 22.19.3 - fsevents: 2.3.3 - jiti: 2.6.1 - lightningcss: 1.30.2 - vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2): dependencies: esbuild: 0.27.2 @@ -14716,45 +14632,6 @@ snapshots: - typescript - universal-cookie - vitest@4.0.16(@types/node@22.19.3)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@22.19.3)(typescript@5.9.3)): - dependencies: - '@vitest/expect': 4.0.16 - '@vitest/mocker': 4.0.16(msw@2.12.4(@types/node@22.19.3)(typescript@5.9.3))(vite@7.3.0(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)) - '@vitest/pretty-format': 4.0.16 - '@vitest/runner': 4.0.16 - '@vitest/snapshot': 4.0.16 - '@vitest/spy': 4.0.16 - '@vitest/utils': 4.0.16 - es-module-lexer: 1.7.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.1 - pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 3.10.0 - tinybench: 2.9.0 - tinyexec: 1.0.2 - tinyglobby: 0.2.15 - tinyrainbow: 3.0.3 - vite: 7.3.0(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 22.19.3 - '@vitest/browser-playwright': 4.0.16(msw@2.12.4(@types/node@22.19.3)(typescript@5.9.3))(playwright@1.57.0)(vite@7.3.0(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2))(vitest@4.0.16) - jsdom: 27.3.0 - transitivePeerDependencies: - - jiti - - less - - lightningcss - - msw - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - yaml - vitest@4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3)): dependencies: '@vitest/expect': 4.0.16