diff --git a/.gitignore b/.gitignore index 0fec4df..99146af 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ # Script output lib theme -tokens.* +theme.css tailwind.figma2theme.js # Storybook output diff --git a/src/export/export-css.ts b/src/export/export-css.ts index 32ef917..f870266 100644 --- a/src/export/export-css.ts +++ b/src/export/export-css.ts @@ -1,65 +1,200 @@ -import fs from 'fs-extra'; -import StyleDictionary from 'style-dictionary'; +import path from 'path'; +import { version } from '../../package.json'; +import { renderTemplate } from '../utils/file'; import { convertShadowsDesignTokenToCss } from '../utils/convertDesignTokenToCss'; -import type { Tokens } from '../utils/types'; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const reshapeDesignTokens = (obj: any) => { - for (const key in obj) { - // TODO: See if there is a way to output styles not just variables - if (key === 'icons' || key === 'textStyles') { - delete obj[key]; - continue; - } - - if (typeof obj[key] !== 'string' && typeof obj[key] !== 'object') { - obj[key] = obj[key].toString(); - } - - if (Array.isArray(obj[key])) { - obj[key] = convertShadowsDesignTokenToCss(obj[key]); - } - - const newKey = key.replace('$', ''); - if (newKey !== key) { - obj[newKey] = obj[key]; - delete obj[key]; - } - - if (obj[key] !== null && typeof obj[key] === 'object') { - reshapeDesignTokens(obj[key]); - } - } +import type { Dictionary, NestedDictionary, Tokens } from '../utils/types'; + +// Convert a design token dictionary into a simpler format +// e.g. convert `{ "sm": { "$type": "dimension", "$value": "30em" } }` to `{ "sm": "30em" }` +const processTokens = ( + dictionary: NestedDictionary<{ $type: Type; $value: Token }>, + transformKey: (key: string) => string = (key) => key, + transformValue: (value: Token) => unknown = (value) => value +): Dictionary => + Object.entries(dictionary).reduce((obj, [key, dictionaryOrToken]) => { + return { + ...obj, + [transformKey(key)]: dictionaryOrToken.$value + ? transformValue(dictionaryOrToken.$value as Token) + : processTokens( + dictionaryOrToken as typeof dictionary, + transformKey, + transformValue + ), + }; + }, {}); + +// Convert the text styles dictionary into a format that's closer to CSS +// e.g. convert `{ "fontSize": { "sm": "1rem", "xl": "1.125rem" }` to `{ "font-size": "1rem", "@media (min-width: 80em)": { "font-size": "1.125rem" } }` +const processTextStyles = ( + textStyles: Tokens['textStyles'], + breakpoints: Tokens['breakpoints'] +) => { + // Get the breakpoints in order (e.g. `["sm", "md", "lg", "xl"]`) + const breakpointsInOrder = Object.entries(breakpoints) + .sort(([, a], [, b]) => parseInt(a.$value) - parseInt(b.$value)) + .map(([name]) => name); + + // Get the base value for a given token (e.g. `{ "sm": "1rem", "xl": "1.125rem" }` will return `"1rem"`) + const getBaseValue = (value: string | Dictionary) => { + if (!value) return undefined; + if (typeof value === 'string') return value; + if (value.base) return value.base; + + const smallestBreakpoint = breakpointsInOrder.find((name) => value[name]); + if (!smallestBreakpoint) return undefined; + + return value[smallestBreakpoint]; + }; + + // Get the value for a given breakpoint, return undefined if it doesn't exist + const getBreakpointValue = ( + value: string | Dictionary, + breakpoint: string + ) => { + if (!value) return undefined; + if (typeof value === 'string') return undefined; + if (value[breakpoint]) return value[breakpoint]; + + return undefined; + }; + + return Object.entries(processTokens(textStyles)).reduce( + (obj, [name, style]) => { + const className = `.typography-${name.toLowerCase().replace(/\s/g, '-')}`; + const fontFamily = getBaseValue(style.fontFamily) ?? ''; + // Prefer the variable font if it exists (e.g. "Inter" -> "Inter Variable") + const variableFont = + fontFamily.slice(0, -1) + ' Variable' + fontFamily.slice(-1); + const baseStyle = { + 'font-family': `${variableFont}, ${fontFamily}, sans-serif`, + 'font-size': getBaseValue(style.fontSize), + 'font-style': getBaseValue(style.fontStyle), + 'font-weight': getBaseValue(style.fontWeight), + 'letter-spacing': getBaseValue(style.letterSpacing), + 'line-height': getBaseValue(style.lineHeight), + 'text-decoration-line': getBaseValue(style.textDecorationLine), + 'text-transform': getBaseValue(style.textTransform), + }; + + // Iterate over each breakpoint and create a media query if the value is different from the base + const mediaQueries = breakpointsInOrder.reduce((acc, breakpoint) => { + const breakpointStyle = { + 'font-size': getBreakpointValue(style.fontSize, breakpoint), + 'font-style': getBreakpointValue(style.fontStyle, breakpoint), + 'font-weight': getBreakpointValue(style.fontWeight, breakpoint), + 'letter-spacing': getBreakpointValue(style.letterSpacing, breakpoint), + 'line-height': getBreakpointValue(style.lineHeight, breakpoint), + 'text-decoration-line': getBreakpointValue( + style.textDecorationLine, + breakpoint + ), + 'text-transform': getBreakpointValue(style.textTransform, breakpoint), + }; + Object.keys(breakpointStyle).forEach((k) => { + if (breakpointStyle[k as keyof typeof breakpointStyle] === undefined) + delete breakpointStyle[k as keyof typeof breakpointStyle]; + }); + + // Skip the breakpoint if the style is the same as the base + if ( + Object.entries(breakpointStyle).every( + ([key, value]) => + value === undefined || + value === baseStyle[key as keyof typeof baseStyle] + ) + ) { + return acc; + } + + return { + ...acc, + [`@media (min-width: ${breakpoints[breakpoint].$value})`]: + breakpointStyle, + }; + }, {}); + + return { ...obj, [className]: { ...baseStyle, ...mediaQueries } }; + }, + {} + ); }; export default async function exportCssFromTokens( tokens: Tokens, - outputDir: string + outputDir: string, + figmaFileKey: string, + versionDescription: string, + fontFallbacks?: { [token: string]: string } ) { - await fs.mkdirs(outputDir); - const config = { - source: [`${outputDir}/*.tokens.json`], - platforms: { - css: { - transformGroup: 'css', - files: [ - { - destination: `${outputDir}/tokens.css`, - format: 'css/variables', - }, - ], - }, + const breakpoints = processTokens(tokens.breakpoints); + + const nestedColors = processTokens(tokens.colours); + // Completely flatten the colors object by prefixing any nested keys with the parent key + const flattenColors = ( + obj: Dictionary, + prefix = '' + ): { [key: string]: string } => + Object.entries(obj).reduce>((acc, [key, value]) => { + if (typeof value === 'object') { + return { ...acc, ...flattenColors(value, `${prefix}${key}-`) }; + } + + if (key === 'default') { + return { ...acc, [prefix.slice(0, -1)]: value }; + } + + return { ...acc, [`${prefix}${key}`]: value }; + }, {}); + const colors = flattenColors(nestedColors); + + const borderRadius = processTokens(tokens.radii); + + const boxShadow = processTokens( + tokens.shadows, + undefined, + convertShadowsDesignTokenToCss + ); + + const fonts = processTokens(tokens.typography.fonts); + const fontFamily = Object.keys(fonts).reduce>( + (obj, name) => { + const font = fonts[name]; + // Prefer the variable font if it exists (e.g. "Inter" -> "Inter Variable") + const variableFont = font.slice(0, -1) + ' Variable' + font.slice(-1); + // Add font fallbacks if they exist + const fallbacks = fontFallbacks?.[name] ?? 'sans-serif'; + + return { ...obj, [name]: `${variableFont}, ${font}, ${fallbacks}` }; }, - }; + {} + ); + + const fontSize = processTokens(tokens.typography.fontSizes); - reshapeDesignTokens(tokens); + const lineHeight = processTokens(tokens.typography.lineHeights); - await fs.writeJson(`${outputDir}/temp-css.tokens.json`, tokens, { - spaces: 2, - }); - const styleDictionary = StyleDictionary.extend(config); - await fs.remove(`${outputDir}/temp-css.tokens.json`); + const letterSpacing = processTokens(tokens.typography.letterSpacing); + + const textStyles = processTextStyles(tokens.textStyles, tokens.breakpoints); + + const variables = { + breakpoints, + colors, + borderRadius, + boxShadow, + fontFamily, + fontSize, + lineHeight, + letterSpacing, + textStyles, + urls: tokens.urls, + }; - styleDictionary.buildAllPlatforms(); + await renderTemplate( + `${path.resolve(__dirname, '../../templates/css')}/theme.css.ejs`, + `${outputDir}/theme.css`, + { variables, version, figmaFileKey, versionDescription } + ); } diff --git a/src/generate.ts b/src/generate.ts index 8a4bfa2..d8c1174 100644 --- a/src/generate.ts +++ b/src/generate.ts @@ -186,10 +186,21 @@ export const generateCss = async ( latestChanges?: boolean ) => { // Generate a CSS file using the tokens - const exporter: Exporter = async (tokens) => { + const exporter: Exporter = async ( + tokens, + fileKey, + versionDescription, + fontFallbacks + ) => { const relativeDir = path.relative(process.cwd(), outputDir); - console.log(`Exporting CSS file to "${relativeDir}/tokens.css"...`.bold); - await exportCss(tokens, outputDir); + console.log(`Exporting CSS file to "${relativeDir}/theme.css"...`.bold); + await exportCss( + tokens, + outputDir, + fileKey, + versionDescription, + fontFallbacks + ); }; return generator(exporter, apiKeyOverride, fileUrlOverride, latestChanges); diff --git a/templates/css/theme.css.ejs b/templates/css/theme.css.ejs new file mode 100644 index 0000000..bf92f66 --- /dev/null +++ b/templates/css/theme.css.ejs @@ -0,0 +1,103 @@ +/** + * AUTO-GENERATED! This file was generated by Portable's `figma2theme` tool (v<%- version %>). + * If changes were made to the UI Kit in Figma then you should run `figma2theme` again to replace this file rather than editing it manually. + * + * Generation metadata: + * - UI Kit file used: <%- variables.urls.file %> + * - File version selected: <%- versionDescription %> + * - Generated at: <%- new Date().toString() %> + */ + +:root { + /* Breakpoints from the UI Kit (<%- variables.urls.breakpoints %>) */ +<% for(let i = 0; i < Object.values(variables.breakpoints).length; i++) { -%> + --breakpoint-<%- Object.keys(variables.breakpoints)[i] %>: <%- Object.values(variables.breakpoints)[i] %>; +<% } %> + /* Colours from the UI Kit (<%- variables.urls.colours %>) */ +<% for(let i = 0; i < Object.values(variables.colors).length; i++) { -%> + --color-<%- Object.keys(variables.colors)[i] %>: <%- Object.values(variables.colors)[i] %>; +<% } %> + /* Font families from the UI Kit (<%- variables.urls.typography %>) */ +<% for(let i = 0; i < Object.values(variables.fontFamily).length; i++) { -%> + --font-<%- Object.keys(variables.fontFamily)[i] %>: <%- Object.values(variables.fontFamily)[i] %>; +<% } %> + /* Font sizes from the UI Kit (<%- variables.urls.typography %>) */ +<% for(let i = 0; i < Object.values(variables.fontSize).length; i++) { -%> + --font-size-<%- Object.keys(variables.fontSize)[i] %>: <%- Object.values(variables.fontSize)[i] %>; +<% } %> + /* Letter spacings from the UI Kit (<%- variables.urls.typography %>) */ +<% for(let i = 0; i < Object.values(variables.letterSpacing).length; i++) { -%> + --letter-spacing-<%- Object.keys(variables.letterSpacing)[i] %>: <%- Object.values(variables.letterSpacing)[i] %>; +<% } %> + /* Line heights from the UI Kit (<%- variables.urls.typography %>) */ +<% for(let i = 0; i < Object.values(variables.lineHeight).length; i++) { -%> + --line-height-<%- Object.keys(variables.lineHeight)[i] %>: <%- Object.values(variables.lineHeight)[i] %>; +<% } %> + /* Radii from the UI Kit (<%- variables.urls.radii %>) */ +<% for(let i = 0; i < Object.values(variables.borderRadius).length; i++) { -%> + --radius-<%- Object.keys(variables.borderRadius)[i] %>: <%- Object.values(variables.borderRadius)[i] %>; +<% } %> + /* Shadows from the UI Kit (<%- variables.urls.shadows %>) */ +<% for(let i = 0; i < Object.values(variables.boxShadow).length; i++) { -%> + --shadow-<%- Object.keys(variables.boxShadow)[i] %>: <%- Object.values(variables.boxShadow)[i] %>; +<% } %> + /* Spacing scale from Tailwind CSS (https://tailwindcss.com/docs/customizing-spacing#default-spacing-scale) */ + --spacing-0: 0; + --spacing-px: 1px; + --spacing-0_5: 0.125rem; + --spacing-1: 0.25rem; + --spacing-1_5: 0.375rem; + --spacing-2: 0.5rem; + --spacing-2_5: 0.625rem; + --spacing-3: 0.75rem; + --spacing-3_5: 0.875rem; + --spacing-4: 1rem; + --spacing-5: 1.25rem; + --spacing-6: 1.5rem; + --spacing-7: 1.75rem; + --spacing-8: 2rem; + --spacing-9: 2.25rem; + --spacing-10: 2.5rem; + --spacing-12: 3rem; + --spacing-14: 3.5rem; + --spacing-16: 4rem; + --spacing-20: 5rem; + --spacing-24: 6rem; + --spacing-28: 7rem; + --spacing-32: 8rem; + --spacing-36: 9rem; + --spacing-40: 10rem; + --spacing-44: 11rem; + --spacing-48: 12rem; + --spacing-52: 13rem; + --spacing-56: 14rem; + --spacing-60: 15rem; + --spacing-64: 16rem; + --spacing-72: 18rem; + --spacing-80: 20rem; + --spacing-96: 24rem; +} + +/* Text styles from the UI Kit (<%- variables.urls.typography %>) */ + +<% for(let i = 0; i < Object.values(variables.textStyles).length; i++) { -%> +<%- Object.keys(variables.textStyles)[i] -%> { +<% for(let j = 0; j < Object.values(Object.values(variables.textStyles)[i]).length; j++) { -%> +<% if (Object.keys(Object.values(variables.textStyles)[i])[j].startsWith('@media')) { -%> +} + +<%- Object.keys(Object.values(variables.textStyles)[i])[j] -%> { + <%- Object.keys(variables.textStyles)[i] -%> { + <% for(let k = 0; k < Object.values(Object.values(Object.values(variables.textStyles)[i])[j]).length; k++) { -%> + <%- Object.keys(Object.values(Object.values(variables.textStyles)[i])[j])[k] %>: <%- Object.values(Object.values(Object.values(variables.textStyles)[i])[j])[k] %>; + <% } -%> +} +<% } else { -%> + <%- Object.keys(Object.values(variables.textStyles)[i])[j] %>: <%- Object.values(Object.values(variables.textStyles)[i])[j] %>; +<% } -%> +<% } -%> +} +<% if (i < Object.values(variables.textStyles).length - 1) { -%> + +<% } -%> +<% } -%>