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,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 }