Files
web_huakui_web/scripts/sync-figma-icons.ts
T
谢宇宁andClaude Opus 5.5 d2ddb1c321 [Ai] feat: 花魁短剧 Next.js 站点初始提交
- 原生 Next.js App Router + 严格 TypeScript 实现花魁短剧 PC/H5 页面,按 src/features/{catalog,content,playback} 组织,保留完整 SEO(metadata、JSON-LD、canonical、robots、llms、sitemap、IndexNow)。
- 主域名通过 SITE_URL=https://huakuidj.com 强制覆盖后端 foreverUrlAddr(config/*.env、src/server/config.ts、src/server/seo/shared.ts、src/server/services/shared.ts),支持邮箱改为 support@huakuidj.com。
- 移除登录与账户体系:删除 /account 全部页面、登录/注册/充值弹窗、收藏点赞、评论、金币解锁及对应 BFF 接口;旧账户地址 GET/HEAD 统一 301 到首页(src/lib/routes.ts REMOVED_ACCOUNT_PATHS),保留游客会话、反馈、图片上传、观看上报与搜索联想。
- 播放页:评论位改为同类推荐 + 热播榜单,锁定集改为 App 观看遮罩与底部弹层,新增简介标签、分集图例与独立视觉皮肤(src/features/playback、src/styles/pages/playback.css)。
- 卡片:短剧/视频卡状态与时长移至信息行胶囊标签,封面角标移至左上角(src/components/catalog/Cards.tsx、src/styles/foundations/base.css)。
- 同步更新单元测试、浏览器冒烟脚本、路由清单与 README。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:33:31 +07:00

152 lines
7.6 KiB
TypeScript

