本提交含两条并行工作线,因互相咬合(package.json scripts、regression.yml、 npm run build 链)无法按文件干净拆分,故合并为一次自洽提交。 ## 详情页减重(本轮主任务:修复「AI 干太重」) - 导航收敛:宽屏只用右侧目录、≤1200px 只用页内 sticky 导航,纯 CSS 媒体查询 实现(不引入 JS 宽度监听)。此前两套导航同时可见,active 状态互相打架。 - 入口去重:标题区由「查看示例/在线测试/契约 JSON」三个减为「在线测试」一个; 契约 JSON 归入实现资源;删除示例底部重复的「在线测试」。 - 示例工具栏:删除与目录锚点重复的示例下拉选择器;「全部展开代码」只在 示例数 >1 时出现(103 个组件里 49 个仅 1 个示例,此前恒显示)。 - 重复文案:示例区两句同义导语合并为一句。 - 首页 CTA 由 5 个减为 2 个(浏览组件/快速开始),测试总览入口挂到已有的 通过率统计卡上,不再另占 Hero 按钮。 - 统一详情取数:抽出 fetchDetail/loadDetail 作为 details/*.json 的唯一路径, FAQ 不再自行 fetch 一遍,与组件页共用缓存与失败兜底。 ## i18n - 删除 12 组重复键(含整段 FAQ 说明),字典 604 → 585 唯一键。 - 删除本次改动产生的 6 个死键。 - verify-i18n.mjs 新增 `unique dictionary keys` 断言:重复键在对象字面量里 是静默的后值覆盖,此前无从发现;现由门禁拦住。 ## 文档事实修正(实测为准) - TESTING/CONTRIBUTING:79 → 103 组件;旧断言数改为回指 tests/report.json。 - PLATFORMS:移动端 108 文件/18 端 → 282 文件/47 端;契约 5 → 47; 令牌 17 → 15;uni-app SFC 21 → 50。 - AGENTS:断言 1405/18 页 → 1464/103 页(PC)、807/47 页(移动端)。 - package.json:YOUR-ACCOUNT 占位 → gitea 实址与 kole-ui.mymoyu.top。 ## 品牌标识(并行会话成果,一并入库) - brand-mark.json 收归真源,build:brand 生成 favicon 与单色 SVG; PC 与移动端共用资产,verify:brand 24 条断言。 - regression.yml 增加 verify:brand 步骤。 ## 门禁与验收 新增工具:verify-component-page.mjs(86 条真实浏览器断言,随详情页改造同步 更新为「只允许一套导航可见」)、verify-brand-mark.mjs、lib/i18n-dead-keys.mjs (只读诊断)。 回归:PC 100%(1464/1464,103 页,N/A 50)· 移动端 100%(807/807,47 页)。 门禁:component-page 86 · i18n 17 · brand 24 · routes all · smoke all · theme OK · isolation 31 · nav all · examples 9 · api-docs 10 · mobile-docs 12。 已知未做:i18n 另有约 44 条历史死键(非本次产生),已记为 ROADMAP S8-P6; 顶栏与悬浮区的两个主题入口为刻意设计(verify-theme 断言其互斥),未删。
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* build-brand-mark.mjs — 构建 Kole UI 项目品牌图标。
|
||||
*
|
||||
* 真源:.design_library/kole-ui/brand/brand-mark.json
|
||||
* 产物:site/assets/kole-mark.svg、site/assets/kole-mark-mono.svg、
|
||||
* site/brand-mark.generated.json
|
||||
*/
|
||||
import path from 'node:path';
|
||||
import {
|
||||
atomicWrite,
|
||||
generatedData,
|
||||
readBrandSpec,
|
||||
renderFaviconSvg,
|
||||
renderMonoSvg,
|
||||
validateBrandSpec,
|
||||
BRAND_ASSET_REL,
|
||||
BRAND_DATA_REL,
|
||||
BRAND_MONO_REL,
|
||||
} from './lib/brand-mark.mjs';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const spec = readBrandSpec(ROOT);
|
||||
const errors = validateBrandSpec(spec);
|
||||
if (errors.length) {
|
||||
console.error('[build-brand] FAIL');
|
||||
errors.forEach((error) => console.error(' - ' + error));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const favicon = renderFaviconSvg(spec);
|
||||
const mono = renderMonoSvg(spec);
|
||||
const data = generatedData(spec);
|
||||
|
||||
atomicWrite(path.join(ROOT, BRAND_ASSET_REL), favicon + '\n');
|
||||
atomicWrite(path.join(ROOT, BRAND_MONO_REL), mono + '\n');
|
||||
atomicWrite(path.join(ROOT, BRAND_DATA_REL), JSON.stringify(data, null, 2) + '\n');
|
||||
|
||||
console.log('[build-brand] OK');
|
||||
console.log(` name : ${spec.name}`);
|
||||
const paint = spec.geometry.mode === 'stroke'
|
||||
? `${spec.geometry.strokeWidth}px 描边`
|
||||
: `实心${spec.geometry.opacities ? `(${spec.geometry.opacities.length} 层透明度)` : ''}`;
|
||||
console.log(` geometry : ${spec.geometry.paths.length} 个模块 · ${paint} @24`);
|
||||
console.log(` favicon : ${BRAND_ASSET_REL}`);
|
||||
console.log(` mono : ${BRAND_MONO_REL}`);
|
||||
console.log(` generated : ${BRAND_DATA_REL}`);
|
||||
+20
-33
@@ -31,6 +31,7 @@ import {
|
||||
import { createHash } from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join, relative, sep } from 'node:path';
|
||||
import { generatedData, readBrandSpec, validateBrandSpec } from './lib/brand-mark.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..');
|
||||
@@ -44,6 +45,18 @@ const TESTS = join(ROOT, 'tests', 'mobile');
|
||||
const WEBSITE = join(ROOT, 'site', 'm');
|
||||
const DIST = join(ROOT, 'dist', 'mobile');
|
||||
const PC_INDEX = join(ROOT, '.design_library', 'kole-ui', 'components', 'index.json');
|
||||
const BRAND_SPEC = readBrandSpec(ROOT);
|
||||
const BRAND_ERRORS = validateBrandSpec(BRAND_SPEC);
|
||||
if (BRAND_ERRORS.length) {
|
||||
throw new Error(`[build-mobile] 品牌图标规格无效:${BRAND_ERRORS.join(';')}`);
|
||||
}
|
||||
if (!existsSync(join(ROOT, 'site', 'assets', 'kole-mark.svg')) || !existsSync(join(ROOT, 'site', 'assets', 'kole-mark-mono.svg'))) {
|
||||
throw new Error('[build-mobile] 缺品牌 SVG 产物,请先运行 npm run build:brand');
|
||||
}
|
||||
const brandDataFile = join(ROOT, 'site', 'brand-mark.generated.json');
|
||||
if (!existsSync(brandDataFile) || JSON.stringify(JSON.parse(readFileSync(brandDataFile, 'utf8')), null, 2) + '\n' !== JSON.stringify(generatedData(BRAND_SPEC), null, 2) + '\n') {
|
||||
throw new Error('[build-mobile] brand-mark.generated.json 与品牌规格不同步,请先运行 npm run build:brand');
|
||||
}
|
||||
|
||||
/** 允许写入的前缀(相对仓库根,POSIX 风格) */
|
||||
const ALLOWED_PREFIXES = ['site/m/', 'tests/mobile/', 'dist/mobile/'];
|
||||
@@ -327,37 +340,10 @@ function tokensOf(slug) {
|
||||
平台切换**不在顶栏**:PC 顶栏也没有它 —— 两端的唯一入口都是左栏「平台」组。
|
||||
2026-09-20 去重:此前顶栏另有一个 [PC 端][移动端] 胶囊,与左栏指向同一跳转
|
||||
(实测两条链接 href 均为 ../index.html),是重复入口,已删。 */
|
||||
/* ---------- 品牌标识(与 PC 站同源几何)----------
|
||||
几何真源:24 网格三个互不接触的笔画组成的 K。同一份路径同时用于:
|
||||
① favicon(BRAND_FAVICON,内联 data-URI)
|
||||
② 两端顶栏标记(BRAND_MARK_*,内联 SVG,走 currentColor)
|
||||
描边 2.25 @24 网格 → 16px 下正好 1.5px,等于规范原文「线性图标,描边1.5px」
|
||||
(.design_library/kole-ui/specs/组件1.txt)。
|
||||
favicon 里硬编码品牌蓝/白而不用令牌:它渲染在**浏览器标签栏**,不继承 html.kole-dark;
|
||||
顶栏标记必须走 currentColor 才会随主题令牌反色 —— 两者分工不同,不要混。 */
|
||||
const BRAND_PATHS = [
|
||||
'M7 5.5V18.5',
|
||||
'M17 5.5l-6.25 4.06',
|
||||
'M10.75 14.44L17 18.5',
|
||||
]
|
||||
.map((d) => `<path d="${d}"/>`)
|
||||
.join('');
|
||||
const BRAND_SW = '2.25';
|
||||
/* 移动端顶栏盒 26×26(PC 是 32×32 / 20px 标记),标记取 17px 保持同视觉重量 */
|
||||
const BRAND_MARK = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${BRAND_SW}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${BRAND_PATHS}</svg>`;
|
||||
const BRAND_FAVICON = (() => {
|
||||
const svg =
|
||||
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'>` +
|
||||
`<rect width='24' height='24' rx='6' fill='#2F54EB'/>` +
|
||||
`<g fill='none' stroke='#FFFFFF' stroke-width='${BRAND_SW}' stroke-linecap='round' stroke-linejoin='round'>` +
|
||||
BRAND_PATHS.split('"').join("'") +
|
||||
`</g></svg>`;
|
||||
/* 与 site/index.html 的 favicon 同编码规则:只转义 < > 空格 #,属性单引号原样 */
|
||||
return (
|
||||
'data:image/svg+xml,' +
|
||||
svg.replace(/</g, '%3C').replace(/>/g, '%3E').replace(/ /g, '%20').replace(/#/g, '%23')
|
||||
);
|
||||
})();
|
||||
/* ---------- 品牌标识(brand-mark.json 唯一真源)----------
|
||||
移动端静态页使用相对站点根的独立 SVG favicon;顶栏标记由 site/m/style.css
|
||||
以同一规格生成的单色 SVG mask 渲染。这样构建器不再复制或拼接一套几何路径。 */
|
||||
const BRAND_FAVICON_HREF = '../assets/kole-mark.svg';
|
||||
|
||||
function headerHtml(activeKey, isComp) {
|
||||
const toRoot = isComp ? '../' : '';
|
||||
@@ -370,7 +356,7 @@ function headerHtml(activeKey, isComp) {
|
||||
return ` <header class="m-top">
|
||||
<div class="m-top-inner">
|
||||
<a class="m-logo" href="${toRoot}index.html">
|
||||
<span class="m-logo-mark" aria-hidden="true">${BRAND_MARK}</span>
|
||||
<span class="m-logo-mark" data-brand-mark="mono" aria-hidden="true"></span>
|
||||
<span class="m-logo-text">Kole UI<em>移动端</em></span>
|
||||
</a>
|
||||
<div class="m-ver-picker" id="m-ver-picker">
|
||||
@@ -856,11 +842,12 @@ function renderSitePage(templateName, repl) {
|
||||
__BRAND_HEAD__ 由模板放在 <head> 里(本站此前完全没有 favicon,浏览器标签页是
|
||||
默认地球图标);theme-color 取 --kole-color-card-bg —— .m-top 的 background 就是它。 */
|
||||
function shellRepl(activeKey, isComp) {
|
||||
const faviconHref = isComp ? '../../assets/kole-mark.svg' : BRAND_FAVICON_HREF;
|
||||
return {
|
||||
__HEADER__: headerHtml(activeKey, isComp),
|
||||
__THEME_BOOT__: THEME_BOOT,
|
||||
__BRAND_HEAD__: [
|
||||
`<link rel="icon" href="${BRAND_FAVICON}">`,
|
||||
`<link rel="icon" href="${faviconHref}">`,
|
||||
'<meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)">',
|
||||
'<meta name="theme-color" content="#1C1F26" media="(prefers-color-scheme: dark)">',
|
||||
].join('\n'),
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
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 = 2;
|
||||
|
||||
export function readBrandSpec(root) {
|
||||
const file = path.join(root, BRAND_SPEC_REL);
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
|
||||
}
|
||||
|
||||
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 = g.paths;
|
||||
if (!Array.isArray(paths) || !paths.length || paths.some((d) => typeof d !== 'string' || !d.trim())) {
|
||||
errors.push('geometry.paths 必须包含至少一条非空路径');
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/** 逐路径生成 <path>:实心模式按 opacities 给 fill-opacity;描边模式交给外层 <g> 统一给描边属性。 */
|
||||
function pathsMarkup(spec) {
|
||||
const { paths, mode, opacities } = spec.geometry;
|
||||
return paths
|
||||
.map((d, i) => {
|
||||
const opacity = mode === 'fill' && opacities && opacities[i] != null ? ` fill-opacity="${opacities[i]}"` : '';
|
||||
return `<path d="${d}"${opacity}/>`;
|
||||
})
|
||||
.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'
|
||||
? `<g ${strokeAttrs}>${pathsMarkup(spec)}</g>`
|
||||
: `<g fill="${mono ? '#000000' : color}">${pathsMarkup(spec)}</g>`;
|
||||
}
|
||||
|
||||
export function renderFaviconSvg(spec) {
|
||||
return [
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${spec.viewBox}">`,
|
||||
`<rect width="24" height="24" rx="${spec.favicon.radius}" fill="${spec.favicon.background}"/>`,
|
||||
groupMarkup(spec, spec.favicon.foreground, { mono: false }),
|
||||
'</svg>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
export function renderMonoSvg(spec) {
|
||||
return [
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${spec.viewBox}">`,
|
||||
groupMarkup(spec, '#000000', { mono: true }),
|
||||
'</svg>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
export function generatedData(spec) {
|
||||
return {
|
||||
schemaVersion: spec.schemaVersion,
|
||||
name: spec.name,
|
||||
viewBox: spec.viewBox,
|
||||
geometry: {
|
||||
mode: spec.geometry.mode,
|
||||
paths: 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);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* i18n-dead-keys.mjs — 精确列出「确定死亡」的字典键,供人工确认后删除。
|
||||
*
|
||||
* 判定分两档(避免误删用拼接方式消费的键):
|
||||
* DEAD — 键串在 site/ 任何源文件(除 i18n.js)中都不出现,也不作为任何文件的子串。
|
||||
* REVIEW — 键串只作为更长字符串的一部分出现(典型是 T('前缀') + x 拼出来的),
|
||||
* 必须人工判断,工具不当作可删。
|
||||
*
|
||||
* 只读:不修改任何文件。
|
||||
*/
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join, extname } from 'node:path';
|
||||
|
||||
const ROOT = join(import.meta.dirname, '..', '..');
|
||||
const SITE = join(ROOT, 'site');
|
||||
const i18n = readFileSync(join(SITE, 'i18n.js'), 'utf8');
|
||||
const block = i18n.match(/var\s+EN\s*=\s*\{([\s\S]*?)\n\s*\};/);
|
||||
if (!block) { console.error('FAIL: 没找到 EN 字典块'); process.exit(1); }
|
||||
|
||||
const keys = [];
|
||||
const re = /^[ \t]*(["'])((?:\\.|(?!\1)[\s\S])*?)\1[ \t]*:/gm;
|
||||
let m;
|
||||
while ((m = re.exec(block[1])) !== null) keys.push(m[2].replace(/\\(["'])/g, '$1'));
|
||||
|
||||
/* 消费方:site 下的 js / html(i18n.js 本身除外);changelog.json、data.json 是构建产物,
|
||||
其中的中文是历史记录,不代表 i18n 键被消费,故不纳入。 */
|
||||
const consumers = readdirSync(SITE)
|
||||
.filter((f) => (extname(f) === '.js' && f !== 'i18n.js') || extname(f) === '.html')
|
||||
.map((f) => readFileSync(join(SITE, f), 'utf8'))
|
||||
.join('\n');
|
||||
|
||||
const dead = [];
|
||||
const review = [];
|
||||
for (const key of keys) {
|
||||
if (consumers.includes(key)) continue; // 精确命中:在用
|
||||
/* 是否作为更长字符串的一部分出现 —— 拼接消费的迹象 */
|
||||
const probe = key.length > 8 ? key.slice(0, 8) : key;
|
||||
if (consumers.includes(probe)) review.push(key); // 疑似拼接消费,人工判断
|
||||
else dead.push(key);
|
||||
}
|
||||
|
||||
console.log(`字典 ${keys.length} 键 · 确定死亡 ${dead.length} · 待人工判断 ${review.length}\n`);
|
||||
console.log('=== DEAD(键串在 site/ 源文件中完全不出现)===');
|
||||
for (const k of dead) console.log(` ${k}`);
|
||||
console.log('\n=== REVIEW(疑似以拼接方式消费,不要自动删)===');
|
||||
for (const k of review) console.log(` ${k}`);
|
||||
@@ -46,6 +46,7 @@ const MUST_ABSENT = [
|
||||
const MUST_PRESENT = [
|
||||
'site/index.html', 'site/app.js', 'site/logger.js', 'site/data.json', 'site/data.js',
|
||||
'site/tokens/tokens.css', 'site/style.css', 'site/i18n.js',
|
||||
'site/assets/kole-mark.svg', 'site/assets/kole-mark-mono.svg', 'site/brand-mark.generated.json',
|
||||
/* 逐示例用法片段(P21):目录留在 data.js,正文按组件懒加载;缺了页面就没有「一个使用场景一块代码」 */
|
||||
'site/examples/button.json',
|
||||
'frameworks/Button.css', 'frameworks/Button.html', 'frameworks/Button.vue3.vue',
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* verify-brand-mark.mjs — 项目品牌图标静态门禁。
|
||||
* 组件 Icon registry 是另一套系统,本脚本只检查 favicon 与文档站品牌标记。
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { collectPublishSet } from './lib/publish-set.mjs';
|
||||
import {
|
||||
generatedData,
|
||||
readBrandSpec,
|
||||
renderFaviconSvg,
|
||||
renderMonoSvg,
|
||||
validateBrandSpec,
|
||||
BRAND_ASSET_REL,
|
||||
BRAND_DATA_REL,
|
||||
BRAND_MONO_REL,
|
||||
BRAND_SPEC_REL,
|
||||
} from './lib/brand-mark.mjs';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8').replace(/^\uFEFF/, '');
|
||||
const exists = (rel) => fs.existsSync(path.join(ROOT, rel));
|
||||
let pass = 0;
|
||||
const failures = [];
|
||||
function check(ok, label, detail = '') {
|
||||
if (ok) {
|
||||
pass++;
|
||||
console.log(` PASS ${label}${detail ? ` — ${detail}` : ''}`);
|
||||
} else {
|
||||
failures.push(`${label}${detail ? ` — ${detail}` : ''}`);
|
||||
console.log(` FAIL ${label}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Kole UI 项目品牌图标门禁');
|
||||
check(exists(BRAND_SPEC_REL), 'B1 品牌规格存在', BRAND_SPEC_REL);
|
||||
check(exists(BRAND_ASSET_REL), 'B2 favicon SVG 存在', BRAND_ASSET_REL);
|
||||
check(exists(BRAND_MONO_REL), 'B3 单色 SVG 存在', BRAND_MONO_REL);
|
||||
check(exists(BRAND_DATA_REL), 'B4 生成数据存在', BRAND_DATA_REL);
|
||||
|
||||
const spec = readBrandSpec(ROOT);
|
||||
const specErrors = validateBrandSpec(spec);
|
||||
check(specErrors.length === 0, 'B5 品牌规格结构有效', specErrors.join(';'));
|
||||
|
||||
const expectedFavicon = renderFaviconSvg(spec) + '\n';
|
||||
const expectedMono = renderMonoSvg(spec) + '\n';
|
||||
const expectedData = JSON.stringify(generatedData(spec), null, 2) + '\n';
|
||||
const favicon = exists(BRAND_ASSET_REL) ? read(BRAND_ASSET_REL) : '';
|
||||
const mono = exists(BRAND_MONO_REL) ? read(BRAND_MONO_REL) : '';
|
||||
const data = exists(BRAND_DATA_REL) ? read(BRAND_DATA_REL) : '';
|
||||
check(favicon === expectedFavicon, 'B6 favicon 是规格生成物');
|
||||
check(mono === expectedMono, 'B7 单色标记是规格生成物');
|
||||
check(data === expectedData, 'B8 generated JSON 是规格生成物');
|
||||
check(!/<image\b|(?:href|src)=["']https?:\/\//i.test(favicon + mono), 'B9 品牌 SVG 不含外部资源');
|
||||
check((favicon.match(/<path\b/g) || []).length === spec.geometry.paths.length, 'B10 favicon 完整包含全部几何路径', `${spec.geometry.paths.length} 条`);
|
||||
check((mono.match(/<path\b/g) || []).length === spec.geometry.paths.length, 'B11 单色标记完整包含全部几何路径', `${spec.geometry.paths.length} 条`);
|
||||
const faviconPaint = spec.geometry.mode === 'fill' ? `fill="${spec.favicon.foreground}"` : `stroke="${spec.favicon.foreground}"`;
|
||||
check(favicon.includes(`fill="${spec.favicon.background}"`) && favicon.includes(faviconPaint), 'B12 favicon 颜色固定');
|
||||
const monoPaint = spec.geometry.mode === 'fill' ? 'fill="#000000"' : 'stroke="#000000"';
|
||||
check(mono.includes(monoPaint) && !mono.includes(`fill="${spec.favicon.background}"`), 'B13 单色标记透明背景');
|
||||
|
||||
const pc = read('site/index.html');
|
||||
const mobileBuilder = read('tools/build-mobile.mjs');
|
||||
const mobileStyle = read('site/m/style.css');
|
||||
check(pc.includes('href="assets/kole-mark.svg"'), 'B14 PC 使用 favicon 资产');
|
||||
check(pc.includes('class="logo-mark"') && pc.includes('data-brand-mark="mono"'), 'B15 PC 顶栏使用单色品牌标记');
|
||||
check(!pc.includes('M7 5.5V18.5'), 'B16 PC 不再内联旧几何路径');
|
||||
check(mobileBuilder.includes("readBrandSpec(ROOT)"), 'B17 移动端构建读取品牌规格');
|
||||
check(!mobileBuilder.includes('const BRAND_PATHS ='), 'B18 移动端构建不重复维护几何路径');
|
||||
check(mobileStyle.includes('data-brand-mark="mono"') || mobileStyle.includes('kole-mark-mono.svg'), 'B19 移动端样式声明单色品牌资产');
|
||||
check((pc.match(/theme-color/g) || []).length >= 2, 'B20 PC theme-color 保留亮暗两态');
|
||||
|
||||
const mobileIndex = JSON.parse(read('.design_library/kole-ui-mobile/components/index.json'));
|
||||
const mobilePages = [
|
||||
...['index', 'guide', 'design', 'faq', 'changelog', 'platform'].map((name) => `site/m/${name}.html`),
|
||||
...mobileIndex.components.map((component) => `site/m/component/${component.slug}.html`),
|
||||
];
|
||||
const pageProblems = [];
|
||||
for (const rel of mobilePages) {
|
||||
if (!exists(rel)) { pageProblems.push(`${rel}: 文件缺失`); continue; }
|
||||
const html = read(rel);
|
||||
const head = (html.match(/<head\b[^>]*>([\s\S]*?)<\/head>/i) || [])[1] || '';
|
||||
const icons = [...head.matchAll(/<link\b[^>]*rel="icon"[^>]*>/g)];
|
||||
const href = icons.length === 1 ? (icons[0][0].match(/\bhref="([^"]+)"/) || [])[1] : '';
|
||||
if (!href || path.posix.normalize(path.posix.join(path.posix.dirname(rel), href)) !== BRAND_ASSET_REL) {
|
||||
pageProblems.push(`${rel}: favicon 未指向统一资产`);
|
||||
}
|
||||
const header = (html.match(/<header class="m-top">([\s\S]*?)<\/header>/) || [])[1] || '';
|
||||
if (!header.includes('<span class="m-logo-mark" data-brand-mark="mono" aria-hidden="true"></span>')) {
|
||||
pageProblems.push(`${rel}: 顶栏标记或装饰性语义缺失`);
|
||||
}
|
||||
if (header.includes('M7 5.5V18.5') || (head.match(/name="theme-color"/g) || []).length !== 2) {
|
||||
pageProblems.push(`${rel}: 旧几何残留或 theme-color 缺失`);
|
||||
}
|
||||
}
|
||||
check(pageProblems.length === 0, 'B21 移动端全部生成页使用统一品牌资产', pageProblems.slice(0, 5).join(';') || `${mobilePages.length} 页`);
|
||||
const pcStyle = read('site/style.css');
|
||||
const pcMask = (pcStyle.match(/\.logo-mark\[data-brand-mark="mono"\]::before\s*\{([^}]+)\}/) || [])[1] || '';
|
||||
const mobileMask = (mobileStyle.match(/\.m-logo-mark\[data-brand-mark="mono"\]::before\s*\{([^}]+)\}/) || [])[1] || '';
|
||||
check(pcMask.includes('background: currentColor') && pcMask.includes("url('assets/kole-mark-mono.svg')")
|
||||
&& mobileMask.includes('background: currentColor') && mobileMask.includes("url('../assets/kole-mark-mono.svg')"),
|
||||
'B22 两端 mask 路径同源且继承 currentColor');
|
||||
const published = new Set(collectPublishSet(ROOT).kept);
|
||||
check([BRAND_ASSET_REL, BRAND_MONO_REL, BRAND_DATA_REL].every((rel) => published.has(rel)), 'B23 发布集包含全部品牌产物');
|
||||
const scripts = JSON.parse(read('package.json')).scripts;
|
||||
check(scripts['build:brand'] === 'node tools/build-brand-mark.mjs'
|
||||
&& scripts['verify:brand'] === 'node tools/verify-brand-mark.mjs'
|
||||
&& scripts.build.startsWith('node tools/build-brand-mark.mjs && '), 'B24 品牌构建与门禁入口完整');
|
||||
|
||||
if (failures.length) {
|
||||
console.error(`\n[FAIL] ${failures.length} 条品牌图标断言失败(通过 ${pass} 条)`);
|
||||
failures.forEach((failure) => console.error(' - ' + failure));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\n[OK] 品牌图标门禁全部通过(${pass} 条断言)`);
|
||||
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env node
|
||||
import { chromium } from 'playwright';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const BASE = process.env.REG_BASE || 'http://127.0.0.1:3311';
|
||||
const failures = [];
|
||||
let checks = 0;
|
||||
function check(ok, message) {
|
||||
checks++;
|
||||
if (!ok) failures.push(message);
|
||||
console.log(`[component-page] ${ok ? 'OK' : 'FAIL'} ${message}`);
|
||||
}
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('kole-lang', 'zh');
|
||||
localStorage.setItem('kole-mode', 'light');
|
||||
});
|
||||
async function ready(slug, suffix = 'h5') {
|
||||
await page.goto(`${BASE}/site/component/${slug}/${suffix}`, { waitUntil: 'networkidle' });
|
||||
await page.waitForFunction(() => {
|
||||
const root = document.querySelector('.kole-component-page');
|
||||
return root && root.dataset.examplesReady !== 'pending' && root.dataset.detailReady !== 'pending';
|
||||
});
|
||||
}
|
||||
/* 宽屏(>1200px)右侧目录是唯一导航,窄屏它被隐藏、页内 sticky 导航接管。
|
||||
active 状态与可达性都要按当前生效的那套来判,不能要求两套同时可见。 */
|
||||
const visibleNav = () => page.evaluate(() => {
|
||||
const toc = document.querySelector('#toc');
|
||||
const localNav = document.querySelector('.kole-component-nav');
|
||||
const tocVisible = !!toc && getComputedStyle(toc).display !== 'none' && toc.getBoundingClientRect().width > 0;
|
||||
const navVisible = !!localNav && !localNav.hidden && localNav.getBoundingClientRect().width > 0;
|
||||
return { tocVisible, navVisible };
|
||||
});
|
||||
try {
|
||||
const served = await (await page.request.get(`${BASE}/site/app.js`)).text();
|
||||
check(served === readFileSync(new URL('../site/app.js', import.meta.url), 'utf8'), 'preview serves the current workspace app.js');
|
||||
for (const slug of ['button', 'input', 'table', 'modal', 'dragupload', 'icon']) {
|
||||
await ready(slug);
|
||||
const structure = await page.evaluate(() => {
|
||||
const root = document.querySelector('.kole-component-page');
|
||||
const sections = [...root.querySelectorAll(':scope > section')].map(x => x.id);
|
||||
const ids = [...root.querySelectorAll('[id]')].map(x => x.id);
|
||||
const anchors = [...document.querySelectorAll('#toc [data-anchor], .kole-component-nav [data-anchor]')];
|
||||
const toc = [...document.querySelectorAll('#toc [data-anchor]')].map(x => document.getElementById(x.dataset.anchor));
|
||||
return {
|
||||
sections,
|
||||
unique: new Set(ids).size === ids.length,
|
||||
validAnchors: anchors.every(x => document.getElementById(x.dataset.anchor)),
|
||||
tocOrdered: toc.every((x, i) => !i || !!(toc[i-1].compareDocumentPosition(x) & Node.DOCUMENT_POSITION_FOLLOWING)),
|
||||
exampleCount: root.querySelectorAll('.ex-card').length,
|
||||
openCode: root.querySelectorAll('.ex-code:not([hidden])').length,
|
||||
hasApi: !!root.querySelector('#api'),
|
||||
apiLink: !!root.querySelector('.kole-component-nav [data-anchor="api"]'),
|
||||
/* #toc 在 .kole-component-page 之外(右侧栏),必须用文档级查询 ——
|
||||
用 root.querySelector('#toc …') 会恒为 null,让这条断言变成假通过。 */
|
||||
tocApiLink: !!document.querySelector('#toc [data-anchor="api"]'),
|
||||
reference: !!root.querySelector('#design #spec .kole-spec-disclosure'),
|
||||
wrappedTables: [...root.querySelectorAll('table')].every(x => x.parentElement.classList.contains('kole-doc-table')),
|
||||
pickers: root.querySelectorAll('.kole-example-picker').length,
|
||||
expandAll: root.querySelectorAll('.kole-expand-code').length
|
||||
};
|
||||
});
|
||||
check(structure.sections[0] === 'demo' && structure.sections.includes('design'), `${slug}: examples precede reference material`);
|
||||
check(structure.unique && structure.validAnchors && structure.tocOrdered, `${slug}: unique anchors and TOC match document order`);
|
||||
check(structure.exampleCount === 0 || structure.openCode === 1, `${slug}: first example code is open, remaining code is folded`);
|
||||
check(structure.hasApi === structure.tocApiLink, `${slug}: API navigation matches available data`);
|
||||
check(structure.reference && structure.wrappedTables, `${slug}: specification and tables remain accessible`);
|
||||
/* 入口减重:示例选择器已删(跳转由目录承担);
|
||||
「全部展开代码」只在多示例时出现 —— 单示例时它没有可批量操作的对象。 */
|
||||
check(structure.pickers === 0, `${slug}: the duplicate example picker is gone`);
|
||||
check(structure.expandAll === (structure.exampleCount > 1 ? 1 : 0), `${slug}: expand-all only exists for multi-example pages (examples=${structure.exampleCount}, buttons=${structure.expandAll})`);
|
||||
const nav = await visibleNav();
|
||||
check(nav.tocVisible !== nav.navVisible, `${slug}: exactly one navigation is visible at 1440px (toc=${nav.tocVisible}, local=${nav.navVisible})`);
|
||||
}
|
||||
|
||||
await ready('button');
|
||||
const payload = JSON.parse(readFileSync(new URL('../site/examples/button.json', import.meta.url), 'utf8'));
|
||||
const first = page.locator('.ex-card').first();
|
||||
const firstId = await first.getAttribute('id');
|
||||
await page.evaluate(() => document.querySelector('.ex-card').dataset.identity = 'retained');
|
||||
await first.locator('.kole-example-toggle').focus();
|
||||
await page.keyboard.press('Enter');
|
||||
check(await first.locator('.ex-code').isHidden() && await first.locator('.kole-example-toggle').getAttribute('aria-expanded') === 'false', 'keyboard folds the code with accurate aria-expanded');
|
||||
await page.locator('.kole-expand-code').click();
|
||||
check(await page.locator('.ex-code[hidden]').count() === 0, 'expand all opens every usage snippet');
|
||||
await first.locator('.ex-tabs [data-kind="jsx"]').click();
|
||||
check((await first.locator('code').textContent()) === payload.examples[0].code.jsx, 'React tab shows the exact generated usage snippet');
|
||||
check(await first.locator('.ex-tabs [aria-pressed="true"]').count() === 1, 'exactly one example stack is selected');
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
await first.locator('.code-copy').click();
|
||||
await page.waitForFunction(() => document.querySelector('.ex-card .code-copy').textContent.includes('已复制'));
|
||||
const copied = await page.evaluate(() => navigator.clipboard.readText());
|
||||
// Windows clipboard normalizes LF to CRLF; only normalize line endings, not text or whitespace.
|
||||
// A missing prop, different stack or altered indentation must still fail this exact comparison.
|
||||
check(copied.replace(/\r\n/g, '\n') === payload.examples[0].code.jsx.replace(/\r\n/g, '\n'), 'copy uses the active stack snippet');
|
||||
await page.locator('.kole-expand-code').click();
|
||||
check(await page.locator('.ex-code:not([hidden])').count() === 0, 'collapse all folds every snippet');
|
||||
|
||||
/* 宽屏用右侧目录做节间跳转(页内 sticky 导航此时隐藏) */
|
||||
await page.locator('#toc [data-anchor="api"]').click();
|
||||
await page.waitForFunction(() => document.querySelector('#toc [data-anchor="api"]').getAttribute('aria-current') === 'location');
|
||||
check(await page.locator('.ex-card').first().getAttribute('data-identity') === 'retained', 'section navigation does not recreate previews');
|
||||
const apiTop = await page.locator('#api').evaluate(x => x.getBoundingClientRect().top);
|
||||
check(apiTop >= 0 && apiTop < 180, `API heading lands below the top bar after anchor navigation (top=${Math.round(apiTop)})`);
|
||||
|
||||
const lastId = payload.examples[payload.examples.length - 1].id;
|
||||
await page.locator(`#toc [data-anchor="${lastId}"]`).click();
|
||||
await page.waitForFunction(id => {
|
||||
const node = document.getElementById(id);
|
||||
return node && node.getBoundingClientRect().top < 260;
|
||||
}, lastId);
|
||||
check(new URL(page.url()).hash === '#' + lastId, 'example TOC link creates a shareable section anchor');
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
await page.waitForFunction(id => {
|
||||
const node = document.getElementById(id);
|
||||
return node && node.getBoundingClientRect().top >= 0 && node.getBoundingClientRect().top < 260;
|
||||
}, lastId);
|
||||
check(true, 'example deep link survives reload and async content');
|
||||
await page.locator('#toc [data-anchor="spec"]').click();
|
||||
check(await page.locator('.kole-spec-disclosure').getAttribute('open') !== null, 'specification TOC link opens the disclosure');
|
||||
|
||||
await ready('button', 'react');
|
||||
check(await page.locator('.ex-card').first().locator('.ex-tabs [aria-pressed="true"]').getAttribute('data-kind') === 'jsx', 'framework deep link selects matching usage code');
|
||||
await page.locator('#lang-trigger').click();
|
||||
await page.locator('#lang-menu [data-lang="en"]').click();
|
||||
check((await page.locator('#toc').textContent()).includes('Design & resources'), 'English section labels are translated in the on-page TOC');
|
||||
check(await page.locator('.ex-card').first().locator('.ex-tabs [aria-pressed="true"]').getAttribute('data-kind') === 'jsx', 'language switch preserves framework');
|
||||
/* 窄屏:右侧目录隐藏,页内 sticky 导航接管 —— 它必须仍然完整可达 */
|
||||
for (const width of [320, 375, 768, 1024, 1280, 1440]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
const layout = await page.evaluate(() => ({
|
||||
overflow: document.documentElement.scrollWidth - innerWidth,
|
||||
nav: [...document.querySelectorAll('.kole-component-nav a')].every(x => {
|
||||
const r = x.getBoundingClientRect();
|
||||
return r.width > 0 && r.left >= 0 && r.right <= innerWidth;
|
||||
}),
|
||||
tables: [...document.querySelectorAll('.kole-doc-table')].every(x => x.getBoundingClientRect().width <= innerWidth)
|
||||
}));
|
||||
check(layout.overflow <= 1, `English ${width}px: no page overflow (overflow=${layout.overflow})`);
|
||||
check(layout.tables, `English ${width}px: data tables stay inside the viewport`);
|
||||
const nav = await visibleNav();
|
||||
check(nav.tocVisible !== nav.navVisible, `English ${width}px: exactly one navigation is visible (toc=${nav.tocVisible}, local=${nav.navVisible})`);
|
||||
if (nav.navVisible) check(layout.nav, `English ${width}px: every local-navigation control is reachable`);
|
||||
}
|
||||
check(errors.length === 0, `no uncaught page errors${errors.length ? ': ' + errors.join('; ') : ''}`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (failures.length) {
|
||||
console.error(`[component-page] FAIL — ${failures.length}/${checks} checks failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`[component-page] OK — ${checks} checks passed`);
|
||||
@@ -44,11 +44,18 @@ check('no stale module-level translated caches', !(relatedDecl && /\bT\s*\(/.tes
|
||||
|
||||
const dictBlock = i18n.match(/var\s+EN\s*=\s*\{([\s\S]*?)\n\s*\};/);
|
||||
const dictionaryKeys = new Set();
|
||||
const duplicatedKeys = [];
|
||||
if (dictBlock) {
|
||||
for (const match of dictBlock[1].matchAll(/^\s*(['"])((?:\\.|(?!\1)[\s\S])*?)\1\s*:/gm)) {
|
||||
dictionaryKeys.add(match[2].replace(/\\(['"])/g, '$1'));
|
||||
const key = match[2].replace(/\\(['"])/g, '$1');
|
||||
/* 重复键在对象字面量里是「后值覆盖前值」:静默生效、无报错,历史上真的积累出
|
||||
12 组(含整段 FAQ 说明),排查时完全看不见。这里显式拦住 —— 判据是「字典键唯一」,
|
||||
发现重复即失败,而不是等某个语言下文案变成另一句。 */
|
||||
if (dictionaryKeys.has(key)) duplicatedKeys.push(key);
|
||||
dictionaryKeys.add(key);
|
||||
}
|
||||
}
|
||||
check('unique dictionary keys', duplicatedKeys.length === 0, duplicatedKeys.length ? duplicatedKeys.join(' | ') : `${dictionaryKeys.size} unique keys`);
|
||||
const sourceKeys = new Set();
|
||||
for (const match of app.matchAll(/\bT\(\s*(['"])((?:\\.|(?!\1)[\s\S])*?)\1\s*\)/g)) {
|
||||
sourceKeys.add(match[2].replace(/\\(['"])/g, '$1'));
|
||||
|
||||
Reference in New Issue
Block a user