-
-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
1 changed file
with
78 additions
and
273 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
} |