335 lines
11 KiB
JavaScript
335 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* generate-tokens.mjs
|
|
*
|
|
* Reads design-tokens/*.json and generates platform-specific code:
|
|
* - web/src/theme/tokens.ts
|
|
* - iOS/Kordant/Theme/GeneratedTokens.swift
|
|
* - android/app/src/main/res/values/generated_tokens.xml
|
|
*
|
|
* Usage: node scripts/generate-tokens.mjs
|
|
*/
|
|
|
|
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
import { join, dirname } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const root = join(__dirname, "..");
|
|
const tokensDir = join(root, "design-tokens");
|
|
|
|
function load(name) {
|
|
return JSON.parse(readFileSync(join(tokensDir, `${name}.json`), "utf-8"));
|
|
}
|
|
|
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
function toCamel(key) {
|
|
return key.replace(/([A-Z])/g, (_, c) => `_${c}`).toLowerCase();
|
|
}
|
|
|
|
function hexToRgb(hex) {
|
|
const h = hex.replace("#", "");
|
|
return {
|
|
r: parseInt(h.substring(0, 2), 16),
|
|
g: parseInt(h.substring(2, 4), 16),
|
|
b: parseInt(h.substring(4, 6), 16),
|
|
};
|
|
}
|
|
|
|
// ─── Web (TypeScript) ──────────────────────────────────────────────────────
|
|
|
|
function generateWebTokens(colors, typography, spacing, shadows, radius) {
|
|
const lines = [
|
|
"// Auto-generated from design-tokens/*.json — DO NOT EDIT MANUALLY",
|
|
"// Run: node scripts/generate-tokens.mjs",
|
|
"",
|
|
];
|
|
|
|
// Colors
|
|
lines.push("export const tokenColors = {");
|
|
|
|
// Brand
|
|
lines.push(" brand: {");
|
|
for (const [key, val] of Object.entries(colors.brand)) {
|
|
lines.push(` ${toCamel(key)}: "${val.value}",`);
|
|
}
|
|
lines.push(" },");
|
|
|
|
// Semantic
|
|
lines.push(" semantic: {");
|
|
for (const [key, val] of Object.entries(colors.semantic)) {
|
|
if (typeof val === "string") {
|
|
lines.push(` ${toCamel(key)}: "${val}",`);
|
|
} else if (val.light !== undefined) {
|
|
lines.push(` ${toCamel(key)}: { light: "${val.light}", dark: "${val.dark}" },`);
|
|
}
|
|
}
|
|
lines.push(" },");
|
|
|
|
// Background
|
|
lines.push(" background: {");
|
|
for (const [key, val] of Object.entries(colors.background)) {
|
|
lines.push(` ${toCamel(key)}: { light: "${val.light}", dark: "${val.dark}" },`);
|
|
}
|
|
lines.push(" },");
|
|
|
|
// Text
|
|
lines.push(" text: {");
|
|
for (const [key, val] of Object.entries(colors.text)) {
|
|
lines.push(` ${toCamel(key)}: { light: "${val.light}", dark: "${val.dark}" },`);
|
|
}
|
|
lines.push(" },");
|
|
|
|
// Border
|
|
lines.push(" border: {");
|
|
for (const [key, val] of Object.entries(colors.border)) {
|
|
lines.push(` ${toCamel(key)}: { light: "${val.light}", dark: "${val.dark}" },`);
|
|
}
|
|
lines.push(" },");
|
|
|
|
lines.push("};");
|
|
|
|
// Typography
|
|
lines.push("");
|
|
lines.push("export const tokenTypography = {");
|
|
lines.push(` fontFamily: "${typography.fontFamily.value}",`);
|
|
lines.push(` fallback: "${typography.fontFamily.fallback}",`);
|
|
lines.push(" scale: {");
|
|
for (const [key, val] of Object.entries(typography.scale)) {
|
|
lines.push(` ${toCamel(key)}: { size: "${val.size}", lineHeight: "${val.lineHeight}" },`);
|
|
}
|
|
lines.push(" },");
|
|
lines.push(" weights: {");
|
|
for (const [key, val] of Object.entries(typography.weights)) {
|
|
lines.push(` ${key}: ${val.value},`);
|
|
}
|
|
lines.push(" },");
|
|
lines.push("};");
|
|
|
|
// Spacing
|
|
lines.push("");
|
|
lines.push("export const tokenSpacing = {");
|
|
for (const [key, val] of Object.entries(spacing.scale)) {
|
|
lines.push(` ${key}: "${val.value}",`);
|
|
}
|
|
lines.push("};");
|
|
|
|
// Shadows
|
|
lines.push("");
|
|
lines.push("export const tokenShadows = {");
|
|
for (const [key, val] of Object.entries(shadows.scale)) {
|
|
const shadow = `${val.x}px ${val.y}px ${val.blur}px ${val.spread}px ${val.color}`;
|
|
lines.push(` ${key}: "${shadow}",`);
|
|
}
|
|
lines.push("};");
|
|
|
|
// Radius
|
|
lines.push("");
|
|
lines.push("export const tokenRadius = {");
|
|
for (const [key, val] of Object.entries(radius.scale)) {
|
|
lines.push(` ${key}: "${val.value}",`);
|
|
}
|
|
lines.push("};");
|
|
|
|
return lines.join("\n") + "\n";
|
|
}
|
|
|
|
// ─── iOS (Swift) ────────────────────────────────────────────────────────────
|
|
|
|
function generateSwiftTokens(colors, typography, spacing) {
|
|
const lines = [
|
|
"// Auto-generated from design-tokens/*.json — DO NOT EDIT MANUALLY",
|
|
"// Run: node scripts/generate-tokens.mjs",
|
|
"",
|
|
"import SwiftUI",
|
|
"",
|
|
];
|
|
|
|
// Color extension
|
|
lines.push("extension Color {");
|
|
lines.push(" // MARK: - Brand");
|
|
for (const [key, val] of Object.entries(colors.brand)) {
|
|
const { r, g, b } = hexToRgb(val.value);
|
|
const swiftKey = key.charAt(0).toLowerCase() + key.slice(1);
|
|
lines.push(
|
|
` static let ${swiftKey} = Color(red: ${r} / 255, green: ${g} / 255, blue: ${b} / 255)`
|
|
);
|
|
}
|
|
lines.push("");
|
|
lines.push(" // MARK: - Semantic");
|
|
for (const [key, val] of Object.entries(colors.semantic)) {
|
|
if (val.value) {
|
|
const { r, g, b } = hexToRgb(val.value);
|
|
const swiftKey = key.charAt(0).toLowerCase() + key.slice(1);
|
|
lines.push(
|
|
` static let ${swiftKey} = Color(red: ${r} / 255, green: ${g} / 255, blue: ${b} / 255)`
|
|
);
|
|
}
|
|
}
|
|
lines.push("}");
|
|
|
|
// AdaptiveColor helper
|
|
lines.push("");
|
|
lines.push("extension UIColor {");
|
|
lines.push(" convenience init(hex: String) {");
|
|
lines.push(
|
|
' var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)'
|
|
);
|
|
lines.push(
|
|
' hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")'
|
|
);
|
|
lines.push(" var rgb: UInt64 = 0");
|
|
lines.push(" Scanner(string: hexSanitized).scanHexInt64(&rgb)");
|
|
lines.push(" let r = CGFloat((rgb & 0xFF0000) >> 16) / 255.0");
|
|
lines.push(" let g = CGFloat((rgb & 0x00FF00) >> 8) / 255.0");
|
|
lines.push(" let b = CGFloat(rgb & 0x0000FF) / 255.0");
|
|
lines.push(" self.init(red: r, green: g, blue: b, alpha: 1.0)");
|
|
lines.push(" }");
|
|
lines.push("}");
|
|
|
|
// Spacing
|
|
lines.push("");
|
|
lines.push("enum DesignTokens {");
|
|
lines.push(" enum Spacing {");
|
|
for (const [key, val] of Object.entries(spacing.scale)) {
|
|
if (key === "0") continue; // skip numeric key — invalid in Swift
|
|
const px = parseInt(val.value);
|
|
lines.push(` static let ${key}: CGFloat = ${px}`);
|
|
}
|
|
lines.push(" }");
|
|
lines.push("}");
|
|
|
|
return lines.join("\n") + "\n";
|
|
}
|
|
|
|
// ─── Android (XML) ──────────────────────────────────────────────────────────
|
|
|
|
function generateAndroidTokens(colors, typography, spacing, shadows, radius) {
|
|
const lines = [
|
|
'<?xml version="1.0" encoding="utf-8"?>',
|
|
"<!-- Auto-generated from design-tokens/*.json — DO NOT EDIT MANUALLY -->",
|
|
"<!-- Run: node scripts/generate-tokens.mjs -->",
|
|
"<resources>",
|
|
];
|
|
|
|
// Brand colors
|
|
lines.push(" <!-- Brand -->");
|
|
for (const [key, val] of Object.entries(colors.brand)) {
|
|
const name = "brand_" + toCamel(key);
|
|
lines.push(` <color name="${name}">${val.value}</color>`);
|
|
}
|
|
|
|
// Semantic colors
|
|
lines.push(" <!-- Semantic -->");
|
|
for (const [key, val] of Object.entries(colors.semantic)) {
|
|
if (typeof val === "string") {
|
|
const name = "sem_" + toCamel(key);
|
|
lines.push(` <color name="${name}">${val}</color>`);
|
|
}
|
|
}
|
|
|
|
// Light theme colors
|
|
lines.push(" <!-- Light theme -->");
|
|
lines.push(
|
|
` <color name="bg_light">${colors.background.bg.light}</color>`
|
|
);
|
|
lines.push(
|
|
` <color name="bg_secondary_light">${colors.background.bgSecondary.light}</color>`
|
|
);
|
|
lines.push(
|
|
` <color name="text_primary_light">${colors.text.textPrimary.light}</color>`
|
|
);
|
|
lines.push(
|
|
` <color name="text_secondary_light">${colors.text.textSecondary.light}</color>`
|
|
);
|
|
lines.push(
|
|
` <color name="border_light">${colors.border.border.light}</color>`
|
|
);
|
|
|
|
// Dark theme colors
|
|
lines.push(" <!-- Dark theme -->");
|
|
lines.push(
|
|
` <color name="bg_dark">${colors.background.bg.dark}</color>`
|
|
);
|
|
lines.push(
|
|
` <color name="bg_secondary_dark">${colors.background.bgSecondary.dark}</color>`
|
|
);
|
|
lines.push(
|
|
` <color name="text_primary_dark">${colors.text.textPrimary.dark}</color>`
|
|
);
|
|
lines.push(
|
|
` <color name="text_secondary_dark">${colors.text.textSecondary.dark}</color>`
|
|
);
|
|
lines.push(
|
|
` <color name="border_dark">${colors.border.border.dark}</color>`
|
|
);
|
|
|
|
// Spacing dimensions
|
|
lines.push(" <!-- Spacing -->");
|
|
for (const [key, val] of Object.entries(spacing.scale)) {
|
|
const dp = val.value.replace("px", "dp");
|
|
lines.push(` <dimen name="spacing_${key}">${dp}</dimen>`);
|
|
}
|
|
|
|
// Border radius
|
|
lines.push(" <!-- Corner radius -->");
|
|
for (const [key, val] of Object.entries(radius.scale)) {
|
|
const name = key === "full" ? "corner_full" : `corner_${key}`;
|
|
const dp = val.value.replace("px", "dp");
|
|
lines.push(` <dimen name="${name}">${dp}</dimen>`);
|
|
}
|
|
|
|
// Font sizes
|
|
lines.push(" <!-- Typography -->");
|
|
for (const [key, val] of Object.entries(typography.scale)) {
|
|
lines.push(` <dimen name="font_${toCamel(key)}">${val.size.replace("px", "sp")}</dimen>`);
|
|
lines.push(` <dimen name="font_${toCamel(key)}_lh">${val.lineHeight.replace("px", "sp")}</dimen>`);
|
|
}
|
|
|
|
lines.push("</resources>");
|
|
|
|
return lines.join("\n") + "\n";
|
|
}
|
|
|
|
// ─── Main ───────────────────────────────────────────────────────────────────
|
|
|
|
function main() {
|
|
const colors = load("colors");
|
|
const typography = load("typography");
|
|
const spacing = load("spacing");
|
|
const shadows = load("shadows");
|
|
const radius = load("radius");
|
|
|
|
// Web
|
|
const webPath = join(root, "web", "src", "theme", "tokens.ts");
|
|
mkdirSync(dirname(webPath), { recursive: true });
|
|
writeFileSync(webPath, generateWebTokens(colors, typography, spacing, shadows, radius));
|
|
console.log(`✅ ${webPath}`);
|
|
|
|
// iOS
|
|
const iosPath = join(root, "iOS", "Kordant", "Theme", "GeneratedTokens.swift");
|
|
mkdirSync(dirname(iosPath), { recursive: true });
|
|
writeFileSync(iosPath, generateSwiftTokens(colors, typography, spacing));
|
|
console.log(`✅ ${iosPath}`);
|
|
|
|
// Android
|
|
const androidPath = join(
|
|
root,
|
|
"android",
|
|
"app",
|
|
"src",
|
|
"main",
|
|
"res",
|
|
"values",
|
|
"generated_tokens.xml"
|
|
);
|
|
mkdirSync(dirname(androidPath), { recursive: true });
|
|
writeFileSync(androidPath, generateAndroidTokens(colors, typography, spacing, shadows, radius));
|
|
console.log(`✅ ${androidPath}`);
|
|
|
|
console.log("🎨 Token generation complete.");
|
|
}
|
|
|
|
main();
|