#!/usr/bin/env node import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { assertValidGeneratedTsx } from './utils/generatedTsxValidator.mjs'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const CONFIG = { projectRoot: path.resolve(__dirname, '..'), pagesDir: path.resolve(__dirname, '../src/prototypes'), pendingLogicFileName: '.stitch-pending.json', pendingLogicMarker: 'STITCH_PENDING_LOGIC', }; function log(message, type = 'info') { const prefix = { info: '✓', warn: '⚠', error: '✗', progress: '⏳' }[type] || 'ℹ'; console.log(`${prefix} ${message}`); } function ensureDir(dirPath) { if (!fs.existsSync(dirPath)) { fs.mkdirSync(dirPath, { recursive: true }); } } function sanitizePageName(name) { return String(name) .replace(/[^a-z0-9-_]/gi, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, '') .toLowerCase(); } function escapeForTemplateLiteral(value) { return String(value) .replace(/\\/g, '\\\\') .replace(/`/g, '\\`') .replace(/\$\{/g, '\\${'); } function extractTailwindConfig(html) { const startMatch = html.match(/tailwind\.config\s*=\s*\{/); if (!startMatch) return null; const startIndex = startMatch.index + startMatch[0].length - 1; let braceCount = 0; let endIndex = startIndex; for (let i = startIndex; i < html.length; i += 1) { if (html[i] === '{') braceCount += 1; else if (html[i] === '}') { braceCount -= 1; if (braceCount === 0) { endIndex = i + 1; break; } } } if (braceCount !== 0) return null; const configStr = html.substring(startIndex, endIndex); try { let cleanedStr = configStr .split('\n') .map((line) => { const commentIndex = line.indexOf('//'); if (commentIndex >= 0) { return line.substring(0, commentIndex).trimEnd(); } return line; }) .join('\n'); cleanedStr = cleanedStr.replace(/,(\s*[}\]])/g, '$1'); return (function () { return eval(`(${cleanedStr})`); }()); } catch (error) { console.error('[Stitch Converter] 解析 Tailwind 配置失败:', error.message); return null; } } function generateTailwindCSS(config) { if (!config || !config.theme) return ''; const theme = config.theme; const extend = theme.extend || {}; let css = ''; const processColors = (colors, prefix = '') => { let colorCSS = ''; for (const [name, value] of Object.entries(colors)) { if (typeof value === 'object' && !Array.isArray(value)) { colorCSS += processColors(value, `${prefix}${name}-`); } else { const cssName = `${prefix}${name}`.replace(/([A-Z])/g, '-$1').toLowerCase(); colorCSS += ` --color-${cssName}: ${value};\n`; } } return colorCSS; }; if (extend.colors) { css += '\n@theme {\n'; css += processColors(extend.colors); css += '}\n'; } if (extend.spacing) { css += '\n@theme {\n'; for (const [name, value] of Object.entries(extend.spacing)) { css += ` --spacing-${name}: ${value};\n`; } css += '}\n'; } if (extend.fontSize) { css += '\n@theme {\n'; for (const [name, value] of Object.entries(extend.fontSize)) { const size = Array.isArray(value) ? value[0] : value; css += ` --font-size-${name}: ${size};\n`; if (Array.isArray(value) && value[1]) { const lineHeight = typeof value[1] === 'object' ? value[1].lineHeight : value[1]; if (lineHeight) { css += ` --line-height-${name}: ${lineHeight};\n`; } } } css += '}\n'; } if (extend.fontFamily) { css += '\n@theme {\n'; for (const [name, value] of Object.entries(extend.fontFamily)) { const family = Array.isArray(value) ? value.join(', ') : value; css += ` --font-family-${name}: ${family};\n`; } css += '}\n'; } if (extend.borderRadius) { css += '\n@theme {\n'; for (const [name, value] of Object.entries(extend.borderRadius)) { const cssName = name === 'DEFAULT' ? 'default' : name; css += ` --radius-${cssName}: ${value};\n`; } css += '}\n'; } if (extend.boxShadow) { css += '\n@theme {\n'; for (const [name, value] of Object.entries(extend.boxShadow)) { const cssName = name === 'DEFAULT' ? 'default' : name; css += ` --shadow-${cssName}: ${value};\n`; } css += '}\n'; } if (extend.screens) { css += '\n/* 自定义断点 */\n'; for (const [name, value] of Object.entries(extend.screens)) { css += `/* @screen ${name}: ${value} */\n`; } } if (extend.zIndex) { css += '\n@theme {\n'; for (const [name, value] of Object.entries(extend.zIndex)) { css += ` --z-index-${name}: ${value};\n`; } css += '}\n'; } if (extend.opacity) { css += '\n@theme {\n'; for (const [name, value] of Object.entries(extend.opacity)) { css += ` --opacity-${name}: ${value};\n`; } css += '}\n'; } if (extend.keyframes) { css += '\n/* 动画关键帧 */\n'; for (const [name, frames] of Object.entries(extend.keyframes)) { css += `@keyframes ${name} {\n`; for (const [percent, styles] of Object.entries(frames)) { css += ` ${percent} {\n`; for (const [prop, val] of Object.entries(styles)) { const cssProp = prop.replace(/([A-Z])/g, '-$1').toLowerCase(); css += ` ${cssProp}: ${val};\n`; } css += ' }\n'; } css += '}\n\n'; } } if (extend.animation) { css += '\n/* 动画工具类 */\n'; css += '@layer utilities {\n'; for (const [name, value] of Object.entries(extend.animation)) { css += ` .animate-${name} {\n`; css += ` animation: ${value};\n`; css += ' }\n'; } css += '}\n\n'; } if (extend.transitionDuration) { css += '\n@theme {\n'; for (const [name, value] of Object.entries(extend.transitionDuration)) { css += ` --duration-${name}: ${value};\n`; } css += '}\n'; } if (extend.transitionTimingFunction) { css += '\n@theme {\n'; for (const [name, value] of Object.entries(extend.transitionTimingFunction)) { css += ` --ease-${name}: ${value};\n`; } css += '}\n'; } if (extend.backgroundImage) { css += '\n@theme {\n'; for (const [name, value] of Object.entries(extend.backgroundImage)) { css += ` --gradient-${name}: ${value};\n`; } css += '}\n'; } if (config.darkMode) { css += `\n/* Dark Mode: ${config.darkMode} */\n`; } return css; } function isTailwindConfigScript(attrs, content) { return /tailwind\.config/.test(content) || /id=["']tailwind-config["']/i.test(attrs); } function summarizeScript(script) { const source = script.content || script.src || ''; const normalized = source.toLowerCase(); if (/setinterval|settimeout/.test(normalized) && /time|date|clock/.test(normalized)) { return '更新时间或日期显示'; } if (/addEventListener\s*\(\s*['"]click['"]/.test(source) || /\.onclick\s*=/.test(source)) { return '点击交互处理'; } if (/addEventListener\s*\(\s*['"]scroll['"]/.test(source)) { return '滚动相关行为'; } if (/querySelector|getElementById|textContent|innerHTML/.test(source)) { return '页面内容或状态更新'; } if (script.src) { return `外部脚本:${script.src}`; } return '页面动态逻辑'; } function suggestMigration(script) { const source = script.content || script.src || ''; const normalized = source.toLowerCase(); if (/setinterval|settimeout/.test(normalized)) { return '迁移到 React.useEffect,并在需要时配合 useState 管理显示内容。'; } if (/addEventListener|onclick|onsubmit|onchange/.test(normalized)) { return '迁移到组件事件处理函数或 React.useEffect 的事件绑定。'; } if (/querySelector|getElementById|textContent|innerHTML/.test(source)) { return '改为通过组件状态、props 或 ref 更新界面,避免直接操作 DOM。'; } if (script.src) { return '确认该脚本的用途后,再决定是否以 npm 依赖、按需加载或替代实现接入。'; } return '建议拆解为组件状态、效果函数或独立 helper 后接入。'; } function createPendingScript(script, index) { return { id: script.id || `${script.location}-script-${index + 1}`, location: script.location, src: script.src || null, summary: summarizeScript(script), suggestedMigration: suggestMigration(script), code: script.content || '', }; } function extractHeadContent(html) { const headMatch = html.match(/]*>([\s\S]*?)<\/head>/i); if (!headMatch) return { links: [], styles: [], pendingScripts: [] }; const headContent = headMatch[1]; const links = []; const styles = []; const pendingScripts = []; const scriptRegex = /]*)>([\s\S]*?)<\/script>/gi; let match; while ((match = scriptRegex.exec(headContent)) !== null) { const attrs = match[1]; const content = match[2].trim(); const srcMatch = attrs.match(/src=["']([^"']+)["']/i); if (srcMatch) { const src = srcMatch[1].replace(/&/g, '&'); if (src.includes('tailwindcss.com')) continue; pendingScripts.push(createPendingScript({ location: 'head', id: attrs.match(/id=["']([^"']+)["']/i)?.[1] || null, src, content: '', }, pendingScripts.length)); continue; } if (!content || isTailwindConfigScript(attrs, content)) { continue; } pendingScripts.push(createPendingScript({ location: 'head', id: attrs.match(/id=["']([^"']+)["']/i)?.[1] || null, src: null, content, }, pendingScripts.length)); } const linkRegex = /]*>/gi; while ((match = linkRegex.exec(headContent)) !== null) { const tag = match[0]; const href = tag.match(/href=["']([^"']+)["']/i)?.[1]; if (href) { links.push({ href: href.replace(/&/g, '&'), rel: tag.match(/rel=["']([^"']+)["']/i)?.[1] || 'stylesheet', crossorigin: tag.includes('crossorigin'), }); } } const styleRegex = /]*>([\s\S]*?)<\/style>/gi; while ((match = styleRegex.exec(headContent)) !== null) { const content = match[1].trim(); if (content) { styles.push(content); } } return { links, styles, pendingScripts }; } function convertStyleToJSX(styleStr) { if (!styleStr.trim()) return 'style={{}}'; const styles = []; let currentProp = ''; let inUrl = false; for (let i = 0; i < styleStr.length; i += 1) { const char = styleStr[i]; if (char === '(' && styleStr.substring(i - 3, i) === 'url') inUrl = true; else if (char === ')' && inUrl) inUrl = false; if (char === ';' && !inUrl) { if (currentProp.trim()) styles.push(currentProp.trim()); currentProp = ''; } else { currentProp += char; } } if (currentProp.trim()) styles.push(currentProp.trim()); const jsxStyles = styles .filter((item) => item.includes(':')) .map((item) => { const colonIndex = item.indexOf(':'); const key = item.substring(0, colonIndex).trim(); const value = item.substring(colonIndex + 1).trim(); if (!key || !value) return ''; const camelKey = key.replace(/-([a-z])/g, (group) => group[1].toUpperCase()); let jsxValue; if (value.startsWith('url(') || value.includes('var(')) { jsxValue = `'${value.replace(/'/g, "\\'")}'`; } else if (/^-?\d+(\.\d+)?$/.test(value)) { jsxValue = value; } else { jsxValue = `'${value.replace(/'/g, "\\'")}'`; } return `${camelKey}: ${jsxValue}`; }) .filter(Boolean) .join(', '); return `style={{ ${jsxStyles} }}`; } function convertCommonAttributesToJSX(content) { const replacements = [ ['class', 'className'], ['for', 'htmlFor'], ['tabindex', 'tabIndex'], ['readonly', 'readOnly'], ['maxlength', 'maxLength'], ['minlength', 'minLength'], ['colspan', 'colSpan'], ['rowspan', 'rowSpan'], ['viewbox', 'viewBox'], ['preserveaspectratio', 'preserveAspectRatio'], ['clip-path', 'clipPath'], ['fill-rule', 'fillRule'], ['clip-rule', 'clipRule'], ['stroke-width', 'strokeWidth'], ['stroke-dasharray', 'strokeDasharray'], ['stroke-dashoffset', 'strokeDashoffset'], ['stroke-linecap', 'strokeLinecap'], ['stroke-linejoin', 'strokeLinejoin'], ['stroke-miterlimit', 'strokeMiterlimit'], ['stroke-opacity', 'strokeOpacity'], ['fill-opacity', 'fillOpacity'], ['stop-color', 'stopColor'], ['stop-opacity', 'stopOpacity'], ['xlink:href', 'xlinkHref'], ['xmlns:xlink', 'xmlnsXlink'], ]; let nextContent = content; replacements.forEach(([from, to]) => { nextContent = nextContent.replace(new RegExp(`(\\s)${from}=`, 'gi'), `$1${to}=`); }); return nextContent; } function convertHtmlToJsx(content) { return convertCommonAttributesToJSX(content) .replace(//g, '{/* $1 */}') .replace(/(]*>)([\s\S]*?)(<\/pre>)/gi, (match, openTag, preContent) => { const escapedContent = preContent .replace(/\\/g, '\\\\') .replace(/`/g, '\\`') .replace(/\$/g, '\\$') .replace(/\{/g, '\\{'); return `${openTag.slice(0, -1)} dangerouslySetInnerHTML={{ __html: \`${escapedContent}\` }} />`; }) .replace(/style='([^']*)'/gi, (match, styleStr) => convertStyleToJSX(styleStr)) .replace(/style="([^"]*)"/gi, (match, styleStr) => convertStyleToJSX(styleStr)); } function extractBodyContent(html) { const bodyMatch = html.match(/(]*>)([\s\S]*?)<\/body>/i); if (!bodyMatch) { return { wrapperOpenTag: '
', jsxContent: '', pendingScripts: [], }; } const openTag = convertHtmlToJsx(bodyMatch[1]) .replace(/^]*)>([\s\S]*?)<\/script>/gi, (match, attrs, content) => { const trimmedContent = String(content || '').trim(); pendingScripts.push(createPendingScript({ location: 'body', id: attrs.match(/id=["']([^"']+)["']/i)?.[1] || attrs.match(/data-purpose=["']([^"']+)["']/i)?.[1] || null, src: attrs.match(/src=["']([^"']+)["']/i)?.[1]?.replace(/&/g, '&') || null, content: trimmedContent, }, pendingScripts.length)); return ''; }); return { wrapperOpenTag: openTag, jsxContent: convertHtmlToJsx(contentWithoutScripts.trim()), pendingScripts, }; } function buildPendingSummaryLines(pendingScripts) { return pendingScripts.map((item) => ` // - [${item.location}] ${item.id}: ${item.summary}`); } function buildPendingEffectBlock(pendingScripts) { if (pendingScripts.length === 0) return ''; const summaryLines = buildPendingSummaryLines(pendingScripts).join('\n'); return ` React.useEffect(function () { // ${CONFIG.pendingLogicMarker}_START // 后续可在这里继续完善页面的动态细节。 // 参考同目录下的 ${CONFIG.pendingLogicFileName}。 ${summaryLines} // ${CONFIG.pendingLogicMarker}_END }, []); `; } function buildFriendlyNotice() { return `
基础效果已就绪
当前可先查看页面结构与样式效果,部分动态细节可在后续继续完善。
`; } function generateComponent(pageName, wrapperOpenTag, bodyJsxContent, headContent, pendingScripts) { const componentName = pageName .split(/[-_\s]+/) .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(''); const injectionBlocks = []; if (headContent.links.length > 0) { injectionBlocks.push(` React.useEffect(function () { const injected = []; ${JSON.stringify(headContent.links)}.forEach(function (linkInfo) { const existing = document.querySelector(\`link[href="\${linkInfo.href}"]\`); if (!existing) { const link = document.createElement('link'); link.rel = linkInfo.rel; link.href = linkInfo.href; if (linkInfo.crossorigin) link.crossOrigin = 'anonymous'; document.head.appendChild(link); injected.push(link); } }); return function () { injected.forEach(function (el) { if (el.parentNode) el.parentNode.removeChild(el); }); }; }, []); `); } if (pendingScripts.length > 0) { injectionBlocks.push(buildPendingEffectBlock(pendingScripts)); } const friendlyNotice = pendingScripts.length > 0 ? buildFriendlyNotice() : ''; const wrapperJsx = `${wrapperOpenTag} ${friendlyNotice} ${bodyJsxContent}
`; return `/** * @name ${pageName} * * 参考资料: * - /rules/development-guide.md * - /skills/default-resource-recommendations/SKILL.md */ import './style.css'; import React, { forwardRef, useImperativeHandle } from 'react'; import type { AxureProps, AxureHandle } from '../../common/axure-types'; const Component = forwardRef(function ${componentName}( _props: AxureProps, ref: React.ForwardedRef, ) { useImperativeHandle(ref, function () { return { getVar: function () { return undefined; }, fireAction: function () {}, eventList: [], actionList: [], varList: [], configList: [], dataList: [] }; }, []); ${injectionBlocks.join('')} return ( ${wrapperJsx.split('\n').map((line) => ` ${line}`).join('\n')} ); }); export default Component; `; } function generateStyleCSS(headContent, tailwindConfig) { let css = '@import "tailwindcss";\n'; if (tailwindConfig) { css += '\n/* 从 Stitch Tailwind 配置提取的样式 */'; css += generateTailwindCSS(tailwindConfig); } if (headContent.styles && headContent.styles.length > 0) { css += '\n/* 原始自定义样式 */\n'; css += headContent.styles.join('\n\n'); } return css; } function buildPendingLogicPayload(pageName, outputDir, pendingScripts) { const relativeDir = path.relative(CONFIG.projectRoot, outputDir).split(path.sep).join('/'); const relativeIndexFile = `${relativeDir}/index.tsx`; const relativePendingFile = `${relativeDir}/${CONFIG.pendingLogicFileName}`; return { pageName, generatedAt: new Date().toISOString(), marker: CONFIG.pendingLogicMarker, indexFile: relativeIndexFile, pendingFile: relativePendingFile, items: pendingScripts, }; } function buildPrompt(pageName, outputDir, pendingPayload) { const relativeDir = path.relative(CONFIG.projectRoot, outputDir).split(path.sep).join('/'); const relativeHtmlFile = `${relativeDir}/code.html`; const summaries = pendingPayload.items .map((item) => `- [${item.location}] ${item.id}: ${item.summary}。建议:${item.suggestedMigration}`) .join('\n'); return `请继续完善 Stitch 导入后的页面“${pageName}”。 目标目录: - \`${relativeDir}\` 请优先阅读以下文件: - \`${relativeDir}/index.tsx\` - \`${relativeDir}/${CONFIG.pendingLogicFileName}\` - \`${relativeHtmlFile}\` 当前页面已经可以静态预览,但仍有待完善的动态细节: ${summaries} 请按以下方式处理: 1. 在 \`index.tsx\` 中的 \`${CONFIG.pendingLogicMarker}_START\` / \`${CONFIG.pendingLogicMarker}_END\` 区域附近补入需要的 React 逻辑。 2. 把原始脚本改写为 React 状态、effects、事件处理函数或必要的 helper。 3. 尽量不要直接操作 DOM;优先使用 state、props、ref。 4. 完成逻辑迁移后,补充生成同目录下的 \`spec.md\`,说明页面结构、动态行为和实现要点。 5. 最后运行项目内的可用验收方式,确认页面可正常预览。 `; } function convertPage(pagePath, outputDir, pageName) { log(`正在转换页面: ${pageName}`, 'progress'); const htmlPath = path.join(pagePath, 'code.html'); const html = fs.readFileSync(htmlPath, 'utf8'); const tailwindConfig = extractTailwindConfig(html); const headContent = extractHeadContent(html); const bodyContent = extractBodyContent(html); const pendingScripts = [...headContent.pendingScripts, ...bodyContent.pendingScripts]; ensureDir(outputDir); const componentCode = generateComponent( pageName, bodyContent.wrapperOpenTag, bodyContent.jsxContent, headContent, pendingScripts, ); const styleCSS = generateStyleCSS(headContent, tailwindConfig); const outputTsxPath = path.join(outputDir, 'index.tsx'); assertValidGeneratedTsx(componentCode, outputTsxPath); fs.writeFileSync(outputTsxPath, componentCode, 'utf8'); fs.writeFileSync(path.join(outputDir, 'style.css'), styleCSS, 'utf8'); const pendingPayload = buildPendingLogicPayload(pageName, outputDir, pendingScripts); const pendingFilePath = path.join(outputDir, CONFIG.pendingLogicFileName); if (pendingScripts.length > 0) { fs.writeFileSync(pendingFilePath, `${JSON.stringify(pendingPayload, null, 2)}\n`, 'utf8'); log(` ✓ 已整理 ${pendingScripts.length} 段待完善逻辑`, 'info'); } else if (fs.existsSync(pendingFilePath)) { fs.unlinkSync(pendingFilePath); } log(`页面转换完成: ${pageName}`, 'info'); return { pageName, outputDir, pendingScripts, pendingFile: pendingScripts.length > 0 ? pendingFilePath : null, prompt: pendingScripts.length > 0 ? buildPrompt(pageName, outputDir, pendingPayload) : null, }; } function detectProjectType(stitchDir) { const items = fs.readdirSync(stitchDir); if (items.includes('code.html')) { return { type: 'single', prototypes: [{ name: 'index', path: stitchDir }] }; } const prototypes = []; for (const item of items) { const itemPath = path.join(stitchDir, item); const stat = fs.statSync(itemPath); if (stat.isDirectory() && fs.existsSync(path.join(itemPath, 'code.html'))) { prototypes.push({ name: item, path: itemPath }); } } if (prototypes.length > 0) return { type: 'multi', prototypes }; throw new Error('未找到有效的 Stitch 项目结构'); } function printResult(result) { console.log(JSON.stringify(result)); } async function main() { const args = process.argv.slice(2); if (args.length === 0 || args[0] === '--help') { console.log(` 使用方法: node scripts/stitch-converter.mjs [output-name] `); process.exit(0); } const stitchDirArg = args[0]; const outputName = sanitizePageName(args[1] || path.basename(stitchDirArg)); const stitchDir = path.resolve(CONFIG.projectRoot, stitchDirArg); const outputDir = path.join(CONFIG.pagesDir, outputName); if (!fs.existsSync(stitchDir)) { log(`错误: 找不到目录 ${stitchDir}`, 'error'); process.exit(1); } try { log('开始转换 Stitch 项目...', 'info'); const { type, prototypes } = detectProjectType(stitchDir); log(`项目类型: ${type === 'single' ? '单页面' : '多页面'}`, 'info'); if (type === 'single') { const pageResult = convertPage(prototypes[0].path, outputDir, outputName); const result = { success: true, type, mode: pageResult.pendingScripts.length > 0 ? 'ai_handoff' : 'safe_component', pageName: outputName, outputDir, pendingLogicCount: pageResult.pendingScripts.length, requiresAi: pageResult.pendingScripts.length > 0, prompt: pageResult.prompt, reasons: pageResult.pendingScripts.map((item) => `${item.summary}(${item.location})`), }; log('✅ 转换完成!', 'info'); printResult(result); return; } const convertedPages = []; for (const page of prototypes) { const pageFolderName = sanitizePageName(`${outputName}-${page.name}`); const pageOutputDir = path.join(CONFIG.pagesDir, pageFolderName); const pageResult = convertPage(page.path, pageOutputDir, page.name); convertedPages.push({ pageName: page.name, folderName: pageFolderName, outputDir: pageOutputDir, pendingLogicCount: pageResult.pendingScripts.length, requiresAi: pageResult.pendingScripts.length > 0, prompt: pageResult.prompt, reasons: pageResult.pendingScripts.map((item) => `${item.summary}(${item.location})`), }); } log('✅ 转换完成!', 'info'); printResult({ success: true, type, mode: convertedPages.some((item) => item.requiresAi) ? 'ai_handoff' : 'safe_component', pageName: outputName, outputDir, pages: convertedPages, pendingLogicCount: convertedPages.reduce((sum, item) => sum + item.pendingLogicCount, 0), requiresAi: convertedPages.some((item) => item.requiresAi), prompt: convertedPages.find((item) => item.prompt)?.prompt || null, reasons: convertedPages.flatMap((item) => item.reasons), }); } catch (error) { log(`转换失败: ${error.message}`, 'error'); process.exit(1); } } if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { main(); } export { generateComponent, };