import fs from 'node:fs'; import path from 'node:path'; export const BRAND_SPEC_REL = '.design_library/kole-ui/brand/brand-mark.json'; export const BRAND_DATA_REL = 'site/brand-mark.generated.json'; export const BRAND_ASSET_REL = 'site/assets/kole-mark.svg'; export const BRAND_MONO_REL = 'site/assets/kole-mark-mono.svg'; export const BRAND_SCHEMA_VERSION = 3; export function readBrandSpec(root) { const file = path.join(root, BRAND_SPEC_REL); return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); } /** 路径项归一化:允许写成字符串(简化)或对象 { d, evenodd }(负空间需要 evenodd)。 */ function normalizePaths(raw) { if (!Array.isArray(raw)) return []; return raw.map((item) => { if (typeof item === 'string') return { d: item, evenodd: false }; if (item && typeof item.d === 'string') return { d: item.d, evenodd: !!item.evenodd }; return { d: '', evenodd: false }; }); } export function validateBrandSpec(spec) { const errors = []; if (!spec || spec.schemaVersion !== BRAND_SCHEMA_VERSION) { errors.push(`schemaVersion 必须为 ${BRAND_SCHEMA_VERSION}`); } if (!spec || spec.viewBox !== '0 0 24 24') errors.push('viewBox 必须为 0 0 24 24'); const g = spec?.geometry || {}; const paths = normalizePaths(g.paths); if (!paths.length || paths.some((p) => !p.d.trim())) { errors.push('geometry.paths 必须包含至少一条非空路径(字符串或 { d, evenodd })'); } if (g.mode !== 'fill' && g.mode !== 'stroke') errors.push('geometry.mode 必须为 fill 或 stroke'); if (g.mode === 'stroke') { if (g.strokeWidth !== 1.5) errors.push('stroke 模式的 geometry.strokeWidth 必须为 1.5'); if (g.linecap !== 'round') errors.push('stroke 模式的 geometry.linecap 必须为 round'); if (g.linejoin !== 'round') errors.push('stroke 模式的 geometry.linejoin 必须为 round'); } if (g.opacities !== undefined) { if (!Array.isArray(g.opacities) || g.opacities.length !== paths.length) { errors.push('geometry.opacities 必须与 paths 等长'); } else if (g.opacities.some((v) => typeof v !== 'number' || !(v > 0) || v > 1)) { errors.push('geometry.opacities 取值必须在 (0, 1] 区间'); } } if (!/^#[0-9A-Fa-f]{6}$/.test(spec?.favicon?.background || '')) errors.push('favicon.background 必须为六位十六进制颜色'); if (!/^#[0-9A-Fa-f]{6}$/.test(spec?.favicon?.foreground || '')) errors.push('favicon.foreground 必须为六位十六进制颜色'); if (spec?.favicon?.radius !== 6) errors.push('favicon.radius 必须为 6'); for (const [key, value] of Object.entries(spec?.sizes || {})) { if (!Number.isFinite(value) || value <= 0) errors.push(`sizes.${key} 必须为正数`); } return errors; } /** 逐路径生成 :负空间路径必须带 fill-rule="evenodd",否则挖空不会生效。 */ function pathsMarkup(spec) { const { mode, opacities } = spec.geometry; return normalizePaths(spec.geometry.paths) .map((p, i) => { const opacity = mode === 'fill' && opacities && opacities[i] != null ? ` fill-opacity="${opacities[i]}"` : ''; const rule = p.evenodd ? ' fill-rule="evenodd"' : ''; return ``; }) .join(''); } /** 图形的公共外层:实心走 fill,描边走 stroke;颜色由调用方决定。 */ function groupMarkup(spec, color, { mono }) { const { mode } = spec.geometry; const strokeAttrs = `fill="none" stroke="${mono ? '#000000' : color}" stroke-width="${spec.geometry.strokeWidth}"` + ` stroke-linecap="${spec.geometry.linecap}" stroke-linejoin="${spec.geometry.linejoin}"`; return mode === 'stroke' ? `${pathsMarkup(spec)}` : `${pathsMarkup(spec)}`; } export function renderFaviconSvg(spec) { return [ ``, ``, groupMarkup(spec, spec.favicon.foreground, { mono: false }), '', ].join(''); } export function renderMonoSvg(spec) { return [ ``, groupMarkup(spec, '#000000', { mono: true }), '', ].join(''); } export function generatedData(spec) { return { schemaVersion: spec.schemaVersion, name: spec.name, viewBox: spec.viewBox, geometry: { mode: spec.geometry.mode, /* 归一化后再写出:字符串项补成 { d, evenodd } 对象,保证下游拿到的结构稳定一致。 */ paths: normalizePaths(spec.geometry.paths), ...(spec.geometry.opacities ? { opacities: spec.geometry.opacities } : {}), ...(spec.geometry.mode === 'stroke' ? { strokeWidth: spec.geometry.strokeWidth, linecap: spec.geometry.linecap, linejoin: spec.geometry.linejoin, } : {}), }, favicon: { background: spec.favicon.background, foreground: spec.favicon.foreground, radius: spec.favicon.radius, }, sizes: spec.sizes, assets: { favicon: 'assets/kole-mark.svg', mono: 'assets/kole-mark-mono.svg', }, }; } export function atomicWrite(file, content) { fs.mkdirSync(path.dirname(file), { recursive: true }); const tmp = `${file}.tmp-${process.pid}`; fs.writeFileSync(tmp, content, 'utf8'); fs.renameSync(tmp, file); }