feat: sync full workspace including web modules, docs, and configurations to Gitea
Optimized the root .gitignore to exclude virtual environments, node modules, and temp folders to ensure clean and lightweight version tracking. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
826
axhub-make/scripts/chrome-export-converter.mjs
Normal file
826
axhub-make/scripts/chrome-export-converter.mjs
Normal file
@@ -0,0 +1,826 @@
|
||||
#!/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(/<head[^>]*>([\s\S]*?)<\/head>/i);
|
||||
if (!headMatch) return { scripts: [], links: [] };
|
||||
|
||||
const headContent = headMatch[1];
|
||||
const scripts = [];
|
||||
const links = [];
|
||||
|
||||
// 提取 script 标签(排除 Tailwind CDN)
|
||||
const scriptRegex = /<script([^>]*)>([\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 = /<link[^>]*>/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(/<!--([\s\S]*?)-->/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(/(<pre[^>]*>)([\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(/<body\b([^>]*)>/gi, '<div data-chrome-export-body="true"$1>')
|
||||
.replace(/<\/body>/gi, '</div>')
|
||||
.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(/__LT__/g, "{'<'}")
|
||||
.replace(/__GT__/g, "{'>'}")
|
||||
.replace(/__AMP__/g, '&');
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取并转换 body 内容
|
||||
*/
|
||||
function extractBodyContent(html) {
|
||||
const bodyMatch = html.match(/(<body[^>]*>)([\s\S]*?)(<\/body>)/i);
|
||||
if (!bodyMatch) return '';
|
||||
|
||||
const [, openTag, innerContent] = bodyMatch;
|
||||
|
||||
// 移除 <root> 标签(Chrome 扩展导出特有的包装标签)
|
||||
let cleanedContent = innerContent.trim()
|
||||
.replace(/^\s*<root>\s*/i, '')
|
||||
.replace(/\s*<\/root>\s*$/i, '');
|
||||
|
||||
const convertedOpenTag = convertCommonAttributesToJSX(openTag)
|
||||
.replace(/^<body\b/i, '<div data-chrome-export-root="true"');
|
||||
const content = convertHtmlToJSX(cleanedContent);
|
||||
|
||||
return `${convertedOpenTag}\n${content}\n </div>`;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ padding: '20px', color: 'red', border: '2px solid red', margin: '20px' }}>
|
||||
<h2>组件渲染失败: ${safeDisplayName}</h2>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', fontSize: '12px' }}>
|
||||
{this.state.error?.toString()}
|
||||
{this.state.error?.stack}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const Component = forwardRef(function ${componentName}(
|
||||
innerProps: AxureProps,
|
||||
ref: React.ForwardedRef<AxureHandle>,
|
||||
) {
|
||||
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<AxureHandle>,
|
||||
) {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Component {...props} ref={ref} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
});
|
||||
|
||||
export default WrappedComponent;
|
||||
`;
|
||||
}
|
||||
|
||||
function isWrappedInSingleElement(content) {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed.startsWith('<')) return false;
|
||||
if (trimmed.startsWith('<body')) return trimmed.endsWith('</body>');
|
||||
|
||||
const firstTagMatch = trimmed.match(/^<([a-zA-Z][a-zA-Z0-9]*)/);
|
||||
if (!firstTagMatch) return false;
|
||||
|
||||
const tagName = firstTagMatch[1];
|
||||
const closingTag = `</${tagName}>`;
|
||||
if (!trimmed.endsWith(closingTag)) return false;
|
||||
|
||||
const openCount = (trimmed.match(new RegExp(`<${tagName}[\\s>]`, 'g')) || []).length;
|
||||
const closeCount = (trimmed.match(new RegExp(`</${tagName}>`, '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 <source-dir> [output-name] [display-name]
|
||||
node scripts/chrome-export-converter.mjs <source-dir> --name <output-name> --display-name <display-name> --target-dir <relative-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,
|
||||
};
|
||||
Reference in New Issue
Block a user