main
js 331 lines 8.61 KB
Raw
1 import os from "node:os"
2 import path from "node:path"
3 import process from "node:process"
4 import { cancel, intro, isCancel, outro, select, spinner, text } from "@clack/prompts"
5 import { colord } from "colord"
6 import fs from "fs-extra"
7 import _ from "lodash"
8
9 const GLOBAL_KEYS = ["border-radius", "line-heights", "font-sizes", "font-families", "font-weights"]
10 const TYPO_KEYS = ["typo"]
11 const COLOR_KEYS = ["color"]
12 const COLOR_OPACITY_SUFFIX_REGEX = /-\d{3}$/
13 const COLOR_OPACITY_LIST = [] // [5, 10, 15, 20, 30, 40, 50, 60, 70, 80, 90]
14 const TOKENS_MAP = [
15 {
16 token: "colors",
17 type: "color"
18 },
19 {
20 token: "fontFamily",
21 type: "fontFamilies"
22 },
23 {
24 token: "fontWeight",
25 type: "fontWeights"
26 },
27 {
28 token: "fontSize",
29 type: "fontSizes"
30 },
31 {
32 token: "lineHeight",
33 type: "lineHeights"
34 },
35 {
36 token: "typography",
37 type: "typo"
38 }
39 ]
40
41 const DESIGN_TOKEN_PATH = fs.pathExistsSync(path.join(process.cwd(), "src"))
42 ? path.join(process.cwd(), "src", "design-tokens.json")
43 : path.join(process.cwd(), "design-tokens.json")
44 const FIGMA_TOKEN_PATH = path.join(process.cwd(), "figma-tokens.json")
45
46 function getValue(origin, val) {
47 if (val && val.indexOf("{") === 0) {
48 const path = val.replace("{", "").replace("}", "")
49 return _.get(origin, path)
50 }
51
52 return val
53 }
54
55 /**
56 * Sanitizes a token or type name based on the provided mapping.
57 *
58 * This function converts a token name to a type name or vice versa based on the direction specified. It uses a predefined map to find the corresponding sanitized name.
59 *
60 * @param {string} name - The name of the token or type to be sanitized.
61 * @param {"token" | "type"} from - Indicates the current type of the name (`"token"` or `"type"`) to determine the direction of the conversion.
62 * @returns {string} The sanitized name, converted to the opposite type. If no mapping is found, returns the original name.
63 */
64 function tokenNameSanitize(name, from) {
65 const to = from === "token" ? "type" : "token"
66
67 const pair = TOKENS_MAP.find(o => o[from] === name)
68
69 if (!pair) return name
70
71 return pair[to]
72 }
73
74 /**
75 * Imports tokens from a JSON file and processes them into a project file format.
76 *
77 * This function reads a JSON file containing token definitions, normalizes paths, and organizes tokens into a project file structure. It handles global tokens, typography tokens, and set tokens, and writes the processed data to a specified design token path.
78 *
79 * @param {string} tokensPath - The path to the JSON file containing the tokens to be imported. The path can be relative or use `~` to refer to the home directory.
80 * @returns {string} The path to the design token file where the processed tokens have been written.
81 */
82 async function importTokens(tokensPath) {
83 const filePath = path.normalize(tokensPath.trim().replace("~/", `${os.homedir()}/`))
84 const tokens = await fs.readJSON(filePath)
85
86 const projectFile = {}
87
88 const globalTokens = tokens.global
89 const colorTokens = [
90 { key: "light", value: tokens.light },
91 { key: "dark", value: tokens.dark }
92 ]
93
94 for (const k in globalTokens) {
95 for (const gk of GLOBAL_KEYS) {
96 const kIndex = k.indexOf(gk)
97 if (kIndex !== -1) {
98 const gkParsed = tokenNameSanitize(_.camelCase(gk), "type")
99 const name = _.camelCase(k.replace(`${gk}-`, ""))
100
101 _.set(projectFile, `${gkParsed}.${name}`, globalTokens[k].value)
102 }
103 }
104 }
105
106 for (const k in globalTokens) {
107 for (const tk of TYPO_KEYS) {
108 const kIndex = k.indexOf(tk)
109 if (kIndex !== -1) {
110 const value = globalTokens[k].value
111 const element = _.split(k, "-")[1]
112 const tkParsed = tokenNameSanitize(_.camelCase(tk), "type")
113
114 for (const k in value) {
115 const prop = value[k]
116 if (prop.indexOf("{") === 0) {
117 const prefix = tokenNameSanitize(k, "token")
118 const ref = prop
119 .replace(`${_.kebabCase(prefix)}-`, "")
120 .replace("{", "")
121 .replace("}", "")
122 value[k] = `{${k}.${_.camelCase(ref)}}`
123 }
124 }
125
126 _.set(projectFile, `${tkParsed}.${element}`, value)
127 }
128 }
129 }
130
131 for (const set of colorTokens) {
132 const setName = set.key
133 const group = set.value
134
135 for (const k in group) {
136 for (const sk of COLOR_KEYS) {
137 // exclude opacity variants
138 if (k.includes(sk) && !COLOR_OPACITY_SUFFIX_REGEX.test(k)) {
139 const skParsed = tokenNameSanitize(_.camelCase(sk), "type")
140 const name = _.camelCase(k.replace(`${sk}-`, ""))
141 let value = group[k].value
142
143 if (value.indexOf("{") === 0) {
144 const ref = value.replace("{", "").replace("}", "")
145 const token = globalTokens[ref]
146 if (token?.value) {
147 value = token?.value
148 }
149 }
150
151 value = colord(value).toRgbString()
152
153 _.set(projectFile, `${skParsed}.${setName}.${name}`, value)
154 }
155 }
156 }
157 }
158
159 await fs.writeJSON(DESIGN_TOKEN_PATH, projectFile, { spaces: "\t" })
160
161 return DESIGN_TOKEN_PATH
162 }
163
164 /**
165 * Exports tokens from the design system and writes them to a JSON file.
166 *
167 * @returns {string} The path to the exported JSON file.
168 */
169 async function exportTokens() {
170 const tokens = await fs.readJSON(DESIGN_TOKEN_PATH)
171
172 const groups = _.chain(tokens)
173 .toPairs()
174 .map(([k, v]) => ({ key: k, value: v }))
175 .value()
176
177 const exportFile = {
178 global: {},
179 light: {},
180 dark: {}
181 }
182
183 const globalTokens = groups.filter(o => !["colors", "typography"].includes(o.key))
184 const colorTokens = groups.filter(o => ["colors"].includes(o.key))
185 const typoTokens = groups.filter(o => ["typography"].includes(o.key))
186
187 for (const group of globalTokens) {
188 const type = tokenNameSanitize(group.key, "token")
189
190 for (const name in group.value) {
191 const tokenName = _.kebabCase(`${type}-${name}`)
192
193 exportFile.global[tokenName] = {
194 value: group.value[name],
195 type
196 }
197 }
198 }
199
200 for (const group of colorTokens) {
201 const type = tokenNameSanitize(group.key, "token")
202 const set = group.value
203
204 for (const setName in set) {
205 for (const name in set[setName]) {
206 const globalName = _.kebabCase(`${type}-${setName}-${name}`)
207 const tokenName = _.kebabCase(`${type}-${name}`)
208
209 const value = colord(set[setName][name]).toRgbString()
210
211 exportFile.global[globalName] = {
212 value,
213 type
214 }
215
216 exportFile[setName][tokenName] = {
217 value: `{${globalName}}`,
218 type
219 }
220
221 for (const opacity of COLOR_OPACITY_LIST) {
222 const opacityName = opacity.toString().padStart(3, "0")
223 const globalNameOpacity = _.kebabCase(`${type}-${setName}-${name}-${opacityName}`)
224 const tokenNameOpacity = _.kebabCase(`${type}-${name}-${opacityName}`)
225 const valueOpacity = colord(value)
226 .alpha(opacity / 100)
227 .toRgbString()
228
229 exportFile.global[globalNameOpacity] = {
230 value: valueOpacity,
231 type
232 }
233
234 exportFile[setName][tokenNameOpacity] = {
235 value: `{${globalNameOpacity}}`,
236 type
237 }
238 }
239 }
240 }
241 }
242
243 for (const group of typoTokens) {
244 const type = group.key
245 const set = group.value
246
247 for (const setName in set) {
248 const globalName = `typo-${setName}`
249 const value = set[setName]
250 const newValue = {}
251
252 for (const k in value) {
253 const prop = value[k]
254 if (prop.indexOf("{") === 0) {
255 const ref = prop.replace("{", "").replace("}", "")
256 const path = _.split(ref, ".")[1]
257 const prefix = tokenNameSanitize(k, "token")
258 newValue[k] = `{${_.kebabCase(`${prefix}-${_.kebabCase(path)}`)}}`
259 } else {
260 newValue[k] = prop
261 }
262 }
263
264 // sanitize lineHeight for figma
265 if (value.fontSize && tokens?.lineHeight?.default) {
266 newValue.lineHeight = Math.round(
267 Number.parseInt(getValue(tokens, value.fontSize)) * Number.parseFloat(tokens.lineHeight.default)
268 ).toString()
269 }
270
271 exportFile.global[globalName] = {
272 value: newValue,
273 type
274 }
275 }
276 }
277
278 await fs.writeJSON(FIGMA_TOKEN_PATH, exportFile, { spaces: "\t" })
279
280 return FIGMA_TOKEN_PATH
281 }
282
283 async function main() {
284 console.log()
285 intro("Design tokens import/export tool")
286
287 const flowType = await select({
288 message: "Choose an action.",
289 options: [
290 { value: "import", label: "Import figma tokens" },
291 { value: "export", label: "Export figma json" }
292 ]
293 })
294
295 if (isCancel(flowType)) {
296 cancel("Operation cancelled")
297 return process.exit(0)
298 }
299
300 if (flowType === "import") {
301 const tokensPath = await text({
302 message: "Tokens json file path...",
303 placeholder: "~/Downloads/tokens.json"
304 })
305
306 if (isCancel(tokensPath)) {
307 cancel("Operation cancelled")
308 return process.exit(0)
309 }
310
311 const s = spinner()
312 s.start("Importing figma token file")
313
314 await importTokens(tokensPath)
315
316 s.stop("Figma token file imported")
317
318 outro(`Template tokens updated`)
319 } else {
320 const s = spinner()
321 s.start("Creating figma token file")
322
323 const filePath = await exportTokens()
324
325 s.stop("Figma token file created")
326
327 outro(`You can find it here: ${filePath}`)
328 }
329 }
330
331 main().catch(console.error)