diff --git a/src/tokens/config.ts b/src/tokens/config.ts index 7977cdbad..553e99036 100644 --- a/src/tokens/config.ts +++ b/src/tokens/config.ts @@ -58,6 +58,30 @@ export default function getConfig(layers: string[]) { 'tailwind/extract_component_styles' ] }, + 'tailwind-email': { + transformGroup: 'tailwind/css', + buildPath: 'tokens/tailwind-email/', + preset: formatLayerPathPart(layers), + files: [ + { + destination: 'tokens.js', + format: 'tailwind/tokens', + filter: 'tw/filterTokens', + options: { + showFileHeader: false + } + }, + { + destination: 'plugins/typography.js', + format: 'tailwind/fonts', + filter: 'tw/filterFonts', + options: { + showFileHeader: false + } + } + ], + actions: ['tailwind/copy_static_files'] + }, css: { transformGroup: 'custom/css', buildPath: 'tokens/css/', diff --git a/src/tokens/transformTokens.ts b/src/tokens/transformTokens.ts index 4f4cd1b76..0663dafc5 100644 --- a/src/tokens/transformTokens.ts +++ b/src/tokens/transformTokens.ts @@ -4,6 +4,7 @@ import getConfig from './config' // Register transforms import './transformation/web' import './transformation/tailwind' +import './transformation/tailwind-email' import './transformation/skia' import './transformation/ios' import './transformation/android' @@ -54,3 +55,6 @@ for (const layer of layers) { StyleDictionaryExtended.buildPlatform('json-flat') StyleDictionaryExtended.buildPlatform('tailwind') } + +const StyleDictionaryExtended = StyleDictionary.extend(getConfig(['universal'])) +StyleDictionaryExtended.buildPlatform('tailwind-email') diff --git a/src/tokens/transformation/tailwind-email/copyStaticFiles.js b/src/tokens/transformation/tailwind-email/copyStaticFiles.js new file mode 100644 index 000000000..67d01fbb3 --- /dev/null +++ b/src/tokens/transformation/tailwind-email/copyStaticFiles.js @@ -0,0 +1,23 @@ +const { join } = require('path') +const { readdirSync, unlink, cpSync, rmdir, statSync } = require('fs') + +const staticFilesPath = join(__dirname, './static') +const staticFiles = readdirSync(staticFilesPath) + +module.exports = { + do: function (dictionary, config) { + const targetDir = join(config.buildPath, config.preset) + cpSync(staticFilesPath, targetDir, { recursive: true }) + }, + undo: function (dictionary, config) { + staticFiles.forEach((file) => { + const target = join(config.buildPath, config.preset, file) + + if (statSync(target).isDirectory()) { + rmdir(target) + } else { + unlink(target) + } + }) + } +} diff --git a/src/tokens/transformation/tailwind-email/formatTokens.ts b/src/tokens/transformation/tailwind-email/formatTokens.ts new file mode 100644 index 000000000..09c4cf3f4 --- /dev/null +++ b/src/tokens/transformation/tailwind-email/formatTokens.ts @@ -0,0 +1,159 @@ +import merge from 'lodash.merge' +import { Formatter } from 'style-dictionary' + +const themes = ['light', 'dark'] + +const kebabCase = (str: string) => str && str.toLowerCase().replaceAll(' ', '-') + +/** + * This function transforms tokens into a nested object + * structure ready for Tailwind. The conditional statements + * are largely to handle creating both static and dynamic + * tokens. E.g. A given token needs three variants: + * dynamic, static light, and static dark. This function gets + * run twice: once to create all static tokens, and another + * time to create the dynamic tokens which places values on + * the parent (e.g. the root object, or 'legacy') and uses the + * appropriate color variable. + */ +function createColorTokensFromGroup(tokens, staticTheme = true) { + const colorTokens = {} + tokens.forEach(({ type, name, ...t }) => { + if (type === 'color') { + /** + * The following conditions are in order to properly group + * color tokens and format into a nested object structure + * for use in Tailwind. + */ + let colorGroup = colorTokens[t.attributes.type] ?? {} + + const tItem = kebabCase(t.attributes.item) + const tSubItem = kebabCase(t.attributes.subitem) + + /** + * `state` is for the deepest level on a token. + * E.g. `icon` in colors.systemfeedback.success.icon + */ + if (t.attributes.state) { + if (!staticTheme) { + // If not on a static theme, do not place within `dark` or `light` groups + colorTokens[tItem] = colorTokens[tItem] || {} + const tokenGroup = colorTokens[tItem][tSubItem] ?? {} + colorTokens[tItem][tSubItem] = merge(tokenGroup, { + [t.attributes.state]: t.value + }) + } else { + // If on a static theme, place within `dark` or `light` groups + const tokenGroup = colorGroup[tItem] + colorGroup[tItem] = merge(tokenGroup, { + [tSubItem]: t.value + }) + } + } else if (tSubItem) { + /** + * If not on a static theme AND theme is determined by `type` + * property do not place within `dark` or `light` groups + */ + if (themes.includes(t.attributes.type) && !staticTheme) { + const tokenGroup = colorTokens[tItem] ?? {} + colorTokens[tItem] = merge(tokenGroup, { + [tSubItem]: t.value + }) + + /** + * If not on a static theme AND theme is determined by `item` + * property (e.g. legacy tokens) do not place within `dark` + * or `light` groups + */ + } else if (themes.includes(t.attributes.item) && !staticTheme) { + const tokenGroup = colorTokens[t.attributes.type] ?? {} + colorTokens[t.attributes.type] = merge(tokenGroup, { + [tSubItem]: t.value + }) + } else { + // If on a static theme, place within `dark` or `light` groups + const tokenGroup = colorGroup[tItem] + colorGroup[tItem] = merge(tokenGroup, { + [tSubItem]: t.value + }) + } + + /** + * If `item` property is the token name, don't nest inside object + */ + } else if (t.attributes.item) { + colorGroup[tItem] = t.value + + /** + * If `item` property is the token name, set directly on colorGroup + */ + } else if (t.attributes.type) { + colorGroup = t.value + } + + if (Object.keys(colorGroup).length > 0) { + colorTokens[t.attributes.type] = colorGroup + } + } + }) + return colorTokens +} + +export default (({ dictionary }) => { + const colorTokens = createColorTokensFromGroup(dictionary.allTokens) + + const borderRadii = new Map([['none', '0']]) + const spacing = new Map([[0, 0]]) // Initialize with option for 0 spacing + const gradients = new Map() + const boxShadows = new Map([['none', 'none']]) + const dropShadows = new Map([ + ['none', '0 0 #0000'] + ]) + + // Format all other tokens + dictionary.allTokens.forEach(({ type, name, ...t }) => { + const attributes = t.attributes! + if (attributes.category === 'radius') { + if (attributes.type === 'full') { + borderRadii.set(attributes.type, '9999px') + } else { + borderRadii.set(attributes.type!, t.value) + } + } else if (attributes.category === 'spacing') { + spacing.set(attributes.type!, t.value) + } else if (type === 'custom-gradient') { + const [, ...pathParts] = t.path + gradients.set(pathParts.join('-'), t.value) + } else if (type === 'custom-shadow') { + const [, ...pathParts] = t.path + boxShadows.set( + pathParts + .filter((v) => !['elevation', 'light', 'dark'].includes(v)) + .join('-') + .replaceAll(' ', '-'), + t.value.boxShadow + ) + dropShadows.set( + pathParts + .filter((v) => !['elevation', 'light', 'dark'].includes(v)) + .join('-') + .replaceAll(' ', '-'), + t.value.dropShadow + ) + } + }) + + // Note: replace strips out 'light-mode' and 'dark-mode' inside media queries + return `module.exports = ${JSON.stringify( + { + colors: colorTokens, + spacing: Object.fromEntries(spacing), + borderRadius: Object.fromEntries(borderRadii), + boxShadow: Object.fromEntries(boxShadows), + dropShadow: Object.fromEntries(dropShadows), + gradients: Object.fromEntries(gradients) + }, + null, + ' '.repeat(2) + )}` +}) as Formatter diff --git a/src/tokens/transformation/tailwind-email/index.ts b/src/tokens/transformation/tailwind-email/index.ts new file mode 100644 index 000000000..aa251ea99 --- /dev/null +++ b/src/tokens/transformation/tailwind-email/index.ts @@ -0,0 +1,87 @@ +import StyleDictionary from 'style-dictionary' + +import twFilterTokens from '../tailwind/twFilterTokens' +import twFilterFonts from '../tailwind/twFilterFonts' + +import sizePx from '../web/sizePx' +import twShadows from '../tailwind/twShadows' +import webRadius from '../web/webRadius' +import webSize from '../web/webSize' +import webPadding from '../web/webPadding' +import twFont from '../tailwind/twFont' +import webGradient from '../web/webGradient' +import formatFonts from '../tailwind/formatFonts' + +import formatTokens from './formatTokens' +import copyStaticFiles from './copyStaticFiles' + +// Filters +StyleDictionary.registerFilter({ + name: 'tw/filterTokens', + matcher: twFilterTokens +}) + +StyleDictionary.registerFilter({ + name: 'tw/filterFonts', + matcher: twFilterFonts +}) + +// Transforms +StyleDictionary.registerTransform({ + name: 'size/px', + ...sizePx +}) +StyleDictionary.registerTransform({ + name: 'tw/shadow', + ...twShadows +}) +StyleDictionary.registerTransform({ + name: 'web/radius', + ...webRadius +}) +StyleDictionary.registerTransform({ + name: 'web/size', + ...webSize +}) +StyleDictionary.registerTransform({ + name: 'web/padding', + ...webPadding +}) +StyleDictionary.registerTransform({ + name: 'tw/font', + ...twFont +}) +StyleDictionary.registerTransform({ + name: 'web/gradient', + ...webGradient +}) + +StyleDictionary.registerTransformGroup({ + name: 'tailwind/css', + transforms: StyleDictionary.transformGroup.css.concat([ + 'size/px', + 'tw/shadow', + 'web/radius', + 'web/size', + 'web/padding', + 'tw/font', + 'web/gradient' + ]) +}) + +StyleDictionary.registerFormat({ + name: 'tailwind/tokens', + formatter: formatTokens +}) + +StyleDictionary.registerFormat({ + name: 'tailwind/fonts', + formatter: formatFonts +}) + +// Actions +StyleDictionary.registerAction({ + name: 'tailwind/copy_static_files', + do: copyStaticFiles.do, + undo: copyStaticFiles.undo +}) diff --git a/src/tokens/transformation/tailwind-email/static/index.js b/src/tokens/transformation/tailwind-email/static/index.js new file mode 100644 index 000000000..b02bde1ca --- /dev/null +++ b/src/tokens/transformation/tailwind-email/static/index.js @@ -0,0 +1,29 @@ +const { + colors, + boxShadow, + dropShadow, + gradients, + borderRadius, + spacing +} = require('./tokens') + +/** @type {import('tailwindcss').Config} */ +module.exports = { + theme: { + boxShadow: {}, + borderRadius: {}, + spacing: {}, + dropShadow: {}, + colors: {}, + extend: { + boxShadow, + borderRadius, + spacing, + dropShadow, + colors: colors, + backgroundImage: { + ...gradients + } + } + } +} diff --git a/src/tokens/transformation/tailwind-email/static/package.json b/src/tokens/transformation/tailwind-email/static/package.json new file mode 100644 index 000000000..5bbefffba --- /dev/null +++ b/src/tokens/transformation/tailwind-email/static/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/src/tokens/transformation/tailwind/formatFonts.ts b/src/tokens/transformation/tailwind/formatFonts.ts index 5c3d9b1ff..5e00821ae 100644 --- a/src/tokens/transformation/tailwind/formatFonts.ts +++ b/src/tokens/transformation/tailwind/formatFonts.ts @@ -1,3 +1,4 @@ +import defaultTwTheme from 'tailwindcss/defaultTheme' import { Formatter } from 'style-dictionary' export default (({ dictionary }) => { @@ -17,6 +18,8 @@ export default (({ dictionary }) => { fontClass += `-${attributes.state}` } + t.value.fontFamily = `${t.value.fontFamily},${defaultTwTheme.fontFamily.sans.join(',')}` + fontClasses.set(fontClass, t.value) })