Skip to content

Commit

Permalink
feat: update css vars
Browse files Browse the repository at this point in the history
  • Loading branch information
adrian-ub committed Oct 22, 2024
1 parent cebe0e2 commit 9934fb2
Showing 1 changed file with 78 additions and 273 deletions.
351 changes: 78 additions & 273 deletions src/utils/updaters/update-css-vars.ts
Original file line number Diff line number Diff line change
@@ -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<typeof registryItemCssVarsSchema> | undefined,
config: Config,
options: {
cleanupDefaultNextStyles?: boolean
silent?: boolean
},
): Promise<void> {
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<typeof registryItemCssVarsSchema>,
config: Config,
options: {
cleanupDefaultNextStyles?: boolean
},
): Promise<string> {
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<typeof registryItemCssVarsSchema>,
): 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<typeof registryBaseColorSchema>['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<string>()
const darkMode = new Set<string>()
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<string, string>,
): 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()
}

0 comments on commit 9934fb2

Please sign in to comment.