Skip to content

Commit

Permalink
Merge :root rules into the single css-rule (#112)
Browse files Browse the repository at this point in the history
* Merge :root rules into the single css-rule

* Check if we have no error if there's no unused rules

* Move file purpose description to the beginning of file

* Use (not so) new js syntax to improve readability

* Use walkDecls while traversing through the rules in plugin

* Use second parameter of OnceExit to get `Rule`
  • Loading branch information
Konfuze authored Mar 26, 2024
1 parent d4ed55c commit f484b57
Show file tree
Hide file tree
Showing 6 changed files with 208 additions and 13 deletions.
2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"test:css": "stylelint **/*.css **/*.svelte",
"test:build": "pnpm build && size-limit",
"build:visual": "NODE_ENV=test storybook build",
"build:web": "vite build && tsx ./scripts/clean-vars.ts",
"build:web": "vite build && tsx ./scripts/clean-css.ts",
"format:stylelint": "stylelint --fix **/*.{css,svelte}",
"clean:dist": "rm -rf dist"
},
Expand Down
45 changes: 45 additions & 0 deletions web/postcss/roots-merger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// PostCSS plugin for ../script/clean-css.ts to merge all rules
// with :root selector into the single rule in the beginning of the file

import type { ChildNode, Plugin, Rule } from 'postcss'

export const rootsMerger: Plugin = {
postcssPlugin: 'roots-merger',
prepare() {
let rootNodes = new Map<string, ChildNode>()
let rulesToRemove: Rule[] = []

return {
OnceExit(root, { Rule }) {
rulesToRemove.forEach(rule => rule.remove())

let rootRule = new Rule({ selector: ':root' })

rootRule.append(...rootNodes.values())
if (rootRule.nodes.length > 0) {
root.prepend(rootRule)
}
},
Rule(rule) {
if (rule.selector !== ':root') {
return
}

if (rule.parent?.type === 'atrule') {
return
}

rule.walkDecls(decl => {
// remove rule from the map to preserve the rules order
if (rootNodes.has(decl.prop)) {
rootNodes.delete(decl.prop)
}

rootNodes.set(decl.prop, decl)
})

rulesToRemove.push(rule)
}
}
}
}
20 changes: 15 additions & 5 deletions web/postcss/vars-cleaner.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// PostCSS plugin for ../script/clean-vars.ts to remove unused palette colors
// PostCSS plugin for ../script/clean-css.ts to remove unused palette colors
// and throw error on unused CSS Custom Properties.

