Build tokens for each theme (#2590)

Nate Butler created

This PR adds the ability to export tokens for each theme. 

You can export tokens by:

1. `cd styles`
2. `npm run build-tokens`
3. Tokens will be output in the target folder (`styles/target`)

The tokens match the ColorScheme object. In the future we may also
export tokens for our styleTrees.

Release Notes:

- N/A (No public facing changes)

---
TODO:

- [x] Generate Token Studio theme index file
- [x] ColorScheme
    - [x] name:
    - [x] isLight
    - [x] lowest
    - [x] middle
    - [x] highest
    - [x] popoverShadow
    - [x] modalShadow
    - [x] players
    - [x] syntax

Change summary

styles/src/buildTokens.ts              | 78 ++++++++++++++++++++++-----
styles/src/theme/tokens/colorScheme.ts | 77 ++++++++++++++++++++++++++-
styles/src/theme/tokens/layer.ts       | 60 +++++++++++++++++++++
styles/src/theme/tokens/players.ts     |  4 
4 files changed, 197 insertions(+), 22 deletions(-)

Detailed changes

styles/src/buildTokens.ts 🔗

@@ -1,11 +1,13 @@
-import * as fs from "fs"
-import * as path from "path"
-import { ColorScheme, createColorScheme } from "./common"
-import { themes } from "./themes"
-import { slugify } from "./utils/slugify"
-import { colorSchemeTokens } from "./theme/tokens/colorScheme"
+import * as fs from "fs";
+import * as path from "path";
+import { ColorScheme, createColorScheme } from "./common";
+import { themes } from "./themes";
+import { slugify } from "./utils/slugify";
+import { colorSchemeTokens } from "./theme/tokens/colorScheme";
 
-const TOKENS_DIRECTORY = path.join(__dirname, "..", "target", "tokens")
+const TOKENS_DIRECTORY = path.join(__dirname, "..", "target", "tokens");
+const TOKENS_FILE = path.join(TOKENS_DIRECTORY, "$themes.json");
+const METADATA_FILE = path.join(TOKENS_DIRECTORY, "$metadata.json");
 
 function clearTokens(tokensDirectory: string) {
     if (!fs.existsSync(tokensDirectory)) {
@@ -19,21 +21,65 @@ function clearTokens(tokensDirectory: string) {
     }
 }
 
+type TokenSet = {
+    id: string;
+    name: string;
+    selectedTokenSets: { [key: string]: "enabled" };
+};
+
+function buildTokenSetOrder(colorSchemes: ColorScheme[]): { tokenSetOrder: string[] } {
+    const tokenSetOrder: string[] = colorSchemes.map(
+        (scheme) => scheme.name.toLowerCase().replace(/\s+/g, "_")
+    );
+    return { tokenSetOrder };
+}
+
+function buildThemesIndex(colorSchemes: ColorScheme[]): TokenSet[] {
+    const themesIndex: TokenSet[] = colorSchemes.map((scheme, index) => {
+        const id = `${scheme.isLight ? "light" : "dark"}_${scheme.name
+            .toLowerCase()
+            .replace(/\s+/g, "_")}_${index}`;
+        const selectedTokenSets: { [key: string]: "enabled" } = {};
+        const tokenSet = scheme.name.toLowerCase().replace(/\s+/g, "_");
+        selectedTokenSets[tokenSet] = "enabled";
+
+        return {
+            id,
+            name: `${scheme.name} - ${scheme.isLight ? "Light" : "Dark"}`,
+            selectedTokenSets,
+        };
+    });
+
+    return themesIndex;
+}
+
 function writeTokens(colorSchemes: ColorScheme[], tokensDirectory: string) {
-    clearTokens(tokensDirectory)
+    clearTokens(tokensDirectory);
 
     for (const colorScheme of colorSchemes) {
-        const fileName = slugify(colorScheme.name)
-        const tokens = colorSchemeTokens(colorScheme)
-        const tokensJSON = JSON.stringify(tokens, null, 2)
-        const outPath = path.join(tokensDirectory, `${fileName}.json`)
-        fs.writeFileSync(outPath, tokensJSON)
-        console.log(`- ${outPath} created`)
+        const fileName = slugify(colorScheme.name) + ".json";
+        const tokens = colorSchemeTokens(colorScheme);
+        const tokensJSON = JSON.stringify(tokens, null, 2);
+        const outPath = path.join(tokensDirectory, fileName);
+        fs.writeFileSync(outPath, tokensJSON, { mode: 0o644 });
+        console.log(`- ${outPath} created`);
     }
+
+    const themeIndexData = buildThemesIndex(colorSchemes);
+
+    const themesJSON = JSON.stringify(themeIndexData, null, 2);
+    fs.writeFileSync(TOKENS_FILE, themesJSON, { mode: 0o644 });
+    console.log(`- ${TOKENS_FILE} created`);
+
+    const tokenSetOrderData = buildTokenSetOrder(colorSchemes);
+
+    const metadataJSON = JSON.stringify(tokenSetOrderData, null, 2);
+    fs.writeFileSync(METADATA_FILE, metadataJSON, { mode: 0o644 });
+    console.log(`- ${METADATA_FILE} created`);
 }
 
 const colorSchemes: ColorScheme[] = themes.map((theme) =>
     createColorScheme(theme)
-)
+);
 
-writeTokens(colorSchemes, TOKENS_DIRECTORY)
+writeTokens(colorSchemes, TOKENS_DIRECTORY);

styles/src/theme/tokens/colorScheme.ts 🔗

@@ -1,12 +1,81 @@
-import { ColorScheme } from "../colorScheme"
-import { PlayerTokens, players } from "./players"
+import { SingleBoxShadowToken, SingleColorToken, SingleOtherToken, TokenTypes } from "@tokens-studio/types"
+import { ColorScheme, Shadow, SyntaxHighlightStyle, ThemeSyntax } from "../colorScheme"
+import { LayerToken, layerToken } from "./layer"
+import { PlayersToken, playersToken } from "./players"
+import { colorToken } from "./token"
+import { Syntax } from "../syntax";
+import editor from "../../styleTree/editor"
 
 interface ColorSchemeTokens {
-    players: PlayerTokens
+    name: SingleOtherToken
+    appearance: SingleOtherToken
+    lowest: LayerToken
+    middle: LayerToken
+    highest: LayerToken
+    players: PlayersToken
+    popoverShadow: SingleBoxShadowToken
+    modalShadow: SingleBoxShadowToken
+    syntax?: Partial<ThemeSyntaxColorTokens>
+}
+
+const createShadowToken = (shadow: Shadow, tokenName: string): SingleBoxShadowToken => {
+    return {
+        name: tokenName,
+        type: TokenTypes.BOX_SHADOW,
+        value: `${shadow.offset[0]}px ${shadow.offset[1]}px ${shadow.blur}px 0px ${shadow.color}`
+    };
+};
+
+const popoverShadowToken = (colorScheme: ColorScheme): SingleBoxShadowToken => {
+    const shadow = colorScheme.popoverShadow;
+    return createShadowToken(shadow, "popoverShadow");
+};
+
+const modalShadowToken = (colorScheme: ColorScheme): SingleBoxShadowToken => {
+    const shadow = colorScheme.modalShadow;
+    return createShadowToken(shadow, "modalShadow");
+};
+
+type ThemeSyntaxColorTokens = Record<keyof ThemeSyntax, SingleColorToken>
+
+function syntaxHighlightStyleColorTokens(syntax: Syntax): ThemeSyntaxColorTokens {
+    const styleKeys = Object.keys(syntax) as (keyof Syntax)[]
+
+    return styleKeys.reduce((acc, styleKey) => {
+        // Hack: The type of a style could be "Function"
+        // This can happen because we have a "constructor" property on the syntax object
+        // and a "constructor" property on the prototype of the syntax object
+        // To work around this just assert that the type of the style is not a function
+        if (!syntax[styleKey] || typeof syntax[styleKey] === 'function') return acc;
+        const { color } = syntax[styleKey] as Required<SyntaxHighlightStyle>;
+        return { ...acc, [styleKey]: colorToken(styleKey, color) };
+    }, {} as ThemeSyntaxColorTokens);
+}
+
+const syntaxTokens = (colorScheme: ColorScheme): ColorSchemeTokens['syntax'] => {
+    const syntax = editor(colorScheme).syntax
+
+    return syntaxHighlightStyleColorTokens(syntax)
 }
 
 export function colorSchemeTokens(colorScheme: ColorScheme): ColorSchemeTokens {
     return {
-        players: players(colorScheme),
+        name: {
+            name: "themeName",
+            value: colorScheme.name,
+            type: TokenTypes.OTHER,
+        },
+        appearance: {
+            name: "themeAppearance",
+            value: colorScheme.isLight ? "light" : "dark",
+            type: TokenTypes.OTHER,
+        },
+        lowest: layerToken(colorScheme.lowest, "lowest"),
+        middle: layerToken(colorScheme.middle, "middle"),
+        highest: layerToken(colorScheme.highest, "highest"),
+        popoverShadow: popoverShadowToken(colorScheme),
+        modalShadow: modalShadowToken(colorScheme),
+        players: playersToken(colorScheme),
+        syntax: syntaxTokens(colorScheme),
     }
 }

styles/src/theme/tokens/layer.ts 🔗

@@ -0,0 +1,60 @@
+import { SingleColorToken } from "@tokens-studio/types";
+import { Layer, Style, StyleSet } from "../colorScheme";
+import { colorToken } from "./token";
+
+interface StyleToken {
+    background: SingleColorToken,
+    border: SingleColorToken,
+    foreground: SingleColorToken,
+}
+
+interface StyleSetToken {
+    default: StyleToken
+    active: StyleToken
+    disabled: StyleToken
+    hovered: StyleToken
+    pressed: StyleToken
+    inverted: StyleToken
+}
+
+export interface LayerToken {
+    base: StyleSetToken
+    variant: StyleSetToken
+    on: StyleSetToken
+    accent: StyleSetToken
+    positive: StyleSetToken
+    warning: StyleSetToken
+    negative: StyleSetToken
+}
+
+export const styleToken = (style: Style, name: string): StyleToken => {
+    const token = {
+        background: colorToken(`${name}Background`, style.background),
+        border: colorToken(`${name}Border`, style.border),
+        foreground: colorToken(`${name}Foreground`, style.foreground),
+    }
+
+    return token
+}
+
+export const styleSetToken = (styleSet: StyleSet, name: string): StyleSetToken => {
+    const token: StyleSetToken = {} as StyleSetToken;
+
+    for (const style in styleSet) {
+        const s = style as keyof StyleSet;
+        token[s] = styleToken(styleSet[s], `${name}${style}`);
+    }
+
+    return token;
+}
+
+export const layerToken = (layer: Layer, name: string): LayerToken => {
+    const token: LayerToken = {} as LayerToken;
+
+    for (const styleSet in layer) {
+        const s = styleSet as keyof Layer;
+        token[s] = styleSetToken(layer[s], `${name}${styleSet}`);
+    }
+
+    return token;
+}

styles/src/theme/tokens/players.ts 🔗

@@ -4,7 +4,7 @@ import { colorToken } from "./token"
 
 export type PlayerToken = Record<"selection" | "cursor", SingleColorToken>
 
-export type PlayerTokens = Record<keyof Players, PlayerToken>
+export type PlayersToken = Record<keyof Players, PlayerToken>
 
 function buildPlayerToken(colorScheme: ColorScheme, index: number): PlayerToken {
 
@@ -16,7 +16,7 @@ function buildPlayerToken(colorScheme: ColorScheme, index: number): PlayerToken
     }
 }
 
-export const players = (colorScheme: ColorScheme): PlayerTokens => ({
+export const playersToken = (colorScheme: ColorScheme): PlayersToken => ({
     "0": buildPlayerToken(colorScheme, 0),
     "1": buildPlayerToken(colorScheme, 1),
     "2": buildPlayerToken(colorScheme, 2),