Skip to content

Commit

Permalink
feat: implement exporting to tailwind theme
Browse files Browse the repository at this point in the history
  • Loading branch information
spykr committed Jun 16, 2024
1 parent 36558d8 commit a2d0e80
Show file tree
Hide file tree
Showing 18 changed files with 778 additions and 73 deletions.
7 changes: 7 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ module.exports = {
// Add basic linting (and Prettier formatting) for the few necessary JS files (i.e. `next.config.js`, `jest.config.js`, etc.)
files: ['**/*.js'],
extends: ['airbnb-base', 'plugin:prettier/recommended'],
overrides: [
{
// Allow importing dev dependencies in the generated theme
files: ['tailwind.figma2theme.js'],
rules: { 'import/no-extraneous-dependencies': 'off' },
},
],
},
],
};
3 changes: 2 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
echo "$(yarn global bin)" >> $GITHUB_PATH
- name: Generate the theme
run: yarn concurrently --group "figma2theme generate-json --latest-changes" "figma2theme generate-chakra --latest-changes"
run: yarn concurrently --group "figma2theme generate-json --latest-changes" "figma2theme generate-chakra --latest-changes" "figma2theme generate-tailwind --latest-changes"
env:
FIGMA_API_KEY: ${{secrets.FIGMA_API_KEY}}

Expand All @@ -33,6 +33,7 @@ jobs:
path: |
./tokens.json
./theme
./tailwind.figma2theme.js
- name: Test, lint and typecheck the project and build Storybook
run: yarn concurrently --group "yarn:test" "yarn:lint" "yarn:typecheck" "yarn:build-storybook"
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
lib
theme
tokens.*
tailwind.figma2theme.js

# Storybook output
storybook-static
Expand Down
49 changes: 44 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,21 @@

This CLI is a Portable tool designed to allow us to extract
design tokens (colour palettes, typography, spacing scales, etc.)
from a Figma file and then use them to generate a theme.
from a UI Kit Figma file and then use them to generate a theme.

figma2theme supports theme exports for the following UI frameworks:

