chore(ui,icons,ui-preset,toolbox): Move design system packages to monorepo (#5470)
This commit is contained in:
committed by
GitHub
parent
71853eafdd
commit
e4ce2f4e07
3
packages/design-system/toolbox/README.md
Normal file
3
packages/design-system/toolbox/README.md
Normal 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.
|
||||
2
packages/design-system/toolbox/bin/toolbox.js
Executable file
2
packages/design-system/toolbox/bin/toolbox.js
Executable file
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env node
|
||||
require("../dist/index.js")
|
||||
33
packages/design-system/toolbox/package.json
Normal file
33
packages/design-system/toolbox/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
173
packages/design-system/toolbox/src/commands/icons/command.ts
Normal file
173
packages/design-system/toolbox/src/commands/icons/command.ts
Normal 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 ✨`)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./generate-index"
|
||||
export * from "./get-icon-data"
|
||||
458
packages/design-system/toolbox/src/commands/tokens/command.ts
Normal file
458
packages/design-system/toolbox/src/commands/tokens/command.ts
Normal 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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
32
packages/design-system/toolbox/src/constants.ts
Normal file
32
packages/design-system/toolbox/src/constants.ts
Normal 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",
|
||||
]
|
||||
31
packages/design-system/toolbox/src/create-cli.ts
Normal file
31
packages/design-system/toolbox/src/create-cli.ts
Normal 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
|
||||
}
|
||||
16
packages/design-system/toolbox/src/figma-client.ts
Normal file
16
packages/design-system/toolbox/src/figma-client.ts
Normal 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,
|
||||
})
|
||||
42
packages/design-system/toolbox/src/figma/assertions.ts
Normal file
42
packages/design-system/toolbox/src/figma/assertions.ts
Normal 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
|
||||
}
|
||||
56
packages/design-system/toolbox/src/figma/client.ts
Normal file
56
packages/design-system/toolbox/src/figma/client.ts
Normal 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
|
||||
308
packages/design-system/toolbox/src/figma/index.ts
Normal file
308
packages/design-system/toolbox/src/figma/index.ts
Normal 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
|
||||
1236
packages/design-system/toolbox/src/figma/types.ts
Normal file
1236
packages/design-system/toolbox/src/figma/types.ts
Normal file
File diff suppressed because it is too large
Load Diff
25
packages/design-system/toolbox/src/figma/utils.ts
Normal file
25
packages/design-system/toolbox/src/figma/utils.ts
Normal 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("&")
|
||||
}
|
||||
8
packages/design-system/toolbox/src/index.ts
Normal file
8
packages/design-system/toolbox/src/index.ts
Normal 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)
|
||||
})
|
||||
18
packages/design-system/toolbox/src/logger.ts
Normal file
18
packages/design-system/toolbox/src/logger.ts
Normal 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)}`)
|
||||
},
|
||||
}
|
||||
@@ -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}
|
||||
`
|
||||
}
|
||||
1
packages/design-system/toolbox/src/templates/index.ts
Normal file
1
packages/design-system/toolbox/src/templates/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./icon-templates"
|
||||
1
packages/design-system/toolbox/src/transformers/index.ts
Normal file
1
packages/design-system/toolbox/src/transformers/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./transform-svg"
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
}
|
||||
25
packages/design-system/toolbox/tsconfig.json
Normal file
25
packages/design-system/toolbox/tsconfig.json
Normal 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"]
|
||||
}
|
||||
10
packages/design-system/toolbox/tsup.config.ts
Normal file
10
packages/design-system/toolbox/tsup.config.ts
Normal 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,
|
||||
})
|
||||
Reference in New Issue
Block a user