831 lines
28 KiB
JavaScript
831 lines
28 KiB
JavaScript
import StyleDictionary from 'style-dictionary';
|
||
import { register, getTransforms } from '@tokens-studio/sd-transforms';
|
||
import crypto from 'crypto';
|
||
import fs from 'fs';
|
||
import path from 'path';
|
||
import { fileURLToPath } from 'url';
|
||
import { buildIcons, buildAssets } from './build-icons.mjs';
|
||
import { stripCr, compareCodeUnits } from './hash-util.mjs';
|
||
|
||
// ── Paths ──────────────────────────────────────────────────────────────────────
|
||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||
const tokensDir = path.join(__dirname, '..', 'ds-tokens', 'tokens');
|
||
const outputDir = path.join(
|
||
__dirname, '..', 'src', 'main', 'java', 'com', 'tangem', 'core', 'ui', 'res', 'generated',
|
||
);
|
||
|
||
const PACKAGE = 'com.tangem.core.ui.res.generated';
|
||
|
||
fs.mkdirSync(outputDir, { recursive: true });
|
||
|
||
// ── Register sd-transforms ─────────────────────────────────────────────────────
|
||
// Use CSS platform for sd-transforms to get ts/color/css/hexrgba (resolves rgba to hex).
|
||
// Compose color conversion is done in the format function instead of as a transform,
|
||
// because the color/composeColor transform interferes with rgba reference resolution.
|
||
await register(StyleDictionary, { platform: 'css' });
|
||
|
||
// ── Token set definitions ──────────────────────────────────────────────────────
|
||
// Reference-only sets (provide values for other tokens but not in the output)
|
||
const coreSets = [
|
||
'core/palette',
|
||
'core/font',
|
||
'core/dimension',
|
||
];
|
||
|
||
// Theme-independent semantic sets
|
||
const sizeSets = [
|
||
'semantic/size/opacity',
|
||
'semantic/size/blur',
|
||
'semantic/size/border',
|
||
'semantic/size/size',
|
||
'semantic/size/spacing',
|
||
];
|
||
|
||
const fontSets = [
|
||
'semantic/font/sizes/android',
|
||
'semantic/font/styles',
|
||
];
|
||
|
||
const sharedSets = [
|
||
'semantic/theme/shadows',
|
||
'semantic/theme/gradient',
|
||
];
|
||
|
||
// Theme-specific sets
|
||
// Note: material variant files (glass/blur/solid) define colliding paths and are excluded.
|
||
// The material color tokens come from materials/light and materials/dark instead.
|
||
const themeBuilds = {
|
||
Light: {
|
||
sets: [
|
||
...coreSets, ...sizeSets, ...fontSets, ...sharedSets,
|
||
'semantic/theme/light',
|
||
'semantic/theme/materials/light',
|
||
],
|
||
},
|
||
Dark: {
|
||
sets: [
|
||
...coreSets, ...sizeSets, ...fontSets, ...sharedSets,
|
||
'semantic/theme/dark',
|
||
'semantic/theme/materials/dark',
|
||
],
|
||
},
|
||
};
|
||
|
||
// For theme-independent builds (dimensions, typography, etc.)
|
||
const sharedBuildSets = [...coreSets, ...sizeSets, ...fontSets, ...sharedSets];
|
||
|
||
for (const [theme, { sets }] of Object.entries(themeBuilds)) {
|
||
console.log(`${theme} theme: ${sets.length} token sets`);
|
||
}
|
||
|
||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||
|
||
/** Convert token path segments to a camelCase property name */
|
||
function toCamelCase(segments) {
|
||
return segments
|
||
.map((seg, i) => {
|
||
// kebab-case → camelCase
|
||
const cleaned = seg.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
|
||
if (i === 0) return cleaned;
|
||
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
|
||
})
|
||
.join('');
|
||
}
|
||
|
||
/** Check if a token is from core/palette, dimension, or gradient source sets (not for output) */
|
||
function isSourceOnlyToken(token) {
|
||
const top = token.path[0];
|
||
return top === 'palette' || top === 'dimension' || top === 'gradient';
|
||
}
|
||
|
||
/**
|
||
* Convert a resolved CSS color string to Compose Color(0xAARRGGBB).
|
||
* Handles: #RRGGBB, #AARRGGBB, rgba(r, g, b, a), rgb(r, g, b)
|
||
*/
|
||
function toComposeColor(value, tokenPath = '') {
|
||
if (typeof value !== 'string') {
|
||
throw new Error(`Unrecognized color value for ${tokenPath}: ${JSON.stringify(value)}`);
|
||
}
|
||
|
||
// #RRGGBB
|
||
const hex6 = value.match(/^#([0-9a-fA-F]{6})$/);
|
||
if (hex6) return `Color(0xFF${hex6[1].toUpperCase()})`;
|
||
|
||
// #AARRGGBB (8-digit hex)
|
||
const hex8 = value.match(/^#([0-9a-fA-F]{8})$/);
|
||
if (hex8) return `Color(0x${hex8[1].toUpperCase()})`;
|
||
|
||
// rgba(r, g, b, a)
|
||
const rgba = value.match(/^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([\d.]+)\s*\)$/);
|
||
if (rgba) {
|
||
const r = parseInt(rgba[1]).toString(16).padStart(2, '0').toUpperCase();
|
||
const g = parseInt(rgba[2]).toString(16).padStart(2, '0').toUpperCase();
|
||
const b = parseInt(rgba[3]).toString(16).padStart(2, '0').toUpperCase();
|
||
const a = Math.round(parseFloat(rgba[4]) * 255).toString(16).padStart(2, '0').toUpperCase();
|
||
return `Color(0x${a}${r}${g}${b})`;
|
||
}
|
||
|
||
// rgb(r, g, b)
|
||
const rgb = value.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
|
||
if (rgb) {
|
||
const r = parseInt(rgb[1]).toString(16).padStart(2, '0').toUpperCase();
|
||
const g = parseInt(rgb[2]).toString(16).padStart(2, '0').toUpperCase();
|
||
const b = parseInt(rgb[3]).toString(16).padStart(2, '0').toUpperCase();
|
||
return `Color(0xFF${r}${g}${b})`;
|
||
}
|
||
|
||
// Transparent
|
||
if (value === 'transparent' || value === '#00000000') return 'Color(0x00000000)';
|
||
|
||
throw new Error(`Unrecognized color format for ${tokenPath}: "${value}"`);
|
||
}
|
||
|
||
/** Group tokens by their first N path segments */
|
||
function groupByPath(tokens, depth = 1) {
|
||
const groups = {};
|
||
for (const token of tokens) {
|
||
const key = token.path.slice(0, depth).join('.');
|
||
if (!groups[key]) groups[key] = [];
|
||
groups[key].push(token);
|
||
}
|
||
return groups;
|
||
}
|
||
|
||
/**
|
||
* Build a tree of nested objects from entries.
|
||
* Each entry: { path: string[], value: string }.
|
||
* The last segment is the property name; preceding segments become nested objects.
|
||
*/
|
||
function buildPropertyTree(entries) {
|
||
const root = { props: [], children: new Map() };
|
||
|
||
for (const { path, value } of entries) {
|
||
let node = root;
|
||
for (let i = 0; i < path.length - 1; i++) {
|
||
const seg = path[i];
|
||
if (!node.children.has(seg)) {
|
||
node.children.set(seg, { props: [], children: new Map() });
|
||
}
|
||
node = node.children.get(seg);
|
||
}
|
||
const propName = path[path.length - 1];
|
||
const existingIdx = node.props.findIndex(p => p.name === propName);
|
||
if (existingIdx >= 0) {
|
||
console.warn(` ⚠ Duplicate property path: ${path.join('.')} — overwriting`);
|
||
node.props[existingIdx] = { name: propName, value };
|
||
} else {
|
||
node.props.push({ name: propName, value });
|
||
}
|
||
}
|
||
|
||
return root;
|
||
}
|
||
|
||
/**
|
||
* Render a property tree as Kotlin nested objects.
|
||
* Returns an array of indented lines.
|
||
*/
|
||
function renderTree(node, indent = 1) {
|
||
const pad = ' '.repeat(indent);
|
||
const lines = [];
|
||
|
||
for (const { name, value } of node.props) {
|
||
lines.push(`${pad}val ${name} = ${value}`);
|
||
}
|
||
|
||
for (const [name, child] of node.children) {
|
||
if (lines.length > 0) lines.push('');
|
||
lines.push(`${pad}object ${name} {`);
|
||
lines.push(...renderTree(child, indent + 1));
|
||
lines.push(`${pad}}`);
|
||
}
|
||
|
||
return lines;
|
||
}
|
||
|
||
/**
|
||
* Capitalize the first letter of a string.
|
||
*/
|
||
function capitalize(str) {
|
||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||
}
|
||
|
||
/**
|
||
* Convert a kebab-case segment to PascalCase (for object names).
|
||
*/
|
||
function toPascalCase(seg) {
|
||
return seg
|
||
.split('-')
|
||
.map(part => capitalize(part))
|
||
.join('');
|
||
}
|
||
|
||
/**
|
||
* Wrap a name in backticks if it starts with a digit or is a Kotlin hard keyword.
|
||
*/
|
||
const KOTLIN_HARD_KEYWORDS = new Set([
|
||
'as', 'break', 'class', 'continue', 'do', 'else', 'false', 'for', 'fun',
|
||
'if', 'in', 'interface', 'is', 'null', 'object', 'package', 'return',
|
||
'super', 'this', 'throw', 'true', 'try', 'typealias', 'typeof', 'val',
|
||
'var', 'when', 'while',
|
||
]);
|
||
|
||
function kotlinSafe(name) {
|
||
if (/^\d/.test(name) || KOTLIN_HARD_KEYWORDS.has(name)) return `\`${name}\``;
|
||
return name;
|
||
}
|
||
|
||
// ── TangemColors3 helpers ─────────────────────────────────────────────────────
|
||
|
||
/** Shared structure tree for TangemColors3, computed from light theme codeSyntax. */
|
||
let colors3StructureTree = null;
|
||
|
||
/** Extract codeSyntax.Android path, stripping TangemTheme.colors3. prefix. */
|
||
function getAndroidCodeSyntax(token) {
|
||
const ext = token.$extensions?.['com.figma.codeSyntax'];
|
||
if (!ext?.Android) return null;
|
||
const prefix = 'TangemTheme.colors3.';
|
||
const android = ext.Android;
|
||
return android.startsWith(prefix) ? android.slice(prefix.length) : null;
|
||
}
|
||
|
||
/** Get the token's property path for the class tree from codeSyntax, or fallback to JSON path. */
|
||
function colorTokenClassPath(token) {
|
||
const cs = getAndroidCodeSyntax(token);
|
||
if (cs) return cs.split('.');
|
||
// Fallback for material tokens (no codeSyntax): use JSON path minus 'color' prefix
|
||
const pathSegs = token.path.slice(1); // remove 'color'
|
||
return pathSegs.map(seg => seg.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase()));
|
||
}
|
||
|
||
/**
|
||
* Extract a simple palette reference from a token's original (unresolved) value.
|
||
* Returns the reference string like "palette.neutral.95" or null for complex values.
|
||
*/
|
||
function extractPaletteRef(token) {
|
||
const original = token.original?.$value;
|
||
if (typeof original !== 'string') return null;
|
||
const match = original.match(/^\{(palette\.[^}]+)\}$/);
|
||
return match ? match[1] : null;
|
||
}
|
||
|
||
/**
|
||
* Convert a palette reference path to Kotlin code referencing TangemColorPalette.
|
||
* e.g., "palette.neutral.95" → "TangemColorPalette.Neutral.`95`"
|
||
* e.g., "palette.opaque.base-black.60" → "TangemColorPalette.Opaque.BaseBlack.`60`"
|
||
*/
|
||
function paletteRefToKotlin(refPath) {
|
||
const parts = refPath.split('.').slice(1); // drop "palette"
|
||
const objParts = parts.slice(0, -1).map(seg => toPascalCase(seg));
|
||
const leaf = parts[parts.length - 1].replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
|
||
return `TangemColorPalette.${objParts.join('.')}.${kotlinSafe(leaf)}`;
|
||
}
|
||
|
||
/** Get Kotlin value for a color token: palette reference if simple, else resolved Color literal. */
|
||
function paletteRefOrColor(token) {
|
||
const ref = extractPaletteRef(token);
|
||
if (ref) return paletteRefToKotlin(ref);
|
||
return toComposeColor(token.$value, token.path.join('.'));
|
||
}
|
||
|
||
/**
|
||
* Build a class structure tree from color tokens using codeSyntax paths.
|
||
* Each node: { props: [{name, jsonPath}], children: Map<string, node> }
|
||
*/
|
||
function buildClassStructureTree(tokens) {
|
||
const root = { props: [], children: new Map() };
|
||
|
||
for (const token of tokens) {
|
||
const classPath = colorTokenClassPath(token);
|
||
const jsonPath = token.path.join('.');
|
||
|
||
let node = root;
|
||
for (let i = 0; i < classPath.length - 1; i++) {
|
||
const seg = classPath[i];
|
||
if (!node.children.has(seg)) {
|
||
node.children.set(seg, { props: [], children: new Map() });
|
||
}
|
||
node = node.children.get(seg);
|
||
}
|
||
|
||
const propName = classPath[classPath.length - 1];
|
||
const existingIdx = node.props.findIndex(p => p.name === propName);
|
||
if (existingIdx >= 0) {
|
||
console.warn(` ⚠ Duplicate codeSyntax path: ${classPath.join('.')} (${jsonPath}) — overwriting`);
|
||
node.props[existingIdx] = { name: propName, jsonPath };
|
||
} else {
|
||
node.props.push({ name: propName, jsonPath });
|
||
}
|
||
}
|
||
|
||
return root;
|
||
}
|
||
|
||
/**
|
||
* Resolve conflicts where a name appears as both a leaf property and a child class.
|
||
* Resolution: move the leaf into the child as "default".
|
||
*/
|
||
function resolveClassTreeConflicts(node) {
|
||
const propsToRemove = [];
|
||
|
||
for (const [childName, childNode] of node.children) {
|
||
const conflictIdx = node.props.findIndex(p => p.name === childName);
|
||
if (conflictIdx >= 0) {
|
||
const prop = node.props[conflictIdx];
|
||
console.log(` ℹ Conflict resolved: "${childName}" is both property and class → moved to "${childName}.default"`);
|
||
childNode.props.unshift({ name: 'default', jsonPath: prop.jsonPath });
|
||
propsToRemove.push(conflictIdx);
|
||
}
|
||
}
|
||
|
||
for (const idx of propsToRemove.sort((a, b) => b - a)) {
|
||
node.props.splice(idx, 1);
|
||
}
|
||
|
||
for (const child of node.children.values()) {
|
||
resolveClassTreeConflicts(child);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Render a @Stable class with mutableStateOf pattern for Compose theme colors.
|
||
* Returns array of Kotlin source lines.
|
||
*/
|
||
function renderStableClass(className, node, indent = 0) {
|
||
const pad = ' '.repeat(indent);
|
||
const pad1 = ' '.repeat(indent + 1);
|
||
const pad2 = ' '.repeat(indent + 2);
|
||
const lines = [];
|
||
|
||
lines.push(`${pad}@Stable`);
|
||
lines.push(`${pad}class ${className} internal constructor(`);
|
||
|
||
for (const { name } of node.props) {
|
||
lines.push(`${pad1}${kotlinSafe(name)}: Color,`);
|
||
}
|
||
for (const [childName] of node.children) {
|
||
lines.push(`${pad1}val ${childName}: ${capitalize(childName)},`);
|
||
}
|
||
|
||
lines.push(`${pad}) {`);
|
||
|
||
// mutableStateOf delegates for leaf Color props
|
||
if (node.props.length > 0) {
|
||
for (const { name } of node.props) {
|
||
const safe = kotlinSafe(name);
|
||
lines.push(`${pad1}var ${safe} by mutableStateOf(${safe})`);
|
||
lines.push(`${pad2}private set`);
|
||
}
|
||
}
|
||
|
||
// Nested child classes
|
||
for (const [childName, childNode] of node.children) {
|
||
lines.push('');
|
||
lines.push(...renderStableClass(capitalize(childName), childNode, indent + 1));
|
||
}
|
||
|
||
// update() function
|
||
lines.push('');
|
||
lines.push(`${pad1}fun update(other: ${className}) {`);
|
||
for (const { name } of node.props) {
|
||
const safe = kotlinSafe(name);
|
||
lines.push(`${pad2}${safe} = other.${safe}`);
|
||
}
|
||
for (const [childName] of node.children) {
|
||
lines.push(`${pad2}${childName}.update(other.${childName})`);
|
||
}
|
||
lines.push(`${pad1}}`);
|
||
|
||
lines.push(`${pad}}`);
|
||
|
||
return lines;
|
||
}
|
||
|
||
/**
|
||
* Render the content lines of a factory constructor call (param assignments + child constructors).
|
||
*/
|
||
function renderFactoryContent(classPath, node, valueMap, indent) {
|
||
const pad = ' '.repeat(indent);
|
||
const lines = [];
|
||
|
||
for (const { name, jsonPath } of node.props) {
|
||
const value = valueMap.get(jsonPath);
|
||
if (!value) console.warn(` ⚠ No value for jsonPath "${jsonPath}" (property: ${name})`);
|
||
lines.push(`${pad}${kotlinSafe(name)} = ${value || 'Color.Unspecified'},`);
|
||
}
|
||
|
||
for (const [childName, childNode] of node.children) {
|
||
const childClassPath = `${classPath}.${capitalize(childName)}`;
|
||
lines.push(`${pad}${childName} = ${childClassPath}(`);
|
||
lines.push(...renderFactoryContent(childClassPath, childNode, valueMap, indent + 1));
|
||
lines.push(`${pad}),`);
|
||
}
|
||
|
||
return lines;
|
||
}
|
||
|
||
const FILE_SUPPRESS = '@file:Suppress("all")';
|
||
|
||
// ── Custom formats ─────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Kotlin format for palette tokens.
|
||
* Generates nested objects: object Base { val black = ... }, object Neutral { val `5` = ... }, etc.
|
||
*/
|
||
StyleDictionary.registerFormat({
|
||
name: 'kotlin/compose-palette',
|
||
format: ({ dictionary }) => {
|
||
const paletteTokens = dictionary.allTokens.filter(
|
||
t => t.$type === 'color' && t.path[0] === 'palette',
|
||
);
|
||
|
||
const entries = paletteTokens.map(token => {
|
||
// palette.base.black → ["Base", "black"]
|
||
// palette.neutral.5 → ["Neutral", "`5`"]
|
||
// palette.opaque.base-black.5 → ["Opaque", "BaseBlack", "`5`"]
|
||
const segments = token.path.slice(1); // drop "palette"
|
||
const path = segments.map((seg, i) => {
|
||
if (i < segments.length - 1) {
|
||
// intermediate segments → PascalCase object names
|
||
return toPascalCase(seg);
|
||
}
|
||
// leaf segment → property name, backtick-wrap if starts with digit
|
||
const camel = seg.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
|
||
return kotlinSafe(camel);
|
||
});
|
||
|
||
const value = toComposeColor(token.$value, token.path.join('.'));
|
||
return { path, value };
|
||
});
|
||
|
||
const tree = buildPropertyTree(entries);
|
||
const body = renderTree(tree);
|
||
|
||
return [
|
||
FILE_SUPPRESS,
|
||
'',
|
||
`package ${PACKAGE}`,
|
||
'',
|
||
'import androidx.compose.ui.graphics.Color',
|
||
'',
|
||
'/**',
|
||
' * Auto-generated from design tokens. Do not edit manually.',
|
||
' */',
|
||
'internal object TangemColorPalette {',
|
||
body.join('\n'),
|
||
'}',
|
||
'',
|
||
].join('\n');
|
||
},
|
||
});
|
||
|
||
/**
|
||
* Kotlin format for TangemTypography3 — @Stable class with nested categories.
|
||
* Generates a class taking FontFamily, with nested classes for each typography category
|
||
* (display, heading, body, subheading, caption).
|
||
*/
|
||
StyleDictionary.registerFormat({
|
||
name: 'kotlin/compose-typography3',
|
||
format: ({ dictionary }) => {
|
||
const typoTokens = dictionary.allTokens.filter(
|
||
t => t.$type === 'typography' && !isSourceOnlyToken(t),
|
||
);
|
||
|
||
// Group by category (path[1]: display, heading, body, subheading, caption)
|
||
const categories = new Map();
|
||
for (const token of typoTokens) {
|
||
const category = token.path[1];
|
||
if (!categories.has(category)) categories.set(category, []);
|
||
categories.get(category).push(token);
|
||
}
|
||
|
||
// Build nested class lines
|
||
const outerProps = [];
|
||
const innerClasses = [];
|
||
|
||
for (const [category, tokens] of categories) {
|
||
const className = capitalize(category);
|
||
const propName = category;
|
||
const isHeading = category === 'display' || category === 'heading';
|
||
|
||
outerProps.push(` val ${propName}: ${className} = ${className}(fontFamily)`);
|
||
|
||
const classLines = [` @Stable`, ` class ${className} internal constructor(fontFamily: FontFamily) {`];
|
||
|
||
for (const token of tokens) {
|
||
const size = token.path[2]; // medium, small, etc.
|
||
const v = token.$value;
|
||
const tp = token.path.join('.');
|
||
|
||
if (!v.fontWeight) throw new Error(`Missing fontWeight for ${tp}`);
|
||
const fontWeight = mapFontWeight(v.fontWeight);
|
||
const fontSize = parseFloat(v.fontSize);
|
||
if (isNaN(fontSize)) throw new Error(`Non-numeric fontSize for ${tp}: "${v.fontSize}"`);
|
||
const lineHeight = parseFloat(v.lineHeight);
|
||
if (isNaN(lineHeight)) throw new Error(`Non-numeric lineHeight for ${tp}: "${v.lineHeight}"`);
|
||
const letterSpacing = parseFloat(v.letterSpacing);
|
||
if (isNaN(letterSpacing)) throw new Error(`Non-numeric letterSpacing for ${tp}: "${v.letterSpacing}"`);
|
||
|
||
const spacingLiteral = letterSpacing < 0 ? `(${letterSpacing})` : `${letterSpacing}`;
|
||
|
||
classLines.push(` val ${size}: TextStyle = TextStyle(`);
|
||
classLines.push(` fontFamily = fontFamily,`);
|
||
classLines.push(` fontWeight = ${fontWeight},`);
|
||
classLines.push(` fontSize = ${fontSize}.sp,`);
|
||
classLines.push(` lineHeight = ${lineHeight}.sp,`);
|
||
classLines.push(` letterSpacing = ${spacingLiteral}.sp,`);
|
||
classLines.push(` lineHeightStyle = LineHeightStyle(`);
|
||
classLines.push(` alignment = LineHeightStyle.Alignment.Center,`);
|
||
classLines.push(` trim = LineHeightStyle.Trim.None,`);
|
||
classLines.push(` ),`);
|
||
if (isHeading) {
|
||
classLines.push(` lineBreak = LineBreak.Heading,`);
|
||
}
|
||
classLines.push(` )`);
|
||
}
|
||
|
||
classLines.push(` }`);
|
||
innerClasses.push(classLines.join('\n'));
|
||
}
|
||
|
||
return [
|
||
FILE_SUPPRESS,
|
||
'',
|
||
`package ${PACKAGE}`,
|
||
'',
|
||
'import androidx.compose.runtime.Stable',
|
||
'import androidx.compose.ui.text.TextStyle',
|
||
'import androidx.compose.ui.text.font.FontFamily',
|
||
'import androidx.compose.ui.text.font.FontWeight',
|
||
'import androidx.compose.ui.text.style.LineBreak',
|
||
'import androidx.compose.ui.text.style.LineHeightStyle',
|
||
'import androidx.compose.ui.unit.sp',
|
||
'',
|
||
'/**',
|
||
' * Auto-generated from design tokens. Do not edit manually.',
|
||
' */',
|
||
'@Stable',
|
||
'class TangemTypography3 internal constructor(fontFamily: FontFamily) {',
|
||
outerProps.join('\n'),
|
||
'',
|
||
innerClasses.join('\n\n'),
|
||
'}',
|
||
'',
|
||
].join('\n');
|
||
},
|
||
});
|
||
|
||
function mapFontWeight(value) {
|
||
const num = parseInt(value, 10);
|
||
if (!isNaN(num)) {
|
||
if (num <= 400) return 'FontWeight.Normal';
|
||
if (num <= 500) return 'FontWeight.Medium';
|
||
if (num <= 600) return 'FontWeight.SemiBold';
|
||
return 'FontWeight.Bold';
|
||
}
|
||
const lower = String(value).toLowerCase();
|
||
if (lower.includes('semibold') || lower.includes('semi bold')) return 'FontWeight.SemiBold';
|
||
if (lower.includes('bold')) return 'FontWeight.Bold';
|
||
if (lower.includes('medium')) return 'FontWeight.Medium';
|
||
return 'FontWeight.Normal';
|
||
}
|
||
|
||
/**
|
||
* Kotlin format for TangemColors3 class definition.
|
||
* Generates the @Stable class hierarchy with mutableStateOf + update() pattern.
|
||
* Structure is derived from light theme codeSyntax.Android fields.
|
||
*/
|
||
StyleDictionary.registerFormat({
|
||
name: 'kotlin/compose-colors3-class',
|
||
format: ({ dictionary }) => {
|
||
const colorTokens = dictionary.allTokens.filter(
|
||
t => t.$type === 'color' && !isSourceOnlyToken(t),
|
||
);
|
||
|
||
colors3StructureTree = buildClassStructureTree(colorTokens);
|
||
resolveClassTreeConflicts(colors3StructureTree);
|
||
|
||
const classLines = renderStableClass('TangemColors3', colors3StructureTree, 0);
|
||
|
||
return [
|
||
FILE_SUPPRESS,
|
||
'',
|
||
`package ${PACKAGE}`,
|
||
'',
|
||
'import androidx.compose.runtime.Stable',
|
||
'import androidx.compose.runtime.getValue',
|
||
'import androidx.compose.runtime.mutableStateOf',
|
||
'import androidx.compose.runtime.setValue',
|
||
'import androidx.compose.ui.graphics.Color',
|
||
'',
|
||
'/**',
|
||
' * Auto-generated from design tokens. Do not edit manually.',
|
||
' */',
|
||
...classLines,
|
||
'',
|
||
].join('\n');
|
||
},
|
||
});
|
||
|
||
/**
|
||
* Kotlin format for TangemColors3 light/dark factory functions.
|
||
* Generates lightColors3() / darkColors3() functions referencing TangemColorPalette.
|
||
*/
|
||
StyleDictionary.registerFormat({
|
||
name: 'kotlin/compose-colors3-factory',
|
||
format: ({ dictionary, options }) => {
|
||
const themeName = options.themeName;
|
||
const funcName = `${themeName.toLowerCase()}Colors3`;
|
||
|
||
// Ensure tree is built (should already be set by class format in Light build)
|
||
if (!colors3StructureTree) {
|
||
const colorTokens = dictionary.allTokens.filter(
|
||
t => t.$type === 'color' && !isSourceOnlyToken(t),
|
||
);
|
||
colors3StructureTree = buildClassStructureTree(colorTokens);
|
||
resolveClassTreeConflicts(colors3StructureTree);
|
||
}
|
||
|
||
// Build value map: jsonPath → Kotlin palette reference or Color literal
|
||
const colorTokens = dictionary.allTokens.filter(
|
||
t => t.$type === 'color' && !isSourceOnlyToken(t),
|
||
);
|
||
const valueMap = new Map();
|
||
for (const token of colorTokens) {
|
||
const jsonPath = token.path.join('.');
|
||
valueMap.set(jsonPath, paletteRefOrColor(token));
|
||
}
|
||
|
||
const bodyLines = renderFactoryContent('TangemColors3', colors3StructureTree, valueMap, 2);
|
||
|
||
return [
|
||
FILE_SUPPRESS,
|
||
'',
|
||
`package ${PACKAGE}`,
|
||
'',
|
||
'import androidx.compose.ui.graphics.Color',
|
||
'',
|
||
'/**',
|
||
` * Auto-generated from design tokens. Do not edit manually.`,
|
||
` * Theme: ${themeName}`,
|
||
' */',
|
||
`internal fun ${funcName}() =`,
|
||
' TangemColors3(',
|
||
...bodyLines,
|
||
' )',
|
||
'',
|
||
].join('\n');
|
||
},
|
||
});
|
||
|
||
// ── Build ──────────────────────────────────────────────────────────────────────
|
||
|
||
const composePlatformTransforms = [
|
||
...getTransforms({ platform: 'css' }),
|
||
'name/camel',
|
||
];
|
||
|
||
// Build color tokens per theme (light/dark)
|
||
for (const [themeName, { sets }] of Object.entries(themeBuilds)) {
|
||
console.log(`\nBuilding ${themeName} color tokens...`);
|
||
|
||
const colorFilter = token => token.$type === 'color' && !isSourceOnlyToken(token);
|
||
|
||
const files = [];
|
||
|
||
// Only light build generates the class definition (canonical codeSyntax structure)
|
||
if (themeName === 'Light') {
|
||
files.push({
|
||
destination: 'TangemColors3.kt',
|
||
format: 'kotlin/compose-colors3-class',
|
||
filter: colorFilter,
|
||
});
|
||
}
|
||
|
||
// Both themes generate factory functions
|
||
files.push({
|
||
destination: `TangemColors3${themeName}.kt`,
|
||
format: 'kotlin/compose-colors3-factory',
|
||
options: { themeName },
|
||
filter: colorFilter,
|
||
});
|
||
|
||
const sd = new StyleDictionary({
|
||
source: sets.map(s => path.join(tokensDir, `${s}.json`)),
|
||
preprocessors: ['tokens-studio'],
|
||
usesDtcg: true,
|
||
log: { warnings: 'disabled', errors: { brokenReferences: 'console' } },
|
||
platforms: {
|
||
compose: {
|
||
transforms: composePlatformTransforms,
|
||
buildPath: outputDir + '/',
|
||
files,
|
||
},
|
||
},
|
||
});
|
||
|
||
await sd.buildAllPlatforms();
|
||
if (themeName === 'Light') console.log(' ✓ TangemColors3.kt');
|
||
console.log(` ✓ TangemColors3${themeName}.kt`);
|
||
}
|
||
|
||
// Build palette tokens
|
||
console.log('\nBuilding palette tokens...');
|
||
|
||
const paletteSd = new StyleDictionary({
|
||
source: [...coreSets, 'semantic/size/opacity'].map(s => path.join(tokensDir, `${s}.json`)),
|
||
preprocessors: ['tokens-studio'],
|
||
usesDtcg: true,
|
||
log: { warnings: 'disabled', errors: { brokenReferences: 'console' } },
|
||
platforms: {
|
||
compose: {
|
||
transforms: composePlatformTransforms,
|
||
buildPath: outputDir + '/',
|
||
files: [
|
||
{
|
||
destination: 'TangemColorPalette.kt',
|
||
format: 'kotlin/compose-palette',
|
||
filter: token => token.$type === 'color' && token.path[0] === 'palette',
|
||
},
|
||
],
|
||
},
|
||
},
|
||
});
|
||
|
||
await paletteSd.buildAllPlatforms();
|
||
console.log(' ✓ TangemColorPalette.kt');
|
||
|
||
// Build theme-independent tokens (typography)
|
||
console.log('\nBuilding typography tokens...');
|
||
|
||
const sd = new StyleDictionary({
|
||
source: sharedBuildSets.map(s => path.join(tokensDir, `${s}.json`)),
|
||
preprocessors: ['tokens-studio'],
|
||
usesDtcg: true,
|
||
log: { warnings: 'disabled', errors: { brokenReferences: 'console' } },
|
||
platforms: {
|
||
compose: {
|
||
transforms: composePlatformTransforms,
|
||
buildPath: outputDir + '/',
|
||
files: [
|
||
{
|
||
destination: 'TangemTypography3.kt',
|
||
format: 'kotlin/compose-typography3',
|
||
filter: token => token.$type === 'typography' && !isSourceOnlyToken(token),
|
||
},
|
||
],
|
||
},
|
||
},
|
||
});
|
||
|
||
await sd.buildAllPlatforms();
|
||
console.log(' ✓ TangemTypography3.kt');
|
||
|
||
// ── Write source hash ─────────────────────────────────────────────────────────
|
||
// Hash all token JSON files so Gradle can verify generated code matches ds-tokens.
|
||
function computeTokensHash() {
|
||
const files = [];
|
||
function walk(dir) {
|
||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||
const full = path.join(dir, entry.name);
|
||
if (entry.isDirectory()) walk(full);
|
||
else if (entry.name.endsWith('.json')) files.push(full);
|
||
}
|
||
}
|
||
walk(tokensDir);
|
||
// Sort by forward-slash relative path in UTF-16 code-unit order to match the Kotlin verifier's
|
||
// sortedBy { …invariantSeparatorsPath } (String.compareTo) deterministically — see hash-util.mjs.
|
||
files.sort((a, b) => compareCodeUnits(
|
||
path.relative(tokensDir, a).split(path.sep).join('/'),
|
||
path.relative(tokensDir, b).split(path.sep).join('/'),
|
||
));
|
||
|
||
const hash = crypto.createHash('sha256');
|
||
for (const file of files) {
|
||
hash.update(path.relative(tokensDir, file).split(path.sep).join('/'));
|
||
hash.update('\0');
|
||
hash.update(stripCr(fs.readFileSync(file)));
|
||
hash.update('\0');
|
||
}
|
||
return hash.digest('hex');
|
||
}
|
||
|
||
// ── Build icons & assets ────────────────────────────────────────────────────────
|
||
// Run before writing .tokens-hash so both SVG hashes can be folded in — Gradle then
|
||
// has a single hash that invalidates on any ds-tokens change (tokens, icons or assets).
|
||
const { hash: iconsHash } = await buildIcons();
|
||
const { hash: assetsHash } = await buildAssets();
|
||
|
||
const tokensInputHash = computeTokensHash();
|
||
const tokensHash = crypto
|
||
.createHash('sha256')
|
||
.update(tokensInputHash)
|
||
.update('\0')
|
||
.update(iconsHash)
|
||
.update('\0')
|
||
.update(assetsHash)
|
||
.digest('hex');
|
||
fs.writeFileSync(path.join(outputDir, '.tokens-hash'), tokensHash + '\n');
|
||
console.log(`\n ✓ .tokens-hash (${tokensHash.substring(0, 12)}…)`);
|
||
|
||
console.log(`\nDone! Output: ${outputDir}`);
|