Regression / regression (push) Canceled after 0s
## 品牌标识(本次会话) 起因:品牌此前没有任何图形标识 —— 唯一 favicon 是内联 data-URI 里的字母「A」, 那是 v2.0.0「Aurora Admin → Kole UI」改名漏掉的一处(PC 顶栏也是「A」, 移动端站已是「K」;移动端文档站则完全没有 favicon)。 - 几何:24 网格三个互不接触的笔画(竖 + 两斜),圆头描边; 描边 2.25 → 16px 标签页尺寸下正好 1.5px = 规范原文「描边1.5px」 - 取色分两套(刻意):favicon 硬编码品牌蓝/白(渲染在浏览器标签栏,不继承 kole-dark); 顶栏标记走 currentColor(实测暗色下自动转 rgb(20,22,28)) - 新增 theme-color 双条(light #FFFFFF / dark #1C1F26,取 --kole-color-card-bg) - 修 site/app.js hero 标语 KOLE ADMIN → KOLE UI(改名变形残留) - 移动端 7 个模板补 favicon(此前计数 0) 验收:门禁 9 条全 OK(site-routing/site-routes/mobile-docs/mobile-site/isolation/ theme/nav/i18n/icons);PC 回归 1464/1464 · 移动端 807/807,各连跑 8 次一致; 两端 favicon 405 字节逐字节一致;PC 站控制台错误 1→0。 ## 并行会话成果(本次一并入库) - 图标系统:2576 图标(TDesign/Element Plus,MIT)+ 11 端注入 + 5 个构建门禁工具 + IconPreview 预览页 + ICON-SPEC.md 冻结规格 - 移动端平台:47 组件 × 6 端 + 文档站 53 页 + 隔离门禁 - PC 组件:103 个大后台组件 / 组件11 批次 - uni-app:PC 端试点 + 移动端端实现 + 真实编译验证 ## 工程 - .gitignore 补 .scratch/ 与 .zcode-preexisting-*.txt(会话中间产物,实测 9.1MB,不入库) - CHANGELOG 补品牌标识条目 - ROADMAP 登 S8-P4(品牌标识任务包 + og:image/apple-touch-icon 未做部分)
322 lines
14 KiB
JavaScript
322 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* 图标源固化:把第三方图标库的 SVG 取下来,归一化成仓库内的单一 JSON 真源。
|
||
*
|
||
* 为什么要有这一步:
|
||
* 1. 图标来自 TDesign 与 Element Plus(均 MIT),但它们的 SVG 文件有真实缺陷 ——
|
||
* TDesign 全部 2356 个文件把 `viewBox` 写成 `view-box`(无效属性),
|
||
* 照抄会让图标在部分浏览器里按错误尺寸缩放。必须归一化后再进仓库。
|
||
* 2. 构建链不能联网:CI 与 `npm run build:icons` 都必须离线可复现,
|
||
* 所以「下载」只发生在这里,产物 sources.json 入库,之后全链路离线。
|
||
*
|
||
* 产物:.design_library/kole-ui/icons/sources.json
|
||
* { schemaVersion, generatedAt, sources: [{ id, package, version, license, sha256, icons }] }
|
||
* icons: { "<name>": { d: [ {p, mode} ], v?: "0 0 24 24" } }
|
||
* mode: "stroke" 线性(描边)| "fill" 面性(填充)
|
||
*
|
||
* 用法:
|
||
* node tools/fetch-icon-sources.mjs # 联网下载(校验 sha256)
|
||
* node tools/fetch-icon-sources.mjs --from-dir <dir> # 用已解压的目录(离线)
|
||
*
|
||
* 零运行时依赖;退出码 0 = 成功,非 0 = 失败。
|
||
*/
|
||
import { writeFileSync, readFileSync, readdirSync, existsSync, mkdirSync, renameSync } from 'node:fs';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { dirname, join } from 'node:path';
|
||
import { createHash } from 'node:crypto';
|
||
import { pathToFileURL } from 'node:url';
|
||
import { execFileSync } from 'node:child_process';
|
||
|
||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||
const OUT_DIR = join(ROOT, '.design_library', 'kole-ui', 'icons');
|
||
const OUT = join(OUT_DIR, 'sources.json');
|
||
|
||
/* 钉死版本与校验和:源换了必须显式改这里,避免"某天图标悄悄变了"。 */
|
||
const SOURCES = [
|
||
{
|
||
id: 'tdesign-outline',
|
||
package: 'tdesign-icons-svg',
|
||
version: '0.4.7',
|
||
tarball: 'https://registry.npmjs.org/tdesign-icons-svg/-/tdesign-icons-svg-0.4.7.tgz',
|
||
sha256: '4918267445549dfa0729c4f4cfe3ff54aa96251c01656cbce175d03e8cc175af',
|
||
license: 'MIT',
|
||
copyright: 'Copyright (c) 2021-present TDesign',
|
||
homepage: 'https://github.com/Tencent/tdesign-icons',
|
||
srcDir: 'src',
|
||
/* 只取线性(描边)集:面性集另起一个 source,避免同名覆盖 */
|
||
filter: (n) => !n.endsWith('-filled'),
|
||
nameStrip: null,
|
||
},
|
||
{
|
||
id: 'tdesign-filled',
|
||
package: 'tdesign-icons-svg',
|
||
version: '0.4.7',
|
||
tarball: 'https://registry.npmjs.org/tdesign-icons-svg/-/tdesign-icons-svg-0.4.7.tgz',
|
||
sha256: '4918267445549dfa0729c4f4cfe3ff54aa96251c01656cbce175d03e8cc175af',
|
||
license: 'MIT',
|
||
copyright: 'Copyright (c) 2021-present TDesign',
|
||
homepage: 'https://github.com/Tencent/tdesign-icons',
|
||
srcDir: 'src',
|
||
filter: (n) => n.endsWith('-filled'),
|
||
/* filled 集的文件名是 <base>-filled,归一到 <base>-filled 保序(不撞线性集) */
|
||
nameStrip: null,
|
||
},
|
||
{
|
||
id: 'element-plus',
|
||
package: '@element-plus/icons-svg',
|
||
version: '2.3.2',
|
||
tarball: 'https://registry.npmjs.org/@element-plus/icons-svg/-/icons-svg-2.3.2.tgz',
|
||
sha256: 'b525dc9d0f1150ac28fc6246df07288d6809dcb417927b7af894feb3fd1bbb4e',
|
||
license: 'MIT',
|
||
copyright: 'Copyright (c) 2016-present Element Plus',
|
||
homepage: 'https://github.com/element-plus/element-plus-icons',
|
||
srcDir: '',
|
||
filter: () => true,
|
||
nameStrip: null,
|
||
},
|
||
];
|
||
|
||
/* ---------- SVG 解析:抽图形 + 判断线性/面性 ---------- */
|
||
|
||
/**
|
||
* 从一份 SVG 文本里提取描边与填充路径。
|
||
*
|
||
* 判定规则(按 TDesign/ElementPlus 的实际写法,不做启发式猜测):
|
||
* - `stroke="currentColor"` → 线性路径(mode: stroke)
|
||
* - `fill="currentColor"` → 面性路径(mode: fill)
|
||
* - `fill="transparent"` → 命中区(hit area),不是视觉内容,丢弃
|
||
* - 同一 d 同时出现在 stroke 与 fill 两条里 → 合并为一条 stroke(TDesign 的
|
||
* outline 集就是这么写的:先画透明命中区,再叠描边)
|
||
*
|
||
* ⚠️ 除 `<path>` 外还必须处理 `<circle>` / `<ellipse>`:
|
||
* 实测上游有 36 个文件(TDesign 的 circle / round / brightness / image 等)
|
||
* 把图形写成 `<circle cx cy r>` 而不是 `<path d>`。只认 `<path>` 会**静默丢掉**
|
||
* 这些图形 —— 图标看起来"渲染成功"但实际是空的(TDesign 的 `circle` 与 `round`
|
||
* 就因此整个消失,是覆盖率审计才发现的)。这里把圆/椭圆等价换算成 path 的 arc 命令。
|
||
*/
|
||
export function parseSvgPaths(text) {
|
||
const nodes = [...text.matchAll(/<(path|circle|ellipse)\b([^>]*?)\/?>/g)].map((m) => ({
|
||
tag: m[1],
|
||
attrs: m[2],
|
||
}));
|
||
const byD = new Map();
|
||
for (const { tag, attrs } of nodes) {
|
||
const d = tag === 'path' ? (attrs.match(/\sd="([^"]+)"/) || [])[1] : shapeToPath(tag, attrs);
|
||
if (!d) continue;
|
||
const isStroke = /stroke="currentColor"/.test(attrs);
|
||
const isFill = /fill="currentColor"/.test(attrs);
|
||
const isTransparent = /fill="transparent"/.test(attrs);
|
||
if (isTransparent) continue;
|
||
if (!isStroke && !isFill) continue;
|
||
const cur = byD.get(d) || { mode: 'fill' };
|
||
/* 描边优先级高于填充:同名 d 两者都有时按描边渲染(TDesign outline 的写法) */
|
||
if (isStroke) cur.mode = 'stroke';
|
||
byD.set(d, cur);
|
||
}
|
||
return [...byD.entries()].map(([d, meta]) => ({ d, mode: meta.mode }));
|
||
}
|
||
|
||
/** `<circle>` / `<ellipse>` → 等价 path(用两段 arc 画整圆/整椭圆) */
|
||
export function shapeToPath(tag, attrs) {
|
||
const num = (name, dflt) => {
|
||
const m = attrs.match(new RegExp(`\\s${name}="([^"]+)"`));
|
||
if (!m) return dflt;
|
||
const v = parseFloat(m[1]);
|
||
return Number.isFinite(v) ? v : dflt;
|
||
};
|
||
if (tag === 'circle') {
|
||
const cx = num('cx', 0), cy = num('cy', 0), r = num('r', 0);
|
||
if (!(r > 0)) return null;
|
||
return `M${cx} ${cy - r}A${r} ${r} 0 1 1 ${cx} ${cy + r}A${r} ${r} 0 1 1 ${cx} ${cy - r}Z`;
|
||
}
|
||
if (tag === 'ellipse') {
|
||
const cx = num('cx', 0), cy = num('cy', 0), rx = num('rx', 0), ry = num('ry', 0);
|
||
if (!(rx > 0 && ry > 0)) return null;
|
||
return `M${cx} ${cy - ry}A${rx} ${ry} 0 1 1 ${cx} ${cy + ry}A${rx} ${ry} 0 1 1 ${cx} ${cy - ry}Z`;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 归一化数字精度:2 位小数 + 压缩空白。
|
||
*
|
||
* ⚠️ 这里有一个真实踩过的坑(Element Plus 的 quartz-watch 图标):
|
||
* SVG path 允许「隐式分隔」—— `q-13.005.48-22.5` 是三个数:`-13.005`、`.48`、`-22.5`,
|
||
* 靠 `-` 和 `.` 自身当分隔符。如果简单地把 `-13.005` 四舍五入成 `-13`(丢掉小数),
|
||
* 后面的 `.48` 就会**粘成** `-13.48` —— 那是另一个坐标,图形会画错,而且不报错。
|
||
* 两条保险:
|
||
* ① 原本有小数位的数字,输出**至少保留 1 位小数**(`-13.005` → `-13.0`),
|
||
* 这样后面的 `.48` 不可能再被吸收;
|
||
* ② 归一化后**复核数字序列**,与原串逐值比对,不一致就原样返回(宁可不省体积也不许画错)。
|
||
*
|
||
* 不额外插空格:SVG 路径语法里 `5-3` 与 `5 .3` 都合法(`-`/`.` 本身就是分隔符),
|
||
* 插空格实测让 registry 涨 185KB 而无一利。
|
||
*/
|
||
export function optimizePath(d) {
|
||
const optimized = d
|
||
/* 有小数位的数:四舍五入到 2 位,去掉多余尾零但**至少留 1 位小数**。
|
||
留 1 位是关键:`-13.005` → `-13.0`(而不是 `-13`),这样后面隐式相连的
|
||
`.48` 不会被吸收成 `-13.48`。 */
|
||
.replace(/-?\d+\.\d+/g, (m) => {
|
||
const v = Math.round(parseFloat(m) * 100) / 100;
|
||
const s = v.toFixed(2).replace(/0+$/, '');
|
||
return s.endsWith('.') ? s + '0' : s;
|
||
})
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
|
||
/* 复核:优化前后的数字序列必须逐个对得上,否则原样返回(宁可不省体积也不许画错) */
|
||
if (!sameNumbers(d, optimized)) return d;
|
||
return optimized;
|
||
}
|
||
|
||
/** 抽出路径串里的数字序列(用于优化前后比对) */
|
||
export function numberSequence(d) {
|
||
return (d.match(/-?\d*\.?\d+(?:[eE][-+]?\d+)?/g) || []).map((x) => {
|
||
const v = parseFloat(x);
|
||
return Number.isFinite(v) ? v : x;
|
||
});
|
||
}
|
||
|
||
/** 两个路径串是否表示同一组数字(顺序与数值都一致) */
|
||
function sameNumbers(a, b) {
|
||
const x = numberSequence(a);
|
||
const y = numberSequence(b);
|
||
if (x.length !== y.length) return false;
|
||
for (let i = 0; i < x.length; i++) {
|
||
if (typeof x[i] === 'number' && typeof y[i] === 'number') {
|
||
/* 四舍五入会改值,所以只比较「结构」:位数一致 + 值差在 0.005 容差内 */
|
||
if (Math.abs(x[i] - y[i]) > 0.005) return false;
|
||
} else if (x[i] !== y[i]) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 读 viewBox。
|
||
*
|
||
* ⚠️ TDesign 全部 2356 个 SVG 把属性写成 `view-box`(连字符、小写 b)——
|
||
* 那是**无效属性**,浏览器会忽略它并把图标按默认 300×150 缩放。
|
||
* 这里两种拼写都认,并在产物里统一写回规范的 `viewBox`,
|
||
* 拼写错误不许流进仓库(tools/verify-icons.mjs 会卡这条)。
|
||
*/
|
||
export function parseViewBox(text) {
|
||
const m = text.match(/\sview-?Box="([^"]+)"/i);
|
||
return m ? m[1].trim() : null;
|
||
}
|
||
|
||
function die(msg) {
|
||
console.error('FAIL: ' + msg);
|
||
process.exit(1);
|
||
}
|
||
|
||
function sha256File(p) {
|
||
return createHash('sha256').update(readFileSync(p)).digest('hex');
|
||
}
|
||
|
||
/* ---------- 取源:联网下载 或 复用已解压目录 ---------- */
|
||
|
||
const fromDirArg = process.argv.indexOf('--from-dir');
|
||
const FROM_DIR = fromDirArg >= 0 ? process.argv[fromDirArg + 1] : null;
|
||
if (fromDirArg >= 0 && !FROM_DIR) die('--from-dir 需要跟一个目录参数');
|
||
|
||
const CACHE = join(ROOT, '.cache-icons');
|
||
if (!FROM_DIR && !existsSync(CACHE)) mkdirSync(CACHE, { recursive: true });
|
||
|
||
/** 返回每个 source 的「图标目录绝对路径」 */
|
||
function resolveDir(src) {
|
||
if (FROM_DIR) {
|
||
/* 离线模式:约定 --from-dir 下按 source.id 分子目录 */
|
||
const d = join(FROM_DIR, src.id, src.srcDir);
|
||
if (!existsSync(d)) die(`离线目录不存在:${d}`);
|
||
return d;
|
||
}
|
||
const tgz = join(CACHE, `${src.id}-${src.version}.tgz`);
|
||
if (!existsSync(tgz)) {
|
||
console.log(` 下载 ${src.package}@${src.version} …`);
|
||
execFileSync('curl', ['-sL', '--fail', '-m', '120', src.tarball, '-o', tgz], { stdio: 'inherit' });
|
||
}
|
||
const got = sha256File(tgz);
|
||
if (got !== src.sha256) die(`${src.id} sha256 不符\n 期望 ${src.sha256}\n 实际 ${got}`);
|
||
const extract = join(CACHE, `${src.id}-${src.version}`);
|
||
/* 先解到 temp 再改名换入:避免半解压的目录被下次运行当成"已解压"复用。
|
||
两个 Windows 实测坑:
|
||
① tar 的 `-C <绝对路径>` 在含 `C:` 时会被当成远程主机("Cannot connect to C:");
|
||
② `--one-top-level` 与 `--strip-components` 同用会解错层级。
|
||
所以改成 cwd=目标目录 + 只用 --strip-components。 */
|
||
if (!existsSync(extract)) {
|
||
const tmpName = `.tmp-${src.id}-${Date.now()}`;
|
||
const tmp = join(CACHE, tmpName);
|
||
mkdirSync(tmp, { recursive: true });
|
||
try {
|
||
/* cwd=CACHE + 相对路径:tar 把含 `C:` 的绝对路径当成远程主机
|
||
("Cannot connect to C:" 实测),所以 -C 只能用相对目录名。 */
|
||
execFileSync('tar', ['xzf', `${src.id}-${src.version}.tgz`, '-C', tmpName, '--strip-components=1'],
|
||
{ cwd: CACHE, stdio: ['ignore', 'ignore', 'pipe'] });
|
||
} catch (e) {
|
||
die(`${src.id} 解压失败:${e.stderr ? e.stderr.toString() : e.message}`);
|
||
}
|
||
renameSync(tmp, extract);
|
||
}
|
||
return join(extract, src.srcDir);
|
||
}
|
||
|
||
/* ---------- 主流程 ----------
|
||
只在「作为脚本直接运行」时执行;被 import 时(测试要复用 optimizePath /
|
||
parseSvgPaths 这些纯函数)不产生任何副作用 —— 否则 import 一次就会去联网拉包。 */
|
||
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
||
|
||
if (isMain) main();
|
||
|
||
function main() {
|
||
console.log('图标源固化');
|
||
console.log(`仓库:${ROOT}`);
|
||
|
||
const out = { schemaVersion: 1, generatedAt: null, sources: [] };
|
||
|
||
for (const src of SOURCES) {
|
||
const dir = resolveDir(src);
|
||
const files = readdirSync(dir).filter((f) => f.endsWith('.svg'));
|
||
const icons = {};
|
||
let paths = 0;
|
||
for (const f of files) {
|
||
const name = src.nameStrip ? src.nameStrip(f.replace(/\.svg$/, '')) : f.replace(/\.svg$/, '');
|
||
if (!src.filter(name)) continue;
|
||
const text = readFileSync(join(dir, f), 'utf8');
|
||
const ps = parseSvgPaths(text);
|
||
if (!ps.length) continue;
|
||
const vb = parseViewBox(text);
|
||
icons[name] = { v: vb, d: ps.map((p) => ({ p: optimizePath(p.d), m: p.mode })) };
|
||
paths += ps.length;
|
||
}
|
||
const names = Object.keys(icons);
|
||
const bytes = JSON.stringify(icons).length;
|
||
console.log(
|
||
` ${src.id.padEnd(16)} ${names.length} 个图标 / ${paths} 条路径 / ${(bytes / 1024).toFixed(1)}KB`
|
||
);
|
||
if (!names.length) die(`${src.id} 没有解析出任何图标`);
|
||
out.sources.push({
|
||
id: src.id,
|
||
package: src.package,
|
||
version: src.version,
|
||
license: src.license,
|
||
copyright: src.copyright,
|
||
homepage: src.homepage,
|
||
sha256: src.sha256,
|
||
iconCount: names.length,
|
||
icons,
|
||
});
|
||
}
|
||
|
||
out.generatedAt = new Date().toISOString().slice(0, 10);
|
||
|
||
if (!existsSync(OUT_DIR)) mkdirSync(OUT_DIR, { recursive: true });
|
||
writeFileSync(OUT, JSON.stringify(out), 'utf8');
|
||
|
||
const total = out.sources.reduce((a, s) => a + s.iconCount, 0);
|
||
console.log('');
|
||
console.log(`写入 ${OUT}`);
|
||
console.log(` 来源 ${out.sources.length} 个 | 图标合计 ${total} 个 | 文件 ${(readFileSync(OUT).length / 1024).toFixed(0)}KB`);
|
||
console.log('OK');
|
||
}
|