chore(ui,icons,ui-preset,toolbox): Move design system packages to monorepo (#5470)

This commit is contained in:
Kasper Fabricius Kristensen
2023-11-07 22:17:44 +01:00
committed by GitHub
parent 71853eafdd
commit e4ce2f4e07
722 changed files with 30300 additions and 186 deletions

View File

@@ -0,0 +1,3 @@
# @medusajs/toolbox
This package is a collection of CLI commands to generate Medusa UI design tokens and components. It is not intended for use outside of the Medusa monorepo.

View File

@@ -0,0 +1,2 @@
#!/usr/bin/env node
require("../dist/index.js")

View File

@@ -0,0 +1,33 @@
{
"name": "@medusajs/toolbox",
"private": true,
"version": "0.0.1",
"description": "CLI tool for importing Figma designs for Medusa UI",
"license": "MIT",
"author": "Kasper Kristensen <kasper@medusajs.com>",
"bin": "./bin/toolbox.js",
"scripts": {
"build": "tsup"
},
"dependencies": {
"@svgr/core": "8.0.0",
"@svgr/plugin-jsx": "8.0.1",
"@svgr/plugin-prettier": "8.0.1",
"@svgr/plugin-svgo": "8.0.1",
"axios": "^0.24.0",
"commander": "11.0.0",
"dotenv": "16.3.1",
"fs-extra": "11.1.1",
"picocolors": "^1.0.0",
"retry-axios": "^2.6.0",
"ts-dedent": "2.2.0"
},
"devDependencies": {
"@types/fs-extra": "11.0.1",
"@types/react": "^18.2.14",
"eslint": "7.32.0",
"react": "^18.2.0",
"tsup": "7.1.0",
"typescript": "5.1.6"
}
}

View File

@@ -0,0 +1,173 @@
import { client } from "@/figma-client"
import fse from "fs-extra"
import { extname, join, resolve } from "path"
import dedent from "ts-dedent"
import { generateIndex, getIconData } from "@/commands/icons/utils"
import { transformSvg } from "@/transformers"
import { FIGMA_FILE_ID, FIGMA_ICONS_NODE_ID } from "@/constants"
import { logger } from "@/logger"
type GenerateIconsArgs = {
output: string
}
// We don't want to generate icons for these frames as they are not optimized
const BANNED_FRAMES = ["Flags"]
export async function generateIcons({ output }: GenerateIconsArgs) {
const skippedIcons: string[] = []
// Ensure the destination directory exists
await fse.mkdirp(output)
logger.info("Fetching components from Figma")
const fileComponents = await client
.getFileComponents(FIGMA_FILE_ID)
.then((file) => {
logger.success("Successfully fetched components from Figma")
return file
})
.catch((_error) => {
logger.error("Failed to fetch components from Figma")
return null
})
if (!fileComponents) {
return
}
logger.info("Fetching URLs for SVGs")
const iconNodes = fileComponents.meta?.components.reduce((acc, component) => {
const frameInfo = component.containing_frame
if (!frameInfo) {
return acc
}
if (BANNED_FRAMES.includes(frameInfo.name)) {
return acc
}
if (frameInfo.pageId !== FIGMA_ICONS_NODE_ID) {
return acc
}
acc.push({
node_id: component.node_id,
name: component.name,
frame_name: frameInfo.name,
})
return acc
}, [] as { node_id: string; name: string; frame_name: string }[])
if (!iconNodes) {
logger.error(
"Found no SVGs to export. Make sure that the Figma file is correct."
)
return
}
const URLData = await client.getImage(FIGMA_FILE_ID, {
ids: iconNodes.map((icon) => icon.node_id),
format: "svg",
scale: 1,
})
logger.success("Successfully fetched URLs for SVGs")
const length = iconNodes.length
logger.info("Transforming SVGs")
for (let i = 0; i < length; i += 20) {
const slice = iconNodes.slice(i, i + 20)
const requests = slice.map(async (icon) => {
const URL = URLData.images[icon.node_id]
if (!URL) {
logger.warn(`Failed to fetch icon ${icon.name}. Skipping...`)
skippedIcons.push(icon.name)
return
}
let code: string | null = null
try {
code = await client.getResource(URL)
} catch (e) {
logger.warn(`Failed to fetch icon ${icon.name}. Skipping...`)
skippedIcons.push(icon.name)
}
if (!code) {
return
}
const { componentName, fileName, testName, fixed } = getIconData(
icon.name,
icon.frame_name
)
const component = await transformSvg({
code,
componentName,
fixed,
})
const filePath = resolve(output, fileName)
await fse.outputFile(filePath, component)
// Get fileName without extension
const ext = extname(fileName)
const fileNameWithoutExt = fileName.replace(ext, "")
// Generate a test file for the icon
const testFilePath = resolve(join(output, "__tests__"), testName)
const testFile = dedent`
import * as React from "react"
import { cleanup, render, screen } from "@testing-library/react"
import ${componentName} from "../${fileNameWithoutExt}"
describe("${componentName}", () => {
it("should render the icon without errors", async () => {
render(<${componentName} data-testid="icon" />)
const svgElement = screen.getByTestId("icon")
expect(svgElement).toBeInTheDocument()
cleanup()
})
})
`
await fse.outputFile(testFilePath, testFile)
})
await Promise.all(requests)
}
logger.success("Successfully transformed SVGs")
if (skippedIcons.length) {
logger.warn(
`Skipped ${skippedIcons.length} icons. Check the logs for more information.`
)
}
logger.info("Generating index file")
await generateIndex(output)
logger.success("Successfully generated index file")
logger.success(`Successfully generated ${length} icons ✨`)
}

View File

@@ -0,0 +1,30 @@
import fse from "fs-extra"
import os from "os"
import { resolve } from "path"
import dedent from "ts-dedent"
import { getComponentName } from "@/commands/icons/utils"
const BANNER =
dedent`
// This file is generated automatically.
` + os.EOL
export async function generateIndex(path: string) {
let index = BANNER
const entries = await fse.readdir(path)
for (const entry of entries) {
if (entry === "index.ts" || entry === "__tests__") {
continue
}
const name = entry.replace(/\.tsx?$/, "")
const exportName = getComponentName(name)
index += `export { default as ${exportName} } from "./${name}"${os.EOL}`
}
await fse.writeFile(resolve(path, "index.ts"), index)
}

View File

@@ -0,0 +1,47 @@
export function getComponentName(name: string) {
return name
.replace(/[-_]+/g, " ")
.replace(/[^\w\s]/g, "")
.replace(
/\s+(.)(\w*)/g,
(_$1, $2, $3) => `${$2.toUpperCase() + $3.toLowerCase()}`
)
.replace(/\w/, (s) => s.toUpperCase())
}
function getFileName(name: string) {
return `${name.replace("$", "").replace("/", "-")}.tsx`
}
function getTestName(name: string) {
return `${name.replace("$", "").replace("/", "-")}.spec.tsx`
}
const FIXED_FRAMES = ["Flags", "Brands"]
function isFixedIcon(name: string, frame_name: string) {
if (FIXED_FRAMES.includes(frame_name)) {
if (frame_name === "Brands" && name.includes("-ex")) {
return false
}
return true
}
return false
}
export function getIconData(name: string, frame_name: string) {
const componentName = getComponentName(name)
const fileName = getFileName(name)
const testName = getTestName(name)
const fixed = isFixedIcon(name, frame_name)
return {
componentName,
testName,
fileName,
fixed,
}
}

View File

@@ -0,0 +1,2 @@
export * from "./generate-index"
export * from "./get-icon-data"

View File

@@ -0,0 +1,458 @@
import fse from "fs-extra"
import path from "path"
import type { CSSProperties } from "react"
import { Node, PaintGradient, PaintType } from "../../figma"
import {
FIGMA_FILE_ID,
FONT_FAMILY_MONO,
FONT_FAMILY_SANS,
} from "../../constants"
import { client } from "../../figma-client"
import { logger } from "../../logger"
import {
colorToRGBA,
createLinearGradientComponent,
gradientValues,
} from "./utils/colors"
import { createDropShadowVariable } from "./utils/effects"
type GenerateTokensArgs = {
output: string
}
type Tokens = {
colors: {
dark: Record<string, string>
light: Record<string, string>
}
effects: {
dark: Record<string, string>
light: Record<string, string>
}
components: {
dark: Record<string, CSSProperties>
light: Record<string, CSSProperties>
}
}
export async function generateTokens({ output }: GenerateTokensArgs) {
logger.info("Fetching file styles")
const res = await client.getFileStyles(FIGMA_FILE_ID).catch((err) => {
logger.error(`Failed to fetch file styles: ${err.message}`)
process.exit(1)
})
logger.success("Fetched file styles successfully")
const colorNodeIds: string[] = []
const textNodeIds: string[] = []
const effectNodeIds: string[] = []
res.meta?.styles.forEach((style) => {
if (style.style_type === "FILL") {
colorNodeIds.push(style.node_id)
}
if (style.style_type === "TEXT") {
textNodeIds.push(style.node_id)
}
if (style.style_type === "EFFECT") {
effectNodeIds.push(style.node_id)
}
})
logger.info("Fetching file nodes")
const [colorStyles, textStyles, effectStyles] = await Promise.all([
client.getFileNodes<"RECTANGLE">(FIGMA_FILE_ID, {
ids: colorNodeIds,
}),
client.getFileNodes(FIGMA_FILE_ID, {
ids: textNodeIds,
}),
client.getFileNodes<"RECTANGLE">(FIGMA_FILE_ID, {
ids: effectNodeIds,
}),
])
.catch((err) => {
logger.error(`Failed to fetch file nodes: ${err.message}`)
process.exit(1)
})
.finally(() => {
logger.success("Fetched file nodes successfully")
})
const themeNode: Tokens = {
colors: {
dark: {},
light: {},
},
effects: {
dark: {},
light: {},
},
components: {
dark: {},
light: {},
},
}
const typo = Object.values(textStyles.nodes).reduce((acc, curr) => {
if (!curr) {
return acc
}
const node = curr.document as unknown as Node<"TEXT">
const isText = node.name.startsWith("Text")
if (isText) {
const [_parent, identifier] = node.name.split("/")
const { lineHeightPx, fontWeight, fontSize } = node.style
const name = "." + identifier.toLowerCase().replace("text", "txt")
const style: CSSProperties = {
fontSize: `${fontSize / 16}rem`,
lineHeight: `${lineHeightPx / 16}rem`,
fontWeight: `${fontWeight}`,
fontFamily: FONT_FAMILY_SANS.join(", "),
}
acc[name] = style
return acc
}
const isHeader = node.name.startsWith("Headers")
if (isHeader) {
const [theme, identifier] = node.name.split("/")
const formattedTheme = theme.toLowerCase().replace("headers ", "")
const formattedIdentifier = identifier.toLowerCase()
const name = "." + `${formattedIdentifier}-${formattedTheme}`
const { lineHeightPx, fontSize, fontWeight } = node.style
const style: CSSProperties = {
fontSize: `${fontSize / 16}rem`,
lineHeight: `${lineHeightPx / 16}rem`,
fontWeight: `${fontWeight}`,
fontFamily: FONT_FAMILY_SANS.join(", "),
}
acc[name] = style
return acc
}
const isCodeBlock = node.name.startsWith("Code Block")
if (isCodeBlock) {
const [_parent, identifier] = node.name.split("/")
const formattedIdentifier = "." + "code-" + identifier.toLowerCase()
const { lineHeightPx, fontSize, fontWeight } = node.style
const style: CSSProperties = {
fontSize: `${fontSize / 16}rem`,
lineHeight: `${lineHeightPx / 16}rem`,
fontWeight: `${fontWeight}`,
fontFamily: FONT_FAMILY_MONO.join(", "),
}
acc[formattedIdentifier] = style
return acc
}
return acc
}, {} as Record<string, CSSProperties>)
const typoPath = path.join(output, "tokens", "typography.ts")
logger.info(`Writing typography tokens to file`)
await fse
.outputFile(
typoPath,
`export const typography = ${JSON.stringify(typo, null, 2)}`
)
.then(() => {
logger.success(`Typography tokens written to file successfully`)
})
.catch((err) => {
logger.error(`Failed to write typography tokens to file: ${err.message}`)
process.exit(1)
})
Object.values(colorStyles.nodes).reduce((acc, curr) => {
if (!curr) {
return acc
}
const [theme, _, variable] = curr.document.name.split("/")
const lowerCaseTheme = theme.toLowerCase()
if (lowerCaseTheme !== "light" && lowerCaseTheme !== "dark") {
return acc
}
const fills = curr.document.fills
const solid = fills.find((fill) => fill.type === "SOLID")
const gradient = fills.find((fill) => fill.type === "GRADIENT_LINEAR")
if (!solid && !gradient) {
return acc
}
const solidVariable = `--${variable}`
const gradientIdentifier = `${variable}-gradient`
if (solid && solid.color) {
acc["colors"][lowerCaseTheme][solidVariable] = colorToRGBA(
solid.color,
solid.opacity
)
}
if (gradient) {
const values = gradientValues(gradient as PaintGradient)
if (values) {
if (values.type === PaintType.GRADIENT_LINEAR) {
const toVariable = `--${gradientIdentifier}-to`
const fromVariable = `--${gradientIdentifier}-from`
const component = createLinearGradientComponent({
...values,
to: toVariable,
from: fromVariable,
})
if (component) {
acc["colors"][lowerCaseTheme][fromVariable] = values.from
acc["colors"][lowerCaseTheme][toVariable] = values.to
acc["components"][lowerCaseTheme][`.${gradientIdentifier}`] =
component
}
} else {
logger.warn(`Unsupported gradient type: ${values.type}`)
}
}
}
return acc
}, themeNode)
Object.values(effectStyles.nodes).reduce((acc, curr) => {
if (!curr) {
return acc
}
const [theme, type, variable] = curr.document.name.split("/")
if (!type || !variable) {
return acc
}
const lowerCaseTheme = theme.toLowerCase()
const lowerCaseType = type.toLowerCase()
if (lowerCaseTheme !== "light" && lowerCaseTheme !== "dark") {
return acc
}
const effects = curr.document.effects
if (!effects) {
return acc
}
const identifier = `--${lowerCaseType}-${variable}`
/**
* Figma returns effects in reverse order
* so we need to reverse them back to get the correct order
*/
const reversedEffects = effects.reverse()
const value = createDropShadowVariable(reversedEffects, identifier)
if (!value) {
return acc
}
acc["effects"][lowerCaseTheme][identifier] = value
return acc
}, themeNode)
logger.info("Writing tokens to files")
logger.info("Writing colors to file")
const colorTokensPath = path.join(output, "tokens", "colors.ts")
await fse
.outputFile(
colorTokensPath,
`export const colors = ${JSON.stringify(themeNode.colors, null, 2)}`
)
.then(() => {
logger.success("Wrote colors to file successfully")
})
.catch((err) => {
logger.error(`Failed to write colors to file: ${err.message}`)
process.exit(1)
})
logger.info("Writing effects to file")
const effectTokensPath = path.join(output, "tokens", "effects.ts")
await fse
.outputFile(
effectTokensPath,
`export const effects = ${JSON.stringify(themeNode.effects, null, 2)}`
)
.then(() => {
logger.success("Wrote effects to file successfully")
})
.catch((err) => {
logger.error(`Failed to write effects to file: ${err.message}`)
process.exit(1)
})
logger.info("Writing components to file")
const componentTokensPath = path.join(output, "tokens", "components.ts")
await fse
.outputFile(
componentTokensPath,
`export const components = ${JSON.stringify(
themeNode.components,
null,
2
)}`
)
.then(() => {
logger.success("Wrote components to file successfully")
})
.catch((err) => {
logger.error(`Failed to write components to file: ${err.message}`)
process.exit(1)
})
logger.success("Wrote tokens to files successfully")
logger.info("Extending Tailwind config")
const colorsExtension: Record<string, any> = {}
Object.keys(themeNode.colors.light).reduce((acc, curr) => {
const [prefix, style, state, context, ...others] =
curr.split(/(?<=\w)-(?=\w)/)
if (
state === "gradient" ||
context === "gradient" ||
(others.length > 0 && others.includes("gradient"))
) {
// We don't want to add gradients to the tailwind config, as they are added as components
return acc
}
const fixedPrefix = prefix.replace("--", "")
if (!acc[fixedPrefix]) {
acc[fixedPrefix] = {}
}
if (!acc[fixedPrefix][style]) {
acc[fixedPrefix][style] = {}
}
if (!state) {
acc[fixedPrefix][style] = {
...acc[fixedPrefix][style],
DEFAULT: `var(${curr})`,
}
return acc
}
if (!acc[fixedPrefix][style][state]) {
acc[fixedPrefix][style][state] = {}
}
if (!context) {
acc[fixedPrefix][style][state] = {
...acc[fixedPrefix][style][state],
DEFAULT: `var(${curr})`,
}
return acc
}
if (context === "gradient") {
// We don't want to add gradients to the tailwind config, as they are added as components
return acc
}
if (!acc[fixedPrefix][style][state][context]) {
acc[fixedPrefix][style][state][context] = {}
}
acc[fixedPrefix][style][state][context] = {
...acc[fixedPrefix][style][state][context],
DEFAULT: `var(${curr})`,
}
return acc
}, colorsExtension)
const boxShadowExtension: Record<string, any> = {}
Object.keys(themeNode.effects.light).reduce((acc, curr) => {
const key = `${curr.replace("--", "")}`
acc[key] = `var(${curr})`
return acc
}, boxShadowExtension)
const themeExtension = {
extend: {
colors: {
ui: colorsExtension,
},
boxShadow: boxShadowExtension,
},
}
const tailwindConfigPath = path.join(output, "extension", "theme.ts")
await fse
.outputFile(
tailwindConfigPath,
`export const theme = ${JSON.stringify(themeExtension, null, 2)}`
)
.then(() => {
logger.success("Wrote Tailwind config extension successfully")
})
.catch((err) => {
logger.error(`Failed to write Tailwind config extension: ${err.message}`)
process.exit(1)
})
logger.success("Extended Tailwind config successfully")
// TODO: Add text styles
}

View File

@@ -0,0 +1,151 @@
import { PaintType, type Color, type PaintGradient, type Vector } from "@/figma"
import { logger } from "@/logger"
import type { CSSProperties } from "react"
/**
* Normalizes a color's opacity to a 0-1 range.
* @param opacity The opacity to normalize.
* @returns The normalized opacity.
*/
function normalizeOpacity(opacity?: number) {
opacity = opacity !== undefined ? opacity : 1
return Math.round(opacity * 100) / 100
}
/**
* Normalizes a channel value to a 0-255 range.
* @param value The channel value to normalize.
* @returns The normalized channel value.
*/
function normalizeChannelValue(value: number) {
return Math.round(value * 255)
}
/**
* Converts a Color to an RGBA string.
* @param color The color to convert to RGBA.
* @param opacity The opacity to apply to the color.
* @returns The RGBA string.
*/
function colorToRGBA(color: Color, opacity?: number): string {
const red = normalizeChannelValue(color.r)
const green = normalizeChannelValue(color.g)
const blue = normalizeChannelValue(color.b)
/**
* How Figma returns opacity for colors is a bit weird.
* They always return the alpha channel as 1, even if the color is less than solid.
* Instead, they return the opacity in a seperate opacity property.
* So we need to check if the opacity is defined, and if it is,
* use that for the alpha channel instead.
*/
const alpha =
opacity !== undefined
? normalizeOpacity(opacity)
: Math.round(color.a * 100) / 100
return `rgba(${red}, ${green}, ${blue}, ${alpha})`
}
/**
* Calculates the gradient degree of a gradient.
* @param handlebarPositions The handlebar positions of the gradient.
* @returns The gradient degree.
*/
function calculateGradientDegree(handlebarPositions: Vector[]): number {
const startPoint = handlebarPositions[0]
const endPoint = handlebarPositions[1]
const angleRadians = Math.atan2(
endPoint.y - startPoint.y,
endPoint.x - startPoint.x
)
const angleDegrees = (angleRadians * 180) / Math.PI
const normalizedAngleDegrees = (angleDegrees + 360) % 360
// Rotate the angle by 90 degrees to get the correct angle for CSS gradients
const rotatedAngleDegrees = normalizedAngleDegrees + 90
return rotatedAngleDegrees
}
interface GradientValues {
type: PaintType
}
interface LinearGradientValues extends GradientValues {
type: PaintType.GRADIENT_LINEAR
opacity: number
degree: number
from: string
to: string
}
/**
* Get the values of a linear gradient.
* @param gradient
* @returns
*/
function linearGradientValues(gradient: PaintGradient): LinearGradientValues {
const opacity = normalizeOpacity(gradient.opacity) * 100
const degree = calculateGradientDegree(gradient.gradientHandlePositions)
const from = colorToRGBA(gradient.gradientStops[0].color)
const to = colorToRGBA(gradient.gradientStops[1].color)
return {
type: gradient.type as PaintType.GRADIENT_LINEAR,
opacity,
degree,
from,
to,
}
}
interface CreateGradientComponentProps {
type: PaintType
}
interface CreateLinearGradientComponentProps
extends CreateGradientComponentProps {
type: PaintType.GRADIENT_LINEAR
degree: number
from: string
to: string
opacity: number
}
/**
* Create a CSSProperties object for a linear gradient.
* @param props
* @returns
*/
function createLinearGradientComponent({
degree,
from,
to,
opacity,
}: CreateLinearGradientComponentProps): CSSProperties {
return {
backgroundImage: `linear-gradient(${degree}deg, var(${from}), var(${to}))`,
opacity: `${opacity}%`,
}
}
/**
* Get the values of a gradient based on its type.
* @param gradient
* @returns
*/
function gradientValues(gradient: PaintGradient) {
if (gradient.type === PaintType.GRADIENT_LINEAR) {
return linearGradientValues(gradient)
}
logger.warn(`The gradient type "${gradient.type}" is not supported.`)
return null
}
export { colorToRGBA, createLinearGradientComponent, gradientValues }

View File

@@ -0,0 +1,58 @@
import { Effect } from "@/figma"
import { colorToRGBA } from "./colors"
/**
* We know that we will need to correct the Y value of the inset shadows
* on these effects due to the difference in the way Figma and CSS
* handle shadows.
*/
const SPECIAL_IDENTIFIERS = [
"--buttons-colored",
"--buttons-neutral",
"--buttons-neutral-focus",
"--buttons-colored-focus",
]
function createDropShadowVariable(effects: Effect[], identifier: string) {
const shadows = effects.filter(
(effect) => effect.type === "DROP_SHADOW" || effect.type === "INNER_SHADOW"
)
if (shadows.length === 0) {
return null
}
const value = shadows
.map((shadow) => {
const { color, offset, radius, spread, type } = shadow
const x = offset?.x ?? 0
let y = offset?.y ?? 0
if (
SPECIAL_IDENTIFIERS.includes(identifier) &&
type === "INNER_SHADOW" &&
y > 0
) {
y = y - 1
}
const b = radius
const s = spread ?? 0
const c = color ? colorToRGBA(color) : ""
const t = type === "INNER_SHADOW" ? "inset" : ""
return `${x}px ${y}px ${b}px ${s}px ${c} ${t}`.trim()
})
.join(", ")
if (value.length === 0) {
return null
}
return value
}
export { createDropShadowVariable }

View File

@@ -0,0 +1,32 @@
export const FIGMA_FILE_ID = "TW0kRpjhpsi3sR1u4a4wF8"
export const FIGMA_ICONS_NODE_ID = "109:599"
export const FONT_FAMILY_SANS = [
"Inter",
"ui-sans-serif",
"system-ui",
"-apple-system",
"BlinkMacSystemFont",
"Segoe UI",
"Roboto",
"Helvetica Neue",
"Arial",
"Noto Sans",
"sans-serif",
"Apple Color Emoji",
"Segoe UI Emoji",
"Segoe UI Symbol",
"Noto Color Emoji",
]
export const FONT_FAMILY_MONO = [
"Roboto Mono",
"ui-monospace",
"SFMono-Regular",
"Menlo",
"Monaco",
"Consolas",
"Liberation Mono",
"Courier New",
"monospace",
]

View File

@@ -0,0 +1,31 @@
import { generateIcons } from "@/commands/icons/command"
import { Command } from "commander"
import { generateTokens } from "@/commands/tokens/command"
import pkg from "../package.json"
export async function createCli() {
const program = new Command()
program.name("toolbox").version(pkg.version)
// Icon
const generateIconsCommand = program.command("icons")
generateIconsCommand.description("Generate icons from Figma")
generateIconsCommand.option("-o, --output <path>", "Output directory")
generateIconsCommand.action(generateIcons)
// Color tokens
const generateTokensCommand = program.command("tokens")
generateTokensCommand.description("Generate tokens from Figma")
generateTokensCommand.option("-o, --output <path>", "Output directory")
generateTokensCommand.action(generateTokens)
return program
}

View File

@@ -0,0 +1,16 @@
import dotenv from "dotenv"
import { resolve } from "path"
import Figma from "./figma"
dotenv.config({ path: resolve(process.cwd(), ".env") })
const accessToken = process.env.FIGMA_TOKEN || ""
if (!accessToken) {
throw new Error("FIGMA_TOKEN is not defined")
}
export const client = new Figma({
accessToken: accessToken,
maxRetries: 3,
})

View File

@@ -0,0 +1,42 @@
import {
Effect,
EffectBlur,
EffectShadow,
EffectType,
Paint,
PaintGradient,
PaintImage,
PaintSolid,
PaintType,
} from "./types"
export function isEffectShadow(effect: Effect): effect is EffectShadow {
return (
effect.type === EffectType.DROP_SHADOW ||
effect.type === EffectType.INNER_SHADOW
)
}
export function isEffectBlur(effect: Effect): effect is EffectBlur {
return (
effect.type === EffectType.BACKGROUND_BLUR ||
effect.type === EffectType.LAYER_BLUR
)
}
export function isPaintSolid(paint: Paint): paint is PaintSolid {
return paint.type === PaintType.SOLID
}
export function isPaintGradient(paint: Paint): paint is PaintGradient {
return (
paint.type === PaintType.GRADIENT_ANGULAR ||
paint.type === PaintType.GRADIENT_DIAMOND ||
paint.type === PaintType.GRADIENT_LINEAR ||
paint.type === PaintType.GRADIENT_RADIAL
)
}
export function isPaintImage(paint: Paint): paint is PaintImage {
return paint.type === PaintType.IMAGE
}

View File

@@ -0,0 +1,56 @@
import axios, { AxiosInstance, AxiosRequestConfig, Method } from "axios"
import axiosRetry from "axios-retry"
export type ClientArgs = {
accessToken: string
baseURL: string
maxRetries?: number
}
class Client {
private _axios: AxiosInstance
constructor({ accessToken, baseURL, maxRetries = 3 }: ClientArgs) {
const instance = axios.create({
baseURL,
headers: {
"X-FIGMA-TOKEN": accessToken,
},
})
// Typecast to any because our monorepo is using an older version of axios-retry and axios
axiosRetry(instance as any, {
retries: maxRetries,
retryDelay: axiosRetry.exponentialDelay,
})
this._axios = instance
}
async request(
method: Method,
url: string,
payload: Record<string, any> = {},
config?: AxiosRequestConfig
) {
const requestConfig: AxiosRequestConfig = {
method,
url,
...config,
}
if (["POST", "DELETE"].includes(method)) {
requestConfig.data = payload
}
const response = await this._axios.request(requestConfig)
if (Math.floor(response.status / 100) !== 2) {
throw response.statusText
}
return response.data
}
}
export default Client

View File

@@ -0,0 +1,308 @@
import axios from "axios"
import Client, { ClientArgs } from "./client"
import {
FrameOffset,
GetCommentsResult,
GetComponentResult,
GetComponentSetResult,
GetFileComponentSetsResult,
GetFileComponentsResult,
GetFileNodesResult,
GetFileResult,
GetFileStylesResult,
GetImageFillsResult,
GetImageResult,
GetProjectFilesResult,
GetStyleResult,
GetTeamComponentSetsResult,
GetTeamComponentsResult,
GetTeamProjectsResult,
GetTeamStylesResult,
GetVersionsResult,
NodeType,
PostCommentResult,
Vector,
} from "./types"
import { encodeQuery } from "./utils"
type FigmaArgs = Omit<ClientArgs, "baseURL">
const FIGMA_BASE_URL = "https://api.figma.com/v1/"
class Figma {
private _api: Client
constructor({ accessToken, maxRetries = 3 }: FigmaArgs) {
this._api = new Client({
accessToken,
baseURL: FIGMA_BASE_URL,
maxRetries,
})
}
/**
* Get a resource by its URL.
*/
async getResource(url: string) {
const response = await axios.get(url)
if (Math.floor(response.status / 100) !== 2) {
throw response.statusText
}
return response.data
}
async getFile(
file_key: string,
options?: {
/** A specific version ID to get. Omitting this will get the current version of the file */
version?: string
/** If specified, only a subset of the document will be returned corresponding to the nodes listed, their children, and everything between the root node and the listed nodes */
ids?: string[]
/** Positive integer representing how deep into the document tree to traverse. For example, setting this to 1 returns only Pages, setting it to 2 returns Pages and all top level objects on each page. Not setting this parameter returns all nodes */
depth?: number
/** Set to "paths" to export vector data */
geometry?: "paths"
/** A comma separated list of plugin IDs and/or the string "shared". Any data present in the document written by those plugins will be included in the result in the `pluginData` and `sharedPluginData` properties. */
plugin_data?: string
/** Set to returns branch metadata for the requested file */
branch_data?: boolean
}
): Promise<GetFileResult> {
const queryString = options
? `?${encodeQuery({
...options,
ids: options.ids && options.ids.join(","),
})}`
: ""
return this._api.request("GET", `files/${file_key}${queryString}`)
}
async getFileNodes<TNode extends NodeType = "DOCUMENT">(
file_key: string,
options: {
/** A comma separated list of node IDs to retrieve and convert */
ids: string[]
/** A specific version ID to get. Omitting this will get the current version of the file */
version?: string
/** Positive integer representing how deep into the document tree to traverse. For example, setting this to 1 returns only Pages, setting it to 2 returns Pages and all top level objects on each page. Not setting this parameter returns all nodes */
depth?: number
/** Set to "paths" to export vector data */
geometry?: "paths"
/** A comma separated list of plugin IDs and/or the string "shared". Any data present in the document written by those plugins will be included in the result in the `pluginData` and `sharedPluginData` properties. */
plugin_data?: string
}
): Promise<GetFileNodesResult<TNode>> {
const queryString = `?${encodeQuery({
...options,
ids: options.ids.join(","),
})}`
return this._api.request("GET", `files/${file_key}/nodes${queryString}`)
}
async getImage(
file_key: string,
options: {
/** A comma separated list of node IDs to render */
ids: string[]
/** A number between 0.01 and 4, the image scaling factor */
scale: number
/** A string enum for the image output format */
format: "jpg" | "png" | "svg" | "pdf"
/** Whether to include id attributes for all SVG elements. `Default: false` */
svg_include_id?: boolean
/** Whether to simplify inside/outside strokes and use stroke attribute if possible instead of <mask>. `Default: true` */
svg_simplify_stroke?: boolean
/** Use the full dimensions of the node regardless of whether or not it is cropped or the space around it is empty. Use this to export text nodes without cropping. `Default: false` */
use_absolute_bounds?: boolean
/** A specific version ID to get. Omitting this will get the current version of the file */
version?: string
}
): Promise<GetImageResult> {
const queryString = options
? `?${encodeQuery({
...options,
ids: options.ids && options.ids.join(","),
})}`
: ""
return this._api.request("GET", `images/${file_key}${queryString}`)
}
async getImageFills(file_key: string): Promise<GetImageFillsResult> {
return this._api.request("GET", `files/${file_key}/images`)
}
async getComments(file_key: string): Promise<GetCommentsResult> {
return this._api.request("GET", `files/${file_key}/comments`)
}
async postComment(
file_key: string,
/** The text contents of the comment to post */
message: string,
/** The position of where to place the comment. This can either be an absolute canvas position or the relative position within a frame. */
client_meta: Vector | FrameOffset,
/** (Optional) The comment to reply to, if any. This must be a root comment, that is, you cannot reply to a comment that is a reply itself (a reply has a parent_id). */
comment_id?: string
): Promise<PostCommentResult> {
return this._api.request("POST", `files/${file_key}/comments`, {
message,
client_meta,
comment_id,
})
}
async deleteComment(file_key: string, comment_id: string): Promise<void> {
return this._api.request(
"DELETE",
`files/${file_key}/comments/${comment_id}`
)
}
async getVersions(file_key: string): Promise<GetVersionsResult> {
return this._api.request("GET", `files/${file_key}/versions`)
}
async getTeamProjects(teamId: string): Promise<GetTeamProjectsResult> {
return this._api.request("GET", `teams/${teamId}/projects`)
}
async getProjectFiles(
project_id: string,
options: {
/** Set to returns branch metadata for the requested file */
branch_data?: boolean
}
): Promise<GetProjectFilesResult> {
const queryString = options
? `?${encodeQuery({
...options,
})}`
: ""
return this._api.request(
"GET",
`projects/${project_id}/files${queryString}`
)
}
/**
* Get a paginated list of published components within a team library
*/
async getTeamComponents(
team_id: string,
options: {
/** Number of items in a paged list of results. Defaults to 30. */
page_size?: number
/** Cursor indicating which id after which to start retrieving components for. Exclusive with before. The cursor value is an internally tracked integer that doesn't correspond to any Ids */
after?: number
/** Cursor indicating which id before which to start retrieving components for. Exclusive with after. The cursor value is an internally tracked integer that doesn't correspond to any Ids */
before?: number
}
): Promise<GetTeamComponentsResult> {
const queryString = options ? `?${encodeQuery(options)}` : ""
return this._api.request("GET", `teams/${team_id}/components${queryString}`)
}
/**
* Get a list of published components within a file library
*/
async getFileComponents(file_key: string): Promise<GetFileComponentsResult> {
return this._api.request("GET", `files/${file_key}/components`)
}
/**
* Get metadata on a component by key.
*/
async getComponent(component_key: string): Promise<GetComponentResult> {
return this._api.request("GET", `components/${component_key}`)
}
/**
* Get a paginated list of published component_sets within a team library
*/
async getTeamComponentSets(
team_id: string,
options: {
/** Number of items in a paged list of results. Defaults to 30. */
page_size?: number
/** Cursor indicating which id after which to start retrieving component sets for. Exclusive with before. The cursor value is an internally tracked integer that doesn't correspond to any Ids */
after?: number
/** Cursor indicating which id before which to start retrieving component sets for. Exclusive with after. The cursor value is an internally tracked integer that doesn't correspond to any Ids */
before?: number
}
): Promise<GetTeamComponentSetsResult> {
const queryString = options ? `?${encodeQuery(options)}` : ""
return this._api.request(
"GET",
`teams/${team_id}/component_sets${queryString}`
)
}
async getFileComponentSets(
file_key: string
): Promise<GetFileComponentSetsResult> {
return this._api.request("GET", `files/${file_key}/component_sets`)
}
/**
* Get metadata on a component_set by key.
*/
async getComponentSet(key: string): Promise<GetComponentSetResult> {
return this._api.request("GET", `component_sets/${key}`)
}
/**
* Get a paginated list of published styles within a team library
*/
async getTeamStyles(
team_id: string,
options: {
/** Number of items in a paged list of results. Defaults to 30. */
page_size?: number
/** Cursor indicating which id after which to start retrieving styles for. Exclusive with before. The cursor value is an internally tracked integer that doesn't correspond to any Ids */
after?: number
/** Cursor indicating which id before which to start retrieving styles for. Exclusive with after. The cursor value is an internally tracked integer that doesn't correspond to any Ids */
before?: number
}
): Promise<GetTeamStylesResult> {
const queryString = options ? `?${encodeQuery(options)}` : ""
return this._api.request("GET", `teams/${team_id}/styles${queryString}`)
}
/**
* Get a list of published styles within a file library
*/
async getFileStyles(file_key: string): Promise<GetFileStylesResult> {
return this._api.request("GET", `files/${file_key}/styles`)
}
/**
* Get metadata on a style by key.
*/
async getStyle(key: string): Promise<GetStyleResult> {
return this._api.request("GET", `styles/${key}`)
}
// Variables - Beta API (TODO)
// Webhooks - Beta API (TODO)
// Activity Logs - Beta API (TODO)
// Payments - Beta API (TODO)
// Dev Resources - Beta API (TODO)
}
export * from "./assertions"
export * from "./types"
export default Figma

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
import { Node, NodeType } from "./types"
/**
* Checks if a node is of a certain type.
*/
export function isNodeType<NType extends NodeType, R = Node<NType>>(
node: Node<any>,
type: NType
): node is R {
return node.type === type
}
/**
* Encodes an object into a query string.
*/
export function encodeQuery(obj: any): string {
if (!obj) {
return ""
}
return Object.entries(obj)
.map(([k, v]) => k && v && `${k}=${encodeURIComponent(v as any)}`)
.filter(Boolean)
.join("&")
}

View File

@@ -0,0 +1,8 @@
import { createCli } from "./create-cli"
createCli()
.then(async (cli) => cli.parseAsync(process.argv))
.catch((err) => {
console.error(err)
process.exit(1)
})

View File

@@ -0,0 +1,18 @@
import colors from "picocolors"
const PREFIX = colors.magenta("[toolbox]")
export const logger = {
info: (message: string) => {
console.log(`${PREFIX} ${colors.gray(message)}`)
},
success: (message: string) => {
console.log(`${PREFIX} ${colors.green(message)}`)
},
warn: (message: string) => {
console.log(`${PREFIX} ${colors.yellow(message)}`)
},
error: (message: string) => {
console.log(`${PREFIX} ${colors.red(message)}`)
},
}

View File

@@ -0,0 +1,39 @@
export const defaultTemplate = (
{ jsx, componentName }: { jsx: any; componentName: string },
{ tpl }: { tpl: any }
) => {
return tpl`
import * as React from "react"
import type { IconProps } from "../types"
const ${componentName} = React.forwardRef<SVGSVGElement, IconProps>(({ color = "currentColor", ...props }, ref) => {
return (
${jsx}
)
})
${componentName}.displayName = "${componentName}"
export default ${componentName}
`
}
export const fixedTemplate = (
{ jsx, componentName }: { jsx: any; componentName: string },
{ tpl }: { tpl: any }
) => {
return tpl`
import * as React from "react"
import type { IconProps } from "../types"
const ${componentName} = React.forwardRef<SVGSVGElement, Omit<IconProps, "color">>((props, ref) => {
return (
${jsx}
)
})
${componentName}.displayName = "${componentName}"
export default ${componentName}
`
}

View File

@@ -0,0 +1 @@
export * from "./icon-templates"

View File

@@ -0,0 +1 @@
export * from "./transform-svg"

View File

@@ -0,0 +1,56 @@
import { transform } from "@svgr/core"
import jsx from "@svgr/plugin-jsx"
import prettier from "@svgr/plugin-prettier"
import svgo from "@svgr/plugin-svgo"
import { defaultTemplate, fixedTemplate } from "@/templates"
type TransformArgs = {
code: string
componentName: string
fixed?: boolean
}
export async function transformSvg({
code,
componentName,
fixed = false,
}: TransformArgs) {
return await transform(
code,
{
typescript: true,
replaceAttrValues: !fixed
? {
"#030712": "{color}",
}
: undefined,
svgProps: {
ref: "{ref}",
},
expandProps: "end",
plugins: [svgo, jsx, prettier],
jsxRuntime: "classic",
prettierConfig: {
semi: false,
parser: "typescript",
},
svgoConfig: {
plugins: [
{
name: "preset-default",
params: {
overrides: {
removeTitle: false,
},
},
},
],
},
template: fixed ? fixedTemplate : defaultTemplate,
},
{
componentName,
}
)
}

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"composite": false,
"declaration": true,
"declarationMap": true,
"inlineSources": false,
"isolatedModules": true,
"moduleResolution": "node",
"noUnusedLocals": false,
"noUnusedParameters": false,
"preserveWatchOutput": true,
"skipLibCheck": true,
"strict": true,
"allowJs": true,
"checkJs": false,
"baseUrl": ".",
"resolveJsonModule": true,
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}

View File

@@ -0,0 +1,10 @@
import { defineConfig } from "tsup"
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm", "cjs"],
dts: true,
sourcemap: true,
outDir: "dist",
clean: true,
})