import type { Node, Plugin } from 'postcss'
Expand All @@ -16,8 +16,8 @@ function removeWithEmptyParent(node: Node): void {
let globalUsed = new Set<string>()
let globalVars = new Set<string>()

export let cleaner: Plugin = {
postcssPlugin: 'clean-vars',
export const varsCleaner: Plugin = {
postcssPlugin: 'vars-cleaner',
prepare() {
let used = new Set<string>()
let vars = new Map<string, Node[]>()
Expand Down Expand Up @@ -53,7 +53,7 @@ export let cleaner: Plugin = {
}
}

export function checkUsed(): string[] {
export function getVarsCleanerError(): string | undefined {
let unused = []
for (let name of globalVars) {
if (!globalUsed.has(name)) {
Expand All @@ -62,5 +62,15 @@ export function checkUsed(): string[] {
}
}
}
return unused

if (unused.length === 0) {
return
}

return `Unused CSS variables: ${unused.join(', ')}`
}

export function resetCleanerGlobals(): void {
globalUsed = new Set<string>()
globalVars = new Set<string>()
}
21 changes: 14 additions & 7 deletions web/scripts/clean-vars.ts → web/scripts/clean-css.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,37 @@
// Remove unused colors from palette (as CSS Custom Properties)
// 1. Merge all rules with :root selector into the single rule
// in the beginning of the file
// 2. Remove unused colors from palette (as CSS Custom Properties)
// and throw and error if other CSS Custom Properties are unused.

import { lstat, readdir, readFile, writeFile } from 'node:fs/promises'
import { extname, join } from 'node:path'
import pico from 'picocolors'
import postcss from 'postcss'

import { checkUsed, cleaner } from '../postcss/vars-cleaner.js'
import { rootsMerger } from '../postcss/roots-merger.js'
import { getVarsCleanerError, varsCleaner } from '../postcss/vars-cleaner.js'

function printError(message: string | undefined): void {
process.stderr.write(pico.red(message) + '\n')
}

async function processCss(dir: string): Promise<void> {
let items = await readdir(dir)

await Promise.all(
items.map(async name => {
let path = join(dir, name)
let stat = await lstat(path)

if (stat.isDirectory()) {
await processCss(path)
} else if (extname(name) === '.css') {
let css = await readFile(path)

try {
let fixed = await cssCleaner.process(css, { from: path })
let fixed = await cssCleaner.process(css, {
from: path
})
await writeFile(path, fixed.css)
} catch (e) {
if (!(e instanceof Error)) {
Expand All @@ -42,12 +50,11 @@ async function processCss(dir: string): Promise<void> {

const ASSETS = join(import.meta.dirname, '..', 'dist', 'assets')

let cssCleaner = postcss([cleaner])
const cssCleaner = postcss([rootsMerger, varsCleaner])

await processCss(ASSETS)

let unused = checkUsed()
if (unused.length > 0) {
printError(`Unused CSS variables: ${pico.yellow(unused.join(', '))}`)
if (getVarsCleanerError()) {
printError(getVarsCleanerError())
process.exit(1)
}
71 changes: 71 additions & 0 deletions web/test/roots-merger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { equal } from 'node:assert'
import { test } from 'node:test'
import postcss from 'postcss'

import { rootsMerger } from '../postcss/roots-merger.js'

function run(input: string, output: string): void {
let result = postcss([rootsMerger]).process(input, { from: undefined })
equal(result.css, output)
}

test('merges all :roots into one', () => {
run(
':root { --color-red: #f00; }' +
':root { --color-blue: #00f; }' +
'.some-class { color: tomato }' +
':root { --color-green: #0f0; }',
':root {' +
' --color-red: #f00;' +
' --color-blue: #00f;' +
' --color-green: #0f0 ' +
'}\n' +
'.some-class { color: tomato }'
)
})

test('merges all :roots into one and respects last defined variable value', () => {
run(
':root { --color-red: #f00; }' +
':root { --color-blue: #00f; }' +
':root { --color-blue: blue; }' +
'.some-class { color: tomato }' +
':root { --color-green: #0f0; }',
':root {' +
' --color-red: #f00;' +
' --color-blue: blue;' +
' --color-green: #0f0 ' +
'}\n' +
'.some-class { color: tomato }'
)
})

test('merges all :roots into one except :roots with classes', () => {
run(
':root { --color-red: #f00; }' +
':root.blue { --color-blue: #00f; }' +
'.some-class { color: tomato }' +
':root { --color-green: #0f0; }',
':root {' +
' --color-red: #f00;' +
' --color-green: #0f0; ' +
'}' +
':root.blue { --color-blue: #00f; }' +
'.some-class { color: tomato }'
)
})

test('merges all :roots into one except :roots under at-rules', () => {
run(
':root { --color-red: #f00; }' +
'@media screen { :root { --color-blue: #00f; } }' +
'.some-class { color: tomato }' +
':root { --color-green: #0f0; }',
':root {' +
' --color-red: #f00;' +
' --color-green: #0f0; ' +
'}' +
'@media screen { :root { --color-blue: #00f; } }' +
'.some-class { color: tomato }'
)
})
62 changes: 62 additions & 0 deletions web/test/vars-cleaner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { equal } from 'node:assert'
import { beforeEach, test } from 'node:test'
import postcss from 'postcss'

import {
getVarsCleanerError,
resetCleanerGlobals,
varsCleaner
} from '../postcss/vars-cleaner.js'

function run(input: string, output: string): string | undefined {
let result = postcss([varsCleaner]).process(input, { from: undefined })
equal(result.css, output)

return getVarsCleanerError()
}

beforeEach(() => {
resetCleanerGlobals()
})

test('clean unused palette colors', () => {
let error = run(
':root {' +
'--red-100: #f00;' +
'--green-200: #0f0;' +
'--blue-300: #00f;' +
'}' +
'.selector {' +
' color: var(--red-100)' +
'}',
':root {' +
'--red-100:#f00;' +
'}' +
'.selector {' +
' color: var(--red-100)' +
'}'
)

equal(error, undefined)
})

test('return error if unused css variables found', () => {
let error = run(
':root {' +
'--used-variable: #00f;' +
'--unused-variable: #00f;' +
'}' +
'.selector {' +
' color: var(--used-variable)' +
'}',
':root {' +
'--used-variable:#00f;' +
'--unused-variable:#00f;' +
'}' +
'.selector {' +
' color: var(--used-variable)' +
'}'
)

equal(error, 'Unused CSS variables: --unused-variable')
})

0 comments on commit f484b57

Please sign in to comment.