#!/usr/bin/env node /** * ===================================================== * Chrome 扩展导出转换器 * * 专门处理通过 Chrome 扩展本项目导出的 HTML 文件 * * 功能: * 1. 转换 index.html 为 React 组件 * 2. 智能处理字体:CDN 保留链接,本地文件复制 * 3. 复制静态资源(图片、字体) * 4. 保留完整的 style.css 样式 * ===================================================== */ 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') }; const JSX_ATTRIBUTE_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'], ]; 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 normalizeRelativeDir(value) { return String(value ?? '') .replace(/\\/g, '/') .split('/') .filter(Boolean) .join('/'); } function isSafeRelativeDir(value) { if (!value) return false; if (value.startsWith('/') || value.startsWith('~')) return false; const segments = value.split('/'); if (segments.length === 0) return false; return segments.every((segment) => { if (!segment || segment === '.' || segment === '..') return false; return !/[\\/]/.test(segment); }); } /** * 判断是否为 CDN 链接 */ function isCDNUrl(url) { return url.startsWith('http://') || url.startsWith('https://') || url.startsWith('//'); } /** * 递归复制目录 */ function copyDirectory(src, dest) { if (!fs.existsSync(src)) return 0; ensureDir(dest); const entries = fs.readdirSync(src, { withFileTypes: true }); let count = 0; for (const entry of entries) { const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); if (entry.isDirectory()) { count += copyDirectory(srcPath, destPath); } else { fs.copyFileSync(srcPath, destPath); count++; } } return count; } /** * 提取 head 内容(仅处理外部资源和字体) */ function extractHeadContent(html) { const headMatch = html.match(/]*>([\s\S]*?)<\/head>/i); if (!headMatch) return { scripts: [], links: [] }; const headContent = headMatch[1]; const scripts = []; const links = []; // 提取 script 标签(排除 Tailwind CDN) 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=["']([^"']+)["']/); if (srcMatch) { const src = srcMatch[1].replace(/&/g, '&'); // 跳过 Tailwind CDN if (src.includes('tailwindcss.com')) continue; scripts.push({ src, id: attrs.match(/id=["']([^"']+)["']/)?.[1] }); } else if (content) { const id = attrs.match(/id=["']([^"']+)["']/)?.[1]; scripts.push({ id, content }); } } // 提取 link 标签 const linkRegex = /]*>/gi; while ((match = linkRegex.exec(headContent)) !== null) { const tag = match[0]; const href = tag.match(/href=["']([^"']+)["']/)?.[1]; if (href) { links.push({ href: href.replace(/&/g, '&'), rel: tag.match(/rel=["']([^"']+)["']/)?.[1] || 'stylesheet', crossorigin: tag.includes('crossorigin') }); } } return { scripts, links }; } /** * 转义文本节点中的花括号 * 只处理标签之间的文本内容,不处理属性值 */ function escapeTextBraces(html) { const parts = []; let lastIndex = 0; const tagRegex = /<[^>]+>/g; let match; while ((match = tagRegex.exec(html)) !== null) { // 提取标签之前的文本 const textBefore = html.substring(lastIndex, match.index); if (textBefore) { // 转义文本中的花括号 - 使用占位符避免重复替换 const escaped = textBefore .replace(/\{/g, "__LBRACE__") .replace(/\}/g, "__RBRACE__"); parts.push(escaped); } // 添加标签本身(不转义) parts.push(match[0]); lastIndex = tagRegex.lastIndex; } // 添加最后一段文本 const textAfter = html.substring(lastIndex); if (textAfter) { const escaped = textAfter .replace(/\{/g, "__LBRACE__") .replace(/\}/g, "__RBRACE__"); parts.push(escaped); } return parts.join('') .replace(/__LBRACE__/g, "{'{'}") .replace(/__RBRACE__/g, "{'}'}") } function convertCommonAttributesToJSX(content) { let nextContent = content; JSX_ATTRIBUTE_REPLACEMENTS.forEach(([from, to]) => { nextContent = nextContent.replace(new RegExp(`(\\s)${from}=`, 'gi'), `$1${to}=`); }); return nextContent; } function createCommentPlaceholders(content) { const comments = []; const withPlaceholders = content.replace(//g, (_, commentBody) => { const placeholder = `__HTML_COMMENT_${comments.length}__`; comments.push(`{/* ${commentBody} */}`); return placeholder; }); return { withPlaceholders, comments }; } function restoreCommentPlaceholders(content, comments) { return comments.reduce( (currentContent, comment, index) => currentContent.replaceAll(`__HTML_COMMENT_${index}__`, comment), content, ); } function convertHtmlToJSX(content) { let nextContent = convertCommonAttributesToJSX(content) .replace(/(]*>)([\s\S]*?)(<\/pre>)/gi, (_, openTag, preContent) => { const escapedContent = preContent .replace(/\\/g, '\\\\') .replace(/`/g, '\\`') .replace(/\$/g, '\\$') .replace(/\{/g, '\\{'); return `${openTag.slice(0, -1)} dangerouslySetInnerHTML={{ __html: \`${escapedContent}\` }} />`; }) .replace(/style='([^']*)'/gi, (_, styleStr) => convertStyleToJSX(styleStr)) .replace(/style="([^"]*)"/gi, (_, styleStr) => convertStyleToJSX(styleStr)) .replace(/<\/(br|hr|img|input|meta|link)>/gi, '') .replace(/<(br|hr|img|input|meta|link)([^>]*)>/gi, '<$1$2 />') .replace(/]*)>/gi, '
') .replace(/<\/body>/gi, '
') .replace(/<\//g, '__LTSLASH__') .replace(/</g, '__LT__') .replace(/>/g, '__GT__') .replace(/&/g, '__AMP__'); const { withPlaceholders, comments } = createCommentPlaceholders(nextContent); nextContent = escapeTextBraces(withPlaceholders); nextContent = restoreCommentPlaceholders(nextContent, comments); return nextContent .replace(/__LTSLASH__/g, "{''}") .replace(/__AMP__/g, '&'); } /** * 提取并转换 body 内容 */ function extractBodyContent(html) { const bodyMatch = html.match(/(]*>)([\s\S]*?)(<\/body>)/i); if (!bodyMatch) return ''; const [, openTag, innerContent] = bodyMatch; // 移除 标签(Chrome 扩展导出特有的包装标签) let cleanedContent = innerContent.trim() .replace(/^\s*\s*/i, '') .replace(/\s*<\/root>\s*$/i, ''); const convertedOpenTag = convertCommonAttributesToJSX(openTag) .replace(/^`; } function convertStyleToJSX(styleStr) { if (!styleStr.trim()) return 'style={{}}'; // 先解码 HTML 实体 const decodedStr = styleStr .replace(/"/g, '"') .replace(/'/g, "'") .replace(/</g, '<') .replace(/>/g, '>') .replace(/&/g, '&'); const styles = []; let currentProp = ''; let inUrl = false; for (let i = 0; i < decodedStr.length; i++) { const char = decodedStr[i]; if (char === '(' && decodedStr.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(s => s.includes(':')) .map(s => { const colonIndex = s.indexOf(':'); const key = s.substring(0, colonIndex).trim(); const value = s.substring(colonIndex + 1).trim(); if (!key || !value) return ''; const camelKey = key.startsWith('-') ? JSON.stringify(key) : key.replace(/-([a-z])/g, (g) => g[1].toUpperCase()); let jsxValue; if (value.startsWith('url(') || value.includes('var(')) { jsxValue = `'${value.replace(/'/g, "\\'")}'`; } else if (/^-?\d+(\.\d+)?$/.test(value)) { jsxValue = value; } else { // 转义单引号和反斜杠 const escapedValue = value.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); jsxValue = `'${escapedValue}'`; } return `${camelKey}: ${jsxValue}`; }) .filter(Boolean) .join(', '); return `style={{ ${jsxStyles} }}`; } /** * 生成组件代码 */ function normalizeDisplayName(displayName) { const text = String(displayName ?? '').trim(); const singleLine = text.replace(/\r?\n/g, ' '); const safeText = singleLine.replace(/\*\//g, '* /'); return safeText.slice(0, 200); } function generateComponent(pageSlug, displayName, bodyContent, headContent) { const componentName = pageSlug .split(/[-_\s]+/) .map(word => word.charAt(0).toUpperCase() + word.slice(1)) .join(''); const safeDisplayName = normalizeDisplayName(displayName || pageSlug); let cleanedContent = bodyContent.trim(); if (cleanedContent.startsWith('{/*')) { const firstTagIndex = cleanedContent.indexOf('<'); if (firstTagIndex > 0) { cleanedContent = cleanedContent.substring(firstTagIndex); } } const needsWrapper = !isWrappedInSingleElement(cleanedContent); const finalContent = needsWrapper ? `<>\n${cleanedContent}\n ` : cleanedContent; // 生成注入代码 let injectionCode = ''; if (headContent.links.length > 0 || headContent.scripts.length > 0) { const hasExternalScripts = headContent.scripts.some(s => s.src); injectionCode = ` // 动态注入外部资源 React.useEffect(function () { const injected: (HTMLElement)[] = []; `; if (headContent.links.length > 0) { injectionCode += ` // 注入 links ${JSON.stringify(headContent.links)}.forEach(function (linkInfo: any) { 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); } }); `; } if (hasExternalScripts) { injectionCode += ` // 注入外部脚本 ${JSON.stringify(headContent.scripts.filter(s => s.src))}.forEach(function (scriptInfo: any) { const existing = document.querySelector(\`script[src="\${scriptInfo.src}"]\`); if (!existing) { const script = document.createElement('script'); if (scriptInfo.id) script.id = scriptInfo.id; script.src = scriptInfo.src; document.head.appendChild(script); injected.push(script); } }); `; } injectionCode += ` return function () { injected.forEach(function (el) { if (el.parentNode) el.parentNode.removeChild(el); }); }; }, []); `; } return `/** * @name ${safeDisplayName} * * 参考资料: * - /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'; class ErrorBoundary extends React.Component< { children: React.ReactNode }, { hasError: boolean; error: Error | null } > { constructor(props: any) { super(props); this.state = { hasError: false, error: null }; } static getDerivedStateFromError(error: Error) { return { hasError: true, error }; } componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { console.error('[${pageSlug}] 组件渲染错误:', error); console.error('[${pageSlug}] 错误详情:', errorInfo); console.error('[${pageSlug}] 错误堆栈:', error.stack); } render() { if (this.state.hasError) { return (

组件渲染失败: ${safeDisplayName}

            {this.state.error?.toString()}
            {this.state.error?.stack}
          
); } return this.props.children; } } const Component = forwardRef(function ${componentName}( innerProps: AxureProps, ref: React.ForwardedRef, ) { console.log('[${pageSlug}] 组件开始渲染'); useImperativeHandle(ref, function () { return { getVar: function () { return undefined; }, fireAction: function () {}, eventList: [], actionList: [], varList: [], configList: [], dataList: [] }; }, []); ${injectionCode} console.log('[${pageSlug}] 准备返回 JSX'); try { return ( ${finalContent.split('\n').map(line => ' ' + line).join('\n')} ); } catch (error) { console.error('[${pageSlug}] JSX 渲染错误:', error); throw error; } }); const WrappedComponent = forwardRef(function WrappedComponent( props: AxureProps, ref: React.ForwardedRef, ) { return ( ); }); export default WrappedComponent; `; } function isWrappedInSingleElement(content) { const trimmed = content.trim(); if (!trimmed.startsWith('<')) return false; if (trimmed.startsWith(''); const firstTagMatch = trimmed.match(/^<([a-zA-Z][a-zA-Z0-9]*)/); if (!firstTagMatch) return false; const tagName = firstTagMatch[1]; const closingTag = ``; if (!trimmed.endsWith(closingTag)) return false; const openCount = (trimmed.match(new RegExp(`<${tagName}[\\s>]`, 'g')) || []).length; const closeCount = (trimmed.match(new RegExp(``, 'g')) || []).length; return openCount === closeCount && openCount === 1; } /** * 生成 CSS 文件(仅处理外部 style.css) */ function generateStyleCSS(fonts, sourcePath) { let css = '@import "tailwindcss";\n'; // 处理字体 if (fonts && fonts.length > 0) { css += '\n/* 字体定义 */\n'; const cdnFonts = fonts.filter(f => f.isCDN); const localFonts = fonts.filter(f => !f.isCDN); if (cdnFonts.length > 0) { css += '\n/* CDN 字体(保留原始链接) */\n'; cdnFonts.forEach(font => { css += font.rule + '\n\n'; }); } if (localFonts.length > 0) { css += '\n/* 本地字体(已复制到 assets 目录) */\n'; localFonts.forEach(font => { // 将字体路径改为相对于 style.css 的路径 const modifiedRule = font.rule.replace( /url\(['"]?([^'")\s]+)['"]?\)/g, (match, url) => `url('./${url}')` ); css += modifiedRule + '\n\n'; }); } } // 读取外部 CSS 文件的完整内容(排除字体定义) const externalCSSPath = path.join(sourcePath, 'style.css'); if (fs.existsSync(externalCSSPath)) { const externalCSS = fs.readFileSync(externalCSSPath, 'utf8'); // 移除 @font-face 规则(已单独处理) const withoutFontFace = externalCSS.replace(/@font-face\s*\{[^}]+\}/g, '').trim(); if (withoutFontFace) { css += '\n/* 样式类定义(来自 style.css)*/\n'; css += withoutFontFace + '\n'; } } return css; } /** * 从外部 CSS 文件提取字体 */ function extractFontsFromCSS(cssPath) { if (!fs.existsSync(cssPath)) return []; const css = fs.readFileSync(cssPath, 'utf8'); const fonts = []; const fontFaceRegex = /@font-face\s*\{([^}]+)\}/g; let match; while ((match = fontFaceRegex.exec(css)) !== null) { const fontRule = match[1]; const srcMatch = fontRule.match(/src:\s*url\(['"]?([^'")\s]+)['"]?\)/); const familyMatch = fontRule.match(/font-family:\s*['"]([^'"]+)['"]/); if (srcMatch && familyMatch) { const fontSrc = srcMatch[1]; const fontFamily = familyMatch[1]; fonts.push({ family: fontFamily, src: fontSrc, isCDN: isCDNUrl(fontSrc), rule: match[0] }); } } return fonts; } /** * 转换单个页面 */ function convertPage(sourcePath, outputDir, pageSlug, displayName) { log(`正在转换页面: ${pageSlug}`, 'progress'); // Chrome 扩展导出固定使用 index.html const htmlPath = path.join(sourcePath, 'index.html'); if (!fs.existsSync(htmlPath)) { throw new Error(`找不到 index.html 文件: ${htmlPath}`); } const html = fs.readFileSync(htmlPath, 'utf8'); const headContent = extractHeadContent(html); const bodyContent = extractBodyContent(html); // 从外部 CSS 文件提取字体 const externalCSSPath = path.join(sourcePath, 'style.css'); let fonts = []; if (fs.existsSync(externalCSSPath)) { fonts = extractFontsFromCSS(externalCSSPath); if (fonts.length > 0) { log(` ✓ 从 style.css 提取了 ${fonts.length} 个字体定义`, 'info'); } } ensureDir(outputDir); // 生成组件和样式 const componentCode = generateComponent(pageSlug, displayName, bodyContent, headContent); const styleCSS = generateStyleCSS(fonts, sourcePath); const outputTsxPath = path.join(outputDir, 'index.tsx'); assertValidGeneratedTsx(componentCode, outputTsxPath); fs.writeFileSync(outputTsxPath, componentCode); fs.writeFileSync(path.join(outputDir, 'style.css'), styleCSS); // 复制静态资源 const assetsPath = path.join(sourcePath, 'assets'); if (fs.existsSync(assetsPath)) { const outputAssetsPath = path.join(outputDir, 'assets'); // 复制图片 const imagesPath = path.join(assetsPath, 'images'); if (fs.existsSync(imagesPath)) { const imageCount = copyDirectory(imagesPath, path.join(outputAssetsPath, 'images')); if (imageCount > 0) { log(` ✓ 复制了 ${imageCount} 个图片文件`, 'info'); } } // 复制本地字体 const localFonts = fonts.filter(f => !f.isCDN); if (localFonts.length > 0) { const fontsPath = path.join(assetsPath, 'fonts'); if (fs.existsSync(fontsPath)) { const fontCount = copyDirectory(fontsPath, path.join(outputAssetsPath, 'fonts')); log(` ✓ 复制了 ${fontCount} 个字体文件`, 'info'); } } // 统计 CDN 字体 const cdnFonts = fonts.filter(f => f.isCDN); if (cdnFonts.length > 0) { log(` ✓ 保留了 ${cdnFonts.length} 个 CDN 字体链接`, 'info'); } } // 复制参考文件(如果存在) const filesToCopy = ['screenshot.png', 'content.md', 'theme.json']; filesToCopy.forEach(filename => { const srcFile = path.join(sourcePath, filename); if (fs.existsSync(srcFile)) { const destFile = path.join(outputDir, filename); fs.copyFileSync(srcFile, destFile); log(` ✓ 复制了 ${filename}`, 'info'); } }); log(`页面转换完成: ${pageSlug}`, 'info'); } /** * 检测项目类型(仅支持 Chrome 扩展导出) */ function detectProjectType(sourcePath) { const items = fs.readdirSync(sourcePath); // 检查是否为 Chrome 扩展导出格式(有 index.html) if (items.includes('index.html')) { return { type: 'chrome-export', prototypes: [{ name: 'index', path: sourcePath }] }; } throw new Error('未找到 index.html 文件,请确认这是 Chrome 扩展导出的项目'); } /** * 主函数 */ async function main() { const args = process.argv.slice(2); if (args.length === 0 || args[0] === '--help') { console.log(` Chrome 扩展导出转换器 使用方法: node scripts/chrome-export-converter.mjs [output-name] [display-name] node scripts/chrome-export-converter.mjs --name --display-name --target-dir 参数说明: source-dir : Chrome 扩展导出的目录(包含 index.html) output-name : 输出页面名称(可选,默认使用目录名) display-name : 页面显示名(可选,写入 index.tsx 的 @name) target-dir : 输出到 src/prototypes 下的相对目录(可选) 示例: node scripts/chrome-export-converter.mjs ".drafts/my-export" my-page node scripts/chrome-export-converter.mjs ".drafts/my-export" my-page "登录页" node scripts/chrome-export-converter.mjs ".drafts/my-export" --name my-page --target-dir grouped/login-page `); process.exit(0); } const flags = {}; const positionals = []; for (let i = 0; i < args.length; i += 1) { const token = args[i]; if (token === '--name' || token === '--display-name' || token === '--target-dir') { const next = args[i + 1]; if (typeof next === 'string' && next) { flags[token] = next; i += 1; } else { flags[token] = ''; } continue; } positionals.push(token); } const sourceDirArg = positionals[0]; const outputNameRaw = flags['--name'] || positionals[1] || path.basename(sourceDirArg); const outputName = String(outputNameRaw) .replace(/[^a-z0-9-]/gi, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, '') .toLowerCase(); const displayNameRaw = flags['--display-name'] ?? positionals[2]; const displayName = (displayNameRaw !== undefined ? String(displayNameRaw).trim() : '') || outputName; if (displayNameRaw !== undefined) { const trimmedDisplayName = String(displayNameRaw).trim(); if (!trimmedDisplayName || trimmedDisplayName.length > 200) { throw new Error('displayName 长度必须在 1-200 字符'); } } const requestedTargetDir = normalizeRelativeDir(flags['--target-dir'] || outputName); if (!isSafeRelativeDir(requestedTargetDir)) { throw new Error('target-dir 必须是 src/prototypes 下的安全相对路径'); } const sourcePath = path.resolve(CONFIG.projectRoot, sourceDirArg); const outputDir = path.resolve(CONFIG.pagesDir, requestedTargetDir); const resolvedPagesDir = path.resolve(CONFIG.pagesDir); if (outputDir === resolvedPagesDir || !outputDir.startsWith(`${resolvedPagesDir}${path.sep}`)) { throw new Error('target-dir 超出 src/prototypes 目录范围'); } if (!fs.existsSync(sourcePath)) { log(`错误: 找不到目录 ${sourcePath}`, 'error'); process.exit(1); } try { log('开始转换 Chrome 扩展导出...', 'info'); const { type, prototypes } = detectProjectType(sourcePath); log(`项目类型: ${type}`, 'info'); convertPage(prototypes[0].path, outputDir, outputName, displayName); log('✅ 转换完成!', 'info'); log(`📁 页面位置: ${outputDir}`, 'info'); } catch (error) { log(`转换失败: ${error.message}`, 'error'); console.error(error); process.exit(1); } } if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { main(); } export { convertCommonAttributesToJSX, convertHtmlToJSX, convertStyleToJSX, extractBodyContent, generateComponent, };