-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: update css variables export to support text styles and more
- Loading branch information
Showing
4 changed files
with
306 additions
and
57 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,7 @@ | ||
# Script output | ||
lib | ||
theme | ||
tokens.* | ||
theme.css | ||
tailwind.figma2theme.js | ||
|
||
# Storybook output | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 = <Type, Token>( | ||
dictionary: NestedDictionary<{ $type: Type; $value: Token }>, | ||
transformKey: (key: string) => string = (key) => key, | ||
transformValue: (value: Token) => unknown = (value) => value | ||
): Dictionary<Token> => | ||
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<string>) => { | ||
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<string>, | ||
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<string>, | ||
prefix = '' | ||
): { [key: string]: string } => | ||
Object.entries(obj).reduce<Dictionary<string>>((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<Dictionary<string>>( | ||
(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 } | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) { -%> | ||
<% } -%> | ||
<% } -%> |