diff --git a/src/utils/updaters/update-css-vars.ts b/src/utils/updaters/update-css-vars.ts index bbf7bab..0bf2b4d 100644 --- a/src/utils/updaters/update-css-vars.ts +++ b/src/utils/updaters/update-css-vars.ts @@ -1,303 +1,108 @@ -import fs from 'node:fs/promises' -import path from 'node:path' - -import * as p from '@clack/prompts' -import postcss from 'postcss' -import AtRule from 'postcss/lib/at-rule' -import type Root from 'postcss/lib/root' -import type Rule from 'postcss/lib/rule' +import { SyntaxKind } from 'ts-morph' import type { z } from 'zod' - -import { highlighter } from '../highlighter' -import type { Config } from '../get-config' -import type { registryItemCssVarsSchema } from '../registry/schema' - -export async function updateCssVars( - cssVars: z.infer | undefined, - config: Config, - options: { - cleanupDefaultNextStyles?: boolean - silent?: boolean - }, -): Promise { - if ( - !cssVars - || !Object.keys(cssVars).length - || !config.resolvedPaths.tailwindCss - ) { - return +import type { registryBaseColorSchema } from '../registry/schema' +import type { Transformer } from '../transformers' + +export const transformCssVars: Transformer = async ({ + sourceFile, + config, + baseColor, +}) => { + // No transform if using css variables. + if (config.tailwind?.cssVariables || !baseColor?.inlineColors) { + return sourceFile } - options = { - cleanupDefaultNextStyles: false, - silent: false, - ...options, - } - const cssFilepath = config.resolvedPaths.tailwindCss - const cssFilepathRelative = path.relative( - config.resolvedPaths.cwd, - cssFilepath, - ) - - const cssVarsSpinner = p.spinner() - cssVarsSpinner.start(`Updating ${highlighter.info(cssFilepathRelative)}`) - - const raw = await fs.readFile(cssFilepath, 'utf8') - const output = await transformCssVars(raw, cssVars, config, { - cleanupDefaultNextStyles: options.cleanupDefaultNextStyles, + sourceFile.getDescendantsOfKind(SyntaxKind.StringLiteral).forEach((node) => { + const value = node.getText() + if (value) { + const valueWithColorMapping = applyColorMapping( + value.replace(/"/g, ''), + baseColor.inlineColors, + ) + node.replaceWithText(`"${valueWithColorMapping.trim()}"`) + } }) - await fs.writeFile(cssFilepath, output, 'utf8') - cssVarsSpinner.stop(`Updated ${highlighter.info(cssFilepathRelative)}`) + + return sourceFile } -export async function transformCssVars( - input: string, - cssVars: z.infer, - config: Config, - options: { - cleanupDefaultNextStyles?: boolean - }, -): Promise { - options = { - cleanupDefaultNextStyles: false, - ...options, +// Splits a className into variant-name-alpha. +// eg. hover:bg-primary-100 -> [hover, bg-primary, 100] +export function splitClassName(className: string): (string | null)[] { + if (!className.includes('/') && !className.includes(':')) { + return [null, className, null] } - const plugins = [updateCssVarsPlugin(cssVars)] - if (options.cleanupDefaultNextStyles) { - plugins.push(cleanupDefaultNextStylesPlugin()) - } + const parts: (string | null)[] = [] + // First we split to find the alpha. + const [rest, alpha] = className.split('/') - // Only add the base layer plugin if we're using css variables. - if (config.tailwind.cssVariables) { - plugins.push(updateBaseLayerPlugin()) + // Check if rest has a colon. + if (!rest.includes(':')) { + return [null, rest, alpha] } - const result = await postcss(plugins).process(input, { - from: undefined, - }) - - return result.css -} + // Next we split the rest by the colon. + const split = rest.split(':') -function updateBaseLayerPlugin(): postcss.Plugin { - return { - postcssPlugin: 'update-base-layer', - Once(root: Root) { - const requiredRules = [ - { selector: '*', apply: 'border-border' }, - { selector: 'body', apply: 'bg-background text-foreground' }, - ] + // We take the last item from the split as the name. + const name = split.pop() - let baseLayer = root.nodes.find( - (node): node is AtRule => - node.type === 'atrule' - && node.name === 'layer' - && node.params === 'base' - && requiredRules.every(({ selector, apply }) => - node.nodes?.some( - (rule): rule is Rule => - rule.type === 'rule' - && rule.selector === selector - && rule.nodes.some( - (applyRule): applyRule is AtRule => - applyRule.type === 'atrule' - && applyRule.name === 'apply' - && applyRule.params === apply, - ), - ), - ), - ) as AtRule | undefined + // We glue back the rest of the split. + const variant = split.join(':') - if (!baseLayer) { - baseLayer = postcss.atRule({ - name: 'layer', - params: 'base', - raws: { semicolon: true, between: ' ', before: '\n' }, - }) - root.append(baseLayer) - } - - requiredRules.forEach(({ selector, apply }) => { - const existingRule = baseLayer?.nodes?.find( - (node): node is Rule => - node.type === 'rule' && node.selector === selector, - ) + // Finally we push the variant, name and alpha. + parts.push(variant ?? null, name ?? null, alpha ?? null) - if (!existingRule) { - baseLayer?.append( - postcss.rule({ - selector, - nodes: [ - postcss.atRule({ - name: 'apply', - params: apply, - raws: { semicolon: true, before: '\n ' }, - }), - ], - raws: { semicolon: true, between: ' ', before: '\n ' }, - }), - ) - } - }) - }, - } + return parts } -function updateCssVarsPlugin( - cssVars: z.infer, -): postcss.Plugin { - return { - postcssPlugin: 'update-css-vars', - Once(root: Root) { - let baseLayer = root.nodes.find( - node => - node.type === 'atrule' - && node.name === 'layer' - && node.params === 'base', - ) as AtRule | undefined +const PREFIXES = ['bg-', 'text-', 'border-', 'ring-offset-', 'ring-'] - if (!(baseLayer instanceof AtRule)) { - baseLayer = postcss.atRule({ - name: 'layer', - params: 'base', - nodes: [], - raws: { - semicolon: true, - before: '\n', - between: ' ', - }, - }) - root.append(baseLayer) - } - - if (baseLayer !== undefined) { - // Add variables for each key in cssVars - Object.entries(cssVars).forEach(([key, vars]) => { - const selector = key === 'light' ? ':root' : `.${key}` - // TODO: Fix typecheck. - addOrUpdateVars(baseLayer as AtRule, selector, vars) - }) - } - }, +export function applyColorMapping( + input: string, + mapping: z.infer['inlineColors'], +): string { + // Handle border classes. + if (input.includes(' border ')) { + input = input.replace(' border ', ' border border-border ') } -} -function removeConflictVars(root: Rule | Root): void { - const rootRule = root.nodes.find( - (node): node is Rule => node.type === 'rule' && node.selector === ':root', - ) - - if (rootRule) { - const propsToRemove = ['--background', '--foreground'] - - rootRule.nodes - .filter( - (node): node is postcss.Declaration => - node.type === 'decl' && propsToRemove.includes(node.prop), - ) - .forEach(node => node.remove()) - - if (rootRule.nodes.length === 0) { - rootRule.remove() + // Build color mappings. + const classNames = input.split(' ') + const lightMode = new Set() + const darkMode = new Set() + for (const className of classNames) { + const [variant, value, modifier] = splitClassName(className) + const prefix = PREFIXES.find(prefix => value?.startsWith(prefix)) + if (!prefix) { + if (!lightMode.has(className)) { + lightMode.add(className) + } + continue } - } -} -function cleanupDefaultNextStylesPlugin(): postcss.Plugin { - return { - postcssPlugin: 'cleanup-default-next-styles', - Once(root: Root) { - const bodyRule = root.nodes.find( - (node): node is Rule => node.type === 'rule' && node.selector === 'body', + const needle = value?.replace(prefix, '') + if (needle && needle in mapping.light) { + lightMode.add( + [variant, `${prefix}${mapping.light[needle]}`] + .filter(Boolean) + .join(':') + (modifier ? `/${modifier}` : ''), ) - if (bodyRule) { - // Remove color from the body node. - bodyRule.nodes - .find( - (node): node is postcss.Declaration => - node.type === 'decl' - && node.prop === 'color' - && ['rgb(var(--foreground-rgb))', 'var(--foreground)'].includes( - node.value, - ), - ) - ?.remove() - - // Remove background: linear-gradient. - bodyRule.nodes - .find((node): node is postcss.Declaration => { - return ( - node.type === 'decl' - && node.prop === 'background' - // This is only going to run on create project, so all good. - && (node.value.startsWith('linear-gradient') - || node.value === 'var(--background)') - ) - }) - ?.remove() - - // If the body rule is empty, remove it. - if (bodyRule.nodes.length === 0) { - bodyRule.remove() - } - } - - removeConflictVars(root) - const darkRootRule = root.nodes.find( - (node): node is Rule => - node.type === 'atrule' - && node.params === '(prefers-color-scheme: dark)', + darkMode.add( + ['dark', variant, `${prefix}${mapping.dark[needle]}`] + .filter(Boolean) + .join(':') + (modifier ? `/${modifier}` : ''), ) + continue + } - if (darkRootRule) { - removeConflictVars(darkRootRule) - if (darkRootRule.nodes.length === 0) { - darkRootRule.remove() - } - } - }, - } -} - -function addOrUpdateVars( - baseLayer: AtRule, - selector: string, - vars: Record, -): void { - let ruleNode = baseLayer.nodes?.find( - (node): node is Rule => node.type === 'rule' && node.selector === selector, - ) - - if (!ruleNode) { - if (Object.keys(vars).length > 0) { - ruleNode = postcss.rule({ - selector, - raws: { between: ' ', before: '\n ' }, - }) - baseLayer.append(ruleNode) + if (!lightMode.has(className)) { + lightMode.add(className) } } - Object.entries(vars).forEach(([key, value]) => { - const prop = `--${key.replace(/^--/, '')}` - const newDecl = postcss.decl({ - prop, - value, - raws: { semicolon: true }, - }) - - const existingDecl = ruleNode?.nodes.find( - (node): node is postcss.Declaration => - node.type === 'decl' && node.prop === prop, - ) - - if (existingDecl) { - existingDecl.replaceWith(newDecl) - } - else { - ruleNode?.append(newDecl) - } - }) + return [...Array.from(lightMode), ...Array.from(darkMode)].join(' ').trim() }