Files
Kordant/scripts/generate-tokens.mjs

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();