- [Tailwind CSS](https://tailwindcss.com/)
- [Chakra UI](https://chakra-ui.com/)

## Resources

**UI Kit Figma template:**

**Figma template:**
https://www.figma.com/design/b6tcOWalyHLDfsD0IBSXyE/Portable-UI-Kit-v2

**Example Storybook generated from the Figma template:**
**Example Storybook generated from the template above using figma2theme and Chakra UI:**

https://figma2theme.netlify.app/

## Usage
Expand Down Expand Up @@ -38,23 +47,53 @@ FIGMA_API_KEY=

### 3. Generate your theme

#### Tailwind CSS

Run the following command to generate your Tailwind CSS theme:

```bash
yarn figma2theme generate-tailwind
```

By default the generated file will be saved as `tailwind.figma2theme.js` in the root of your project.

#### Chakra UI

Run the following command to generate your Chakra UI theme:

```bash
yarn figma2theme generate-chakra
```

By default the generated theme file(s) will be saved to `./theme`.
By default the generated files will be saved to the `./theme` directory.

### 4. Import the theme

Update your imports from `import { theme } from '@chakra-ui/react'` to the generated theme location.
#### Tailwind CSS

Open your `tailwind.config.js` file and add the following line to the top of the config:

```js
presets: [require('./tailwind.figma2theme.js')],
```

This setting will use the generated theme as the base for your Tailwind configuration,
giving you access to all the tokens from the UI Kit while still allowing you to extend
or override them in your `tailwind.config.js` file as needed.

For more information on this setting see the Tailwind docs: https://tailwindcss.com/docs/presets

#### Chakra UI

Change your imports from `import { theme } from '@chakra-ui/react'` to the generated theme
location so that the generated theme is used instead of the default Chakra UI theme.

### 5. Import the stories (Optional)

`figma2theme` provides a variety of Storybook stories that allow you to view elements of your
current Chakra UI theme, including foundational values (e.g. colour palettes, font sizes, etc.)
and component styles (e.g. button variants, text styles, etc.)
These stories are currently only available for Chakra UI.

To view these stories in the Storybook of your project, open `.storybook/main.js` and insert
the following glob to the `stories` array:
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@
"react-is": "^18.2.0",
"rimraf": "^3.0.2",
"storybook-addon-designs": "^6.3.1",
"tailwindcss": "^3.4.4",
"ts-node": "^10.9.1",
"typescript": "^4.8.4",
"uuid": "^9.0.0",
Expand Down
27 changes: 2 additions & 25 deletions src/export/export-chakra.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import fs from 'fs-extra';
import ejs from 'ejs';
import path from 'path';
import prettier from 'prettier';
import svgToJSX from 'svg-to-jsx';
import * as svgson from 'svgson';
import type { Data } from 'ejs';

import { version } from '../../package.json';
import { renderTemplate } from '../utils/file';
import { convertShadowsDesignTokenToCss } from '../utils/convertDesignTokenToCss';
import type {
ChakraTokens,
Expand All @@ -15,27 +12,7 @@ import type {
Tokens,
} from '../utils/types';

const prettierConfigFile = path.resolve(__dirname, '../../.prettierrc');
const templateDir = path.resolve(__dirname, '../../templates');

// Run Prettier on TypeScript code using the config file
const formatFileContents = async (contents: string) => {
return prettier.resolveConfig(prettierConfigFile).then((options) => {
return prettier.format(contents, { ...options, parser: 'typescript' });
});
};

// Render an EJS template with the given data, format it with Prettier and write the result to the output path
const renderTemplate = async (
templatePath: string,
outputPath: string,
data: Data
) => {
const contents = await ejs
.renderFile(templatePath, data)
.then((str) => formatFileContents(str));
return fs.outputFile(outputPath, contents);
};
const templateDir = path.resolve(__dirname, '../../templates/chakra-ui');

type ChakraIcon = {
name: string;
Expand Down
184 changes: 184 additions & 0 deletions src/export/export-tailwind.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import path from 'path';

import { version } from '../../package.json';
import { renderTemplate } from '../utils/file';
import { convertShadowsDesignTokenToCss } from '../utils/convertDesignTokenToCss';
import type { Dictionary, NestedDictionary, Tokens } from '../utils/types';

const templateDir = path.resolve(__dirname, '../../templates/tailwind');

// Convert a design token dictionary into a simpler format that can be used in the Tailwind config
// 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 can be used in a Tailwind plugin
// e.g. convert `{ "fontSize": { "sm": "1rem", "xl": "1.125rem" }` to `{ "fontSize: "1rem", '@media (min-width: 80em)': { fontSize: "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}`;
const baseStyle = {
fontFamily: getBaseValue(style.fontFamily),
fontSize: getBaseValue(style.fontSize),
fontStyle: getBaseValue(style.fontStyle),
fontWeight: getBaseValue(style.fontWeight),
letterSpacing: getBaseValue(style.letterSpacing),
lineHeight: getBaseValue(style.lineHeight),
textDecorationLine: getBaseValue(style.textDecorationLine),
textTransform: 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 = {
fontFamily: getBreakpointValue(style.fontFamily, breakpoint),
fontSize: getBreakpointValue(style.fontSize, breakpoint),
fontStyle: getBreakpointValue(style.fontStyle, breakpoint),
fontWeight: getBreakpointValue(style.fontWeight, breakpoint),
letterSpacing: getBreakpointValue(style.letterSpacing, breakpoint),
lineHeight: getBreakpointValue(style.lineHeight, breakpoint),
textDecorationLine: getBreakpointValue(
style.textDecorationLine,
breakpoint
),
textTransform: getBreakpointValue(style.textTransform, breakpoint),
};

// 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 exportTailwindFromTokens(
tokens: Tokens,
outputDir: string,
figmaFileKey: string,
versionDescription: string,
fontFallbacks?: { [token: string]: string }
) {
const screens = processTokens(tokens.breakpoints);

const colors = processTokens(tokens.colours, (key) => {
// Convert any "default" keys to "DEFAULT" which is what Tailwind uses
if (key === 'default') return 'DEFAULT';

return key;
});

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) => {
// Add font fallbacks if they exist
const font = fonts[name];
const fallback = fontFallbacks?.[name] ?? 'sans-serif';

return { ...obj, [name]: `${font}, ${fallback}` };
},
{}
);
// Add "sans" and "serif" to the font list if they don't exist (Tailwind uses these)
if (!fontFamily.sans) fontFamily.sans = fontFamily.body;
if (!fontFamily.serif) fontFamily.serif = fontFamily.heading;

const fontSize = processTokens(tokens.typography.fontSizes);

const lineHeight = processTokens(tokens.typography.lineHeights);

const letterSpacing = processTokens(tokens.typography.letterSpacing);

const textStyles = processTextStyles(tokens.textStyles, tokens.breakpoints);

const tailwind = {
screens,
colors,
borderRadius,
boxShadow,
fontFamily,
fontSize,
lineHeight,
letterSpacing,
textStyles,
urls: tokens.urls,
};

await renderTemplate(
`${templateDir}/tailwind.figma2theme.js.ejs`,
`${outputDir}/tailwind.figma2theme.js`,
{ tailwind, version, figmaFileKey, versionDescription }
);
}
1 change: 1 addition & 0 deletions src/export/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { default as exportChakra } from './export-chakra';
export { default as exportJson } from './export-json';
export { default as exportCss } from './export-css';
export { default as exportTailwind } from './export-tailwind';
Loading

0 comments on commit a2d0e80

Please sign in to comment.