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
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 }
|
||||
Reference in New Issue
Block a user