diff --git a/web/package.json b/web/package.json index 50e10d3b..3cba9e3d 100644 --- a/web/package.json +++ b/web/package.json @@ -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" }, diff --git a/web/postcss/roots-merger.ts b/web/postcss/roots-merger.ts new file mode 100644 index 00000000..e1a0f350 --- /dev/null +++ b/web/postcss/roots-merger.ts @@ -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() + 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) + } + } + } +} diff --git a/web/postcss/vars-cleaner.ts b/web/postcss/vars-cleaner.ts index e30bdb9f..3c9315ec 100644 --- a/web/postcss/vars-cleaner.ts +++ b/web/postcss/vars-cleaner.ts @@ -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' @@ -16,8 +16,8 @@ function removeWithEmptyParent(node: Node): void { let globalUsed = new Set() let globalVars = new Set() -export let cleaner: Plugin = { - postcssPlugin: 'clean-vars', +export const varsCleaner: Plugin = { + postcssPlugin: 'vars-cleaner', prepare() { let used = new Set() let vars = new Map() @@ -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)) { @@ -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() + globalVars = new Set() } diff --git a/web/scripts/clean-vars.ts b/web/scripts/clean-css.ts similarity index 69% rename from web/scripts/clean-vars.ts rename to web/scripts/clean-css.ts index 57b3c9ce..d0f0c220 100755 --- a/web/scripts/clean-vars.ts +++ b/web/scripts/clean-css.ts @@ -1,4 +1,6 @@ -// 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' @@ -6,7 +8,8 @@ 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') @@ -14,16 +17,21 @@ function printError(message: string | undefined): void { async function processCss(dir: string): Promise { 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)) { @@ -42,12 +50,11 @@ async function processCss(dir: string): Promise { 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) } diff --git a/web/test/roots-merger.test.ts b/web/test/roots-merger.test.ts new file mode 100644 index 00000000..780fa471 --- /dev/null +++ b/web/test/roots-merger.test.ts @@ -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 }' + ) +}) diff --git a/web/test/vars-cleaner.test.ts b/web/test/vars-cleaner.test.ts new file mode 100644 index 00000000..f4fe5a32 --- /dev/null +++ b/web/test/vars-cleaner.test.ts @@ -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') +})