381 lines
12 KiB
JavaScript
381 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
||
|
||
/**
|
||
* Drama Ling 元件驗證工具
|
||
* 功能:驗證HTML元件是否符合設計規範
|
||
* 作者:Drama Ling 開發團隊
|
||
* 日期:2025-09-15
|
||
*/
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
// ANSI 顏色碼
|
||
const colors = {
|
||
red: '\x1b[31m',
|
||
green: '\x1b[32m',
|
||
yellow: '\x1b[33m',
|
||
reset: '\x1b[0m'
|
||
};
|
||
|
||
// 設計規範定義
|
||
const DESIGN_SPECS = {
|
||
// 必須使用的CSS類別前綴
|
||
classPrefixes: ['btn-', 'card-', 'input-', 'alert-', 'badge-', 'modal-'],
|
||
|
||
// 必須包含的屬性
|
||
requiredAttributes: {
|
||
'button': ['type', 'class'],
|
||
'input': ['type', 'id', 'name'],
|
||
'img': ['alt', 'src'],
|
||
'a': ['href']
|
||
},
|
||
|
||
// 顏色變數
|
||
colorVariables: [
|
||
'--primary', '--primary-dark', '--primary-light',
|
||
'--secondary', '--secondary-dark', '--secondary-light',
|
||
'--success', '--warning', '--danger', '--info',
|
||
'--gray-50', '--gray-100', '--gray-200', '--gray-300',
|
||
'--gray-400', '--gray-500', '--gray-600', '--gray-700',
|
||
'--gray-800', '--gray-900'
|
||
],
|
||
|
||
// 間距變數
|
||
spacingVariables: [
|
||
'--space-1', '--space-2', '--space-3', '--space-4',
|
||
'--space-5', '--space-6', '--space-8', '--space-10'
|
||
]
|
||
};
|
||
|
||
class ComponentValidator {
|
||
constructor() {
|
||
this.errors = [];
|
||
this.warnings = [];
|
||
this.passed = 0;
|
||
this.failed = 0;
|
||
}
|
||
|
||
// 日誌方法
|
||
logError(file, message) {
|
||
this.errors.push({ file, message });
|
||
console.log(`${colors.red}[ERROR]${colors.reset} ${file}: ${message}`);
|
||
}
|
||
|
||
logWarning(file, message) {
|
||
this.warnings.push({ file, message });
|
||
console.log(`${colors.yellow}[WARNING]${colors.reset} ${file}: ${message}`);
|
||
}
|
||
|
||
logSuccess(message) {
|
||
console.log(`${colors.green}[✓]${colors.reset} ${message}`);
|
||
}
|
||
|
||
// 驗證HTML文件
|
||
validateHTMLFile(filePath) {
|
||
const fileName = path.basename(filePath);
|
||
console.log(`\n檢查文件: ${fileName}`);
|
||
|
||
try {
|
||
const content = fs.readFileSync(filePath, 'utf8');
|
||
|
||
// 檢查基本結構
|
||
this.checkHTMLStructure(fileName, content);
|
||
|
||
// 檢查必要屬性
|
||
this.checkRequiredAttributes(fileName, content);
|
||
|
||
// 檢查CSS類別命名
|
||
this.checkCSSClasses(fileName, content);
|
||
|
||
// 檢查無障礙性
|
||
this.checkAccessibility(fileName, content);
|
||
|
||
// 檢查響應式設計
|
||
this.checkResponsive(fileName, content);
|
||
|
||
this.passed++;
|
||
this.logSuccess(`${fileName} 驗證通過`);
|
||
|
||
} catch (error) {
|
||
this.failed++;
|
||
this.logError(fileName, `無法讀取文件: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
// 檢查HTML基本結構
|
||
checkHTMLStructure(file, content) {
|
||
// 檢查DOCTYPE
|
||
if (!content.includes('<!DOCTYPE html>')) {
|
||
this.logWarning(file, '缺少 <!DOCTYPE html> 聲明');
|
||
}
|
||
|
||
// 檢查meta viewport
|
||
if (!content.includes('viewport')) {
|
||
this.logError(file, '缺少 viewport meta 標籤(響應式設計必需)');
|
||
}
|
||
|
||
// 檢查字符編碼
|
||
if (!content.includes('charset="UTF-8"') && !content.includes('charset=UTF-8')) {
|
||
this.logError(file, '缺少 UTF-8 字符編碼聲明');
|
||
}
|
||
}
|
||
|
||
// 檢查必要屬性
|
||
checkRequiredAttributes(file, content) {
|
||
for (const [element, attributes] of Object.entries(DESIGN_SPECS.requiredAttributes)) {
|
||
const regex = new RegExp(`<${element}[^>]*>`, 'gi');
|
||
const matches = content.match(regex) || [];
|
||
|
||
matches.forEach(match => {
|
||
attributes.forEach(attr => {
|
||
if (!match.includes(attr)) {
|
||
this.logWarning(file, `<${element}> 元素缺少 ${attr} 屬性`);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
// 檢查CSS類別命名規範
|
||
checkCSSClasses(file, content) {
|
||
const classRegex = /class="([^"]*)"/g;
|
||
let match;
|
||
|
||
while ((match = classRegex.exec(content)) !== null) {
|
||
const classes = match[1].split(' ');
|
||
|
||
classes.forEach(className => {
|
||
// 檢查是否使用 BEM 命名或設計系統前綴
|
||
const isValidClass =
|
||
DESIGN_SPECS.classPrefixes.some(prefix => className.startsWith(prefix)) ||
|
||
className.includes('__') || // BEM element
|
||
className.includes('--'); // BEM modifier
|
||
|
||
if (!isValidClass && className && !className.startsWith('library-') && !className.startsWith('showcase-')) {
|
||
this.logWarning(file, `CSS類別 "${className}" 可能不符合命名規範`);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// 檢查無障礙性
|
||
checkAccessibility(file, content) {
|
||
// 檢查圖片alt屬性
|
||
const imgRegex = /<img[^>]*>/g;
|
||
let match;
|
||
|
||
while ((match = imgRegex.exec(content)) !== null) {
|
||
if (!match[0].includes('alt=')) {
|
||
this.logError(file, '圖片缺少 alt 屬性(無障礙性要求)');
|
||
}
|
||
}
|
||
|
||
// 檢查表單標籤
|
||
const inputRegex = /<input[^>]*>/g;
|
||
const inputs = content.match(inputRegex) || [];
|
||
|
||
inputs.forEach(input => {
|
||
if (!input.includes('type="hidden"') && !input.includes('aria-label')) {
|
||
// 檢查是否有對應的label
|
||
const idMatch = input.match(/id="([^"]*)"/);
|
||
if (idMatch) {
|
||
const hasLabel = content.includes(`for="${idMatch[1]}"`);
|
||
if (!hasLabel) {
|
||
this.logWarning(file, `輸入框缺少對應的 <label> 標籤`);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// 檢查ARIA屬性
|
||
if (content.includes('role="button"') && !content.includes('tabindex')) {
|
||
this.logWarning(file, '具有 role="button" 的元素應該包含 tabindex 屬性');
|
||
}
|
||
}
|
||
|
||
// 檢查響應式設計
|
||
checkResponsive(file, content) {
|
||
// 檢查是否使用響應式單位
|
||
const hasResponsiveUnits =
|
||
content.includes('rem') ||
|
||
content.includes('em') ||
|
||
content.includes('%') ||
|
||
content.includes('vw') ||
|
||
content.includes('vh');
|
||
|
||
if (!hasResponsiveUnits) {
|
||
this.logWarning(file, '未檢測到響應式單位(rem, em, %, vw, vh)');
|
||
}
|
||
|
||
// 檢查媒體查詢
|
||
if (!content.includes('@media')) {
|
||
this.logWarning(file, '未檢測到媒體查詢(響應式設計)');
|
||
}
|
||
}
|
||
|
||
// 驗證CSS文件
|
||
validateCSSFile(filePath) {
|
||
const fileName = path.basename(filePath);
|
||
console.log(`\n檢查CSS文件: ${fileName}`);
|
||
|
||
try {
|
||
const content = fs.readFileSync(filePath, 'utf8');
|
||
|
||
// 檢查設計代幣使用
|
||
this.checkDesignTokens(fileName, content);
|
||
|
||
// 檢查顏色變數
|
||
this.checkColorVariables(fileName, content);
|
||
|
||
// 檢查間距變數
|
||
this.checkSpacingVariables(fileName, content);
|
||
|
||
this.passed++;
|
||
this.logSuccess(`${fileName} CSS驗證通過`);
|
||
|
||
} catch (error) {
|
||
this.failed++;
|
||
this.logError(fileName, `無法讀取CSS文件: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
// 檢查設計代幣
|
||
checkDesignTokens(file, content) {
|
||
// 檢查是否使用CSS變數而非硬編碼值
|
||
const hardcodedColors = content.match(/#[0-9a-fA-F]{3,6}/g) || [];
|
||
|
||
if (hardcodedColors.length > 5) {
|
||
this.logWarning(file, `發現 ${hardcodedColors.length} 個硬編碼顏色值,建議使用CSS變數`);
|
||
}
|
||
|
||
// 檢查硬編碼的間距
|
||
const hardcodedSpacing = content.match(/margin:\s*\d+px|padding:\s*\d+px/g) || [];
|
||
|
||
if (hardcodedSpacing.length > 10) {
|
||
this.logWarning(file, `發現 ${hardcodedSpacing.length} 個硬編碼間距值,建議使用間距變數`);
|
||
}
|
||
}
|
||
|
||
// 檢查顏色變數
|
||
checkColorVariables(file, content) {
|
||
const unusedColors = DESIGN_SPECS.colorVariables.filter(
|
||
color => !content.includes(color)
|
||
);
|
||
|
||
if (unusedColors.length > 0 && unusedColors.length < DESIGN_SPECS.colorVariables.length / 2) {
|
||
this.logWarning(file, `未使用的顏色變數: ${unusedColors.slice(0, 5).join(', ')}...`);
|
||
}
|
||
}
|
||
|
||
// 檢查間距變數
|
||
checkSpacingVariables(file, content) {
|
||
const hasSpacingVars = DESIGN_SPECS.spacingVariables.some(
|
||
spacing => content.includes(spacing)
|
||
);
|
||
|
||
if (!hasSpacingVars) {
|
||
this.logWarning(file, '未使用間距變數,建議使用統一的間距系統');
|
||
}
|
||
}
|
||
|
||
// 生成報告
|
||
generateReport() {
|
||
const reportPath = path.join(__dirname, '../VALIDATION_REPORT.md');
|
||
const timestamp = new Date().toISOString().replace('T', ' ').substr(0, 19);
|
||
|
||
let report = `# 元件驗證報告\n\n`;
|
||
report += `**生成時間**: ${timestamp}\n\n`;
|
||
report += `## 📊 驗證統計\n\n`;
|
||
report += `- ✅ 通過: ${this.passed} 個文件\n`;
|
||
report += `- ❌ 失敗: ${this.failed} 個文件\n`;
|
||
report += `- ⚠️ 警告: ${this.warnings.length} 個\n`;
|
||
report += `- 🚨 錯誤: ${this.errors.length} 個\n\n`;
|
||
|
||
if (this.errors.length > 0) {
|
||
report += `## 🚨 錯誤列表\n\n`;
|
||
this.errors.forEach(({ file, message }) => {
|
||
report += `- **${file}**: ${message}\n`;
|
||
});
|
||
report += '\n';
|
||
}
|
||
|
||
if (this.warnings.length > 0) {
|
||
report += `## ⚠️ 警告列表\n\n`;
|
||
this.warnings.forEach(({ file, message }) => {
|
||
report += `- **${file}**: ${message}\n`;
|
||
});
|
||
report += '\n';
|
||
}
|
||
|
||
report += `## 📝 建議\n\n`;
|
||
report += `1. 修復所有錯誤以確保符合設計規範\n`;
|
||
report += `2. 檢查警告並根據需要進行調整\n`;
|
||
report += `3. 使用設計代幣取代硬編碼值\n`;
|
||
report += `4. 確保所有元件都有適當的無障礙性支援\n`;
|
||
|
||
fs.writeFileSync(reportPath, report);
|
||
console.log(`\n📋 驗證報告已生成: ${reportPath}`);
|
||
}
|
||
|
||
// 執行驗證
|
||
run() {
|
||
console.log('=====================================');
|
||
console.log('Drama Ling 元件驗證工具');
|
||
console.log('=====================================\n');
|
||
|
||
const componentLibraryPath = path.join(__dirname, '../../component-library');
|
||
|
||
// 驗證HTML文件
|
||
this.validateDirectory(componentLibraryPath, '.html', this.validateHTMLFile.bind(this));
|
||
|
||
// 驗證CSS文件
|
||
const cssPath = path.join(componentLibraryPath, 'assets/styles');
|
||
this.validateDirectory(cssPath, '.css', this.validateCSSFile.bind(this));
|
||
|
||
// 生成報告
|
||
this.generateReport();
|
||
|
||
console.log('\n=====================================');
|
||
console.log('驗證完成!');
|
||
console.log('=====================================');
|
||
|
||
// 返回退出碼
|
||
process.exit(this.errors.length > 0 ? 1 : 0);
|
||
}
|
||
|
||
// 驗證目錄中的文件
|
||
validateDirectory(dirPath, extension, validateFunc) {
|
||
if (!fs.existsSync(dirPath)) {
|
||
this.logError('系統', `目錄不存在: ${dirPath}`);
|
||
return;
|
||
}
|
||
|
||
const files = this.getAllFiles(dirPath, extension);
|
||
files.forEach(file => validateFunc(file));
|
||
}
|
||
|
||
// 遞歸獲取所有文件
|
||
getAllFiles(dirPath, extension) {
|
||
let files = [];
|
||
|
||
const items = fs.readdirSync(dirPath);
|
||
|
||
items.forEach(item => {
|
||
const fullPath = path.join(dirPath, item);
|
||
const stat = fs.statSync(fullPath);
|
||
|
||
if (stat.isDirectory()) {
|
||
files = files.concat(this.getAllFiles(fullPath, extension));
|
||
} else if (path.extname(fullPath) === extension) {
|
||
files.push(fullPath);
|
||
}
|
||
});
|
||
|
||
return files;
|
||
}
|
||
}
|
||
|
||
// 執行驗證
|
||
const validator = new ComponentValidator();
|
||
validator.run(); |