Skip to content

Commit

Permalink
feat: update css variables export to support text styles and more
Browse files Browse the repository at this point in the history
  • Loading branch information
spykr committed Aug 27, 2024
1 parent b31a60b commit 7cf826b
Show file tree
Hide file tree
Showing 4 changed files with 306 additions and 57 deletions.
2 changes: 1 addition & 1 deletion .gitignore
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
Expand Down
241 changes: 188 additions & 53 deletions src/export/export-css.ts
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 }
);
}
17 changes: 14 additions & 3 deletions src/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
103 changes: 103 additions & 0 deletions templates/css/theme.css.ejs
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) { -%>
<% } -%>
<% } -%>

0 comments on commit 7cf826b

Please sign in to comment.