import fs from 'node:fs' import { sortObjectByKey } from './utils.ts' type FigmaNumberVariable = { name: string type: 'number' isAlias: boolean value: number } type FigmaColorVariable = | { name: string type: 'color' isAlias: true value: { collection: string name: string } } | { name: string type: 'color' isAlias: false value: string } type FigmaDropShadowEffect = { type: 'DROP_SHADOW' color: { r: number g: number b: number a: number } offset: { x: number y: number } radius: number spread: number } type FigmaEffectVariable = { name: string type: 'effect' isAlias: boolean value: { effects: Array } } type FigmaTypographyValue = { fontSize: number fontFamily: string fontWeight: 'Black' | 'Bold' | 'SemiBold' | 'Regular' lineHeight: number lineHeightUnit: 'PIXELS' | 'PERCENT' letterSpacing: number letterSpacingUnit: 'PIXELS' | 'PERCENT' textCase: 'UPPER' | 'ORIGINAL' textDecoration: 'NONE' } type FigmaTypographyVariable = { name: string type: 'typography' isAlias: boolean value: FigmaTypographyValue } type FigmaVariable = | FigmaNumberVariable | FigmaColorVariable | FigmaEffectVariable | FigmaTypographyVariable type FigmaMode = { name: string variables: FigmaVariable[] } type FigmaCollection = { name: string modes: FigmaMode[] } type FigmaVariableData = { version: string metadata: unknown collections: FigmaCollection[] } function kebabify(str: string) { return str.replaceAll('/', '-').replaceAll(' ', '-').replace(/\(|\)/g, '') } // This parses output JSON from https://github.com/mark-nicepants/variables2json-docs const json: FigmaVariableData = JSON.parse( fs.readFileSync('./variables.json', { encoding: 'utf-8' }) ) const colorGroupsByMode: Record< string, Record> > = {} const allColorsByMode: Record> = {} const allTokens: Record = {} const allTypographyTokens: Record = {} const allNumberedTokens: Record = {} json.collections.forEach((collection) => { collection.modes.forEach((mode) => { mode.variables.forEach((variable) => { if (variable.type === 'color') { if (variable.isAlias === true) { // Token const name = kebabify(variable.name) const value = kebabify(variable.value.name) allTokens[name] = value } else { const name = kebabify(variable.name) const value = variable.value.replaceAll(' ', '').toLowerCase() // Assign all colors per mode const parsedModeName = mode.name .split(' ') .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join('') if (!allColorsByMode[parsedModeName]) { allColorsByMode[parsedModeName] = {} } allColorsByMode[parsedModeName][name] = value const parts = name.split('-') const groupName = parts[0] if (!colorGroupsByMode[parsedModeName]) { colorGroupsByMode[parsedModeName] = {} } if (!colorGroupsByMode[parsedModeName][groupName]) { colorGroupsByMode[parsedModeName][groupName] = {} } colorGroupsByMode[parsedModeName][groupName][name] = value } } else if (variable.type === 'typography') { // Make variables for each value const name = 'typography-' + kebabify(variable.name) Object.keys(variable.value).forEach((valueKey) => { const value = variable.value[valueKey as keyof FigmaTypographyValue] const typeographyVal = `${name}-${valueKey}` const unitValue = variable.value[`${valueKey}Unit` as keyof FigmaTypographyValue] if (unitValue) { if (unitValue === 'PERCENT') { allTypographyTokens[typeographyVal] = value + '%' return } else if (unitValue === 'PIXELS') { allTypographyTokens[typeographyVal] = value + 'px' return } } // Skip making css variables for units, they are already handled if (valueKey.includes('Unit')) { return } if (valueKey === 'fontSize') { allTypographyTokens[typeographyVal] = value + 'px' return } allTypographyTokens[typeographyVal] = value }) } else if (variable.type === 'number') { if (collection.name === 'Text sizes') { const modeName = kebabify(mode.name) const name = `typography-${kebabify(variable.name)}-${modeName}-fontSize` allTypographyTokens[name] = variable.value.toString() + 'px' return } else if (collection.name === 'Layout') { const collectionName = kebabify(collection.name) const modeName = kebabify(mode.name) const name = `${collectionName}-${modeName}-${kebabify(variable.name)}` allNumberedTokens[name] = variable.value + 'px' return } else if (collection.name === 'Spacing') { const collectionName = kebabify(collection.name) let unitName = variable.name // Special namings for spacing if (unitName === 'x025') { unitName = 'x-quarter' } else if (unitName === 'x05') { unitName = 'x-half' } else if (unitName === 'x15') { unitName = 'x-one-and-half' } const name = `${collectionName}-${kebabify(unitName)}` allNumberedTokens[name] = variable.value + 'px' return } const collectionName = kebabify(collection.name) const name = `${collectionName}-${kebabify(variable.name)}` allNumberedTokens[name] = variable.value + 'px' } }) }) }) // Create ts file with all colors and color tokens for displaying swatches in Storybook const tsOutput = [ '/* This file is generated, do not edit manually! */', `export const colors = ${JSON.stringify(allColorsByMode, null, 2)} as const`, '', `export const tokens = ${JSON.stringify(allTokens, null, 2)} as const`, '', ] for (const [modeName, values] of Object.entries(colorGroupsByMode)) { tsOutput.push(`export const ${modeName} = { `) for (const [name, value] of Object.entries(values)) { tsOutput.push(`${name}: ${JSON.stringify(value, null, 2)},`) } tsOutput.push('} as const;') tsOutput.push('') } fs.writeFileSync('./styles/colors.ts', tsOutput.join('\n'), { encoding: 'utf-8', }) // Write a css file for each mode available of core colors const cssOutput = [ '/* This file is generated, do not edit manually! */', ':root {', ] for (const [, values] of Object.entries(sortObjectByKey(allColorsByMode))) { for (const [name, value] of Object.entries(sortObjectByKey(values))) { cssOutput.push(` --${name}: ${value};`) } } cssOutput.push('}') cssOutput.push('') // New line at end of file fs.writeFileSync(`./styles/modes.css`, cssOutput.join('\n'), { encoding: 'utf-8', }) // All css files, regardless of mode, should have the same tokens. Generate one file for all tokens const cssTokensOutput = [ '/* This file is generated, do not edit manually! */', ':root {', ] for (const [token, value] of Object.entries(sortObjectByKey(allTokens))) { cssTokensOutput.push(` --${token}: var(--${value});`) } cssTokensOutput.push('}') cssTokensOutput.push('') // New line at end of file fs.writeFileSync(`./styles/tokens.css`, cssTokensOutput.join('\n'), { encoding: 'utf-8', }) // All css files, regardless of mode, should have the same typography tokens. const typographyOutput = [ '/* This file is generated, do not edit manually! */', ':root {', ] for (const [token, value] of Object.entries( sortObjectByKey(allTypographyTokens) )) { // TODO: handle fontSize for other consumers than CSS modules // Css modules needs fontsizes to be written as numerical values appended with the unit const isNumericalValue = typeof value === 'number' || token.includes('fontSize') || token.includes('lineHeight') || token.includes('letterSpacing') const valueOutput = isNumericalValue ? value : `'${value.toLowerCase()}'` typographyOutput.push(` --${token}: ${valueOutput};`) } typographyOutput.push('}') typographyOutput.push('') // New line at end of file fs.writeFileSync(`./styles/typography.css`, typographyOutput.join('\n'), { encoding: 'utf-8', }) // All css files, regardless of mode, should have the same typography tokens. const numberedTokensOutput = [ '/* This file is generated, do not edit manually! */', ':root {', ] for (const [token, value] of Object.entries( sortObjectByKey(allNumberedTokens) )) { const valueOutput = value numberedTokensOutput.push(` --${token}: ${valueOutput};`) } numberedTokensOutput.push('}') numberedTokensOutput.push('') // New line at end of file fs.writeFileSync( `./styles/numberedTokens.css`, numberedTokensOutput.join('\n'), { encoding: 'utf-8', } )