Regression / regression (push) Canceled after 0s
安全:CHANGELOG.md 是仓库内部变更流水(含服务器目录、镜像回滚标签、内网网段、
部署时序、AI 工作流用语),此前被 build-site.ps1 / precompute.mjs / build-mobile.mjs
原样注入站点,而站点是公网可下载的静态文件 —— 抓一次 /site/m/changelog.html
即可拿到内网地址段与服务器目录布局。
- 新增 tools/lib/redact-publish.mjs(零依赖,构建期过滤,不改 CHANGELOG.md,
内部可追溯性完整保留)
- 接入 precompute.mjs(PC data.json/changelog.json)与 build-mobile.mjs
(移动端更新日志页);build-site.ps1 不碰(ASCII-only 铁律)
- 只处理 changelog 字段:components[].sources 是规范实现源码,逐字保真
(详情页代码区主动高亮注释,剥注释会破坏该功能)
- 实测消除:/opt/aurora-admin.prev-*、kole-ui-showcase:pre-*、docker compose、
192.168.5.7、16 位产物指纹、1531/1531、并发会话/本会话/派子 agent
- settings.html 演示占位 IP 192.168.5.0/24(= 真实网段)改为 RFC 5737 的 192.0.2.0/24
品牌:Kole Cup 饮料杯标记定稿(几何 K → 圆角杯盖 + 杯身负空间 K,无吸管),
brand-mark.json 升 schemaVersion 3(paths 支持 { d, evenodd }),
verify:brand 增至 25 条(新增 B9b:负空间必须带 fill-rule)。
验证:发布集 2465 文件全量扫描 0 泄露;PC 回归 100%(1464/1464) ·
移动端 100%(807/807);门禁品牌 25 / 隔离 31 / 移动文档 12 / 示例 9 / 版本 40 / i18n 17 全绿;
已按 AGENTS §九 发布公网,2461/2461 逐字节一致,五项验收全过。
已知未处理(既有缺口,ROADMAP S7-P28 已记录):data.mobile.json 的
meta.generated 为墙上时钟,会让 CI 的「生成物可复现」断言在重跑构建后永远非空;
该 CI 流水线本身亦从未通过(无 runner)。
1478 lines
69 KiB
JavaScript
1478 lines
69 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* build-mobile.mjs — 移动端平台构建(platform = mobile)
|
||
*
|
||
* 覆盖:移动端 6 端全部产物
|
||
* frameworks-mobile/<Prefix>.{css,html,jsx,vue2.vue,vue3.vue,uniapp.vue}(源,本脚本只读)
|
||
* ↓
|
||
* site/m/data.mobile.json 移动端自包含数据(一次请求拿到全部,含各端源码)
|
||
* site/m/index.html 移动端文档总览(静态,含设备帧预览)
|
||
* site/m/component/<slug>.html 逐组件文档页(静态,SEO 入口)
|
||
* tests/mobile/<slug>.html 逐组件测试页(iframe 375×640 载真实演示页)
|
||
* tests/mobile/index.html 移动端测试总览
|
||
* dist/mobile/** 分发包(tokens / components / react / vue3 / vue2 / uniapp)
|
||
*
|
||
* 隔离硬约束(本脚本自我保护):
|
||
* **只能写入 site/m/、tests/mobile/、dist/mobile/ 三个前缀下的路径**,
|
||
* 任何越界写入直接抛错退出 —— 防止移动端构建碰到 PC 侧的 site/data.json、
|
||
* frameworks/、tests/<slug>.html、dist/components/**。
|
||
* 完整隔离规则见 PLATFORMS.md,硬门禁见 tools/verify-mobile-isolation.mjs。
|
||
*
|
||
* 零依赖:只用 Node 内置模块。
|
||
*/
|
||
import {
|
||
readFileSync,
|
||
writeFileSync,
|
||
mkdirSync,
|
||
existsSync,
|
||
statSync,
|
||
readdirSync,
|
||
} from 'node:fs';
|
||
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';
|
||
/* 发布脱敏:从 CHANGELOG.md 抽出的段落会进对外页面,先滤掉基础设施细节(见模块头注释)。 */
|
||
import { redactText } from './lib/redact-publish.mjs';
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
const ROOT = join(__dirname, '..');
|
||
|
||
/* ---------- 路径与写入守卫 ---------- */
|
||
|
||
const PLATFORM = 'mobile';
|
||
const LIB = join(ROOT, '.design_library', 'kole-ui-mobile');
|
||
const SRC = join(ROOT, 'frameworks-mobile');
|
||
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/'];
|
||
|
||
function guard(absPath) {
|
||
const rel = relative(ROOT, absPath).split(sep).join('/');
|
||
if (!ALLOWED_PREFIXES.some((p) => rel.startsWith(p))) {
|
||
throw new Error(
|
||
`[FATAL] 越界写入被拒绝:${rel}\n` +
|
||
` 本脚本只允许写 ${ALLOWED_PREFIXES.join(' / ')}(见 PLATFORMS.md 隔离规则)`
|
||
);
|
||
}
|
||
return absPath;
|
||
}
|
||
|
||
function write(absPath, content) {
|
||
guard(absPath);
|
||
mkdirSync(dirname(absPath), { recursive: true });
|
||
writeFileSync(absPath, String(content).replace(/\r\n/g, '\n'), 'utf8');
|
||
}
|
||
|
||
function read(absPath) {
|
||
return readFileSync(absPath, 'utf8').replace(/^\uFEFF/, '');
|
||
}
|
||
|
||
function sha256(text) {
|
||
return createHash('sha256').update(text, 'utf8').digest('hex').slice(0, 16);
|
||
}
|
||
|
||
/* ---------- 输入 ---------- */
|
||
|
||
const pkg = JSON.parse(read(join(ROOT, 'package.json')));
|
||
const VERSION = pkg.version;
|
||
|
||
const index = JSON.parse(read(join(LIB, 'components', 'index.json')));
|
||
const ENDS = index.ends;
|
||
const END_LABELS = index.endLabels || {};
|
||
const components = index.components;
|
||
|
||
const problems = [];
|
||
|
||
/* 隔离哨兵 1:移动端 slug 必须与 PC 侧不撞名。两端即使有相近概念,也用各自命名空间
|
||
保持索引、文档、测试和分发入口可区分;实现文件仍分别位于 frameworks-mobile/ 与 frameworks/。 */
|
||
const pcSlugOverlap = [];
|
||
/* PC 组件数从 PC 索引实测读取,不写死:文案里的「79 个中后台组件」在 S6-P21 组件族参数化
|
||
(79 → 103 概念组件)之后已经过期,写死的数字会在 PC 侧增长时静默说谎。 */
|
||
let pcCount = null;
|
||
if (existsSync(PC_INDEX)) {
|
||
const pc = JSON.parse(read(PC_INDEX));
|
||
pcCount = pc.components.length;
|
||
const pcSlugs = new Set(pc.components.map((c) => c.slug));
|
||
for (const c of components) if (pcSlugs.has(c.slug)) pcSlugOverlap.push(c.slug);
|
||
}
|
||
/* 拿不到 PC 索引时不编造数字:文案退回中性表述(见 pcCountText) */
|
||
const pcCountText = pcCount === null ? '中后台组件' : `${pcCount} 个中后台组件`;/* 隔离哨兵 2:slug / 前缀唯一 */
|
||
const seenSlug = new Set();
|
||
const seenPrefix = new Set();
|
||
for (const c of components) {
|
||
if (seenSlug.has(c.slug)) problems.push(`slug 重复:${c.slug}`);
|
||
if (seenPrefix.has(c.frameworksPrefix)) problems.push(`frameworksPrefix 重复:${c.frameworksPrefix}`);
|
||
seenSlug.add(c.slug);
|
||
seenPrefix.add(c.frameworksPrefix);
|
||
}
|
||
/* 隔离哨兵 3:声明的每个端都有实现文件,且文件真实存在 */
|
||
const missing = [];
|
||
for (const c of components) {
|
||
for (const end of ENDS) {
|
||
const f = c.files && c.files[end];
|
||
if (!f) {
|
||
problems.push(`${c.slug} 缺少 ${end} 端文件声明`);
|
||
continue;
|
||
}
|
||
if (!existsSync(join(SRC, f))) missing.push(`${c.slug}/${end} → frameworks-mobile/${f}`);
|
||
}
|
||
if (!existsSync(join(LIB, c.contract))) problems.push(`${c.slug} 契约不存在:${c.contract}`);
|
||
}
|
||
if (missing.length) {
|
||
problems.push(`实现文件缺失 ${missing.length} 个:\n - ${missing.join('\n - ')}`);
|
||
}
|
||
if (problems.length) {
|
||
console.error('[FATAL] 移动端索引校验失败:');
|
||
problems.forEach((p) => console.error(' - ' + p));
|
||
process.exit(1);
|
||
}
|
||
|
||
/* 契约内容 + 各端源码(供 data.mobile.json 自包含) */
|
||
const contracts = new Map();
|
||
const sources = new Map();
|
||
for (const c of components) {
|
||
contracts.set(c.slug, JSON.parse(read(join(LIB, c.contract))));
|
||
const byEnd = {};
|
||
for (const end of ENDS) byEnd[end] = read(join(SRC, c.files[end]));
|
||
sources.set(c.slug, byEnd);
|
||
}
|
||
|
||
/* 移动端令牌(--kole-m-*):按名字去重 —— 安全区两条在 @supports 里各出现两次
|
||
(0px 默认 + env() 覆盖),令牌表与文档都按**唯一令牌名**计数。 */
|
||
const tokenFile = read(join(LIB, 'colors_and_type.css'));
|
||
const tokenRe = /(--kole-m-[a-z0-9-]+)\s*:\s*([^;]+);(?:\s*\/\*\s*([^*]*?)\s*\*\/)?/g;
|
||
const tokens = [];
|
||
const seenTokenNames = new Set();
|
||
let m;
|
||
while ((m = tokenRe.exec(tokenFile))) {
|
||
if (seenTokenNames.has(m[1])) continue;
|
||
seenTokenNames.add(m[1]);
|
||
tokens.push({ name: m[1], value: m[2].trim(), comment: (m[3] || '').trim() });
|
||
}
|
||
|
||
/* ---------- 1. site/m/data.mobile.json ---------- */
|
||
|
||
const dataComponents = components.map((c) => {
|
||
const ct = contracts.get(c.slug) || {};
|
||
return {
|
||
slug: c.slug,
|
||
name: c.name,
|
||
zh: c.name.split(' ')[0],
|
||
en: c.frameworksPrefix,
|
||
tier: c.tier,
|
||
category: c.category,
|
||
confidence: c.confidence,
|
||
specSection: c.specSection,
|
||
contract: c.contract,
|
||
contractData: ct,
|
||
frameworksPrefix: c.frameworksPrefix,
|
||
files: c.files,
|
||
implBase: '../../frameworks-mobile/',
|
||
demo: c.files.html,
|
||
test: '../tests/mobile/' + c.slug + '.html',
|
||
docs: 'component/' + c.slug + '.html',
|
||
variantDimensions: ct.variantDimensions || [],
|
||
representativeVariants: ct.representativeVariants || [],
|
||
anatomy: ct.anatomy || {},
|
||
usageHints: ct.usageHints || [],
|
||
doNotInvent: ct.doNotInvent || [],
|
||
unknowns: ct.unknowns || [],
|
||
behaviors: c.behaviors || [],
|
||
uniappTargets: index.uniappTargets || [],
|
||
sources: sources.get(c.slug),
|
||
};
|
||
});
|
||
|
||
const mobileData = {
|
||
meta: {
|
||
library: index.library,
|
||
kind: 'mobile',
|
||
platform: PLATFORM,
|
||
version: VERSION,
|
||
generated: new Date().toISOString().slice(0, 19).replace('T', ' '),
|
||
components: components.length,
|
||
ends: ENDS,
|
||
endLabels: END_LABELS,
|
||
spec: index.specFile,
|
||
sourceKind: index.sourceKind,
|
||
description: index.description,
|
||
isolation: index.isolation,
|
||
uniappTargets: index.uniappTargets || [],
|
||
uniappNotes: index.uniappNotes || '',
|
||
note:
|
||
'本文件是移动端平台的**自包含**数据(对应 PC 侧 site/data.json 的承诺,但两端互不引用):' +
|
||
'一次请求拿到移动端全部组件的契约与 6 端源码。PC 侧数据不受本文件影响。',
|
||
},
|
||
tokens,
|
||
components: dataComponents,
|
||
};
|
||
write(join(WEBSITE, 'data.mobile.json'), JSON.stringify(mobileData, null, 2));
|
||
|
||
console.log(
|
||
`[build-mobile] site/m/data.mobile.json ← ${components.length} 组件 × ${ENDS.length} 端` +
|
||
`(自包含 ${Math.round(JSON.stringify(mobileData).length / 1024)} KB)· 令牌 ${tokens.length} 个` +
|
||
(pcSlugOverlap.length ? ` · 与 PC 同名(各自独立实现):${pcSlugOverlap.join(', ')}` : '')
|
||
);
|
||
|
||
/* ---------- 2. tests/mobile/<slug>.html + index.html ---------- */
|
||
|
||
/* 哪些组件跑行为断言:**演示页里真的写了 [data-behavior] 的那些**(加上行为库里的兜底名单)。
|
||
2026-09-20 修:此前是行为库里的硬编码白名单 —— 索引/演示页新增了交互声明也不会被点击验证,
|
||
断言会静默少一截(子 agent 复核时发现的静默缺口)。现在把派生结果注入测试页。 */
|
||
const behaviorSlugs = components
|
||
.filter((c) => {
|
||
const demo = read(join(SRC, c.files.html));
|
||
return /data-behavior\s*=/.test(demo);
|
||
})
|
||
.map((c) => c.slug);
|
||
console.log(`[build-mobile] 行为断言覆盖 ${behaviorSlugs.length} 个组件:${behaviorSlugs.join(', ')}`);
|
||
|
||
const testTemplate = read(join(TESTS, '_template.html'));
|
||
for (const c of components) {
|
||
let html = testTemplate;
|
||
/* 顺序敏感:SLUG_ZH_PLACEHOLDER 含 SLUG_PLACEHOLDER 子串,必须先替换 */
|
||
html = html.replaceAll('SLUG_ZH_PLACEHOLDER', c.name.split(' ')[0]);
|
||
html = html.replaceAll('PREFIX_PLACEHOLDER', c.frameworksPrefix);
|
||
html = html.replaceAll('SLUG_PLACEHOLDER', c.slug);
|
||
html = html.replaceAll(
|
||
'window.__SLUG__ = "',
|
||
`window.__koleBehaviorSlugs = ${JSON.stringify(behaviorSlugs)}; window.__SLUG__ = "`
|
||
);
|
||
write(join(TESTS, c.slug + '.html'), html);
|
||
}
|
||
write(
|
||
join(TESTS, 'index.html'),
|
||
read(join(TESTS, '_index_template.html')).replaceAll('__COUNT__', String(components.length))
|
||
);
|
||
console.log(`[build-mobile] tests/mobile/ ← ${components.length} 个测试页 + index.html`);
|
||
|
||
/* ---------- 3. 站点公共部件(左栏 / 复制脚本 / 行生成器) ---------- */
|
||
|
||
function esc(s) {
|
||
return String(s)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
/* 分类口径与 PC 端一致(PC 的 site/app.js CAT_KEYS/CAT_EN 同源):
|
||
通用 / 导航 / 数据录入 / 数据展示 / 反馈 / 工具与系统 —— 两端用同一套 IA 导航。
|
||
真源在移动端索引 index.json 的 categories 块,这里只做兜底。 */
|
||
const CAT_ZH = (index.categories && index.categories.labels) || {
|
||
general: '通用',
|
||
navigation: '导航',
|
||
input: '数据录入',
|
||
display: '数据展示',
|
||
feedback: '反馈',
|
||
system: '工具与系统',
|
||
};
|
||
const CAT_ORDER = (index.categories && index.categories.order) || [
|
||
'general',
|
||
'navigation',
|
||
'input',
|
||
'display',
|
||
'feedback',
|
||
'system',
|
||
];
|
||
|
||
/* 开发指南:条目与 PC 侧栏同名同序(组件总览 / 快速开始 / 设计规范 / 常见问题 / 更新日志)。
|
||
移动端另有两页 PC 没有的(平台与端 / 测试与回归),放在这一组末尾并标注。 */
|
||
const GUIDE_PAGES = [
|
||
{ key: 'index', label: '组件总览', file: 'index.html' },
|
||
{ key: 'guide', label: '快速开始', file: 'guide.html' },
|
||
{ key: 'design', label: '设计规范', file: 'design.html' },
|
||
{ key: 'faq', label: '常见问题', file: 'faq.html' },
|
||
{ key: 'changelog', label: '更新日志', file: 'changelog.html' },
|
||
/* 移动端特有(PC 侧不存在同页):仍留在同一组,用副标题区分 */
|
||
{ key: 'platform', label: '平台与端', file: 'platform.html', mobileOnly: true },
|
||
{ key: 'tests', label: '测试与回归', file: 'tests/mobile/index.html', repo: true, mobileOnly: true },
|
||
];
|
||
|
||
/* PC 令牌字典:移动端令牌层继承它(不复制),文档里要能区分「自有」与「继承」 */
|
||
function parseTokenMap(src, prefix) {
|
||
const out = new Map();
|
||
const re = new RegExp(`(${prefix}[a-z0-9-]+)\\s*:\\s*([^;]+);(?:\\s*\\/\\*\\s*([^*]*?)\\s*\\*\\/)?`, 'g');
|
||
let m;
|
||
while ((m = re.exec(src))) if (!out.has(m[1])) out.set(m[1], { name: m[1], value: m[2].trim(), comment: (m[3] || '').trim() });
|
||
return out;
|
||
}
|
||
const pcTokenMap = parseTokenMap(read(join(ROOT, '.design_library', 'kole-ui', 'colors_and_type.css')), '--kole-');
|
||
const mbTokenMap = new Map();
|
||
for (const t of tokens) if (!mbTokenMap.has(t.name)) mbTokenMap.set(t.name, t);
|
||
|
||
/* 每个组件用到的令牌:构建时从它的 CSS 扫描(var() 引用 + 组件级变量定义) */
|
||
function tokensOf(slug) {
|
||
const css = sources.get(slug).css;
|
||
const names = new Set((css.match(/var\((--kole-[a-z0-9-]+)/g) || []).map((s) => s.slice(4)));
|
||
(css.match(/^\s*(--kole-[a-z0-9-]+)\s*:/gm) || []).forEach((s) => names.add(s.trim().replace(/:$/, '')));
|
||
const own = [];
|
||
const inherited = [];
|
||
const local = [];
|
||
[...names].sort().forEach((n) => {
|
||
if (mbTokenMap.has(n)) own.push(n);
|
||
else if (pcTokenMap.has(n)) inherited.push(n);
|
||
else local.push(n); /* 组件级变量(如 --kole-m-pullrefresh-threshold) */
|
||
});
|
||
return { own, inherited, local };
|
||
}
|
||
|
||
/* 顶栏:与 PC 顶栏同一结构与顺序(logo 含副标题 + 版本控件 + 主导航 + 主题模式),
|
||
控件逐个对齐 PC 的实现:版本是可点的下拉(清单拿不到才退回不可点角标)、
|
||
主题触发器带三态图标、激活项带 2px 底部指示条、内容在 1180px 容器内居中。
|
||
PC 特有的三件不搬:搜索框(PC 103 个组件才需要)、技术栈选择器(PC 5 端各自成页;
|
||
移动端 6 端在同页展示)、语言选择器(移动端无 i18n 字典)。
|
||
平台切换**不在顶栏**:PC 顶栏也没有它 —— 两端的唯一入口都是左栏「平台」组。
|
||
2026-09-20 去重:此前顶栏另有一个 [PC 端][移动端] 胶囊,与左栏指向同一跳转
|
||
(实测两条链接 href 均为 ../index.html),是重复入口,已删。 */
|
||
/* ---------- 品牌标识(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 ? '../' : '';
|
||
const nav = GUIDE_PAGES.filter((p) => !p.mobileOnly)
|
||
.map((p) => {
|
||
const on = activeKey === p.key;
|
||
return ` <a href="${toRoot}${p.file}"${on ? ' class="active" aria-current="page"' : ''}>${esc(p.label)}</a>`;
|
||
})
|
||
.join('\n');
|
||
return ` <header class="m-top">
|
||
<div class="m-top-inner">
|
||
<a class="m-logo" href="${toRoot}index.html">
|
||
<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">
|
||
<button id="m-ver-trigger" class="m-ver-trigger" type="button" data-single="1" aria-haspopup="listbox" aria-expanded="false" aria-label="选择版本" title="文档版本(清单来自部署根的 versions.json)">
|
||
<span id="m-ver">v${esc(VERSION)}</span>
|
||
<svg class="m-ver-caret" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
|
||
</button>
|
||
<div id="m-ver-menu" class="m-ver-menu hidden" role="listbox" aria-label="文档版本">
|
||
<div class="m-ver-menu-head" id="m-ver-head">文档版本</div>
|
||
</div>
|
||
</div>
|
||
<nav class="m-nav">${nav}</nav>
|
||
<div class="m-mode">
|
||
<button id="m-mode-trigger" class="m-mode-trigger" type="button" aria-haspopup="listbox" aria-expanded="false" aria-label="主题模式" title="主题模式">
|
||
<svg class="m-mode-icon" id="m-icon-moon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||
<svg class="m-mode-icon" id="m-icon-sun" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="display:none"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
|
||
<svg class="m-mode-icon" id="m-icon-auto" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="display:none"><rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
|
||
<span id="m-mode-cur">自动模式</span>
|
||
<svg class="m-mode-caret" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
|
||
</button>
|
||
<div id="m-mode-menu" class="m-menu hidden" role="listbox" aria-label="主题模式">
|
||
<div class="m-menu-head">主题模式</div>
|
||
<button class="m-menu-opt" type="button" role="option" data-mode="light" aria-selected="false"><span data-mode-label="light">日间模式</span></button>
|
||
<button class="m-menu-opt" type="button" role="option" data-mode="dark" aria-selected="false"><span data-mode-label="dark">夜间模式</span></button>
|
||
<button class="m-menu-opt" type="button" role="option" data-mode="auto" aria-selected="true"><span data-mode-label="auto">自动模式</span></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>`;
|
||
}
|
||
/* 左侧栏:三段式与 PC 侧栏一致 —— 「平台」组 + 「开发指南」组 + 「组件 N」按分类分组。
|
||
depth:组件页在 site/m/component/ 下,往站点根退一级、往仓库根退三级。 */
|
||
function sidebarHtml(activeKey, activeSlug, activeLabel) {
|
||
const isComp = !!activeSlug;
|
||
const toRoot = isComp ? '../' : '';
|
||
const toRepo = isComp ? '../../../' : '../../';
|
||
const parts = [];
|
||
|
||
/* ① 平台(PC 侧栏同款:当前站 + 另一侧站,各带副标题) */
|
||
parts.push(' <div class="m-side-section"><span>平台</span></div>');
|
||
const platform = [
|
||
{
|
||
label: 'PC 端组件',
|
||
sub: `${pcCountText} · 5 端`,
|
||
href: isComp ? '../../index.html' : '../index.html',
|
||
current: false,
|
||
},
|
||
{
|
||
label: '移动端组件',
|
||
sub: `${components.length} 个组件 · ${ENDS.length} 端 · 含 uni-app`,
|
||
href: toRoot + 'index.html',
|
||
current: true,
|
||
},
|
||
];
|
||
for (const p of platform) {
|
||
parts.push(
|
||
` <a class="m-side-link m-side-platform${p.current && activeKey === 'index' && !isComp ? ' is-active' : ''}" href="${p.href}">` +
|
||
`<span class="sp-top"><span class="sp-name">${esc(p.label)}</span>` +
|
||
(p.current ? '<span class="sp-badge">当前</span>' : '') +
|
||
`</span><span class="sp-sub">${esc(p.sub)}</span></a>`
|
||
);
|
||
}
|
||
|
||
/* ② 开发指南(与 PC 同名同序;移动端特有的两页置后) */
|
||
parts.push(' <div class="m-side-section"><span>开发指南</span></div>');
|
||
for (const g of GUIDE_PAGES) {
|
||
const on = activeKey === g.key;
|
||
const href = g.repo ? toRepo + 'tests/mobile/index.html' : toRoot + g.file;
|
||
parts.push(
|
||
` <a class="m-side-link${on ? ' is-active' : ''}" href="${href}"` +
|
||
`${on ? ' aria-current="page"' : ''}${g.repo ? ' target="_blank"' : ''}>${esc(g.label)}` +
|
||
(g.mobileOnly ? '<em>移动端</em>' : '') +
|
||
`</a>`
|
||
);
|
||
}
|
||
|
||
/* ③ 组件(按 PC 分类分组 + 计数) */
|
||
parts.push(` <div class="m-side-section"><span>组件</span><span class="cnt">${components.length}</span></div>`);
|
||
for (const cat of CAT_ORDER) {
|
||
const items = components.filter((c) => c.category === cat);
|
||
if (!items.length) continue;
|
||
parts.push(` <div class="m-side-group"><span>${esc(CAT_ZH[cat] || cat)}</span><span class="cnt">${items.length}</span></div>`);
|
||
for (const c of items) {
|
||
const on = activeSlug === c.slug;
|
||
const href = isComp ? c.slug + '.html' : 'component/' + c.slug + '.html';
|
||
parts.push(
|
||
` <a class="m-side-link${on ? ' is-active' : ''}" href="${href}"` +
|
||
`${on ? ' aria-current="page"' : ''}>${esc(c.name.split(' ')[0])}<em>${esc(c.frameworksPrefix)}</em></a>`
|
||
);
|
||
}
|
||
}
|
||
return (
|
||
/* open 默认展开:桌面无需 JS 即可看到左栏(渐进增强的底线)。
|
||
窄屏收起由侧栏脚本按断点切换 —— 实测教训:不带 open 时 <details> 高度按"关闭态"
|
||
算成 0,`overflow:auto` 会把里面的 nav 整块裁掉(DOM 里有、屏幕上看不见)。 */
|
||
` <details class="m-side" open>\n` +
|
||
` <summary><span>移动端导航</span><span class="cur">${esc(activeLabel || '')}</span></summary>\n` +
|
||
` <nav>\n${parts.join('\n')}\n </nav>\n </details>`
|
||
);
|
||
}
|
||
|
||
/* 主题三态 bootstrap:必须在 <head> 里同步执行,否则首帧会先白后黑(PC 的 S2-P5 同款处理)。
|
||
约定与 PC 完全一致:localStorage['kole-mode'] ∈ light|dark|auto,反色靠 html.kole-dark ——
|
||
两端共用一份令牌,所以同一个键在两边都成立,用户在 PC 选夜间,进移动端站点也是夜间。 */
|
||
const THEME_BOOT = [
|
||
'<script>',
|
||
'(function () {',
|
||
" var m = 'auto';",
|
||
" try { var v = localStorage.getItem('kole-mode'); if (v === 'light' || v === 'dark' || v === 'auto') m = v; } catch (e) {}",
|
||
" var dark = m === 'dark' || (m === 'auto' && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);",
|
||
" document.documentElement.classList.toggle('kole-dark', !!dark);",
|
||
" document.documentElement.style.colorScheme = dark ? 'dark' : 'light';",
|
||
'})();',
|
||
'</script>',
|
||
].join('\n');
|
||
|
||
/* 站点脚本:复制按钮 + 左栏在窄屏自动收起(零依赖,CSP 允许内联脚本) */
|
||
const COPY_SCRIPT = [
|
||
'<script>',
|
||
'(function () {',
|
||
' /* 左栏:≤1000px 默认收起(手机上不让导航占满一屏),跨断点时同步一次;不监听 resize,',
|
||
' 免得跟用户手动的展开/收起抢状态。桌面不设 open 之外的动作。 */',
|
||
" var side = document.querySelector('details.m-side');",
|
||
' if (side && window.matchMedia) {',
|
||
" var mq = window.matchMedia('(max-width: 1000px)');",
|
||
" var sync = function () { if (mq.matches) side.removeAttribute('open'); else side.setAttribute('open', ''); };",
|
||
' sync();',
|
||
" if (mq.addEventListener) mq.addEventListener('change', sync);",
|
||
" else if (mq.addListener) mq.addListener(sync);",
|
||
' }',
|
||
' /* 单演示预览帧按内容高度自适应(同源可读 contentDocument;上限 360px、下限 140px)。',
|
||
' 固定高度会在内容少的演示上留一大片空白(实测 SwipeCell 单个演示只用 ~120px)。 */',
|
||
' function fitDemoFrames() {',
|
||
" var frames = document.querySelectorAll('.m-demo-stage iframe');",
|
||
' Array.prototype.forEach.call(frames, function (f) {',
|
||
' var fit = function () {',
|
||
' try {',
|
||
' var d = f.contentDocument;',
|
||
' if (!d || !d.body) return;',
|
||
' /* 只量 body.scrollHeight:documentElement.scrollHeight 等于**帧视口高度**(html 撑满视口),',
|
||
' 拿它做 max 会形成「帧高→视口高→量到的高度」反馈环,永远收敛在初始值(实测锁在 308px)。 */',
|
||
' var h = d.body.scrollHeight;',
|
||
' if (!h) h = d.documentElement ? d.documentElement.scrollHeight : 0;',
|
||
' if (!h) return;',
|
||
" f.style.height = Math.min(360, Math.max(140, h + 8)) + 'px';",
|
||
' } catch (e) { /* 跨源或未就绪:保持 CSS 兜底高度 */ }',
|
||
' };',
|
||
" f.addEventListener('load', function () { fit(); setTimeout(fit, 300); });",
|
||
' fit();',
|
||
' });',
|
||
' }',
|
||
' fitDemoFrames();',
|
||
" window.addEventListener('load', fitDemoFrames);",
|
||
' /* 主题模式(三态):与 PC 完全同一约定 —— localStorage[kole-mode] ∈ light|dark|auto,',
|
||
' auto 跟随系统,反色靠 html.kole-dark(令牌文件里已写好该组,两端共用一份令牌)。',
|
||
' 用户在 PC 站选夜间,进移动端站也是夜间。 */',
|
||
" var MODE_KEY = 'kole-mode';",
|
||
" var MODE_LABEL = { light: '日间模式', dark: '夜间模式', auto: '自动模式' };",
|
||
' function readMode() {',
|
||
" try { var v = localStorage.getItem(MODE_KEY); if (v === 'light' || v === 'dark' || v === 'auto') return v; } catch (e) {}",
|
||
" return 'auto';",
|
||
' }',
|
||
' function systemDark() {',
|
||
" return !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);",
|
||
' }',
|
||
' function applyMode(mode) {',
|
||
" var dark = mode === 'dark' || (mode === 'auto' && systemDark());",
|
||
" document.documentElement.classList.toggle('kole-dark', dark);",
|
||
" document.documentElement.style.colorScheme = dark ? 'dark' : 'light';",
|
||
" var cur = document.getElementById('m-mode-cur');",
|
||
' if (cur) cur.textContent = MODE_LABEL[mode] || MODE_LABEL.auto;',
|
||
" Array.prototype.forEach.call(document.querySelectorAll('.m-menu-opt'), function (o) {",
|
||
" o.setAttribute('aria-selected', o.getAttribute('data-mode') === mode ? 'true' : 'false');",
|
||
' });',
|
||
' /* 三态图标与 PC 同口径:显式选择时按时态给图标(日间=月亮 / 夜间=太阳),',
|
||
' 自动模式下恒为「跟随系统」的显示器图标(此时两个显式图标都不显示)。 */',
|
||
" var moon = document.getElementById('m-icon-moon');",
|
||
" var sun = document.getElementById('m-icon-sun');",
|
||
" var auto = document.getElementById('m-icon-auto');",
|
||
" if (moon) moon.style.display = (mode === 'light') ? '' : 'none';",
|
||
" if (sun) sun.style.display = (mode === 'dark') ? '' : 'none';",
|
||
" if (auto) auto.style.display = (mode === 'auto') ? '' : 'none';",
|
||
' }',
|
||
' function bindModeMenu() {',
|
||
" var trigger = document.getElementById('m-mode-trigger');",
|
||
" var menu = document.getElementById('m-mode-menu');",
|
||
' if (!trigger || !menu) return;',
|
||
' var close = function (refocus) {',
|
||
" menu.classList.add('hidden'); trigger.setAttribute('aria-expanded', 'false');",
|
||
' if (refocus) trigger.focus();',
|
||
' };',
|
||
" trigger.addEventListener('click', function () {",
|
||
" var open = menu.classList.toggle('hidden');",
|
||
" trigger.setAttribute('aria-expanded', open ? 'false' : 'true');",
|
||
' });',
|
||
" trigger.addEventListener('keydown', function (e) {",
|
||
" if (e.key === 'Escape') close(true);",
|
||
' });',
|
||
" Array.prototype.forEach.call(menu.querySelectorAll('.m-menu-opt'), function (opt) {",
|
||
" opt.addEventListener('click', function () {",
|
||
" var mode = opt.getAttribute('data-mode');",
|
||
" try { localStorage.setItem(MODE_KEY, mode); } catch (e) {}",
|
||
' applyMode(mode);',
|
||
' close(true);',
|
||
' });',
|
||
' });',
|
||
" document.addEventListener('click', function (e) {",
|
||
" if (!menu.classList.contains('hidden') && !menu.contains(e.target) && !trigger.contains(e.target)) close(false);",
|
||
' });',
|
||
" document.addEventListener('keydown', function (e) {",
|
||
" if (e.key === 'Escape' && !menu.classList.contains('hidden')) close(true);",
|
||
' });',
|
||
' /* 自动模式下跟随系统切换 */',
|
||
" if (window.matchMedia) {",
|
||
" var mq = window.matchMedia('(prefers-color-scheme: dark)');",
|
||
" var onChange = function () { if (readMode() === 'auto') applyMode('auto'); };",
|
||
" if (mq.addEventListener) mq.addEventListener('change', onChange);",
|
||
" else if (mq.addListener) mq.addListener(onChange);",
|
||
' }',
|
||
' applyMode(readMode());',
|
||
' }',
|
||
' bindModeMenu();',
|
||
' /* 版本下拉:与 PC 顶栏同一实现口径 —— 清单从部署根取(<prefix>/versions.json 与',
|
||
' <prefix>/site/versions.json 两份按序尝试,取有可用条目的那份),拿不到就退回不可点的',
|
||
' 纯角标(data-single="1"),不留一个点不开的下拉。',
|
||
' 清单不许把用户带去外站:path 只允许 ".."(站点根自身)或 x.y.z 快照目录名。 */',
|
||
" var VERSION_KEY_PATH = '..';",
|
||
' function isValidVersionPath(p) {',
|
||
" if (typeof p !== 'string' || !p.length || p.indexOf('\\\\') >= 0 || p.indexOf('//') >= 0) return false;",
|
||
" if (p === VERSION_KEY_PATH) return true;",
|
||
' return /^\\d+\\.\\d+\\.\\d+$/.test(p);',
|
||
' }',
|
||
' /* 清单里的 path 相对部署根,".." = /site/、x.y.z = /x.y.z/site/。',
|
||
' 当前页可能不在部署根下(快照页),故先还原部署根绝对路径再算回相对当前页。 */',
|
||
' function versionHref(absSite) {',
|
||
" var i = location.pathname.indexOf('/site/');",
|
||
" var prefix = i >= 0 ? location.pathname.slice(0, i) : '';",
|
||
" if (!prefix || absSite.indexOf(prefix + '/') !== 0) return absSite;",
|
||
' var rest = absSite.slice(prefix.length + 1);',
|
||
" var up = '';",
|
||
" var segs = prefix.slice(1).split('/');",
|
||
' for (var k = 0; k < segs.length; k++) up += "../";',
|
||
' return up + rest;',
|
||
' }',
|
||
' function buildVersionList(doc) {',
|
||
' var raw = doc && doc.versions;',
|
||
' if (!raw || !raw.length) return [];',
|
||
" var i = location.pathname.indexOf('/site/');",
|
||
" var prefix = i >= 0 ? location.pathname.slice(0, i) : '';",
|
||
' var list = [];',
|
||
' for (var k = 0; k < raw.length; k++) {',
|
||
' var v = raw[k] || {};',
|
||
" var ver = v.version ? String(v.version) : '';",
|
||
" var path = v.path ? String(v.path) : '';",
|
||
' if (!ver || !isValidVersionPath(path)) continue;',
|
||
" var absSite = (path === VERSION_KEY_PATH ? '' : '/' + path) + '/site/';",
|
||
" list.push({ version: ver, date: v.date ? String(v.date) : '', latest: !!v.latest, href: versionHref(absSite), absSite: absSite, current: false });",
|
||
' }',
|
||
' if (list.length) {',
|
||
' /* 当前版本 = 绝对路径上命中的最长前缀(最具体的那个)——与 PC 的判定一致,',
|
||
' 快照页与根站点都能算对。 */',
|
||
' var here = location.pathname;',
|
||
' var best = -1;',
|
||
' for (var j = 0; j < list.length; j++) {',
|
||
' if (here.indexOf(list[j].absSite) === 0 && list[j].absSite.length > best) best = list[j].absSite.length;',
|
||
' }',
|
||
' for (var m = 0; m < list.length; m++) list[m].current = best >= 0 && list[m].absSite.length === best;',
|
||
' if (best < 0 && list[0]) list[0].current = true;',
|
||
' }',
|
||
' return list;',
|
||
' }',
|
||
' function bindVersionMenu() {',
|
||
" var trigger = document.getElementById('m-ver-trigger');",
|
||
" var menu = document.getElementById('m-ver-menu');",
|
||
" var chip = document.getElementById('m-ver');",
|
||
' if (!trigger || !menu) return;',
|
||
' var close = function (refocus) {',
|
||
" menu.classList.add('hidden'); trigger.setAttribute('aria-expanded', 'false');",
|
||
' if (refocus) trigger.focus();',
|
||
' };',
|
||
' var interactive = false;',
|
||
' var render = function (list) {',
|
||
' interactive = !!(list && list.length);',
|
||
' /* 只有清单拿不到才退回纯角标;只要有一版就是下拉(里面至少能看清当前是哪个版本) */',
|
||
" trigger.setAttribute('data-single', interactive ? '0' : '1');",
|
||
' if (!interactive) { menu.classList.add("hidden"); return; }',
|
||
' var cur = null;',
|
||
' for (var k = 0; k < list.length; k++) if (list[k].current) cur = list[k];',
|
||
" if (chip) chip.textContent = 'v' + ((cur && cur.version) || VERSION);",
|
||
' /* 重建选项(幂等:重复调用不会叠加) */',
|
||
" Array.prototype.slice.call(menu.querySelectorAll('.m-ver-opt')).forEach(function (n) { n.remove(); });",
|
||
' list.forEach(function (it) {',
|
||
" var opt = document.createElement('button');",
|
||
" opt.type = 'button'; opt.className = 'm-ver-opt'; opt.setAttribute('role', 'option');",
|
||
" opt.setAttribute('data-version', it.version);",
|
||
" opt.setAttribute('aria-selected', it.current ? 'true' : 'false');",
|
||
" var tag = it.current ? '当前浏览' : (it.latest ? '最新版' : (it.date || ''));",
|
||
" opt.innerHTML = '<span class=\"m-ver-opt-num\">v' + it.version + '</span><span class=\"m-ver-opt-tag\">' + tag + '</span>';",
|
||
" if (it.href) opt.setAttribute('data-href', it.href);",
|
||
' opt.addEventListener("click", function () {',
|
||
' if (!it.current && it.href) location.href = it.href;',
|
||
' else close(true);',
|
||
' });',
|
||
' menu.appendChild(opt);',
|
||
' });',
|
||
' };',
|
||
" trigger.addEventListener('click', function () {",
|
||
' if (!interactive) return;',
|
||
" if (menu.classList.contains('hidden')) { menu.classList.remove('hidden'); trigger.setAttribute('aria-expanded', 'true'); }",
|
||
' else close(false);',
|
||
' });',
|
||
" trigger.addEventListener('keydown', function (e) {",
|
||
" if (!interactive) return;",
|
||
" if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); menu.classList.remove('hidden'); trigger.setAttribute('aria-expanded', 'true'); }",
|
||
" else if (e.key === 'Escape') close(true);",
|
||
' });',
|
||
" document.addEventListener('click', function (e) {",
|
||
" if (!menu.classList.contains('hidden') && !menu.contains(e.target) && !trigger.contains(e.target)) close(false);",
|
||
' });',
|
||
" document.addEventListener('keydown', function (e) {",
|
||
" if (e.key === 'Escape' && !menu.classList.contains('hidden')) close(true);",
|
||
' });',
|
||
' /* 两份清单按序尝试(不并行:快照页那份必然 404,并行会把 404 记进控制台,',
|
||
' 而站点门禁的「零控制台错误」是硬断言)。 */',
|
||
" if (!window.fetch || location.protocol === 'file:') return;",
|
||
" var i = location.pathname.indexOf('/site/');",
|
||
' if (i < 0) return;',
|
||
" var prefix = location.pathname.slice(0, i);",
|
||
" var urls = [prefix + '/versions.json', prefix + '/site/versions.json'];",
|
||
' var attempt = function (idx) {',
|
||
' if (idx >= urls.length) return;',
|
||
" fetch(urls[idx], { cache: 'no-store' })",
|
||
" .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })",
|
||
' .then(function (doc) { var list = buildVersionList(doc); if (list.length) render(list); else attempt(idx + 1); })',
|
||
' .catch(function () { attempt(idx + 1); });',
|
||
' };',
|
||
' attempt(0);',
|
||
' }',
|
||
' bindVersionMenu();',
|
||
" function done(btn) {",
|
||
" var old = btn.textContent; btn.textContent = '已复制'; btn.classList.add('is-done');",
|
||
" setTimeout(function () { btn.textContent = old; btn.classList.remove('is-done'); }, 1200);",
|
||
' }',
|
||
" document.addEventListener('click', function (e) {",
|
||
" var btn = e.target && e.target.closest ? e.target.closest('.m-copy') : null;",
|
||
' if (!btn) return;',
|
||
" var src = document.getElementById(btn.getAttribute('data-copy') || '');",
|
||
" var text = src ? (src.innerText || src.textContent || '') : '';",
|
||
' if (navigator.clipboard && navigator.clipboard.writeText) {',
|
||
" navigator.clipboard.writeText(text).then(function () { done(btn); }, function () { fallback(btn, text); });",
|
||
' } else { fallback(btn, text); }',
|
||
' });',
|
||
' function fallback(btn, text) {',
|
||
' try {',
|
||
" var ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', '');",
|
||
" ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta);",
|
||
' ta.select(); document.execCommand("copy"); document.body.removeChild(ta); done(btn);',
|
||
" } catch (err) { btn.textContent = '复制失败'; }",
|
||
' }',
|
||
'})();',
|
||
'</script>',
|
||
].join('\n');
|
||
|
||
let codeSeq = 0;
|
||
|
||
/** 代码块(含复制按钮):id 由调用顺序生成,避免手写 id 撞车 */
|
||
function codeBlock(label, code, meta) {
|
||
const id = 'code-' + ++codeSeq;
|
||
return (
|
||
` <div class="m-code">\n` +
|
||
` <div class="m-code-head"><span>${esc(label)}</span>` +
|
||
(meta ? `<span class="m-chip">${esc(meta)}</span>` : '') +
|
||
`<button class="m-copy" type="button" data-copy="${id}">复制</button></div>\n` +
|
||
` <pre id="${id}"><code>${esc(code)}</code></pre>\n` +
|
||
` </div>`
|
||
);
|
||
}
|
||
|
||
/** 详情折叠里的代码块(组件页 6 端源码用) */
|
||
function codeDetails(label, code, meta) {
|
||
const id = 'code-' + ++codeSeq;
|
||
const lines = code.split('\n').length;
|
||
return (
|
||
` <details class="m-details">\n` +
|
||
` <summary>${esc(label)}${meta ? ' · ' + esc(meta) : ''} · ${lines} 行</summary>\n` +
|
||
` <div class="m-code">\n` +
|
||
` <div class="m-code-head"><span>${esc(label)}</span><button class="m-copy" type="button" data-copy="${id}">复制</button></div>\n` +
|
||
` <pre id="${id}"><code>${esc(code)}</code></pre>\n` +
|
||
` </div>\n` +
|
||
` </details>`
|
||
);
|
||
}
|
||
|
||
/* ---------- 3.1 覆盖矩阵 / 目录映射 / 端引入(总览、平台、快速开始三页共用) ---------- */
|
||
|
||
function axisRows() {
|
||
const n = components.length;
|
||
const cell = (txt) => `<td>${txt}</td>`;
|
||
const pcCells = pcCount === null
|
||
? new Array(ENDS.length).fill('—')
|
||
: [...new Array(5).fill('✅ ' + pcCount), '3 / ' + pcCount + '(试点)'];
|
||
return [
|
||
` <tr><th>移动端(${n})</th>${ENDS.map(() => cell('✅ ' + n)).join('')}</tr>`,
|
||
` <tr><th>PC(${pcCount === null ? '—' : pcCount})</th>${pcCells.map(cell).join('')}</tr>`,
|
||
].join('\n');
|
||
}
|
||
|
||
function mappingRows() {
|
||
const pcImpl = pcCount === null ? '—' : String(pcCount * 5);
|
||
const pcNum = (n) => (pcCount === null ? '—' : `<code>${n}</code>`);
|
||
const pcCont = pcCount === null ? '<code>.design_library/kole-ui/components/</code>' : `<code>.design_library/kole-ui/components/</code>(${pcCount})`;
|
||
const pcTests = pcCount === null ? '<code>tests/<slug>.html</code>' : `<code>tests/<slug>.html</code>(${pcCount})`;
|
||
const rows = [
|
||
['实现目录', `<code>frameworks/</code>(${pcImpl})`, '<code>frameworks-mobile/</code>(' + components.length * ENDS.length + ')', '否'],
|
||
['契约', pcCont, '<code>.design_library/kole-ui-mobile/components/</code>(' + components.length + ')', '否'],
|
||
['索引(唯一真源)', '<code>components/index.json</code>', '<code>kole-ui-mobile/components/index.json</code>', '否'],
|
||
['类名前缀', '<code>kole-</code>(含既有短名 <code>btn</code>)', '<code>kole-m-</code>(状态类 <code>is-*</code>)', '否'],
|
||
['令牌前缀', '<code>--kole-*</code>(75)', '<code>--kole-m-*</code>(' + mbTokenMap.size + ')', '颜色 / 字体 / 圆角 / 阴影同源(@import)'],
|
||
['导出名', '<code>KoleButton</code>', '<code>KoleMNavBar</code>', '否'],
|
||
['测试页', pcTests, '<code>tests/mobile/<slug>.html</code>(' + components.length + ')', '共享断言引擎 <code>tests/_runtime.js</code>'],
|
||
['回归报告', '<code>tests/report.json</code>', '<code>tests/mobile-report.json</code>', '否'],
|
||
['文档站', '<code>site/</code>(SPA 路由)', '<code>site/m/</code>(静态页,不进 PC 路由表)', '否'],
|
||
['自包含数据', '<code>site/data.json</code>', '<code>site/m/data.mobile.json</code>', '否'],
|
||
['分发产物', '<code>dist/components|react|vue3|vue2</code>', '<code>dist/mobile/*</code>', '否'],
|
||
['构建脚本', '<code>build-site.ps1</code> + <code>build-dist.mjs</code>', '<code>build-mobile.mjs</code> + <code>build-uniapp.mjs</code>', '否'],
|
||
];
|
||
return rows
|
||
.map((r) => ` <tr><td>${r[0]}</td><td>${r[1]}</td><td>${r[2]}</td><td>${r[3]}</td></tr>`)
|
||
.join('\n');
|
||
}
|
||
|
||
function endImportRows() {
|
||
const END_INFO = {
|
||
css: ['纯样式(CSS)', 'kole-ui/mobile/components/<slug>.css', '拿走样式表,结构自己写(照 <code><Prefix>.html</code> 的类名)'],
|
||
html: ['H5 原生(无框架)', 'frameworks-mobile/<Prefix>.html', '零依赖演示页源码:内联脚本即完整交互,可直接改成业务页'],
|
||
jsx: ['React', 'kole-ui/mobile/react(聚合)· kole-ui/mobile/react/<Prefix>.jsx(单个)', '函数组件 + hooks,需宿主具备 JSX 编译能力'],
|
||
vue2: ['Vue 2', 'kole-ui/mobile/vue2(聚合)· kole-ui/mobile/vue2/<Prefix>.vue(单个)', '需要 Vue 2 SFC 编译能力(vue-loader 15+ / vue-template-compiler)'],
|
||
vue3: ['Vue 3', 'kole-ui/mobile/vue3(聚合)· kole-ui/mobile/vue3/<Prefix>.vue(单个)', '需 Vue 3 SFC 编译能力;样式在 SFC 内'],
|
||
uniapp: ['uni-app(App / 小程序 / H5)', 'kole-ui/mobile/uniapp/<Prefix>.vue', 'uni 基础组件 + <code>rpx</code> + touch 事件;令牌在宿主工程全局引一次'],
|
||
};
|
||
return ENDS.map(
|
||
(e) =>
|
||
` <tr><td>${END_INFO[e][0]}</td><td><code>${END_INFO[e][1]}</code></td><td>${END_INFO[e][2]}</td></tr>`
|
||
).join('\n');
|
||
}
|
||
|
||
const cards = components
|
||
.map((c) => {
|
||
const ct = contracts.get(c.slug) || {};
|
||
const dims = (ct.variantDimensions || [])
|
||
.map((d) => `${d.name}: ${d.values.join('/')}`)
|
||
.join(' · ');
|
||
return ` <article class="m-card" data-slug="${esc(c.slug)}">
|
||
<header>
|
||
<div class="zh">${esc(c.name.split(' ')[0])}<span class="en" style="margin-left:6px;">${esc(c.frameworksPrefix)}</span></div>
|
||
<div class="en">${esc(CAT_ZH[c.category] || c.category)} · ${esc(dims)}</div>
|
||
</header>
|
||
<div class="preview"><iframe src="../../frameworks-mobile/${esc(c.files.html)}" title="${esc(c.name)} 预览" loading="lazy"></iframe></div>
|
||
<footer>
|
||
<a href="component/${esc(c.slug)}.html">详情</a>
|
||
<a href="../../tests/mobile/${esc(c.slug)}.html">测试页</a>
|
||
<a href="../../frameworks-mobile/${esc(c.files.html)}" target="_blank">演示页 ↗</a>
|
||
<span class="m-pill" style="margin-left:auto;">${esc(c.files.uniapp ? 'uni-app ✓' : '')}</span>
|
||
</footer>
|
||
</article>`;
|
||
})
|
||
.join('\n');
|
||
|
||
/** 把占位符灌进模板:侧栏、复制脚本、各表行统一在这里注入 */
|
||
function renderSitePage(templateName, repl) {
|
||
let html = read(join(WEBSITE, templateName));
|
||
const all = Object.assign(
|
||
/* __PC_N__ 单独给一个纯数字占位符:模板里的句子是「PC 端 __PC_N__ 个中后台组件 × 5 端」,
|
||
灌 __PC_COUNT__(已含量词)会读成「103 个中后台组件 个中后台组件」。 */
|
||
{ __COPY_SCRIPT__: COPY_SCRIPT, __PC_COUNT__: pcCountText, __PC_N__: pcCount === null ? '—' : String(pcCount) },
|
||
repl
|
||
);
|
||
for (const [k, v] of Object.entries(all)) html = html.replaceAll(k, v);
|
||
return html;
|
||
}
|
||
|
||
/* 每个页面都要替换的公共占位符:顶栏(与 PC 同结构)、主题 bootstrap、品牌图标。
|
||
__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="${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'),
|
||
};
|
||
}
|
||
|
||
write(
|
||
join(WEBSITE, 'index.html'),
|
||
renderSitePage('_index_template.html', Object.assign(shellRepl('index'), {
|
||
__SIDEBAR__: sidebarHtml('index', null, '组件总览'),
|
||
__CARDS__: cards,
|
||
__COUNT__: String(components.length),
|
||
__MOBILE_IMPL__: String(components.length * ENDS.length),
|
||
__AXIS_ROWS__: axisRows(),
|
||
__MAPPING_ROWS__: mappingRows(),
|
||
}))
|
||
);
|
||
|
||
/* ---------- 4. site/m/component/<slug>.html(逐组件完整文档) ---------- */
|
||
|
||
/* 回归报告(若已跑过):逐页结果 → 「断言 N 条 · 全通过」。报告缺 pageResults 时降级为「已运行」。 */
|
||
const REPORT_PATH = join(ROOT, 'tests', 'mobile-report.json');
|
||
let mobileReport = null;
|
||
if (existsSync(REPORT_PATH)) {
|
||
try {
|
||
mobileReport = JSON.parse(read(REPORT_PATH));
|
||
} catch (e) {
|
||
mobileReport = null;
|
||
}
|
||
}
|
||
const reportBySlug = new Map(((mobileReport && mobileReport.pageResults) || []).map((r) => [r.slug, r]));
|
||
|
||
function rows(obj) {
|
||
return Object.entries(obj)
|
||
.map(([k, v]) => ` <tr><td><code>${esc(k)}</code></td><td>${esc(v)}</td></tr>`)
|
||
.join('\n');
|
||
}
|
||
function listItems(arr) {
|
||
return (arr || []).map((x) => ` <li>${esc(x)}</li>`).join('\n');
|
||
}
|
||
function gapRows(ct) {
|
||
const out = [];
|
||
(ct.doNotInvent || []).forEach((x) =>
|
||
out.push(` <tr><td><span class="m-chip">禁止发明</span></td><td>${esc(x)}</td></tr>`)
|
||
);
|
||
(ct.unknowns || []).forEach((x) =>
|
||
out.push(` <tr><td><span class="m-chip">规格未定</span></td><td>${esc(x)}</td></tr>`)
|
||
);
|
||
return out.join('\n') || ' <tr><td colspan="2">无</td></tr>';
|
||
}
|
||
function endLinks(c) {
|
||
return ENDS.map((end) => {
|
||
const file = c.files[end];
|
||
/* 相对深度:文档页在 site/m/component/,故到仓库根要退三级(../../../frameworks-mobile/…)。
|
||
实测踩过:写成两级会解析成 /site/frameworks-mobile/… → 404(样式与演示帧一起丢)。 */
|
||
return ` <a href="../../../frameworks-mobile/${esc(file)}" target="_blank">${esc(
|
||
END_LABELS[end] || end
|
||
)}<span style="font-family: var(--kole-font-family-num); font-size: 11px; color: var(--kole-color-text-secondary);">${esc(
|
||
file
|
||
)}</span></a>`;
|
||
}).join('\n');
|
||
}
|
||
|
||
/** 变体维度 × 取值 → 类名/变量(契约 variantClasses;verify-mobile-docs 会逐条对照 CSS 存在性) */
|
||
function variantRows(ct) {
|
||
const vc = ct.variantClasses || {};
|
||
return (ct.variantDimensions || [])
|
||
.map((d) => {
|
||
const map = vc[d.name] || {};
|
||
const cells = d.values
|
||
.map((v) => {
|
||
const cls = map[v] || [];
|
||
const txt = cls.length
|
||
? cls
|
||
.map((x) => (x.startsWith('--') ? `<code>${esc(x)}</code>` : `<span class="m-cls">${esc(x)}</span>`))
|
||
.join(' ')
|
||
: '<span class="m-none">(由数据驱动,无专属类)</span>';
|
||
return `<div><b>${esc(v)}</b> ${txt}</div>`;
|
||
})
|
||
.join('');
|
||
return ` <tr><td><code>${esc(d.name)}</code></td><td>${esc(d.values.join(' / '))}</td><td>${cells}</td></tr>`;
|
||
})
|
||
.join('\n');
|
||
}
|
||
|
||
function repRows(ct) {
|
||
return (ct.representativeVariants || [])
|
||
.map((v) => {
|
||
const keys = Object.keys(v).filter((k) => k !== 'label');
|
||
const combo = keys.map((k) => `${k}=${v[k]}`).join(' · ');
|
||
return ` <tr><td><code>${esc(combo)}</code></td><td>${esc(v.label || '')}</td></tr>`;
|
||
})
|
||
.join('\n');
|
||
}
|
||
|
||
function apiTables(ct) {
|
||
const api = ct.api || { props: [], events: [], slots: [] };
|
||
const props = (api.props || [])
|
||
.map(
|
||
(p) =>
|
||
` <tr><td><code>${esc(p.name)}</code></td><td><code>${esc(p.type)}</code></td><td><code>${esc(
|
||
p.default
|
||
)}</code></td><td>${esc(p.desc)}</td><td>${p.required ? 'Y' : 'N'}</td></tr>`
|
||
)
|
||
.join('\n');
|
||
const events = (api.events || [])
|
||
.map(
|
||
(e) =>
|
||
` <tr><td><code>${esc(e.name)}</code></td><td><code>${esc(e.params)}</code></td><td>${esc(e.desc)}</td></tr>`
|
||
)
|
||
.join('\n');
|
||
const slots = (api.slots || [])
|
||
.map((s) => ` <tr><td><code>${esc(s.name)}</code></td><td>${esc(s.desc)}</td></tr>`)
|
||
.join('\n');
|
||
return { props, events, slots, note: esc(api.note || '') };
|
||
}
|
||
|
||
/** 6 端源码:每端一个折叠块(写进页面 = 文档离线可读,不依赖数据请求) */
|
||
function sourceBlocks(c) {
|
||
return ENDS.map((end) => {
|
||
const file = c.files[end];
|
||
return codeDetails(
|
||
`frameworks-mobile/${file}`,
|
||
sources.get(c.slug)[end],
|
||
END_LABELS[end] || end
|
||
);
|
||
}).join('\n');
|
||
}
|
||
|
||
/** 用到的令牌:自有(--kole-m-*)/ 继承(PC)/ 组件级变量 */
|
||
function tokenChips(c) {
|
||
const { own, inherited, local } = tokensOf(c.slug);
|
||
const chips = [];
|
||
own.forEach((n) => chips.push(` <span class="m-chip m" title="${esc((mbTokenMap.get(n) || {}).comment || '')}">${esc(n)}</span>`));
|
||
inherited.forEach((n) =>
|
||
chips.push(` <span class="m-chip inherit" title="${esc((pcTokenMap.get(n) || {}).comment || '')}">${esc(n)}</span>`)
|
||
);
|
||
local.forEach((n) => chips.push(` <span class="m-chip" title="组件级变量(可在业务侧覆盖)">${esc(n)}</span>`));
|
||
return chips.join('\n');
|
||
}
|
||
|
||
function testStatus(c) {
|
||
const r = reportBySlug.get(c.slug);
|
||
if (!r) return { cls: '', text: REPORT_LABEL_EMPTY };
|
||
if (r.fail > 0) return { cls: 'fail', text: `断言 ${r.total} 条 · ${r.fail} 条失败` };
|
||
return { cls: 'pass', text: `断言 ${r.total} 条 · 全部通过` };
|
||
}
|
||
const REPORT_LABEL_EMPTY = '尚未运行回归(npm run regression:mobile)';
|
||
|
||
function relatedLinks(c) {
|
||
const others = components.filter((x) => x.category === c.category && x.slug !== c.slug);
|
||
const pool = others.length ? others : components.filter((x) => x.slug !== c.slug);
|
||
return pool
|
||
.map(
|
||
(x) =>
|
||
` <a href="${esc(x.slug)}.html">${esc(x.name.split(' ')[0])}<span style="font-family: var(--kole-font-family-num); font-size: 11px; color: var(--kole-color-text-secondary);">${esc(
|
||
x.frameworksPrefix
|
||
)}</span></a>`
|
||
)
|
||
.join('\n');
|
||
}
|
||
|
||
/** 从演示页里按 data-demo 抽出该块的**原文**(含 <section> 包装,故与源码逐字节一致) */
|
||
function demoSectionHtml(c, id) {
|
||
const html = sources.get(c.slug).html;
|
||
const marker = `<section class="demo-block" data-demo="${id}">`;
|
||
const start = html.indexOf(marker);
|
||
if (start < 0) return null;
|
||
const end = html.indexOf('</section>', start);
|
||
if (end < 0) return null;
|
||
/* 去掉每行统一的两格缩进(section 在 .demo 内缩进两层),让代码块左对齐 */
|
||
return html
|
||
.slice(start, end + '</section>'.length)
|
||
.split('\n')
|
||
.map((line) => line.replace(/^ {2}/, ''))
|
||
.join('\n');
|
||
}
|
||
|
||
/** 演示块:按契约 demos 分组渲染(01 组件类型 / 02 组件状态),每块一个预览帧 + 原文代码 */
|
||
function demoBlocks(c, ct) {
|
||
const groups = new Map();
|
||
for (const d of ct.demos || []) {
|
||
if (!groups.has(d.group)) groups.set(d.group, []);
|
||
groups.get(d.group).push(d);
|
||
}
|
||
const out = [];
|
||
let seq = 0;
|
||
for (const [group, list] of groups) {
|
||
out.push(` <h3 class="m-demo-group">${esc(group)}</h3>`);
|
||
for (const d of list) {
|
||
seq++;
|
||
const code = demoSectionHtml(c, d.id);
|
||
const id = `code-demo-${seq}`;
|
||
out.push(
|
||
` <div class="m-demo" id="demo-${esc(d.id)}">\n` +
|
||
` <div class="m-demo-head"><span class="m-demo-title">${esc(d.title)}</span>` +
|
||
(d.variant ? `<span class="m-chip">${esc(d.variant)}</span>` : '') +
|
||
`</div>\n` +
|
||
` <p class="m-demo-desc">${esc(d.desc)}</p>\n` +
|
||
` <div class="m-demo-stage"><iframe src="../../../frameworks-mobile/${esc(c.files.html)}?demo=${esc(d.id)}" title="${esc(c.name)} · ${esc(d.title)}" loading="lazy"></iframe></div>\n` +
|
||
(code
|
||
? ` <details class="m-details"><summary>查看代码(演示页原文 · ${code.split('\n').length} 行)</summary>\n` +
|
||
` <div class="m-code"><div class="m-code-head"><span>frameworks-mobile/${esc(c.files.html)} · ${esc(d.id)}</span><button class="m-copy" type="button" data-copy="${id}">复制</button></div>\n` +
|
||
` <pre id="${id}"><code>${esc(code)}</code></pre></div>\n` +
|
||
` </details>\n`
|
||
: ` <p class="m-note">(演示页里找不到 data-demo="${esc(d.id)}",请检查契约与演示页是否同步)</p>\n`) +
|
||
` </div>`
|
||
);
|
||
}
|
||
}
|
||
return out.join('\n');
|
||
}
|
||
|
||
/** CSS 变量表:组件样式表里**定义**的 --kole-m-* 组件级变量(业务侧可覆盖) */
|
||
function cssVarsRows(c) {
|
||
const css = sources.get(c.slug).css;
|
||
const rows = [];
|
||
const re = /^\s*(--kole-m-[a-z0-9-]+)\s*:\s*([^;]+);(?:\s*\/\*\s*([^*]*?)\s*\*\/)?/gm;
|
||
let m;
|
||
while ((m = re.exec(css))) {
|
||
/* 只收「组件级变量」:即不在令牌层里的那些(如 --kole-m-pullrefresh-threshold) */
|
||
if (mbTokenMap.has(m[1])) continue;
|
||
rows.push(
|
||
` <tr><td><code>${esc(m[1])}</code></td><td><code>${esc(m[2].trim())}</code></td><td>${esc(
|
||
(m[3] || '').trim() || '组件内部默认值,可在业务侧覆盖'
|
||
)}</td></tr>`
|
||
);
|
||
}
|
||
return rows.join('\n') || ' <tr><td colspan="3" class="m-none">本组件没有组件级 CSS 变量</td></tr>';
|
||
}
|
||
|
||
/** 相似组件表 */
|
||
function relatedRows(c, ct) {
|
||
const rows = (ct.related || []).map((r) => {
|
||
const other = components.find((x) => x.slug === r.slug);
|
||
if (!other) return ` <tr><td><code>${esc(r.slug)}</code></td><td>${esc(r.why)}</td></tr>`;
|
||
return (
|
||
` <tr><td><a href="${esc(other.slug)}.html">${esc(other.name.split(' ')[0])}</a>` +
|
||
`<em style="font-family: var(--kole-font-family-num); font-style: normal; font-size: 11px; color: var(--kole-color-text-placeholder); margin-left: 6px;">${esc(
|
||
other.frameworksPrefix
|
||
)}</em></td><td>${esc(r.why)}</td></tr>`
|
||
);
|
||
});
|
||
return rows.join('\n') || ' <tr><td colspan="2" class="m-none">无</td></tr>';
|
||
}
|
||
|
||
/** 引入代码(H5 原生:令牌 + 本组件样式;其余端见快速开始) */
|
||
function importCode(c) {
|
||
return [
|
||
'<!-- ① 令牌:PC 令牌 + 移动端 --kole-m-* 合成单文件,引一次 -->',
|
||
'<link rel="stylesheet" href="kole-ui/mobile/tokens.css">',
|
||
'',
|
||
`<!-- ② 本组件样式(全量则用 kole-ui/mobile/components/index.css) -->`,
|
||
`<link rel="stylesheet" href="kole-ui/mobile/components/${c.slug}.css">`,
|
||
'',
|
||
'<!-- ③ 结构照抄下方任一演示块(类名与 6 端实现一致) -->',
|
||
].join('\n');
|
||
}
|
||
|
||
const compTemplate = read(join(WEBSITE, '_component_template.html'));
|
||
for (const c of components) {
|
||
const ct = contracts.get(c.slug) || {};
|
||
const purpose = (ct.usageHints || [])[0] || '';
|
||
const api = apiTables(ct);
|
||
const status = testStatus(c);
|
||
const repl = {
|
||
__SIDEBAR__: sidebarHtml(null, c.slug, c.name.split(' ')[0]),
|
||
__COPY_SCRIPT__: COPY_SCRIPT,
|
||
__SLUG__: c.slug,
|
||
__NAME__: c.name,
|
||
__ZH__: c.name.split(' ')[0],
|
||
__PREFIX__: c.frameworksPrefix,
|
||
__CATEGORY__: CAT_ZH[c.category] || c.category,
|
||
__SPEC_SECTION__: c.specSection || '',
|
||
__PURPOSE__: esc(purpose),
|
||
__IMPORT_CODE__: esc(importCode(c)),
|
||
__DEMO_BLOCKS__: demoBlocks(c, ct),
|
||
__ANATOMY__: rows(ct.anatomy || {}),
|
||
__VARIANT_ROWS__: variantRows(ct),
|
||
__REP_ROWS__: repRows(ct),
|
||
__USAGE__: listItems(ct.usageHints),
|
||
__INTERACTION__: listItems(ct.interaction),
|
||
__A11Y__: listItems(ct.accessibility),
|
||
__API_NOTE__: api.note,
|
||
__API_REQUIRED_NOTE__: esc(ct.api?.requiredNote || ''),
|
||
__API_PROPS__: api.props,
|
||
__API_EVENTS__: api.events,
|
||
__API_SLOTS__: api.slots,
|
||
__CSS_VARS__: cssVarsRows(c),
|
||
__RELATED_ROWS__: relatedRows(c, ct),
|
||
__SOURCE_BLOCKS__: sourceBlocks(c),
|
||
__TOKEN_CHIPS__: tokenChips(c),
|
||
__GAPS__: gapRows(ct),
|
||
__TEST_CLASS__: status.cls,
|
||
__TEST_TEXT__: status.text,
|
||
__REPORT_DATE__: (mobileReport && mobileReport.generated) || '未运行',
|
||
__CONTRACT__: c.contract,
|
||
__CONTRACT_JSON__: JSON.stringify(ct, null, 2),
|
||
__RELATED__: relatedLinks(c),
|
||
__ENDS__: endLinks(c),
|
||
};
|
||
/* 组件页在顶栏高亮「组件总览」(PC 顶栏在组件页高亮「组件」,同一行为口径) */
|
||
Object.assign(repl, shellRepl('index', true));
|
||
let html = compTemplate;
|
||
for (const [k, v] of Object.entries(repl)) html = html.replaceAll(k, v);
|
||
write(join(WEBSITE, 'component', c.slug + '.html'), html);
|
||
}
|
||
console.log(`[build-mobile] site/m/component/ ← ${components.length} 个组件文档页(含 API / 6 端源码 / 令牌 / 回归状态)`);
|
||
|
||
/* ---------- 4.5 site/m 的五个指南页(与 PC 同名同序)---------- */
|
||
|
||
/* 更新日志:从仓库根 CHANGELOG.md 提取「标题含 Mobile / 移动端」的段落,转成静态 HTML。
|
||
不手抄 —— 站点与仓库两处说法不一致是这类页面的典型失败模式。
|
||
出口脱敏:CHANGELOG 是内部流水,含部署目录 / 镜像标签 / 内网网段 / 部署时序,
|
||
而本页是公网可下载的静态文件;在**读入口**过一遍,下游(代码块 / 列表 / 引用)
|
||
拿到的就都是干净文本。CHANGELOG.md 本身不改。 */
|
||
function inlineMd(s) {
|
||
return esc(s)
|
||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
|
||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||
}
|
||
function mobileChangelogHtml() {
|
||
const src = redactText(read(join(ROOT, 'CHANGELOG.md')));
|
||
const lines = src.split(/\r?\n/);
|
||
const blocks = [];
|
||
let cur = null;
|
||
let fence = null;
|
||
for (const line of lines) {
|
||
const h = line.match(/^### (.+)$/);
|
||
if (h) {
|
||
if (cur) blocks.push(cur);
|
||
cur = /Mobile|移动端/.test(h[1]) ? { title: h[1], body: [] } : null;
|
||
continue;
|
||
}
|
||
if (!cur) continue;
|
||
if (line.startsWith('```')) {
|
||
if (fence === null) fence = [];
|
||
else {
|
||
cur.body.push({ kind: 'code', text: fence.join('\n') });
|
||
fence = null;
|
||
}
|
||
continue;
|
||
}
|
||
if (fence !== null) {
|
||
fence.push(line);
|
||
continue;
|
||
}
|
||
if (/^\s*>\s?/.test(line)) cur.body.push({ kind: 'note', text: line.replace(/^\s*>\s?/, '') });
|
||
else if (/^\s*-\s+/.test(line)) cur.body.push({ kind: 'li', text: line.replace(/^\s*-\s+/, '') });
|
||
else if (line.trim() === '') cur.body.push({ kind: 'gap', text: '' });
|
||
else cur.body.push({ kind: 'p', text: line });
|
||
}
|
||
if (cur) blocks.push(cur);
|
||
|
||
const out = [];
|
||
for (const b of blocks) {
|
||
out.push(` <h3 class="m-h3" style="margin-top: var(--kole-space-20);">${inlineMd(b.title)}</h3>`);
|
||
let inList = false;
|
||
for (const item of b.body) {
|
||
if (item.kind === 'li') {
|
||
if (!inList) {
|
||
out.push(' <ul class="m-steps">');
|
||
inList = true;
|
||
}
|
||
out.push(` <li>${inlineMd(item.text)}</li>`);
|
||
continue;
|
||
}
|
||
if (inList) {
|
||
out.push(' </ul>');
|
||
inList = false;
|
||
}
|
||
if (item.kind === 'note') out.push(` <p class="m-note">${inlineMd(item.text)}</p>`);
|
||
else if (item.kind === 'code') out.push(` <div class="m-code"><pre><code>${esc(item.text)}</code></pre></div>`);
|
||
else if (item.kind === 'p' && item.text.trim()) out.push(` <p class="m-sub">${inlineMd(item.text)}</p>`);
|
||
}
|
||
if (inList) out.push(' </ul>');
|
||
}
|
||
return out.length ? out.join('\n') : ' <p class="m-sub">(CHANGELOG 里暂时没有标题含「Mobile / 移动端」的段落)</p>';
|
||
}
|
||
|
||
/* 快速开始 */
|
||
write(
|
||
join(WEBSITE, 'guide.html'),
|
||
renderSitePage('_guide_template.html', Object.assign(shellRepl('guide'), {
|
||
__SIDEBAR__: sidebarHtml('guide', null, '快速开始'),
|
||
__END_ROWS__: endImportRows(),
|
||
}))
|
||
);
|
||
|
||
/* 设计令牌:移动端自有令牌表 + 组件实际用到的继承令牌表 */
|
||
const tokenRows = [...mbTokenMap.values()]
|
||
.map((t) => ` <tr><td><code>${esc(t.name)}</code></td><td><code>${esc(t.value)}</code></td><td>${esc(t.comment)}</td></tr>`)
|
||
.join('\n');
|
||
const inheritedUsage = new Map();
|
||
for (const c of components) {
|
||
for (const n of tokensOf(c.slug).inherited) {
|
||
if (!inheritedUsage.has(n)) inheritedUsage.set(n, []);
|
||
inheritedUsage.get(n).push(c.name.split(' ')[0]);
|
||
}
|
||
}
|
||
const inheritedRows = [...inheritedUsage.keys()]
|
||
.sort()
|
||
.map((n) => {
|
||
const t = pcTokenMap.get(n) || { value: '—', comment: '' };
|
||
return ` <tr><td><code>${esc(n)}</code></td><td><code>${esc(t.value)}</code></td><td>${esc(
|
||
inheritedUsage.get(n).join('、')
|
||
)}</td></tr>`;
|
||
})
|
||
.join('\n');
|
||
write(
|
||
join(WEBSITE, 'design.html'),
|
||
renderSitePage('_design_template.html', Object.assign(shellRepl('design'), {
|
||
__SIDEBAR__: sidebarHtml('design', null, '设计规范'),
|
||
__MOBILE_COUNT__: String(mbTokenMap.size),
|
||
__COMPONENT_COUNT__: String(components.length),
|
||
__TOKEN_ROWS__: tokenRows,
|
||
__INHERITED_ROWS__: inheritedRows,
|
||
}))
|
||
);
|
||
|
||
/* 常见问题 */
|
||
write(
|
||
join(WEBSITE, 'faq.html'),
|
||
renderSitePage('_faq_template.html', Object.assign(shellRepl('faq'), {
|
||
__SIDEBAR__: sidebarHtml('faq', null, '常见问题'),
|
||
}))
|
||
);
|
||
|
||
/* 更新日志(从 CHANGELOG.md 提取) */
|
||
write(
|
||
join(WEBSITE, 'changelog.html'),
|
||
renderSitePage('_changelog_template.html', Object.assign(shellRepl('changelog'), {
|
||
__SIDEBAR__: sidebarHtml('changelog', null, '更新日志'),
|
||
__ENTRIES__: mobileChangelogHtml(),
|
||
}))
|
||
);
|
||
|
||
/* 平台与端 */
|
||
write(
|
||
join(WEBSITE, 'platform.html'),
|
||
renderSitePage('_platform_template.html', Object.assign(shellRepl('platform'), {
|
||
__SIDEBAR__: sidebarHtml('platform', null, '平台与端'),
|
||
__AXIS_ROWS__: axisRows(),
|
||
__MAPPING_ROWS__: mappingRows(),
|
||
__MOBILE_COUNT__: String(components.length),
|
||
__MOBILE_IMPL__: String(components.length * ENDS.length),
|
||
}))
|
||
);
|
||
console.log('[build-mobile] site/m/ ← index / guide / design / faq / changelog / platform 六页(顶栏与左栏与 PC 同结构)');
|
||
|
||
/* ---------- 5. dist/mobile/** ---------- */
|
||
|
||
/* 5.1 令牌:PC 令牌 + 移动端令牌合成**自包含单文件**(移动端消费只需引一次) */
|
||
const pcTokens = read(join(ROOT, '.design_library', 'kole-ui', 'colors_and_type.css'));
|
||
const mobileTokens = tokenFile.replace(/@import url\([^)]*\);\s*/g, '');
|
||
write(
|
||
join(DIST, 'tokens', 'tokens.css'),
|
||
`/* Kole UI Mobile — 令牌(自包含:PC 令牌 + 移动端令牌)\n` +
|
||
` * 生成:node tools/build-mobile.mjs(勿手改)\n` +
|
||
` * 组成:.design_library/kole-ui/colors_and_type.css(颜色/字体/圆角/阴影,与 PC 同源)\n` +
|
||
` * + .design_library/kole-ui-mobile/colors_and_type.css 去掉 @import 后的 --kole-m-* 部分\n` +
|
||
` */\n\n${pcTokens}\n\n/* ===== mobile layer (--kole-m-*) ===== */\n${mobileTokens}`
|
||
);
|
||
|
||
/* 5.2 组件样式:单文件 + 聚合(同 PC:@import 相对路径 ../tokens/tokens.css) */
|
||
const aggregate = [
|
||
'/* Kole UI Mobile — 全部移动端组件样式聚合',
|
||
` * 版本 ${VERSION} · ${components.length} 个组件 · 生成:node tools/build-mobile.mjs`,
|
||
' */',
|
||
'@import url("../tokens/tokens.css");',
|
||
'',
|
||
];
|
||
const manifestComponents = [];
|
||
for (const c of components) {
|
||
const css = sources.get(c.slug).css;
|
||
write(
|
||
join(DIST, 'components', c.slug + '.css'),
|
||
`/* Kole UI Mobile · ${c.name} — 生成:node tools/build-mobile.mjs */\n@import url("../tokens/tokens.css");\n\n${css}`
|
||
);
|
||
aggregate.push(`/* --- ${c.slug} (${c.frameworksPrefix}) --- */`, css, '');
|
||
manifestComponents.push({
|
||
slug: c.slug,
|
||
name: c.name,
|
||
category: c.category,
|
||
ends: ENDS.slice(),
|
||
files: c.files,
|
||
css: `components/${c.slug}.css`,
|
||
sha256: Object.fromEntries(ENDS.map((e) => [e, sha256(sources.get(c.slug)[e])])),
|
||
});
|
||
}
|
||
write(join(DIST, 'components', 'index.css'), aggregate.join('\n'));
|
||
|
||
/* 5.3 框架端入口(react / vue3 / vue2 / uniapp)
|
||
* uni-app 端额外给一份「移动端 uni-app 清单」,PC 端 uni-app 列见 dist/uniapp-pc */
|
||
const reactImports = [];
|
||
const vue3Imports = [];
|
||
const vue2Imports = [];
|
||
const uniappImports = [];
|
||
for (const c of components) {
|
||
const p = c.frameworksPrefix;
|
||
write(join(DIST, 'react', `${p}.jsx`), sources.get(c.slug).jsx);
|
||
write(join(DIST, 'react', `${p}.css`), sources.get(c.slug).css);
|
||
reactImports.push(p);
|
||
write(join(DIST, 'vue3', `${p}.vue`), sources.get(c.slug).vue3);
|
||
vue3Imports.push(p);
|
||
write(join(DIST, 'vue2', `${p}.vue`), sources.get(c.slug).vue2);
|
||
vue2Imports.push(p);
|
||
write(join(DIST, 'uniapp', `${p}.vue`), sources.get(c.slug).uniapp);
|
||
uniappImports.push(p);
|
||
}
|
||
|
||
const header = (title, lines) => [`/* Kole UI Mobile — ${title}`, ...lines, ' */', ''].join('\n');
|
||
|
||
write(
|
||
join(DIST, 'react', 'index.js'),
|
||
header('React 端聚合入口', [
|
||
` * 版本 ${VERSION} · ${reactImports.length} 个组件`,
|
||
' * 需要宿主构建链具备 JSX 编译能力;样式随组件 import 进入。',
|
||
" * 用法:import { KoleMNavBar } from 'kole-ui/mobile/react';",
|
||
]) + reactImports.map((p) => `export { default as KoleM${p} } from './${p}.jsx';`).join('\n') + '\n'
|
||
);
|
||
write(
|
||
join(DIST, 'vue3', 'index.js'),
|
||
header('Vue 3 端聚合入口', [
|
||
` * 版本 ${VERSION} · ${vue3Imports.length} 个组件`,
|
||
' * 需要构建链具备 Vue 3 SFC 编译能力;样式在 SFC 内。',
|
||
" * 用法:import { KoleMNavBar } from 'kole-ui/mobile/vue3';",
|
||
]) + vue3Imports.map((p) => `export { default as KoleM${p} } from './${p}.vue';`).join('\n') + '\n'
|
||
);
|
||
write(
|
||
join(DIST, 'vue2', 'index.js'),
|
||
header('Vue 2 端聚合入口', [
|
||
` * 版本 ${VERSION} · ${vue2Imports.length} 个组件`,
|
||
' * 需要构建链具备 Vue 2 SFC 编译能力(vue-loader 15+ / vue-template-compiler);样式在 SFC 内。',
|
||
" * 用法:import { KoleMNavBar } from 'kole-ui/mobile/vue2';",
|
||
]) + vue2Imports.map((p) => `export { default as KoleM${p} } from './${p}.vue';`).join('\n') + '\n'
|
||
);
|
||
write(
|
||
join(DIST, 'uniapp', 'index.js'),
|
||
header('uni-app 端聚合入口(移动端组件)', [
|
||
` * 版本 ${VERSION} · ${uniappImports.length} 个组件`,
|
||
` * 目标:${(index.uniappTargets || []).join(' / ')}(App / 微信小程序 / H5)`,
|
||
' * 组件用 uni 基础组件(view/text/input)+ rpx + touch 事件实现,无 DOM 依赖。',
|
||
' * 令牌需在宿主工程全局引入一次:@import "kole-ui/mobile/tokens.css";',
|
||
" * 用法:import KoleMNavBar from 'kole-ui/mobile/uniapp/NavBar.vue';",
|
||
]) + uniappImports.map((p) => `export { default as KoleM${p} } from './${p}.vue';`).join('\n') + '\n'
|
||
);
|
||
|
||
/* 5.4 manifest */
|
||
write(
|
||
join(DIST, 'manifest.json'),
|
||
JSON.stringify(
|
||
{
|
||
name: 'kole-ui-mobile',
|
||
platform: 'mobile',
|
||
version: VERSION,
|
||
generated: new Date().toISOString().slice(0, 19).replace('T', ' '),
|
||
isolatedFrom: pcCount === null ? 'kole-ui(PC 端)' : `kole-ui(PC 端 ${pcCount} 组件)`,
|
||
ends: ENDS,
|
||
endLabels: END_LABELS,
|
||
uniappTargets: index.uniappTargets || [],
|
||
tokens: tokens.length,
|
||
classPrefix: 'kole-m-',
|
||
tokenPrefix: '--kole-m-',
|
||
exportPrefix: 'KoleM',
|
||
components: manifestComponents,
|
||
},
|
||
null,
|
||
2
|
||
)
|
||
);
|
||
|
||
/* 5.5 用法速查 */
|
||
write(
|
||
join(DIST, 'README.md'),
|
||
`# Kole UI Mobile — 分发包
|
||
|
||
版本 ${VERSION} · ${components.length} 个移动端组件 · ${ENDS.length} 端(${ENDS.join(' / ')})· 零运行时依赖
|
||
|
||
与 PC 端(\`kole-ui\` 主包的 ${pcCountText})**物理隔离**:独立目录、类名前缀 \`kole-m-\`、
|
||
令牌前缀 \`--kole-m-\`、独立测试与回归报告。只有颜色 / 字体 / 圆角 / 阴影令牌与 PC 同源。
|
||
|
||
## 最快用法
|
||
|
||
\`\`\`html
|
||
<link rel="stylesheet" href="./tokens/tokens.css">
|
||
<link rel="stylesheet" href="./components/index.css">
|
||
\`\`\`
|
||
|
||
## 按需引入
|
||
|
||
\`\`\`
|
||
kole-ui/mobile/tokens.css 令牌(PC 令牌 + --kole-m-* 合成单文件)
|
||
kole-ui/mobile/components/*.css 单组件样式(已 @import tokens)
|
||
kole-ui/mobile/components/index.css 全部样式聚合
|
||
kole-ui/mobile/react React 端入口
|
||
kole-ui/mobile/vue3 Vue 3 端入口
|
||
kole-ui/mobile/vue2 Vue 2 端入口
|
||
kole-ui/mobile/uniapp uni-app 端入口(App / 小程序 / H5)
|
||
\`\`\`
|
||
|
||
## uni-app
|
||
|
||
\`\`\`vue
|
||
<script setup>
|
||
import KoleMNavBar from 'kole-ui/mobile/uniapp/NavBar.vue';
|
||
</script>
|
||
\`\`\`
|
||
|
||
uni-app 端用 uni 基础组件(\`view\` / \`text\` / \`input\`)+ \`rpx\` + \`touch\` 事件实现,
|
||
小程序与 App 端无 DOM、无 PointerEvent,因此**不要**在本端使用 \`document\` / \`window\` / \`pointerdown\`。
|
||
|
||
## 组件清单
|
||
|
||
${manifestComponents.map((c) => `- \`${c.slug}\` ${c.name}(${c.files.uniapp ? '含 uni-app' : ''})`).join('\n')}
|
||
|
||
生成:\`node tools/build-mobile.mjs\` | 门禁:\`node tools/verify-mobile-isolation.mjs\` · \`node tools/verify-uniapp.mjs\`
|
||
`
|
||
);
|
||
|
||
console.log(
|
||
`[build-mobile] dist/mobile/ ← tokens(1) · components(${components.length}+1) · react(${reactImports.length}) · vue3(${vue3Imports.length}) · vue2(${vue2Imports.length}) · uniapp(${uniappImports.length}) · manifest.json · README.md`
|
||
);
|
||
console.log('[build-mobile] 完成(PC 侧文件未触碰)');
|