Initial commit: OneOS prototype workspace baseline.

Include weekly report and other prototype sources with project tooling config.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
王冕
2026-06-26 15:36:52 +08:00
commit 7ef8852919
1310 changed files with 282074 additions and 0 deletions

View File

@@ -0,0 +1,873 @@
#!/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(/&amp;/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(/&amp;/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;
nextContent = nextContent.replace(/\s(?:srcset|sizes)=(["'])\1/gi, '');
JSX_ATTRIBUTE_REPLACEMENTS.forEach(([from, to]) => {
nextContent = nextContent.replace(new RegExp(`(\\s)${from}=`, 'gi'), `$1${to}=`);
});
return nextContent;
}
function findCompatibleImageAsset(filename, availableAssets) {
if (!filename || availableAssets.has(filename)) {
return filename;
}
const extension = path.extname(filename);
const basename = path.basename(filename, extension);
const dedupeMatch = basename.match(/^(.*?)-\d+$/);
if (!dedupeMatch) {
return filename;
}
const fallbackName = `${dedupeMatch[1]}${extension}`;
return availableAssets.has(fallbackName) ? fallbackName : filename;
}
function reconcileImageAssetReferences(content, imagesPath) {
if (!content || !fs.existsSync(imagesPath)) {
return content;
}
const availableAssets = new Set(
fs.readdirSync(imagesPath, { withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => entry.name),
);
return content.replace(/assets\/images\/([^"')\s>]+)/g, (fullMatch, filename) => {
const resolvedFilename = findCompatibleImageAsset(filename, availableAssets);
if (resolvedFilename === filename) {
return fullMatch;
}
return `assets/images/${resolvedFilename}`;
});
}
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(/&lt;\//g, '__LTSLASH__')
.replace(/&lt;/g, '__LT__')
.replace(/&gt;/g, '__GT__')
.replace(/&amp;/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 openTagMatch = html.match(/<body\b[^>]*>/i);
if (!openTagMatch || openTagMatch.index === undefined) return '';
const openTag = openTagMatch[0];
const contentStart = openTagMatch.index + openTag.length;
const closeTagIndex = html.toLowerCase().lastIndexOf('</body>');
if (closeTagIndex < contentStart) return '';
const innerContent = html.slice(contentStart, closeTagIndex);
// 移除 <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(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/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
* - /rules/default-resource-recommendations.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 = reconcileImageAssetReferences(
extractBodyContent(html),
path.join(sourcePath, 'assets', 'images'),
);
// 从外部 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,
reconcileImageAssetReferences,
};