#!/usr/bin/env node /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ import { readdir, readFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' const scriptPath = fileURLToPath(import.meta.url) const appRoot = path.resolve(path.dirname(scriptPath), '..') const srcRoot = path.join(appRoot, 'src') const files = { base: path.join(srcRoot, 'styles/base.css'), modern: path.join(srcRoot, 'styles/modern.css'), tokens: path.join(srcRoot, 'styles/tokens.css'), elementOverrides: path.join(srcRoot, 'styles/element-overrides.css'), dataTable: path.join(srcRoot, 'components/erp/ErpDataTable.vue'), appShell: path.join(srcRoot, 'components/erp/ErpAppShell.vue') } const bannedVisibleTerms = [ '组件展厅', '预览', '迁移', '页面清单', '业务等价', '待验收', '技术预览', '旧入口' ] async function sourceFiles(directory) { const entries = await readdir(directory, { withFileTypes: true }) const nested = await Promise.all(entries.map(async (entry) => { const file = path.join(directory, entry.name) if (entry.isDirectory()) return sourceFiles(file) if (/\.(vue|ts|css)$/.test(entry.name)) return [file] return [] })) return nested.flat() } function blockFor(source, selector) { const pattern = new RegExp(`${selector .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') .replace(/\s+/g, '\\s*')}\\s*\\{`, 'g') let match let lastMatch = null while ((match = pattern.exec(source)) !== null) { lastMatch = match } if (!lastMatch) return '' const start = source.indexOf('{', lastMatch.index) let depth = 0 for (let cursor = start; cursor < source.length; cursor += 1) { const char = source[cursor] if (char === '{') depth += 1 if (char === '}') depth -= 1 if (depth === 0) return source.slice(start + 1, cursor) } return '' } const sources = Object.fromEntries(await Promise.all( Object.entries(files).map(async ([key, file]) => [key, await readFile(file, 'utf8')]) )) // Exclude the text-sanitizer utility: its whole purpose is to name and replace // leaked internal terms (e.g. /预览/g -> 查看), so its replacement-rule literals // are not visible UI copy and must not count as a leak. const sanitizerSuffix = path.join('utils', 'display.ts') const allSourceText = (await Promise.all( (await sourceFiles(srcRoot)) .filter((file) => !file.endsWith(sanitizerSuffix)) .map((file) => readFile(file, 'utf8')) )).join('\n') const navActiveBlock = blockFor(sources.appShell, '.erp-nav :deep(.el-menu-item.is-active)') const navHoverBlock = blockFor(sources.appShell, '.erp-nav :deep(.el-menu-item:hover),\n.erp-nav :deep(.el-sub-menu__title:hover)') const tableBlock = blockFor(sources.modern, '.modern-table-block .erp-table') const tableCellBlock = blockFor(sources.modern, '.erp-admin-surface .erp-table .el-table__cell,\n.business-center .erp-table .el-table__cell,\n.modern-table-block .erp-table .el-table__cell') const checks = [ { id: 'no-visible-internal-terms', passed: bannedVisibleTerms.every((term) => !allSourceText.includes(term)) }, { id: 'responsive-root-allows-mobile-width', passed: !sources.base.includes('min-width: 1180px') && sources.base.includes('min-width: 0') }, { id: 'primary-navigation-is-a-grouped-collapsible-sidebar-tree', passed: sources.appShell.includes('class="erp-nav"') && sources.appShell.includes('el-menu-item-group') && sources.appShell.includes('el-sub-menu') && sources.appShell.includes('核心业务') && sources.appShell.includes('渠道与扩展') && !sources.appShell.includes('modern-top-menu') }, { id: 'side-navigation-uses-line-state-not-pill-fill', passed: navActiveBlock.includes('background: transparent') && navActiveBlock.includes('border-left: 2px solid var(--erp-color-primary)') && navActiveBlock.includes('color: var(--erp-color-primary)') && navHoverBlock.includes('background: var(--erp-color-surface-muted)') }, { id: 'erp-cards-have-large-medium-small-density-tokens', passed: [ '--erp-card-density-large-min', '--erp-card-density-medium-min', '--erp-card-density-small-min', '--erp-card-padding-large', '--erp-card-padding-medium', '--erp-card-padding-small' ].every((token) => sources.tokens.includes(token)) }, { id: 'erp-table-density-is-quiet-and-readable', passed: tableBlock.includes('border-radius: var(--erp-radius-xs)') && tableCellBlock.includes('padding: 5px 0') && sources.elementOverrides.includes('--el-table-row-hover-bg-color: var(--erp-color-surface-muted)') && sources.elementOverrides.includes('.el-table th.el-table__cell') && sources.dataTable.includes('data-modern="erp-data-table"') }, { id: 'sidebar-supports-collapse-for-narrow-screens', passed: sources.appShell.includes(':collapse="collapsed"') && sources.appShell.includes("collapsed ? '64px' : '244px'") && (sources.appShell.includes('Fold') && sources.appShell.includes('Expand')) } ] const failed = checks.filter((check) => !check.passed) console.log(JSON.stringify({ status: failed.length ? 'failed' : 'passed', checks }, null, 2)) if (failed.length) { process.exitCode = 1 }