Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 6 additions & 28 deletions scripts/build-styles.ts
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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");
}
7 changes: 6 additions & 1 deletion scripts/utils/minify-css.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down
67 changes: 67 additions & 0 deletions scripts/utils/postcss-inline-css-vars.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {};
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;
});
}
});
},
};
};
28 changes: 28 additions & 0 deletions scripts/utils/postcss-scoped-styles.ts
Original file line number Diff line number Diff line change
@@ -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;
});
});
},
};
};
123 changes: 123 additions & 0 deletions tests/postcss-inline-css-vars.test.ts
Original file line number Diff line number Diff line change
@@ -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)");
});
});
49 changes: 49 additions & 0 deletions tests/postcss-scoped-styles.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});