Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/generate tailwind config #494

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
3 changes: 3 additions & 0 deletions build.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { FormatterArguments } from 'style-dictionary/types/Format';
import { config } from './config';
import StyleDictionaryBase, { TransformedToken } from 'style-dictionary';
import { createTailwindSdFormatter } from './tailwind/createTailwindConfig';
import * as fs from 'fs';

const StyleDictionary = StyleDictionaryBase.extend(config);
Expand All @@ -9,6 +10,8 @@ const fileHeader = StyleDictionary.formatHelpers.fileHeader;
console.log('Build started...');
console.log('\n==============================================');

StyleDictionary.registerFormat(createTailwindSdFormatter());

StyleDictionary.registerFormat({
formatter: ({ file, dictionary, options }: FormatterArguments) => {
const symbols = dictionary.allProperties.map(cssTemplate).join('') + '\n';
Expand Down
19 changes: 19 additions & 0 deletions config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,25 @@ export const config: Config = {
transformGroup: 'js',
transforms: ['attribute/cti', 'name/cti/kebab', 'color/css'],
},
tailwind: {
buildPath: 'dist/tailwind/',
prefix: 'sbb',
files: [
{
destination: 'tailwind.config.json',
format: 'custom/tailwind',
},
],
transforms: [
'attribute/cti',
'name/cti/kebab',
'time/seconds',
'content/icon',
'color/css',
'size/pxToRem',
'size/rem',
],
},
scss: {
buildPath: 'dist/scss/',
prefix: 'sbb',
Expand Down
4 changes: 2 additions & 2 deletions designTokens/animation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const attributes = (): Partial<DesignToken> => ({
},
});

export const animation: DesignTokens = {
export const animation = {
duration: {
'-1x': {
value: duration(1),
Expand Down Expand Up @@ -42,4 +42,4 @@ export const animation: DesignTokens = {
easing: {
value: 'cubic-bezier(.47, .1, 1, .63)',
},
};
} satisfies DesignTokens;
4 changes: 2 additions & 2 deletions designTokens/border.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const attributes = (): Partial<DesignToken> => ({
},
});

export const border: DesignTokens = {
export const border = {
width: {
'1x': {
value: borderWidth(1),
Expand Down Expand Up @@ -44,5 +44,5 @@ export const border: DesignTokens = {
value: borderRadius(16),
...attributes(),
},
},
} satisfies DesignTokens,
};
4 changes: 2 additions & 2 deletions designTokens/breakpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const attributes = (): Partial<DesignToken> => ({
},
});

export const breakpoint: DesignTokens = {
export const breakpoint = {
zero: {
min: {
value: 0,
Expand Down Expand Up @@ -77,4 +77,4 @@ export const breakpoint: DesignTokens = {
...attributes(),
},
},
};
} satisfies DesignTokens;
4 changes: 2 additions & 2 deletions designTokens/typo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const attributes = (): Partial<DesignToken> => ({
},
});

export const typo: DesignTokens = {
export const typo = {
fontFamily: {
value: '"SBB", "Helvetica Neue", Helvetica, Arial, sans-serif',
},
Expand Down Expand Up @@ -79,4 +79,4 @@ export const typo: DesignTokens = {
...attributes(),
},
},
};
} satisfies DesignTokens;
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"lint-staged": "15.2.2",
"prettier": "3.2.5",
"style-dictionary": "3.9.2",
"tailwindcss": "3.4.1",
"tsx": "4.10.2",
"typescript": "5.4.5",
"typescript-eslint": "7.9.0"
Expand Down
162 changes: 162 additions & 0 deletions tailwind/createTailwindConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Format, Named, TransformedToken } from 'style-dictionary';
import * as SBBTokens from '../designTokens';
import { Config } from 'tailwindcss/types/config';

export function createTailwindSdFormatter(): Named<Format> {
return {
name: 'custom/tailwind',
formatter: (args) => {
return createTailwindConfig(args.dictionary.allTokens);
},
};
}
type SbbTokens = typeof SBBTokens;

export function createTailwindConfig(tokens: TransformedToken[]) {
const sbbTokens = unnestObjects<SbbTokens>(tokens);

// #region colors
// map colors and respective transparency variants to a common color
// e.g. "black" and "blackAlpha" will get merged into one color object, with the value of "black" as the default
const colors = sbbTokens.color;
Object.keys(colors).forEach((color) => {
if (color.endsWith('Alpha') && typeof colors[color] === 'object') {
const realColorName = color.replace('Alpha', '');
colors[realColorName] = withDefault(colors[realColorName], colors[color]);
delete colors[color];
}
});
// #endregion colors

// #region spacing
const { fixed, responsive } = sbbTokens.spacing;
// the css variables for the responsive sizes in the design tokens look like this:
// --sbb-spacing-responsive-xxl-zero
// this is because the spacings are dependent on breakpoints. this variable contains
// the value that the spacing "xxl" should have on breakpoint "zero".
// to achieve this, there are some css media queries, that define a new variable
// for each size, without the breakpoint name at the end ("--sbb-spacing-responsive-xxl" in this case),
// that will set the value of this variable based on the current breakpoint.
// so to work with the responsive sizes, we can just use the variable names without
// the breakpoint name, and the correct value will be set by the css in "composed-variables.css"
const responsiveSpacing = Object.fromEntries(
Object.keys(responsive).map((size) => {
// it doesn't really matter which breakpoint we choose, any one will work
const breakpoint: keyof SbbTokens['breakpoint'] = 'zero';
const variableName = responsive[size][breakpoint].replace(`-${breakpoint}`, '');
return [size, variableName];
}),
);
const fixedSpacing = removeDashPrefix(fixed);
// #endregion spacing

// #region screens
const minWidthScreens = Object.fromEntries(
Object.entries(sbbTokens.breakpoint).map(([bpName, range]) => [bpName, range.min]),
);

const maxWidthScreens = Object.fromEntries(
Object.entries(sbbTokens.breakpoint).map(([bpName, range]) => [
`max-${bpName}`,
{ max: range.max },
]),
);
// #endregion screens

// #region fontSize
const { default: defaultFontSize, ...otherFontSizes } = sbbTokens.typo.scale;
// #endregion fontSize

// #region boxShadow
const boxShadows = Object.entries<Record<string, any>>(sbbTokens.shadow.elevation.level).reduce(
(prev, [key, value]) => {
function getShadowDefinition(number: number, type: 'soft' | 'hard') {
return `${value.shadow[number].offset.x} ${value.shadow[number].offset.y} ${value.shadow[number].blur} ${value.shadow[number].spread} ${value[type][number].color}`;
}

return {
...prev,
[`${key}s`]: `${getShadowDefinition(1, 'soft')}, ${getShadowDefinition(2, 'soft')}`,
[`${key}h`]: `${getShadowDefinition(1, 'hard')}, ${getShadowDefinition(2, 'hard')}`,
};
},
{} as Record<string, any>,
);
const defaultBoxShadow = Object.values(boxShadows)[0];
// #endregion boxShadow

const tailwindConfig: Partial<Config> = {
theme: {
colors: { transparent: 'transparent', current: 'currentColor', ...colors },
screens: { ...minWidthScreens, ...maxWidthScreens },
transitionDuration: removeDashPrefix(sbbTokens.animation.duration),
transitionTimingFunction: withDefault(sbbTokens.animation.easing),
borderRadius: { ...sbbTokens.border.radius, '0': '0', full: '9999px' },
borderWidth: { ...sbbTokens.border.width, '0': '0' },
outlineOffset: withDefault(sbbTokens.focus.outline.offset),
spacing: { ...fixedSpacing, ...responsiveSpacing, '0': '0' },
letterSpacing: sbbTokens.typo.letterSpacing,
lineHeight: sbbTokens.typo.lineHeight,
fontFamily: withDefault(sbbTokens.typo.fontFamily),
fontSize: withDefault(defaultFontSize, otherFontSizes),
boxShadow: withDefault(defaultBoxShadow, boxShadows),
// TODO:
// grid layout
},
};
return JSON.stringify(tailwindConfig, null, 2);
}

const withDefault = <T extends object, V>(defaultValue: V, obj = {} as T) => ({
...obj,
DEFAULT: defaultValue,
});

// remove the dash prefix from the keys
// e.g. "-1x" becomes "1x" in the key
const removeDashPrefix = (obj: Record<string, any>) =>
Object.fromEntries(
Object.entries(obj).map(([key, value]) => [
key.startsWith('-') ? key.substring(1) : key,
value,
]),
);

// this type recursively unnests objects that have a "value" property
// e.g. recursively transforms objects like { a: { value: "b" } } to { a: "b" }
type UnnestValue<T> = {
[K in keyof T]: T[K] extends { value: any } ? T[K]['value'] : UnnestValue<T[K]>;
};

function unnestObjects<T extends object>(objects: TransformedToken[]): UnnestValue<T> {
const nestedObject: any = {};

for (const obj of objects) {
let currentObject = nestedObject;
const path = obj.path;

for (let i = 0; i < path.length - 1; i++) {
const key = path[i];

if (!(key in currentObject)) {
currentObject[key] = {};
}

currentObject = currentObject[key];
}

const finalKey = path[path.length - 1];

if (path[0] === 'breakpoint') {
// breakpoints don't support css variables, we need to use the actual value of the variable instead
currentObject[finalKey] = `${obj.value} /* var(--${obj.name}) */`;
} else {
// add the actual value behind the variable as a comment for a better developer experience
currentObject[finalKey] =
`var(--${obj.name}) /* ${obj.attributes?.category === 'size' ? obj.original.value + 'px' : obj.value} */`;
}
}

return nestedObject;
}
Loading