Files
aurora-admin b59247231a
Regression / regression (push) Canceled after 0s
feat(安全+品牌): 对外产物发布脱敏(3注入点/2465文件0泄露) + Kole Cup 定稿
安全: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)。
2026-09-23 07:31:06 +08:00

1478 lines
69 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/* 分类口径与 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/&lt;slug&gt;.html</code>' : `<code>tests/&lt;slug&gt;.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/&lt;slug&gt;.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/&lt;slug&gt;.css', '拿走样式表,结构自己写(照 <code>&lt;Prefix&gt;.html</code> 的类名)'],
html: ['H5 原生(无框架)', 'frameworks-mobile/&lt;Prefix&gt;.html', '零依赖演示页源码:内联脚本即完整交互,可直接改成业务页'],
jsx: ['React', 'kole-ui/mobile/react(聚合)· kole-ui/mobile/react/&lt;Prefix&gt;.jsx(单个)', '函数组件 + hooks,需宿主具备 JSX 编译能力'],
vue2: ['Vue 2', 'kole-ui/mobile/vue2(聚合)· kole-ui/mobile/vue2/&lt;Prefix&gt;.vue(单个)', '需要 Vue 2 SFC 编译能力(vue-loader 15+ / vue-template-compiler)'],
vue3: ['Vue 3', 'kole-ui/mobile/vue3(聚合)· kole-ui/mobile/vue3/&lt;Prefix&gt;.vue(单个)', '需 Vue 3 SFC 编译能力;样式在 SFC 内'],
uniapp: ['uni-app(App / 小程序 / H5)', 'kole-ui/mobile/uniapp/&lt;Prefix&gt;.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 侧文件未触碰)');