import { createHash } from 'node:crypto';
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
type Size = { width: number; height: number };
type IconAsset = {
file: string;
nodeId: string;
role: string;
sourcePath: string;
sourceSize: Size;
size: Size;
palette: string[];
sha256?: string;
usedBy?: string[];
};
type RetainedIcon = { file: string; size: Size; sha256: string; reason: string; usedBy?: string[] };
type IconManifest = { fileKey: string; assets: IconAsset[]; retained: RetainedIcon[] };
const manifestPath = resolve('scripts/figma-icon-manifest.json');
const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as IconManifest;
const verifyOnly = process.argv.includes('--check');
const hash = (value: string | Uint8Array) => createHash('sha256').update(value).digest('hex');
async function refreshReferences(): Promise<void> {
async function files(directory: string): Promise<string[]> {
const entries = await readdir(directory, {withFileTypes: true});
return (await Promise.all(entries.map(entry => entry.isDirectory() ? files(directory + '/' + entry.name) : Promise.resolve([directory + '/' + entry.name])))).flat();
}
const paths = [...await files('src')].filter(path => /\.(?:tsx?|css)$/.test(path));
const sources = await Promise.all(paths.map(async path => ({path, text:await readFile(path,'utf8')})));
for (const asset of [...manifest.assets,...manifest.retained]) {
const url = asset.file.replace(/^public/, '');
const name = url.replace(/^\/static\/images\//, '').replace(/\.svg$/, '');
const literal = new RegExp('["\x27`]' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '["\x27`]');
asset.usedBy = sources.filter(source => source.text.includes(url) || literal.test(source.text)).map(source => source.path).sort();
}
await writeFile(manifestPath, JSON.stringify(manifest,null,2)+'\n');
console.log('Refreshed Figma icon references from native TS/TSX and CSS.');
}
function svgRoot(svg: string): string {
const root = svg.match(/<svg\b[^>]*>/i)?.[0];
if (!root || !/<\/(?:[\w-]+:)?svg\s*>/i.test(svg)) throw new Error('Invalid or empty SVG document');
return root;
}
function attribute(root: string, name: string): string {
return root.match(new RegExp(`\\b${name}\\s*=\\s*["']([^"']+)["']`, 'i'))?.[1] ?? '';
}
function validateSVG(asset: IconAsset | RetainedIcon, bytes: Buffer, checkHash: boolean): void {
const svg = bytes.toString('utf8');
const root = svgRoot(svg);
if (!/<(?:path|rect|circle|ellipse|line|polyline|polygon|text|use)\b/i.test(svg)) throw new Error('SVG has no visible geometry');
for (const [name, value] of Object.entries(asset.size)) {
if (Number(attribute(root, name).replace(/px$/, '')) !== value) throw new Error(`Incorrect root ${name}`);
if ('sourceSize' in asset && Math.abs(asset.sourceSize[name as keyof Size] - value) > 0.01) throw new Error(`Root ${name} differs from the Figma node`);
}
const viewBox = attribute(root, 'viewBox').trim().split(/[\s,]+/).map(Number);
if (viewBox.length !== 4 || !viewBox.every(Number.isFinite) || viewBox[2] <= 0 || viewBox[3] <= 0) throw new Error('Invalid SVG viewBox');
if (Math.abs(viewBox[2] / viewBox[3] - asset.size.width / asset.size.height) > 0.00001) throw new Error('SVG aspect ratio would distort the icon');
if (checkHash && asset.sha256 && hash(bytes) !== asset.sha256) throw new Error('SVG differs from its recorded Figma export or retained original');
if ('palette' in asset) {
const colors = new Set((svg.match(/#[\da-f]{6}\b|#[\da-f]{3}\b/gi) ?? []).map(value => {
const lower = value.toLowerCase();
return lower.length === 4 ? '#' + [...lower.slice(1)].map(char => char + char).join('') : lower;
}));
if (/\b(?:fill|stroke)=["']white["']/i.test(svg)) colors.add('#ffffff');
for (const color of colors) if (!asset.palette.includes(color)) throw new Error(`Paint ${color} does not match the Figma node`);
}
}
async function verifyLocal(): Promise<void> {
for (const asset of [...manifest.assets, ...manifest.retained]) {
try {
validateSVG(asset, await readFile(resolve(asset.file)), true);
} catch (error) {
throw new Error(`${asset.file}: ${(error as Error).message}`);
}
}
console.log(`Verified ${manifest.assets.length} Figma SVG exports and ${manifest.retained.length} retained icons: geometry, palette, dimensions and hashes.`);
}
async function request(url: string, headers?: Record<string, string>): Promise<Response> {
for (let attempt = 0; attempt < 3; attempt++) {
let response: Response;
try {
response = await fetch(url, { headers, signal: AbortSignal.timeout(60_000) });
} catch {
if (attempt < 2) continue;
throw new Error('Figma asset request failed');
}
if (response.ok) return response;
if ((response.status === 429 || response.status >= 500) && attempt < 2) {
const delay = Math.min(30, Math.max(1, Number(response.headers.get('retry-after')) || 3));
await new Promise(resolveDelay => setTimeout(resolveDelay, delay * 1000));
continue;
}
throw new Error(`Figma asset request returned HTTP ${response.status}`);
}
throw new Error('Figma asset request failed');
}
async function syncIcons(): Promise<void> {
const token = process.env.FIGMA_TOKEN;
if (!token) throw new Error('FIGMA_TOKEN is required to synchronize icons; --check works offline');
const ids = [...new Set(manifest.assets.map(asset => asset.nodeId))];
const exported = new Map<string, Buffer>();
for (let offset = 0; offset < ids.length; offset += 30) {
const batch = ids.slice(offset, offset + 30);
const query = new URLSearchParams({
ids: batch.join(','), format: 'svg', svg_outline_text: 'true', use_absolute_bounds: 'true',
});
const response = await request(`https://api.figma.com/v1/images/${manifest.fileKey}?${query}`, { 'X-Figma-Token': token });
const result = await response.json() as { images?: Record<string, string | null> };
// Signed download URLs and credentials are deliberately never logged or saved.
await Promise.all(batch.map(async id => {
const url = result.images?.[id];
if (!url) throw new Error(`Figma returned no SVG for node ${id}`);
exported.set(id, Buffer.from(await (await request(url)).arrayBuffer()));
}));
console.log(`Downloaded Figma SVG batch ${Math.floor(offset / 30) + 1}: ${batch.length} nodes.`);
}
// Save complete REST response bytes: no root, whitespace, geometry or paint edits.
// The exported intrinsic size is recorded here; display size belongs to CSS/HTML.
const pending = manifest.assets.map(previous => {
const bytes = exported.get(previous.nodeId)!;
const root = svgRoot(bytes.toString('utf8'));
const asset = { ...previous, size: { width: Number(attribute(root, 'width').replace(/px$/, '')), height: Number(attribute(root, 'height').replace(/px$/, '')) } };
try {
validateSVG(asset, bytes, false);
} catch (error) {
throw new Error(`${asset.file} (node ${asset.nodeId}): ${(error as Error).message}`);
}
return { asset, bytes };
});
for (const { asset, bytes } of pending) {
await mkdir(dirname(resolve(asset.file)), { recursive: true });
await writeFile(resolve(asset.file), bytes);
asset.sha256 = hash(bytes);
}
manifest.assets = pending.map(({ asset }) => asset);
await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
await refreshReferences();
await verifyLocal();
}
if (process.argv.includes('--refresh-references')) await refreshReferences();
else if (verifyOnly) await verifyLocal();
else await syncIcons();