feat(design-tokens): add validation tooling and React Native bridge
- validate-tokens.js: Scan source files for hardcoded colors - token-coverage.js: Report token adoption percentage per product - generate-react-native.ts: Generator for Expo/NomGap StyleSheet tokens All scripts include: - Cross-platform file detection (.ts, .tsx, .swift, .kt) - Product-specific token categorization - Exit codes for CI integration
This commit is contained in:
parent
78d28307ec
commit
2a0a59e56c
143
packages/design-tokens/scripts/generate-react-native.ts
Normal file
143
packages/design-tokens/scripts/generate-react-native.ts
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* React Native token generator for Expo/NomGap
|
||||||
|
* Reads bytelyst.tokens.json, outputs RN StyleSheet-compatible tokens
|
||||||
|
*
|
||||||
|
* Usage: tsx scripts/generate-react-native.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const tokensPath = resolve(__dirname, '../tokens/bytelyst.tokens.json');
|
||||||
|
const outDir = resolve(__dirname, '../generated/react-native');
|
||||||
|
|
||||||
|
mkdirSync(outDir, { recursive: true });
|
||||||
|
|
||||||
|
const tokens = JSON.parse(readFileSync(tokensPath, 'utf-8'));
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function generateReactNative(): string {
|
||||||
|
const lines: string[] = [
|
||||||
|
'/**',
|
||||||
|
' * React Native Design Tokens — Auto-generated from bytelyst.tokens.json',
|
||||||
|
' * Do not edit manually. Run: tsx scripts/generate-react-native.ts',
|
||||||
|
' */',
|
||||||
|
'',
|
||||||
|
'export const tokens = {',
|
||||||
|
'',
|
||||||
|
' // ── Semantic Colors (Dark Theme) ──────────────────────────────────',
|
||||||
|
' colors: {',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Semantic dark colors
|
||||||
|
for (const [key, value] of Object.entries(tokens.color.semantic.dark)) {
|
||||||
|
if (typeof value === 'string' && value.startsWith('#')) {
|
||||||
|
lines.push(` ${key}: '${value}',`);
|
||||||
|
} else if (typeof value === 'string' && value.startsWith('rgba')) {
|
||||||
|
// Convert rgba to hex8 for React Native
|
||||||
|
const match = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)/);
|
||||||
|
if (match) {
|
||||||
|
const [, r, g, b, a] = match;
|
||||||
|
const alpha = Math.round(parseFloat(a) * 255)
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, '0');
|
||||||
|
const hex =
|
||||||
|
`#${alpha}${parseInt(r).toString(16).padStart(2, '0')}${parseInt(g).toString(16).padStart(2, '0')}${parseInt(b).toString(16).padStart(2, '0')}`.toUpperCase();
|
||||||
|
lines.push(` ${key}: '${hex}',`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push(' },', '');
|
||||||
|
|
||||||
|
// NomGap-specific colors
|
||||||
|
lines.push(' // ── NomGap Product Colors ──────────────────────────────────────────');
|
||||||
|
lines.push(' nomgap: {');
|
||||||
|
for (const [key, value] of Object.entries(tokens.color.nomgap)) {
|
||||||
|
lines.push(` ${key}: '${value}',`);
|
||||||
|
}
|
||||||
|
lines.push(' },', '');
|
||||||
|
|
||||||
|
// Spacing
|
||||||
|
lines.push(' // ── Spacing (8pt grid) ──────────────────────────────────────────────');
|
||||||
|
lines.push(' spacing: {');
|
||||||
|
for (const [key, value] of Object.entries(tokens.spacing)) {
|
||||||
|
lines.push(` ${key}: ${value},`);
|
||||||
|
}
|
||||||
|
lines.push(' },', '');
|
||||||
|
|
||||||
|
// Radius
|
||||||
|
lines.push(' // ── Border Radius ───────────────────────────────────────────────────');
|
||||||
|
lines.push(' radius: {');
|
||||||
|
for (const [key, value] of Object.entries(tokens.radius)) {
|
||||||
|
lines.push(` ${key}: ${value},`);
|
||||||
|
}
|
||||||
|
lines.push(' },', '');
|
||||||
|
|
||||||
|
// Typography
|
||||||
|
lines.push(' // ── Typography ─────────────────────────────────────────────────────');
|
||||||
|
lines.push(' typography: {');
|
||||||
|
lines.push(' fontFamily: {');
|
||||||
|
for (const key of Object.keys(tokens.typography.fontFamily)) {
|
||||||
|
// Use system fonts for React Native
|
||||||
|
const systemFont = key === 'display' ? 'System' : key === 'mono' ? 'Courier' : 'System';
|
||||||
|
lines.push(` ${key}: '${systemFont}',`);
|
||||||
|
}
|
||||||
|
lines.push(' },');
|
||||||
|
lines.push(' fontSize: {');
|
||||||
|
for (const [key, value] of Object.entries(tokens.typography.fontSize)) {
|
||||||
|
lines.push(` ${key}: ${value},`);
|
||||||
|
}
|
||||||
|
lines.push(' },');
|
||||||
|
lines.push(' fontWeight: {');
|
||||||
|
for (const [key, value] of Object.entries(tokens.typography.fontWeight)) {
|
||||||
|
lines.push(` ${key}: '${value}',`);
|
||||||
|
}
|
||||||
|
lines.push(' },');
|
||||||
|
lines.push(' },', '');
|
||||||
|
|
||||||
|
// Icon sizes
|
||||||
|
lines.push(' // ── Icon Sizes ──────────────────────────────────────────────────────');
|
||||||
|
lines.push(' icon: {');
|
||||||
|
for (const [key, value] of Object.entries(tokens.icon)) {
|
||||||
|
lines.push(` ${key}: ${value},`);
|
||||||
|
}
|
||||||
|
lines.push(' },', '');
|
||||||
|
|
||||||
|
// Z-index (for RN zIndex style prop)
|
||||||
|
lines.push(' // ── Z-Index Layers ───────────────────────────────────────────────────');
|
||||||
|
lines.push(' zIndex: {');
|
||||||
|
for (const [key, value] of Object.entries(tokens.zIndex)) {
|
||||||
|
lines.push(` ${key}: ${value},`);
|
||||||
|
}
|
||||||
|
lines.push(' },', '');
|
||||||
|
|
||||||
|
// Opacity
|
||||||
|
lines.push(' // ── Opacity ─────────────────────────────────────────────────────────');
|
||||||
|
lines.push(' opacity: {');
|
||||||
|
for (const [key, value] of Object.entries(tokens.opacity)) {
|
||||||
|
lines.push(` ${key}: ${value},`);
|
||||||
|
}
|
||||||
|
lines.push(' },', '');
|
||||||
|
|
||||||
|
// Motion (duration in ms)
|
||||||
|
lines.push(' // ── Motion ────────────────────────────────────────────────────────────');
|
||||||
|
lines.push(' motion: {');
|
||||||
|
for (const [key, value] of Object.entries(tokens.motion.duration)) {
|
||||||
|
lines.push(` ${key}: ${value},`);
|
||||||
|
}
|
||||||
|
lines.push(' },', '');
|
||||||
|
|
||||||
|
lines.push('} as const;', '');
|
||||||
|
lines.push('');
|
||||||
|
lines.push('export type Tokens = typeof tokens;', '');
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Write ──────────────────────────────────────────────────────────
|
||||||
|
writeFileSync(resolve(outDir, 'tokens.ts'), generateReactNative());
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('Generated React Native tokens in generated/react-native/');
|
||||||
184
packages/design-tokens/scripts/token-coverage.js
Executable file
184
packages/design-tokens/scripts/token-coverage.js
Executable file
@ -0,0 +1,184 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Token coverage report — analyzes how well a product uses design tokens
|
||||||
|
*
|
||||||
|
* Usage: node scripts/token-coverage.js <product-path>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { readFileSync, readdirSync, statSync } = require('fs');
|
||||||
|
const { join, resolve } = require('path');
|
||||||
|
|
||||||
|
const EXCLUDED_DIRS = ['node_modules', 'dist', 'build', '.git', 'generated', '__mocks__'];
|
||||||
|
const INCLUDED_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.swift', '.kt'];
|
||||||
|
|
||||||
|
const TOKEN_PATTERNS = {
|
||||||
|
web: {
|
||||||
|
cssVar: /--ml-[a-z-]+/g,
|
||||||
|
tokensImport: /@bytelyst\/design-tokens/,
|
||||||
|
},
|
||||||
|
ios: {
|
||||||
|
mindLystColors: /MindLystColors\./g,
|
||||||
|
colorExtension: /Color\(hex:/g,
|
||||||
|
},
|
||||||
|
kmp: {
|
||||||
|
mindLystTokens: /MindLystTokens\./g,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function findFiles(dir, files = []) {
|
||||||
|
try {
|
||||||
|
const items = readdirSync(dir);
|
||||||
|
for (const item of items) {
|
||||||
|
const fullPath = join(dir, item);
|
||||||
|
if (EXCLUDED_DIRS.some(ex => fullPath.includes(ex))) continue;
|
||||||
|
|
||||||
|
const stat = statSync(fullPath);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
findFiles(fullPath, files);
|
||||||
|
} else if (INCLUDED_EXTENSIONS.some(ext => item.endsWith(ext))) {
|
||||||
|
files.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Directory might not exist
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectPlatform(files) {
|
||||||
|
const hasSwift = files.some(f => f.endsWith('.swift'));
|
||||||
|
const hasKotlin = files.some(f => f.endsWith('.kt'));
|
||||||
|
const hasTSX = files.some(f => f.endsWith('.tsx'));
|
||||||
|
|
||||||
|
if (hasSwift) return 'ios';
|
||||||
|
if (hasKotlin) return 'kmp';
|
||||||
|
if (hasTSX) return 'web';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function analyzeCoverage(files, platform) {
|
||||||
|
let tokenUsages = 0;
|
||||||
|
let hardcodedColors = 0;
|
||||||
|
let filesUsingTokens = 0;
|
||||||
|
let filesWithHardcoded = 0;
|
||||||
|
|
||||||
|
const patterns = TOKEN_PATTERNS[platform] || {};
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const content = readFileSync(file, 'utf-8');
|
||||||
|
let hasTokens = false;
|
||||||
|
let hasHardcoded = false;
|
||||||
|
|
||||||
|
// Check token usage
|
||||||
|
if (patterns.cssVar) {
|
||||||
|
const matches = content.match(patterns.cssVar);
|
||||||
|
if (matches) {
|
||||||
|
tokenUsages += matches.length;
|
||||||
|
hasTokens = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (patterns.mindLystColors) {
|
||||||
|
const matches = content.match(patterns.mindLystColors);
|
||||||
|
if (matches) {
|
||||||
|
tokenUsages += matches.length;
|
||||||
|
hasTokens = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (patterns.mindLystTokens) {
|
||||||
|
const matches = content.match(patterns.mindLystTokens);
|
||||||
|
if (matches) {
|
||||||
|
tokenUsages += matches.length;
|
||||||
|
hasTokens = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check hardcoded colors
|
||||||
|
const colorMatches = content.match(/#[0-9A-Fa-f]{6}\b/g);
|
||||||
|
if (colorMatches) {
|
||||||
|
// Filter out likely non-color hex (like #FFFFFF in different contexts)
|
||||||
|
const likelyColors = colorMatches.filter(c => {
|
||||||
|
const hex = c.slice(1);
|
||||||
|
// Skip pure grays that might be intentional
|
||||||
|
if (hex[0] === hex[2] && hex[2] === hex[4]) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (likelyColors.length > 0) {
|
||||||
|
hardcodedColors += likelyColors.length;
|
||||||
|
hasHardcoded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasTokens) filesUsingTokens++;
|
||||||
|
if (hasHardcoded) filesWithHardcoded++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tokenUsages,
|
||||||
|
hardcodedColors,
|
||||||
|
filesUsingTokens,
|
||||||
|
filesWithHardcoded,
|
||||||
|
totalFiles: files.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const targetPath = process.argv[2];
|
||||||
|
if (!targetPath) {
|
||||||
|
console.log('Usage: node scripts/token-coverage.js <product-path>');
|
||||||
|
console.log('');
|
||||||
|
console.log('Examples:');
|
||||||
|
console.log(' node scripts/token-coverage.js ../../mindlyst-native/iosApp');
|
||||||
|
console.log(' node scripts/token-coverage.js ../../learning_ai_clock/web/src');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const absolutePath = resolve(targetPath);
|
||||||
|
console.log(`📊 Analyzing token coverage for ${absolutePath}\n`);
|
||||||
|
|
||||||
|
const files = findFiles(absolutePath);
|
||||||
|
const platform = detectPlatform(files);
|
||||||
|
|
||||||
|
console.log(`Detected platform: ${platform}`);
|
||||||
|
console.log(`Total files: ${files.length}\n`);
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
console.log('❌ No source files found');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const coverage = analyzeCoverage(files, platform);
|
||||||
|
|
||||||
|
console.log(`${'='.repeat(50)}`);
|
||||||
|
console.log('📈 Coverage Report');
|
||||||
|
console.log(`${'='.repeat(50)}`);
|
||||||
|
console.log(
|
||||||
|
`Files using tokens: ${coverage.filesUsingTokens}/${coverage.totalFiles} (${((coverage.filesUsingTokens / coverage.totalFiles) * 100).toFixed(1)}%)`
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`Files with hardcoded: ${coverage.filesWithHardcoded}/${coverage.totalFiles} (${((coverage.filesWithHardcoded / coverage.totalFiles) * 100).toFixed(1)}%)`
|
||||||
|
);
|
||||||
|
console.log(`Token usages: ${coverage.tokenUsages}`);
|
||||||
|
console.log(`Hardcoded colors: ${coverage.hardcodedColors}`);
|
||||||
|
console.log(`${'='.repeat(50)}`);
|
||||||
|
|
||||||
|
const tokenRatio = coverage.tokenUsages + coverage.hardcodedColors;
|
||||||
|
const tokenPercentage =
|
||||||
|
tokenRatio > 0 ? ((coverage.tokenUsages / tokenRatio) * 100).toFixed(1) : 'N/A';
|
||||||
|
|
||||||
|
console.log(`\n🎯 Token Adoption: ${tokenPercentage}%`);
|
||||||
|
|
||||||
|
if (coverage.hardcodedColors > 0) {
|
||||||
|
console.log(`\n⚠️ Found ${coverage.hardcodedColors} hardcoded colors.`);
|
||||||
|
console.log(' Run validate-tokens.js for details.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coverage.filesUsingTokens === 0) {
|
||||||
|
console.log('\n❌ No token usage detected. Product needs token integration.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n✅ Coverage analysis complete.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
110
packages/design-tokens/scripts/validate-tokens.js
Executable file
110
packages/design-tokens/scripts/validate-tokens.js
Executable file
@ -0,0 +1,110 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Token validation script — checks for hardcoded colors in source files
|
||||||
|
* and reports token coverage per product.
|
||||||
|
*
|
||||||
|
* Usage: node scripts/validate-tokens.js [product-path]
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { readFileSync, readdirSync, statSync } = require('fs');
|
||||||
|
const { join, resolve } = require('path');
|
||||||
|
|
||||||
|
const HARD_COLOR_REGEX = /#[0-9A-Fa-f]{3,8}\b|rgb\([^)]+\)|rgba\([^)]+\)|hsl\([^)]+\)/g;
|
||||||
|
const EXCLUDED_DIRS = ['node_modules', 'dist', 'build', '.git', 'generated', '__mocks__'];
|
||||||
|
const INCLUDED_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.swift', '.kt'];
|
||||||
|
|
||||||
|
function findFiles(dir, files = []) {
|
||||||
|
try {
|
||||||
|
const items = readdirSync(dir);
|
||||||
|
for (const item of items) {
|
||||||
|
const fullPath = join(dir, item);
|
||||||
|
if (EXCLUDED_DIRS.some(ex => fullPath.includes(ex))) continue;
|
||||||
|
|
||||||
|
const stat = statSync(fullPath);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
findFiles(fullPath, files);
|
||||||
|
} else if (INCLUDED_EXTENSIONS.some(ext => item.endsWith(ext))) {
|
||||||
|
files.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Directory might not exist or be accessible
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function analyzeFile(filePath) {
|
||||||
|
const content = readFileSync(filePath, 'utf-8');
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const issues = [];
|
||||||
|
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
// Skip comments
|
||||||
|
if (line.trim().startsWith('//') || line.trim().startsWith('*') || line.trim().startsWith('/*'))
|
||||||
|
return;
|
||||||
|
|
||||||
|
const matches = line.match(HARD_COLOR_REGEX);
|
||||||
|
if (matches) {
|
||||||
|
// Filter out legitimate uses (like transparency values)
|
||||||
|
const suspicious = matches.filter(m => {
|
||||||
|
if (m.startsWith('#') && (m.length === 9 || m.length === 5)) return false; // Skip alpha hex
|
||||||
|
if (m.includes('0.0') || m.includes('1.0')) return false; // Skip clear/opaque
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (suspicious.length > 0) {
|
||||||
|
issues.push({
|
||||||
|
line: index + 1,
|
||||||
|
colors: suspicious,
|
||||||
|
content: line.trim().slice(0, 80),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const targetPath = process.argv[2] || '.';
|
||||||
|
const absolutePath = resolve(targetPath);
|
||||||
|
|
||||||
|
console.log(`🔍 Scanning ${absolutePath} for hardcoded colors...\n`);
|
||||||
|
|
||||||
|
const files = findFiles(absolutePath);
|
||||||
|
let totalIssues = 0;
|
||||||
|
let filesWithIssues = 0;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const issues = analyzeFile(file);
|
||||||
|
if (issues.length > 0) {
|
||||||
|
filesWithIssues++;
|
||||||
|
totalIssues += issues.length;
|
||||||
|
const relativePath = file.replace(absolutePath, '').slice(1);
|
||||||
|
console.log(`\n📄 ${relativePath}`);
|
||||||
|
issues.forEach(issue => {
|
||||||
|
console.log(` Line ${issue.line}: ${issue.colors.join(', ')}`);
|
||||||
|
console.log(` ${issue.content}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${'='.repeat(60)}`);
|
||||||
|
console.log(`📊 Summary:`);
|
||||||
|
console.log(` Files scanned: ${files.length}`);
|
||||||
|
console.log(` Files with hardcoded colors: ${filesWithIssues}`);
|
||||||
|
console.log(` Total hardcoded colors found: ${totalIssues}`);
|
||||||
|
|
||||||
|
if (totalIssues > 0) {
|
||||||
|
console.log(`\n⚠️ Consider replacing hardcoded colors with design tokens:`);
|
||||||
|
console.log(` Web: var(--ml-<token>) from @bytelyst/design-tokens`);
|
||||||
|
console.log(` iOS: MindLystColors.dark<Token> / MindLystColors.light<Token>`);
|
||||||
|
console.log(` KMP: MindLystTokens.Dark.<TOKEN> / MindLystTokens.Light.<TOKEN>`);
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log(`\n✅ No hardcoded colors found! All colors use design tokens.`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
Loading…
Reference in New Issue
Block a user