#!/usr/bin/env node /** * build-mobile.mjs — 移动端平台构建(platform = mobile) * * 覆盖:移动端 6 端全部产物 * frameworks-mobile/.{css,html,jsx,vue2.vue,vue3.vue,uniapp.vue}(源,本脚本只读) * ↓ * site/m/data.mobile.json 移动端自包含数据(一次请求拿到全部,含各端源码) * site/m/index.html 移动端文档总览(静态,含设备帧预览) * site/m/component/.html 逐组件文档页(静态,SEO 入口) * tests/mobile/.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/.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'; 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/.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, '"'); } /* 分类口径与 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 ` ${esc(p.label)}`; }) .join('\n'); return `
`; } /* 左侧栏:三段式与 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('
平台
'); 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( ` ` + `${esc(p.label)}` + (p.current ? '当前' : '') + `${esc(p.sub)}` ); } /* ② 开发指南(与 PC 同名同序;移动端特有的两页置后) */ parts.push('
开发指南
'); 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( ` ${esc(g.label)}` + (g.mobileOnly ? '移动端' : '') + `` ); } /* ③ 组件(按 PC 分类分组 + 计数) */ parts.push(`
组件${components.length}
`); for (const cat of CAT_ORDER) { const items = components.filter((c) => c.category === cat); if (!items.length) continue; parts.push(`
${esc(CAT_ZH[cat] || cat)}${items.length}
`); for (const c of items) { const on = activeSlug === c.slug; const href = isComp ? c.slug + '.html' : 'component/' + c.slug + '.html'; parts.push( ` ${esc(c.name.split(' ')[0])}${esc(c.frameworksPrefix)}` ); } } return ( /* open 默认展开:桌面无需 JS 即可看到左栏(渐进增强的底线)。 窄屏收起由侧栏脚本按断点切换 —— 实测教训:不带 open 时
高度按"关闭态" 算成 0,`overflow:auto` 会把里面的 nav 整块裁掉(DOM 里有、屏幕上看不见)。 */ `
\n` + ` 移动端导航${esc(activeLabel || '')}\n` + ` \n
` ); } /* 主题三态 bootstrap:必须在 里同步执行,否则首帧会先白后黑(PC 的 S2-P5 同款处理)。 约定与 PC 完全一致:localStorage['kole-mode'] ∈ light|dark|auto,反色靠 html.kole-dark —— 两端共用一份令牌,所以同一个键在两边都成立,用户在 PC 选夜间,进移动端站点也是夜间。 */ const THEME_BOOT = [ '', ].join('\n'); /* 站点脚本:复制按钮 + 左栏在窄屏自动收起(零依赖,CSP 允许内联脚本) */ const COPY_SCRIPT = [ '', ].join('\n'); let codeSeq = 0; /** 代码块(含复制按钮):id 由调用顺序生成,避免手写 id 撞车 */ function codeBlock(label, code, meta) { const id = 'code-' + ++codeSeq; return ( `
\n` + `
${esc(label)}` + (meta ? `${esc(meta)}` : '') + `
\n` + `
${esc(code)}
\n` + `
` ); } /** 详情折叠里的代码块(组件页 6 端源码用) */ function codeDetails(label, code, meta) { const id = 'code-' + ++codeSeq; const lines = code.split('\n').length; return ( `
\n` + ` ${esc(label)}${meta ? ' · ' + esc(meta) : ''} · ${lines} 行\n` + `
\n` + `
${esc(label)}
\n` + `
${esc(code)}
\n` + `
\n` + `
` ); } /* ---------- 3.1 覆盖矩阵 / 目录映射 / 端引入(总览、平台、快速开始三页共用) ---------- */ function axisRows() { const n = components.length; const cell = (txt) => `${txt}`; const pcCells = pcCount === null ? new Array(ENDS.length).fill('—') : [...new Array(5).fill('✅ ' + pcCount), '3 / ' + pcCount + '(试点)']; return [ ` 移动端(${n})${ENDS.map(() => cell('✅ ' + n)).join('')}`, ` PC(${pcCount === null ? '—' : pcCount})${pcCells.map(cell).join('')}`, ].join('\n'); } function mappingRows() { const pcImpl = pcCount === null ? '—' : String(pcCount * 5); const pcNum = (n) => (pcCount === null ? '—' : `${n}`); const pcCont = pcCount === null ? '.design_library/kole-ui/components/' : `.design_library/kole-ui/components/(${pcCount})`; const pcTests = pcCount === null ? 'tests/<slug>.html' : `tests/<slug>.html(${pcCount})`; const rows = [ ['实现目录', `frameworks/(${pcImpl})`, 'frameworks-mobile/(' + components.length * ENDS.length + ')', '否'], ['契约', pcCont, '.design_library/kole-ui-mobile/components/(' + components.length + ')', '否'], ['索引(唯一真源)', 'components/index.json', 'kole-ui-mobile/components/index.json', '否'], ['类名前缀', 'kole-(含既有短名 btn)', 'kole-m-(状态类 is-*)', '否'], ['令牌前缀', '--kole-*(75)', '--kole-m-*(' + mbTokenMap.size + ')', '颜色 / 字体 / 圆角 / 阴影同源(@import)'], ['导出名', 'KoleButton', 'KoleMNavBar', '否'], ['测试页', pcTests, 'tests/mobile/<slug>.html(' + components.length + ')', '共享断言引擎 tests/_runtime.js'], ['回归报告', 'tests/report.json', 'tests/mobile-report.json', '否'], ['文档站', 'site/(SPA 路由)', 'site/m/(静态页,不进 PC 路由表)', '否'], ['自包含数据', 'site/data.json', 'site/m/data.mobile.json', '否'], ['分发产物', 'dist/components|react|vue3|vue2', 'dist/mobile/*', '否'], ['构建脚本', 'build-site.ps1 + build-dist.mjs', 'build-mobile.mjs + build-uniapp.mjs', '否'], ]; return rows .map((r) => ` ${r[0]}${r[1]}${r[2]}${r[3]}`) .join('\n'); } function endImportRows() { const END_INFO = { css: ['纯样式(CSS)', 'kole-ui/mobile/components/<slug>.css', '拿走样式表,结构自己写(照 <Prefix>.html 的类名)'], 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 基础组件 + rpx + touch 事件;令牌在宿主工程全局引一次'], }; return ENDS.map( (e) => ` ${END_INFO[e][0]}${END_INFO[e][1]}${END_INFO[e][2]}` ).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 `
${esc(c.name.split(' ')[0])}${esc(c.frameworksPrefix)}
${esc(CAT_ZH[c.category] || c.category)} · ${esc(dims)}
`; }) .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__ 由模板放在 里(本站此前完全没有 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__: [ ``, '', '', ].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/.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]) => ` ${esc(k)}${esc(v)}`) .join('\n'); } function listItems(arr) { return (arr || []).map((x) => `
  • ${esc(x)}
  • `).join('\n'); } function gapRows(ct) { const out = []; (ct.doNotInvent || []).forEach((x) => out.push(` 禁止发明${esc(x)}`) ); (ct.unknowns || []).forEach((x) => out.push(` 规格未定${esc(x)}`) ); return out.join('\n') || ' 无'; } function endLinks(c) { return ENDS.map((end) => { const file = c.files[end]; /* 相对深度:文档页在 site/m/component/,故到仓库根要退三级(../../../frameworks-mobile/…)。 实测踩过:写成两级会解析成 /site/frameworks-mobile/… → 404(样式与演示帧一起丢)。 */ return ` ${esc( END_LABELS[end] || end )}${esc( file )}`; }).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('--') ? `${esc(x)}` : `${esc(x)}`)) .join(' ') : '(由数据驱动,无专属类)'; return `
    ${esc(v)} ${txt}
    `; }) .join(''); return ` ${esc(d.name)}${esc(d.values.join(' / '))}${cells}`; }) .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 ` ${esc(combo)}${esc(v.label || '')}`; }) .join('\n'); } function apiTables(ct) { const api = ct.api || { props: [], events: [], slots: [] }; const props = (api.props || []) .map( (p) => ` ${esc(p.name)}${esc(p.type)}${esc( p.default )}${esc(p.desc)}${p.required ? 'Y' : 'N'}` ) .join('\n'); const events = (api.events || []) .map( (e) => ` ${esc(e.name)}${esc(e.params)}${esc(e.desc)}` ) .join('\n'); const slots = (api.slots || []) .map((s) => ` ${esc(s.name)}${esc(s.desc)}`) .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(` ${esc(n)}`)); inherited.forEach((n) => chips.push(` ${esc(n)}`) ); local.forEach((n) => chips.push(` ${esc(n)}`)); 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) => ` ${esc(x.name.split(' ')[0])}${esc( x.frameworksPrefix )}` ) .join('\n'); } /** 从演示页里按 data-demo 抽出该块的**原文**(含
    包装,故与源码逐字节一致) */ function demoSectionHtml(c, id) { const html = sources.get(c.slug).html; const marker = `
    `; const start = html.indexOf(marker); if (start < 0) return null; const end = html.indexOf('
    ', start); if (end < 0) return null; /* 去掉每行统一的两格缩进(section 在 .demo 内缩进两层),让代码块左对齐 */ return html .slice(start, end + '
    '.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(`

    ${esc(group)}

    `); for (const d of list) { seq++; const code = demoSectionHtml(c, d.id); const id = `code-demo-${seq}`; out.push( `
    \n` + `
    ${esc(d.title)}` + (d.variant ? `${esc(d.variant)}` : '') + `
    \n` + `

    ${esc(d.desc)}

    \n` + `
    \n` + (code ? `
    查看代码(演示页原文 · ${code.split('\n').length} 行)\n` + `
    frameworks-mobile/${esc(c.files.html)} · ${esc(d.id)}
    \n` + `
    ${esc(code)}
    \n` + `
    \n` : `

    (演示页里找不到 data-demo="${esc(d.id)}",请检查契约与演示页是否同步)

    \n`) + `
    ` ); } } 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( ` ${esc(m[1])}${esc(m[2].trim())}${esc( (m[3] || '').trim() || '组件内部默认值,可在业务侧覆盖' )}` ); } return rows.join('\n') || ' 本组件没有组件级 CSS 变量'; } /** 相似组件表 */ function relatedRows(c, ct) { const rows = (ct.related || []).map((r) => { const other = components.find((x) => x.slug === r.slug); if (!other) return ` ${esc(r.slug)}${esc(r.why)}`; return ( ` ${esc(other.name.split(' ')[0])}` + `${esc( other.frameworksPrefix )}${esc(r.why)}` ); }); return rows.join('\n') || ' 无'; } /** 引入代码(H5 原生:令牌 + 本组件样式;其余端见快速开始) */ function importCode(c) { return [ '', '', '', ``, ``, '', '', ].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。 不手抄 —— 站点与仓库两处说法不一致是这类页面的典型失败模式。 */ function inlineMd(s) { return esc(s) .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') .replace(/`([^`]+)`/g, '$1') .replace(/\*\*([^*]+)\*\*/g, '$1'); } function mobileChangelogHtml() { const src = 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(`

    ${inlineMd(b.title)}

    `); let inList = false; for (const item of b.body) { if (item.kind === 'li') { if (!inList) { out.push('
      '); inList = true; } out.push(`
    • ${inlineMd(item.text)}
    • `); continue; } if (inList) { out.push('
    '); inList = false; } if (item.kind === 'note') out.push(`

    ${inlineMd(item.text)}

    `); else if (item.kind === 'code') out.push(`
    ${esc(item.text)}
    `); else if (item.kind === 'p' && item.text.trim()) out.push(`

    ${inlineMd(item.text)}

    `); } if (inList) out.push(' '); } return out.length ? out.join('\n') : '

    (CHANGELOG 里暂时没有标题含「Mobile / 移动端」的段落)

    '; } /* 快速开始 */ 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) => ` ${esc(t.name)}${esc(t.value)}${esc(t.comment)}`) .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 ` ${esc(n)}${esc(t.value)}${esc( inheritedUsage.get(n).join('、') )}`; }) .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 \`\`\` ## 按需引入 \`\`\` 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 \`\`\` 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 侧文件未触碰)');