Files
zeekay add397cc62 rebrand: @lobehub/icons → @hanzoai/icons (Hanzo Icons)
Fork of lobe-icons (MIT) rebranded for Hanzo. Renamed all 6 published packages
(@hanzoai/icons + -static-svg/png/webp/avatar + -rn) and every import across
src/ + docs, repo URLs (lobehub/lobe-icons → hanzoai/icons), and prose
(LobeHub → Hanzo). Fresh Hanzo Icons README dropping the upstream marketing
chrome. Preserved: the MIT LICENSE + original copyright (required by the fork),
the LobeHub brand icon itself (src/LobeHub/ — content, like any other brand),
and external @lobehub/* build/demo deps (ui, lint, docs-kit, assets-logo,
fluent-emoji-3d) that have no @hanzoai equivalent. CHANGELOG left as historical.
2026-07-24 01:29:04 -07:00

344 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { consola } from 'consola';
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import pMap from 'p-map';
import puppeteer, { type Browser } from 'puppeteer';
import { ComponentType, createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import sharp from 'sharp';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const rootDir = resolve(__dirname, '../..');
const srcDir = resolve(rootDir, 'src');
const outputDir = resolve(rootDir, 'packages/static-svg/icons');
const outputPngDir = resolve(rootDir, 'packages/static-png');
const outputWebpDir = resolve(rootDir, 'packages/static-webp');
const outputAvatarsDir = resolve(rootDir, 'packages/static-avatar/avatars');
type CompoundIcon = ComponentType & {
Avatar?: ComponentType;
Brand?: ComponentType;
BrandColor?: ComponentType;
Color?: ComponentType;
Text?: ComponentType;
TextCn?: ComponentType;
TextColor?: ComponentType;
};
const getIconIds = () => {
const source = readFileSync(resolve(srcDir, 'icons.ts'), 'utf8');
return Array.from(source.matchAll(/default as (\w+)/g), (match) => match[1]);
};
const loadIcons = async (): Promise<Record<string, CompoundIcon>> => {
// Preload feature modules to avoid circular initialization:
// any icon -> @/features/IconAvatar -> @lobehub/ui -> @hanzoai/icons (src/index.ts)
// -> src/features -> modelConfig -> back into the icon being loaded.
await import(pathToFileURL(resolve(srcDir, 'features/IconAvatar/index.tsx')).href);
await import(pathToFileURL(resolve(srcDir, 'features/IconCombine/index.tsx')).href);
const iconIds = getIconIds();
const entries = await Promise.all(
iconIds.map(async (id) => {
const iconPath = resolve(srcDir, id, 'index.ts');
const mod = (await import(pathToFileURL(iconPath).href)) as { default: CompoundIcon };
return [id, mod.default] as const;
}),
);
return Object.fromEntries(entries);
};
type ThemeMode = 'light' | 'dark';
const themeColors = {
// 浅色主题使用黑色
dark: '#FFFFFF',
light: '#000000', // 深色主题使用白色
};
class SvgWorkflow {
ignoreList: string[] = [];
browserInstance: Browser | null = null;
browserPromise: Promise<Browser> | null = null;
icons: Record<string, CompoundIcon> = {};
async getBrowser(): Promise<Browser> {
// 使用 Promise 避免并发时启动多个浏览器实例
if (!this.browserPromise) {
this.browserPromise = puppeteer.launch({
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
// 允许 file:// 下加载字体等资源
'--allow-file-access-from-files',
],
headless: true,
});
this.browserInstance = await this.browserPromise;
}
return this.browserInstance!;
}
async closeBrowser(): Promise<void> {
if (this.browserInstance) {
await this.browserInstance.close();
this.browserInstance = null;
this.browserPromise = null;
}
}
async exportSvgAvatar(Component: ComponentType, outputFileName: string): Promise<void> {
const size = 640;
// 确保输出目录存在
if (!existsSync(outputAvatarsDir)) {
mkdirSync(outputAvatarsDir, { recursive: true });
}
const node = createElement(Component, {
// @ts-ignore
shape: 'square',
size,
style: {
alignItems: 'center',
borderRadius: 0,
display: 'flex',
justifyContent: 'center',
},
});
// 使用 renderToStaticMarkup 生成静态 HTML
// 注意:如果组件使用了 hooks(如 useThemeMode),这里可能会报错
// 但由于 Puppeteer 在浏览器中运行,静态 HTML 通常已足够
let htmlString: string;
try {
htmlString = renderToStaticMarkup(node);
} catch {
// 如果因为 hooks 报错,尝试使用 renderToString(虽然也会报错,但至少能捕获)
// 实际上,由于 IconAvatar 使用了 useThemeMode,我们需要在浏览器中渲染
// 这里我们抛出一个更友好的错误信息
throw new Error(
`Failed to render ${outputFileName}: Component uses hooks that cannot be rendered on server side. Consider using a server-safe version or rendering in browser.`,
);
}
// 构造完整 HTML,内联所有样式
const fullHtml = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: ${size}px;
height: ${size}px;
overflow: hidden;
/* 透明背景,如果组件自身有背景色会保留 */
background: transparent;
}
</style>
</head>
<body>${htmlString}</body>
</html>`;
const browser = await this.getBrowser();
const page = await browser.newPage();
try {
await page.setViewport({
deviceScaleFactor: 2,
height: size,
width: size, // 2x 高清,最终输出 1280x1280 再压缩
});
// 对于静态 HTML,使用 'load' 或 'domcontentloaded' 更快
await page.setContent(fullHtml, {
waitUntil: 'load',
});
// 等待样式和布局正确应用(使用 waitForFunction 替代已废弃的 waitForTimeout)
await page.waitForFunction(
() => {
const body = document.body;
return body.offsetWidth > 0 && body.offsetHeight > 0;
},
{ timeout: 1000 },
);
const filename = resolve(outputAvatarsDir, `${outputFileName}.webp`);
await page.screenshot({
clip: { height: size, width: size, x: 0, y: 0 },
omitBackground: true,
path: filename,
quality: 95,
type: 'webp', // 保留透明通道
});
consola.success(`Exported avatar to ${filename}`);
} finally {
await page.close();
}
}
exportSvg(Component: ComponentType, outputFileName: string): void {
const svgString = renderToStaticMarkup(createElement(Component));
const svgContent = svgString.match(/<svg[^>]*>[\S\s]*<\/svg>/i)?.[0];
if (!svgContent) {
throw new Error('No SVG content found in the rendered component');
}
const filename = resolve(outputDir, outputFileName + '.svg');
writeFileSync(filename, svgContent, 'utf8');
consola.success(`Exported SVG to ${filename}`);
}
async convertSvgToPng(
svgPath: string,
outputPath: string,
height: number,
themeMode?: ThemeMode,
): Promise<void> {
try {
let svgContent = readFileSync(svgPath, 'utf8');
if (themeMode) {
const color = themeColors[themeMode];
svgContent = svgContent.replaceAll('currentColor', color);
}
await sharp(Buffer.from(svgContent))
.resize({ height })
.png({ compressionLevel: 9, palette: true, quality: 95 })
.toFile(outputPath);
consola.success(`PNG file has been saved to ${outputPath}`);
} catch (error) {
consola.error('Error converting SVG to PNG:', error);
}
}
async convertSvgToWebp(
svgPath: string,
outputPath: string,
height: number,
themeMode?: ThemeMode,
): Promise<void> {
try {
let svgContent = readFileSync(svgPath, 'utf8');
if (themeMode) {
const color = themeColors[themeMode];
svgContent = svgContent.replaceAll('currentColor', color);
}
await sharp(Buffer.from(svgContent))
.resize({ height })
.webp({ quality: 95 })
.toFile(outputPath);
consola.success(`WebP file has been saved to ${outputPath}`);
} catch (error) {
consola.error('Error converting SVG to WebP:', error);
}
}
async runSvg() {
await pMap(Object.entries(this.icons), async ([key, Icon]: [string, any]) => {
if (!this.ignoreList.includes(key.toLowerCase())) {
try {
this.exportSvg(Icon, `${key.toLowerCase()}`);
if (Icon?.Color) {
this.exportSvg(Icon.Color, `${key.toLowerCase()}-color`);
}
if (Icon?.Text) {
this.exportSvg(Icon.Text, `${key.toLowerCase()}-text`);
}
if (Icon?.TextColor) {
this.exportSvg(Icon.TextColor, `${key.toLowerCase()}-text-color`);
}
if (Icon?.TextCn) {
this.exportSvg(Icon.TextCn, `${key.toLowerCase()}-text-cn`);
}
if (Icon?.Brand) {
this.exportSvg(Icon.Brand, `${key.toLowerCase()}-brand`);
}
if (Icon?.BrandColor) {
this.exportSvg(Icon.BrandColor, `${key.toLowerCase()}-brand-color`);
}
} catch (error) {
consola.error(`Failed to export ${key.toLowerCase()}:`, error);
}
}
});
}
async runAvatar() {
try {
// 先启动浏览器,避免并发时启动多个实例
await this.getBrowser();
consola.info('Browser launched, starting avatar export...');
await pMap(
Object.entries(this.icons),
async ([key, Icon]: [string, any]) => {
if (!this.ignoreList.includes(key.toLowerCase())) {
try {
if (Icon?.Avatar) {
await this.exportSvgAvatar(Icon.Avatar, `${key.toLowerCase()}`);
}
} catch (error) {
consola.error(`Failed to export ${key.toLowerCase()}:`, error);
}
}
},
{ concurrency: 5 }, // 限制并发数,避免同时打开太多页面
);
} finally {
await this.closeBrowser();
}
}
async runPng() {
await pMap(
readdirSync(outputDir),
async (file) => {
if (file.endsWith('.svg') && !this.ignoreList.includes(file)) {
const svgPath = resolve(outputDir, file);
const pngLightPath = resolve(outputPngDir, 'light', file.replace('.svg', '.png'));
const pngDarkPath = resolve(outputPngDir, 'dark', file.replace('.svg', '.png'));
await this.convertSvgToPng(svgPath, pngLightPath, 640, 'light');
await this.convertSvgToPng(svgPath, pngDarkPath, 640, 'dark');
}
},
{ concurrency: 5 },
);
}
async runWebp() {
await pMap(
readdirSync(outputDir),
async (file) => {
if (file.endsWith('.svg') && !this.ignoreList.includes(file)) {
const svgPath = resolve(outputDir, file);
const webpLightPath = resolve(outputWebpDir, 'light', file.replace('.svg', '.webp'));
const webpDarkPath = resolve(outputWebpDir, 'dark', file.replace('.svg', '.webp'));
await this.convertSvgToWebp(svgPath, webpLightPath, 640, 'light');
await this.convertSvgToWebp(svgPath, webpDarkPath, 640, 'dark');
}
},
{ concurrency: 5 },
);
}
async run() {
this.icons = await loadIcons();
await this.runSvg();
await this.runPng();
await this.runWebp();
await this.runAvatar();
}
}
const workflow = new SvgWorkflow();
workflow.run();