Add foundation for exporting tokens from our color schemes (#2588)

Nate Butler created

We removed our Figma Tokens/Tokens Studio export a while back when we
moved to the theme to ColorSchemes. I'd like to get back to exporting
these so we can be working from up to date themes in Figma, especially
with the large amount of UI design work we'll be doing in the next few
weeks on channels.

This PR adds some basic plumbing to start working through the
theme/colorScheme and export the parts as tokens.

I also discovered that Tokens Studio now publishes their types, so we
can use them directly rather than writing our own:
https://github.com/tokens-studio/types

Pulled those in and started connecting them as well.

Running `npm run build-tokens` will export the tokens for each theme to
`styles/target/tokens`.

Currently only a few element's tokens are exported, will expand this
further as time permits.

Release Notes:

- N/A (No public facing changes)

Change summary

styles/package-lock.json               | 11 +++++++
styles/package.json                    |  4 ++
styles/src/buildTokens.ts              | 39 ++++++++++++++++++++++++++++
styles/src/theme/tokens/colorScheme.ts | 12 ++++++++
styles/src/theme/tokens/players.ts     | 28 ++++++++++++++++++++
styles/src/theme/tokens/token.ts       | 14 ++++++++++
styles/src/utils/slugify.ts            |  1 
7 files changed, 108 insertions(+), 1 deletion(-)

Detailed changes

styles/package-lock.json 🔗

@@ -9,6 +9,7 @@
             "version": "1.0.0",
             "license": "ISC",
             "dependencies": {
+                "@tokens-studio/types": "^0.2.3",
                 "@types/chroma-js": "^2.4.0",
                 "@types/node": "^18.14.1",
                 "ayu": "^8.0.1",
@@ -53,6 +54,11 @@
                 "@jridgewell/sourcemap-codec": "^1.4.10"
             }
         },
+        "node_modules/@tokens-studio/types": {
+            "version": "0.2.3",
+            "resolved": "https://registry.npmjs.org/@tokens-studio/types/-/types-0.2.3.tgz",
+            "integrity": "sha512-2KN3V0JPf+Zh8aoVMwykJq29Lsi7vYgKGYBQ/zQ+FbDEmrH6T/Vwn8kG7cvbTmW1JAAvgxVxMIivgC9PmFelNA=="
+        },
         "node_modules/@tsconfig/node10": {
             "version": "1.0.9",
             "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz",
@@ -271,6 +277,11 @@
                 "@jridgewell/sourcemap-codec": "^1.4.10"
             }
         },
+        "@tokens-studio/types": {
+            "version": "0.2.3",
+            "resolved": "https://registry.npmjs.org/@tokens-studio/types/-/types-0.2.3.tgz",
+            "integrity": "sha512-2KN3V0JPf+Zh8aoVMwykJq29Lsi7vYgKGYBQ/zQ+FbDEmrH6T/Vwn8kG7cvbTmW1JAAvgxVxMIivgC9PmFelNA=="
+        },
         "@tsconfig/node10": {
             "version": "1.0.9",
             "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz",

styles/package.json 🔗

@@ -5,11 +5,13 @@
     "main": "index.js",
     "scripts": {
         "build": "ts-node ./src/buildThemes.ts",
-        "build-licenses": "ts-node ./src/buildLicenses.ts"
+        "build-licenses": "ts-node ./src/buildLicenses.ts",
+        "build-tokens": "ts-node ./src/buildTokens.ts"
     },
     "author": "",
     "license": "ISC",
     "dependencies": {
+        "@tokens-studio/types": "^0.2.3",
         "@types/chroma-js": "^2.4.0",
         "@types/node": "^18.14.1",
         "ayu": "^8.0.1",

styles/src/buildTokens.ts 🔗

@@ -0,0 +1,39 @@
+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")
+
+function clearTokens(tokensDirectory: string) {
+    if (!fs.existsSync(tokensDirectory)) {
+        fs.mkdirSync(tokensDirectory, { recursive: true })
+    } else {
+        for (const file of fs.readdirSync(tokensDirectory)) {
+            if (file.endsWith(".json")) {
+                fs.unlinkSync(path.join(tokensDirectory, file))
+            }
+        }
+    }
+}
+
+function writeTokens(colorSchemes: ColorScheme[], tokensDirectory: string) {
+    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 colorSchemes: ColorScheme[] = themes.map((theme) =>
+    createColorScheme(theme)
+)
+
+writeTokens(colorSchemes, TOKENS_DIRECTORY)

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

@@ -0,0 +1,12 @@
+import { ColorScheme } from "../colorScheme"
+import { PlayerTokens, players } from "./players"
+
+interface ColorSchemeTokens {
+    players: PlayerTokens
+}
+
+export function colorSchemeTokens(colorScheme: ColorScheme): ColorSchemeTokens {
+    return {
+        players: players(colorScheme),
+    }
+}

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

@@ -0,0 +1,28 @@
+import { SingleColorToken } from "@tokens-studio/types"
+import { ColorScheme, Players } from "../../common"
+import { colorToken } from "./token"
+
+export type PlayerToken = Record<"selection" | "cursor", SingleColorToken>
+
+export type PlayerTokens = Record<keyof Players, PlayerToken>
+
+function buildPlayerToken(colorScheme: ColorScheme, index: number): PlayerToken {
+
+    const playerNumber = index.toString() as keyof Players
+
+    return {
+        selection: colorToken(`player${index}Selection`, colorScheme.players[playerNumber].selection),
+        cursor: colorToken(`player${index}Cursor`, colorScheme.players[playerNumber].cursor),
+    }
+}
+
+export const players = (colorScheme: ColorScheme): PlayerTokens => ({
+    "0": buildPlayerToken(colorScheme, 0),
+    "1": buildPlayerToken(colorScheme, 1),
+    "2": buildPlayerToken(colorScheme, 2),
+    "3": buildPlayerToken(colorScheme, 3),
+    "4": buildPlayerToken(colorScheme, 4),
+    "5": buildPlayerToken(colorScheme, 5),
+    "6": buildPlayerToken(colorScheme, 6),
+    "7": buildPlayerToken(colorScheme, 7)
+})

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

@@ -0,0 +1,14 @@
+import { SingleColorToken, TokenTypes } from "@tokens-studio/types"
+
+export function colorToken(name: string, value: string, description?: string): SingleColorToken {
+    const token: SingleColorToken = {
+        name,
+        type: TokenTypes.COLOR,
+        value,
+        description,
+    }
+
+    if (!token.value || token.value === '') throw new Error("Color token must have a value")
+
+    return token
+}

styles/src/utils/slugify.ts 🔗

@@ -0,0 +1 @@
+export function slugify(t: string): string { return t.toString().toLowerCase().replace(/\s+/g, '-').replace(/[^\w\-]+/g, '').replace(/\-\-+/g, '-').replace(/^-+/, '').replace(/-+$/, '') }