gg-tailwind
Version:
Giga Design Studio - Tokens to Tailwind
294 lines (256 loc) • 8.64 kB
text/typescript
import type { Plugin } from "vite";
import fs from "fs";
import path from "path";
interface TokenTheme {
[key: string]: any;
breakpoint?: Record<string, string>;
}
interface TokenConfig {
theme: TokenTheme;
fonts?: Record<
string,
{
"font-family": string;
"font-weight": string | number;
"font-style": string;
src: string;
}
>;
utilities?: Record<string, Record<string, any>>;
components?: Record<string, Record<string, unknown>>;
}
function generateCssVariableName(path: string[]): string {
return `--${path.join("-")}`;
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function generateMediaQuery(breakpoint: string, styles: string): string {
return `@media (min-width: ${breakpoint}) {\n ${styles}\n }`;
}
function processThemeValue(
path: string[],
value: unknown,
breakpoints: Record<string, string>
): string[] {
const cssRules: string[] = [];
let varName = generateCssVariableName(path);
// Special handling for colors
if (path[0] === "color") {
if (path.length === 2) {
varName = `--${path.join("-")}`;
} else if (isObject(value) && !("default" in value)) {
// For nested color objects, use the existing path (will be handled in the object case)
varName = `--${path.join("-")}`;
}
}
if (typeof value === "string" || typeof value === "number") {
cssRules.push(`${varName}: ${value};`);
} else if (isObject(value)) {
if ("default" in value) {
cssRules.push(`${varName}: ${value.default};`);
Object.entries(value).forEach(([key, val]) => {
if (key !== "default" && breakpoints[key]) {
cssRules.push(generateMediaQuery(breakpoints[key], `${varName}: ${val};`));
}
});
} else {
Object.entries(value).forEach(([key, val]) => {
cssRules.push(...processThemeValue([...path, key], val, breakpoints));
});
}
}
return cssRules;
}
function generateThemeVariables(theme: TokenTheme): string {
const breakpoints = theme.breakpoint || {};
const rootRules: string[] = [];
Object.entries(theme).forEach(([key, value]) => {
if (key !== "breakpoint") {
rootRules.push(...processThemeValue([key], value, breakpoints));
}
});
return `:root {\n ${rootRules.join("\n ")}\n}`;
}
function generateThemeSection(theme: TokenTheme, fonts?: TokenConfig["fonts"]): string {
const variables = Object.entries(theme)
.filter(([key]) => key !== "breakpoint")
.map(([key, value]) => {
if (key === "color") {
const colorVars: string[] = [];
Object.entries(value).forEach(([colorName, colorValue]) => {
if (typeof colorValue === "string") {
colorVars.push(`--color-${colorName}: var(--color-${colorName});`);
} else if (isObject(colorValue)) {
Object.keys(colorValue).forEach((shade) => {
colorVars.push(`--color-${colorName}-${shade}: var(--color-${colorName}-${shade});`);
});
}
});
return colorVars.join("\n ");
}
if (isObject(value)) {
return Object.keys(value)
.map((subKey) => `--${key}-${subKey}: var(--${key}-${subKey});`)
.join("\n ");
}
return `--${key}: var(--${key});`;
})
.join("\n ");
const breakpoints = Object.entries(theme.breakpoint || {})
.map(([key, value]) => `--breakpoint-${key}: ${value};`)
.join("\n ");
const fontVariables = fonts
? Object.entries(fonts)
.map(([key, font]) => `--font-${key}: "${font["font-family"]}";`)
.join("\n ")
: "";
return `@theme {\n --*: initial;\n ${breakpoints}\n ${fontVariables}\n ${variables}\n}`;
}
function generateFontFaces(fonts: NonNullable<TokenConfig["fonts"]>): string {
return Object.entries(fonts)
.map(([_, font]) => {
return `@font-face {\n font-family: "${font["font-family"]}";\n font-weight: ${font["font-weight"]};\n font-style: ${font["font-style"]};\n src: ${font["src"]};\n}`;
})
.join("\n\n");
}
function generateComponents(
components: NonNullable<TokenConfig["components"]>,
breakpoints: Record<string, string>
): string {
const processComponentStyles = (
baseSelector: string,
styles: Record<string, unknown>
): string[] => {
const cssRules: string[] = [];
Object.entries(styles).forEach(([selector, value]) => {
if (isObject(value)) {
const rules: string[] = [];
Object.entries(value).forEach(([prop, propValue]) => {
if (isObject(propValue) && "default" in propValue) {
// Handle responsive properties
rules.push(` ${prop}: ${propValue.default};`);
Object.entries(propValue).forEach(([bp, val]) => {
if (bp !== "default" && breakpoints[bp]) {
rules.push(` @media (min-width: ${breakpoints[bp]}) {`);
rules.push(` ${prop}: ${val};`);
rules.push(` }`);
}
});
} else {
// Handle regular properties
rules.push(` ${prop}: ${propValue};`);
}
});
cssRules.push(` ${baseSelector}-${selector} {`);
cssRules.push(rules.join("\n"));
cssRules.push(` }`);
}
});
return cssRules;
};
const componentRules = Object.entries(components)
.map(([selector, styles]) => {
if (isObject(styles)) {
const processedStyles = processComponentStyles(selector, styles);
return processedStyles.join("\n");
}
return "";
})
.filter(Boolean)
.join("\n\n");
return `@layer components {\n${componentRules}\n}`;
}
function generateUtilities(
utilities: NonNullable<TokenConfig["utilities"]>,
breakpoints: Record<string, string>
): string {
const processUtilityStyles = (selector: string, styles: Record<string, unknown>): string[] => {
const cssRules: string[] = [];
Object.entries(styles).forEach(([prop, value]) => {
if (isObject(value) && "default" in value) {
cssRules.push(` ${prop}: ${value.default};`);
Object.entries(value).forEach(([bp, val]) => {
if (bp !== "default" && breakpoints[bp]) {
cssRules.push(` @media (min-width: ${breakpoints[bp]}) {`);
cssRules.push(` ${prop}: ${val};`);
cssRules.push(` }`);
}
});
} else if (typeof value === "string" || typeof value === "number") {
cssRules.push(` ${prop}: ${value};`);
}
});
return [`@utility ${selector} {`, ...cssRules, `}`];
};
return Object.entries(utilities)
.map(([selector, styles]) => {
if (isObject(styles)) {
return processUtilityStyles(selector, styles).join("\n");
}
return "";
})
.filter(Boolean)
.join("\n\n");
}
export default function tokensToTw(
tokenUrl: string = "assets/css/tokens.json",
outputUrl: string = "assets/css/tokens.css"
): Plugin {
const tokenPath = path.resolve(tokenUrl);
const outputPath = path.resolve(outputUrl);
return {
name: "tokens-to-tw",
async buildStart() {
await generateCSS();
},
async hotUpdate({ file, server }) {
if (file.endsWith("tokens.json")) {
await generateCSS();
}
},
};
async function generateCSS() {
try {
const tokenContent = await fs.promises.readFile(tokenPath, "utf-8");
const tokens = JSON.parse(tokenContent) as TokenConfig;
const cssContent = [
generateThemeVariables(tokens.theme),
tokens.fonts ? generateFontFaces(tokens.fonts) : "",
generateThemeSection(tokens.theme, tokens.fonts),
tokens.components
? generateComponents(tokens.components, tokens.theme.breakpoint || {})
: "",
tokens.utilities ? generateUtilities(tokens.utilities, tokens.theme.breakpoint || {}) : "",
// v-lay from gds-style :')
`@utility lay {
display: grid;
grid-auto-flow: initial;
grid-auto-columns: minmax(0, 1fr);
grid-auto-rows: minmax(0, auto);
}
@utility lay-v {
grid-auto-flow: row;
}
@utility lay-h {
grid-auto-flow: column;
}
@utility lay-o {
&>* {
grid-area: 1 / 1;
}
}
@utility lay-fluid {
grid-auto-columns: initial;
grid-auto-rows: initial;
justify-content: flex-start;
}`,
]
.filter(Boolean)
.join("\n\n");
await fs.promises.writeFile(outputPath, cssContent);
} catch (error) {
console.error("Error generating CSS:", error);
}
}
}