diff --git a/scripts/build-styles.ts b/scripts/build-styles.ts index e2520aca..90cec3b1 100644 --- a/scripts/build-styles.ts +++ b/scripts/build-styles.ts @@ -1,33 +1,11 @@ import { $, Glob } from "bun"; import path from "node:path"; -import postcss from "postcss"; import { createMarkdown } from "./utils/create-markdown"; import { minifyCss } from "./utils/minify-css"; +import { postcssScopedStyles } from "./utils/postcss-scoped-styles"; import { toCamelCase } from "./utils/to-camel-case"; import { writeTo } from "./utils/write-to"; -const createScopedStyles = (props: { source: string; moduleName: string }) => { - const { source, moduleName } = props; - - return postcss([ - { - postcssPlugin: "postcss-plugin:scoped-styles", - Once(root) { - root.walkRules((rule) => { - rule.selectors = rule.selectors.map((selector) => { - if (/^pre /.test(selector)) { - selector = `pre.${moduleName}${selector.replace(/^pre /, " ")}`; - } else { - selector = `.${moduleName} ${selector}`; - } - return selector; - }); - }); - }, - }, - ]).process(source).css; -}; - export type ModuleNames = Array<{ name: string; moduleName: string }>; export async function buildStyles() { @@ -78,7 +56,10 @@ export async function buildStyles() { ); await writeTo(`src/styles/${name}.css`, css_minified); - const scoped_style = createScopedStyles({ source: content, moduleName }); + const scoped_style = minifyCss(content, { + discardComments: "remove-all", + plugins: [postcssScopedStyles(moduleName)], + }); scoped_styles += scoped_style; } else { @@ -139,10 +120,7 @@ export async function buildStyles() { // Don't format metadata used in docs. await Bun.write("www/data/styles.json", JSON.stringify(styles)); - await Bun.write( - "www/data/scoped-styles.css", - minifyCss(scoped_styles, { discardComments: "remove-all" }), - ); + await Bun.write("www/data/scoped-styles.css", scoped_styles); console.timeEnd("build styles"); } diff --git a/scripts/utils/minify-css.ts b/scripts/utils/minify-css.ts index d801db58..700bca5f 100644 --- a/scripts/utils/minify-css.ts +++ b/scripts/utils/minify-css.ts @@ -1,16 +1,21 @@ import cssnano from "cssnano"; import litePreset from "cssnano-preset-lite"; -import postcss from "postcss"; +import postcss, { type Plugin } from "postcss"; import discardDuplicates from "postcss-discard-duplicates"; import mergeRules from "postcss-merge-rules"; +import { postcssInlineCssVars } from "./postcss-inline-css-vars"; export const minifyCss = ( css: string, options?: { + plugins?: Plugin[]; + scopeStyles?: boolean; discardComments?: "preserve-license" | "remove-all"; }, ) => { return postcss([ + postcssInlineCssVars(), + ...(options?.plugins ?? []), discardDuplicates(), mergeRules(), cssnano({ diff --git a/scripts/utils/postcss-inline-css-vars.ts b/scripts/utils/postcss-inline-css-vars.ts new file mode 100644 index 00000000..3864e5a2 --- /dev/null +++ b/scripts/utils/postcss-inline-css-vars.ts @@ -0,0 +1,67 @@ +import type { Plugin } from "postcss"; + +const RE_CSS_VARS = /var\((--[^)]+)\)/g; + +const isCssVar = (value: string) => value.includes("var("); + +/** + * PostCSS plugin to inline CSS variables. + * @example + * **Before** + * ```ts + * :root { --color-primary: #000; } + * .class { color: var(--color-primary); } + * ``` + * + * **After** + * ```css + * .class { color: #000; } + * ``` + */ +export const postcssInlineCssVars = (): Plugin => { + return { + postcssPlugin: "postcss-plugin:inline-css-vars", + Once(root) { + // Extract CSS variables from :root. + let cssVars: Record = {}; + let hasRootVars = false; + + root.walkRules(":root", (rule) => { + hasRootVars = true; + rule.walkDecls((decl) => { + cssVars[decl.prop] = decl.value; + }); + rule.remove(); + }); + + // Skip if no CSS variables were found. + if (!hasRootVars) return; + + // Resolve nested variables in the CSS vars definitions first. + let changed = true; + while (changed) { + changed = false; + for (const [prop, value] of Object.entries(cssVars)) { + if (isCssVar(value)) { + const newValue = value.replace(RE_CSS_VARS, (match, varName) => { + return cssVars[varName] || match; + }); + if (newValue !== value) { + cssVars[prop] = newValue; + changed = true; + } + } + } + } + + // Replace var() references with resolved values. + root.walkDecls((decl) => { + if (isCssVar(decl.value)) { + decl.value = decl.value.replace(RE_CSS_VARS, (match, varName) => { + return cssVars[varName] || match; + }); + } + }); + }, + }; +}; diff --git a/scripts/utils/postcss-scoped-styles.ts b/scripts/utils/postcss-scoped-styles.ts new file mode 100644 index 00000000..143861c0 --- /dev/null +++ b/scripts/utils/postcss-scoped-styles.ts @@ -0,0 +1,28 @@ +import type { Plugin } from "postcss"; + +/** + * Scopes CSS selectors to a given module name. + * + * @example + * ```css + * .moduleName p { color: red; } + * .moduleName div { background: blue; } + * ``` + */ +export const postcssScopedStyles = (moduleName: string): Plugin => { + return { + postcssPlugin: "postcss-plugin:scoped-styles", + Once(root) { + root.walkRules((rule) => { + rule.selectors = rule.selectors.map((selector) => { + if (/^pre /.test(selector)) { + selector = `pre.${moduleName}${selector.replace(/^pre /, " ")}`; + } else { + selector = `.${moduleName} ${selector}`; + } + return selector; + }); + }); + }, + }; +}; diff --git a/tests/postcss-inline-css-vars.test.ts b/tests/postcss-inline-css-vars.test.ts new file mode 100644 index 00000000..340fb4cc --- /dev/null +++ b/tests/postcss-inline-css-vars.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test } from "bun:test"; +import postcss from "postcss"; +import { postcssInlineCssVars } from "../scripts/utils/postcss-inline-css-vars"; + +describe("postcssInlineCssVars", () => { + const process = (css: string) => { + return postcss([postcssInlineCssVars()]).process(css).css; + }; + + test("removes :root rule after processing", () => { + const source = ` + :root { + --primary-color: #ff0000; + } + h1 { color: red; } + `; + const result = process(source); + + expect(result).not.toContain(":root"); + expect(result).toContain("h1 { color: red; }"); + }); + + test("replaces CSS variable with its value", () => { + const source = ` + :root { + --primary-color: #ff0000; + } + h1 { color: var(--primary-color); } + `; + const result = process(source); + + expect(result).not.toContain("var(--primary-color)"); + expect(result).toContain("color: #ff0000"); + }); + + test("handles multiple CSS variables", () => { + const source = ` + :root { + --primary-color: #ff0000; + --secondary-color: #00ff00; + } + h1 { color: var(--primary-color); } + p { color: var(--secondary-color); } + `; + const result = process(source); + + expect(result).toContain("color: #ff0000"); + expect(result).toContain("color: #00ff00"); + }); + + test("preserves original value if variable is not defined", () => { + const source = ` + :root { + --primary-color: #ff0000; + } + h1 { color: var(--undefined-color); } + `; + const result = process(source); + + expect(result).toContain("color: var(--undefined-color)"); + }); + + test("handles nested CSS variables", () => { + const source = ` + :root { + --primary: #ff0000; + --button-color: var(--primary); + } + button { color: var(--button-color); } + `; + const result = process(source); + + expect(result).not.toContain(":root"); + expect(result).toContain("color: #ff0000"); + }); + + test("handles multiple var() references in single declaration", () => { + const source = ` + :root { + --spacing-x: 10px; + --spacing-y: 20px; + } + .box { margin: var(--spacing-y) var(--spacing-x); } + `; + const result = process(source); + + expect(result).toContain("margin: 20px 10px"); + }); + + test("skips processing when no root vars present", () => { + const source = ` + .button { + color: blue; + padding: 10px; + } + .header { + font-size: 16px; + } + `; + const result = postcss([postcssInlineCssVars()]).process(source).css; + + // Should be identical to source since no processing needed + expect(result.trim()).toBe(source.trim()); + }); + + test("ignores :root when part of a larger selector", () => { + const source = ` + :root.dark { + --primary-color: #000000; + } + :root.light { + --primary-color: #ffffff; + } + .button { color: var(--primary-color); } + `; + const result = process(source); + + // Should preserve the :root selectors and vars since they're part of larger selectors + expect(result).toContain(":root.dark"); + expect(result).toContain(":root.light"); + expect(result).toContain("var(--primary-color)"); + }); +}); diff --git a/tests/postcss-scoped-styles.test.ts b/tests/postcss-scoped-styles.test.ts new file mode 100644 index 00000000..4f5cef67 --- /dev/null +++ b/tests/postcss-scoped-styles.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "bun:test"; +import postcss from "postcss"; +import { postcssScopedStyles } from "../scripts/utils/postcss-scoped-styles"; + +describe("postcssScopedStyles", () => { + const process = (css: string) => { + return postcss([postcssScopedStyles("moduleName")]).process(css).css; + }; + + it("should scope regular CSS selectors", async () => { + const input = ` + p { color: red; } + div { background: blue; } + `; + const expected = ` + .moduleName p { color: red; } + .moduleName div { background: blue; } + `; + expect(process(input)).toBe(expected); + }); + + it("should handle pre selectors", async () => { + const input = ` + pre .className { color: red; } + pre.someClass { background: blue; } + pre { padding: 1em; } + `; + const expected = ` + pre.moduleName .className { color: red; } + .moduleName pre.someClass { background: blue; } + .moduleName pre { padding: 1em; } + `; + expect(process(input)).toBe(expected); + }); + + it("should handle complex selectors", async () => { + const input = ` + div > p { color: red; } + .class1 .class2 { background: blue; } + #id1 span { padding: 1em; } + `; + const expected = ` + .moduleName div > p { color: red; } + .moduleName .class1 .class2 { background: blue; } + .moduleName #id1 span { padding: 1em; } + `; + expect(process(input)).toBe(expected); + }); +});