GDGT-1893: Generate .d.css files to support CSS Variables autocomplete (#68723)
* CssVarsDeclarationPlugin: Add plugin and documentation to generate .d.css files at build step * CssVarsDeclarationPlugin: Add recommended .vscode settings to support CSS vars autocomplete * CssVarsDeclarationPlugin: Fix nested unions parsing * CssVarsDeclarationPlugin: Add unit tests * Update vscode settings for css vars to include Mantine
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
*.class
|
||||
*.*.rej
|
||||
*.d.css
|
||||
*.iml
|
||||
*.jar
|
||||
*.log
|
||||
|
||||
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@@ -9,6 +9,7 @@
|
||||
"github.vscode-pull-request-github",
|
||||
"limejuly.vscode-yaml-block-highlight",
|
||||
"streetsidesoftware.code-spell-checker",
|
||||
"styled-components.vscode-styled-components"
|
||||
"styled-components.vscode-styled-components",
|
||||
"vunguyentuan.vscode-css-variables"
|
||||
]
|
||||
}
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -4,5 +4,6 @@
|
||||
"jake.autoDetect": "off",
|
||||
"gulp.autoDetect": "off",
|
||||
"npm.autoDetect": "off",
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"cssVariables.lookupFiles": ["**/*.d.css", "node_modules/@mantine/core/*.css"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# CssVarsDeclarationPlugin
|
||||
|
||||
Rspack plugin that generates `.d.css` declaration files from TypeScript source files.
|
||||
It's primarily oriented towards VSCode-based IDEs which in combination with [CSS Variable Autocomplete](https://marketplace.visualstudio.com/items?itemName=vunguyentuan.vscode-css-variables) extension provide autocomplete functionality for
|
||||
all CSS variables declared in `.d.css` files across the codebase.
|
||||
|
||||
## Why
|
||||
|
||||
CSS variables defined in TypeScript (object keys, union types) are invisible to CSS tooling.
|
||||
This plugin extracts those definitions at build time and writes `.d.css` files that IDEs
|
||||
can pick up for autocomplete.
|
||||
|
||||
## When it runs
|
||||
|
||||
- **Dev mode only** — added conditionally in `rspack.main.config.js`
|
||||
- **Once per build** — hooks into `compiler.hooks.environment` (no re-runs on HMR)
|
||||
|
||||
## Config API
|
||||
|
||||
Each entry in `CSS_VAR_CONFIGS` generates one `.d.css` file:
|
||||
|
||||
```js
|
||||
{
|
||||
// Source file whose .d.css sibling will be generated (file.ts → file.d.css)
|
||||
path: "metabase/path/to/file.ts",
|
||||
|
||||
// Optional static variable names to include as-is
|
||||
staticVars: ["--mb-some-var"],
|
||||
|
||||
// Optional extraction sources — where to read variable names from
|
||||
sources: [
|
||||
{
|
||||
// Source file to parse. Defaults to path if omitted.
|
||||
file: "metabase/path/to/other-file.ts",
|
||||
|
||||
// Extraction method:
|
||||
// "objectKeys" — extract --mb-* keys from object literal properties
|
||||
// "unionType" — extract string literals from a union type alias
|
||||
type: "objectKeys",
|
||||
|
||||
// Variable or type alias names to extract from
|
||||
names: ["SOME_OBJECT", "ANOTHER_OBJECT"],
|
||||
|
||||
// Optional prefix added to each extracted value
|
||||
varPrefix: "--mb-color-",
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## Adding a new source
|
||||
|
||||
Add an entry to `CSS_VAR_CONFIGS` in the plugin file. Simplest case:
|
||||
|
||||
```js
|
||||
{
|
||||
path: "metabase/path/to/new-file.ts",
|
||||
sources: [{ type: "objectKeys", names: ["MY_CSS_VARS_MAP"] }],
|
||||
}
|
||||
```
|
||||
|
||||
## Output format
|
||||
|
||||
Since most CSS variables we want to expose are declared dynamically, we can't properly derive their values at the build step.
|
||||
But having just the names is enough for IDEs to provide autocomplete. In future if we want to support extracting static values from the source files, autocomplete would be able to provide preview for actual values.
|
||||
|
||||
```css
|
||||
/* Auto-generated by CssVarsDeclarationPlugin. Do not edit. */
|
||||
:root {
|
||||
--mb-color-brand: ;
|
||||
--mb-color-danger: ;
|
||||
}
|
||||
```
|
||||
|
||||
## Warnings
|
||||
|
||||
The plugin prints warnings to the console when:
|
||||
- A source file listed in the config doesn't exist on disk
|
||||
- A variable or type alias name isn't found in the source file
|
||||
@@ -0,0 +1,279 @@
|
||||
// @ts-check
|
||||
/* eslint-env node */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const { Project } = require("ts-morph");
|
||||
|
||||
const PLUGIN_NAME = "CssVarsDeclarationPlugin";
|
||||
|
||||
/**
|
||||
* @typedef {Object} ExtractionSource
|
||||
* @property {string} [file] - Path to source file (defaults to config's path)
|
||||
* @property {"objectKeys" | "unionType"} type - Extraction method
|
||||
* @property {string[]} names - Variable or type alias names to extract from
|
||||
* @property {string} [varPrefix] - Prefix to add to extracted values (e.g. "--mb-color-")
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} CssVarConfig
|
||||
* @property {string} path - File path (relative to frontendSrcPath), output is derived as .d.css
|
||||
* @property {string[]} [staticVars] - Static CSS variable names to include
|
||||
* @property {ExtractionSource[]} [sources] - Extraction sources
|
||||
*/
|
||||
|
||||
/** @type {CssVarConfig[]} */
|
||||
const CSS_VAR_CONFIGS = [
|
||||
{
|
||||
path: "metabase/embedding-sdk/theme/css-vars-to-sdk-theme.ts",
|
||||
sources: [
|
||||
{
|
||||
type: "objectKeys",
|
||||
names: [
|
||||
"CSS_VARIABLES_TO_SDK_THEME_MAP",
|
||||
"COLLECTION_BROWSER_THEME_OPTIONS",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "metabase/embedding-sdk/theme/dynamic-css-vars-config.ts",
|
||||
sources: [{ type: "objectKeys", names: ["DYNAMIC_CSS_VARIABLES"] }],
|
||||
},
|
||||
{
|
||||
path: "metabase/styled-components/theme/css-variables.ts",
|
||||
staticVars: [
|
||||
"--mb-default-monospace-font-family",
|
||||
"--mb-default-font-family",
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "metabase/lib/colors/types/color-keys.ts",
|
||||
sources: [
|
||||
{
|
||||
type: "unionType",
|
||||
// eslint-disable-next-line metabase/no-literal-metabase-strings -- referencing TypeScript type names
|
||||
names: ["MetabaseColorKey"],
|
||||
varPrefix: "--mb-color-",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Rspack plugin that generates .d.css files for CSS variables defined in TypeScript files.
|
||||
* These provide IDE autocomplete for --mb-* CSS variables.
|
||||
* Only runs in development mode.
|
||||
*/
|
||||
class CssVarsDeclarationPlugin {
|
||||
/** @type {string} */
|
||||
#frontendSrcPath;
|
||||
/** @type {string} */
|
||||
#rootPath;
|
||||
/** @type {CssVarConfig[]} */
|
||||
#configs;
|
||||
|
||||
/**
|
||||
* @param {{ frontendSrcPath: string, rootPath: string, configs?: CssVarConfig[] }} options
|
||||
*/
|
||||
constructor({ frontendSrcPath, rootPath, configs }) {
|
||||
this.#frontendSrcPath = frontendSrcPath;
|
||||
this.#rootPath = rootPath;
|
||||
this.#configs = configs ?? CSS_VAR_CONFIGS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('@rspack/core').Compiler} compiler
|
||||
*/
|
||||
apply(compiler) {
|
||||
// `environment` runs once per compiler init, not on HMR rebuilds
|
||||
compiler.hooks.environment.tap(PLUGIN_NAME, () => {
|
||||
this.#generateAllDeclarationFiles();
|
||||
});
|
||||
}
|
||||
|
||||
#generateAllDeclarationFiles() {
|
||||
const project = new Project({
|
||||
tsConfigFilePath: path.join(this.#rootPath, "tsconfig.json"),
|
||||
skipAddingFilesFromTsConfig: true,
|
||||
});
|
||||
|
||||
for (const config of this.#configs) {
|
||||
this.#processConfig(project, config);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Project} project
|
||||
* @param {CssVarConfig} config
|
||||
*/
|
||||
#processConfig(project, config) {
|
||||
const cssVars = new Set();
|
||||
|
||||
// Add static variables
|
||||
for (const staticVar of config.staticVars ?? []) {
|
||||
cssVars.add(staticVar);
|
||||
}
|
||||
|
||||
// Process each extraction source
|
||||
for (const source of config.sources ?? []) {
|
||||
const relativePath = source.file ?? config.path;
|
||||
const filePath = path.join(this.#frontendSrcPath, relativePath);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
this.#warn(`Source file not found: ${relativePath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceFile = project.addSourceFileAtPath(filePath);
|
||||
const extracted = new Set();
|
||||
|
||||
for (const name of source.names) {
|
||||
switch (source.type) {
|
||||
case "objectKeys":
|
||||
this.#extractObjectKeys(sourceFile, name, extracted);
|
||||
break;
|
||||
case "unionType":
|
||||
this.#extractUnionTypeStrings(sourceFile, name, extracted);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply prefix and add to result
|
||||
const prefix = source.varPrefix ?? "";
|
||||
for (const value of extracted) {
|
||||
cssVars.add(`${prefix}${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
const outputPath = config.path.replace(/\.ts$/, ".d.css");
|
||||
this.#writeCssDeclarationFile(
|
||||
path.join(this.#frontendSrcPath, outputPath),
|
||||
cssVars,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} message
|
||||
*/
|
||||
#warn(message) {
|
||||
console.warn(`\x1b[33m[${PLUGIN_NAME}] WARNING: ${message}\x1b[0m`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract --mb-* keys from an object literal variable.
|
||||
* @param {import('ts-morph').SourceFile} sourceFile
|
||||
* @param {string} varName
|
||||
* @param {Set<string>} result
|
||||
*/
|
||||
#extractObjectKeys(sourceFile, varName, result) {
|
||||
const decl = sourceFile.getVariableDeclaration(varName);
|
||||
if (!decl) {
|
||||
this.#warn(
|
||||
`Variable "${varName}" not found in ${sourceFile.getBaseName()}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const initializer = this.#unwrapExpression(decl.getInitializer());
|
||||
if (
|
||||
!initializer ||
|
||||
initializer.getKindName() !== "ObjectLiteralExpression"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const objLiteral =
|
||||
/** @type {import('ts-morph').ObjectLiteralExpression} */ (initializer);
|
||||
|
||||
for (const prop of objLiteral.getProperties()) {
|
||||
if (prop.getKindName() === "PropertyAssignment") {
|
||||
const propAssignment =
|
||||
/** @type {import('ts-morph').PropertyAssignment} */ (prop);
|
||||
const name = propAssignment.getName();
|
||||
const cleanName = name.replace(/^["']|["']$/g, "");
|
||||
|
||||
if (cleanName.startsWith("--mb-")) {
|
||||
result.add(cleanName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract string literals from a union type alias.
|
||||
* Uses the type checker to fully resolve nested type references
|
||||
* and indexed access types (e.g. `(typeof FOO)[number]`).
|
||||
* @param {import('ts-morph').SourceFile} sourceFile
|
||||
* @param {string} typeName
|
||||
* @param {Set<string>} result
|
||||
*/
|
||||
#extractUnionTypeStrings(sourceFile, typeName, result) {
|
||||
const typeAlias = sourceFile.getTypeAlias(typeName);
|
||||
if (!typeAlias) {
|
||||
this.#warn(
|
||||
`Type alias "${typeName}" not found in ${sourceFile.getBaseName()}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedType = typeAlias.getType();
|
||||
if (resolvedType.isUnion()) {
|
||||
for (const member of resolvedType.getUnionTypes()) {
|
||||
if (member.isStringLiteral()) {
|
||||
result.add(/** @type {string} */ (member.getLiteralValue()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap SatisfiesExpression and AsExpression to get the underlying expression.
|
||||
* @param {import('ts-morph').Expression | undefined} expr
|
||||
* @returns {import('ts-morph').Expression | undefined}
|
||||
*/
|
||||
#unwrapExpression(expr) {
|
||||
if (!expr) {
|
||||
return expr;
|
||||
}
|
||||
|
||||
while (
|
||||
expr.getKindName() === "SatisfiesExpression" ||
|
||||
expr.getKindName() === "AsExpression"
|
||||
) {
|
||||
if (expr.getKindName() === "SatisfiesExpression") {
|
||||
expr = /** @type {import('ts-morph').SatisfiesExpression} */ (
|
||||
expr
|
||||
).getExpression();
|
||||
} else if (expr.getKindName() === "AsExpression") {
|
||||
expr = /** @type {import('ts-morph').AsExpression} */ (
|
||||
expr
|
||||
).getExpression();
|
||||
}
|
||||
}
|
||||
|
||||
return expr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write CSS declaration file.
|
||||
* @param {string} outputPath
|
||||
* @param {Set<string>} cssVars
|
||||
*/
|
||||
#writeCssDeclarationFile(outputPath, cssVars) {
|
||||
const sortedVars = Array.from(cssVars).sort();
|
||||
const content = [
|
||||
"/* Auto-generated by CssVarsDeclarationPlugin. Do not edit. */",
|
||||
":root {",
|
||||
...sortedVars.map((v) => ` ${v}: ;`),
|
||||
"}",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
fs.writeFileSync(outputPath, content);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[${PLUGIN_NAME}] Generated ${path.basename(outputPath)}`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.CssVarsDeclarationPlugin = CssVarsDeclarationPlugin;
|
||||
@@ -0,0 +1,505 @@
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
|
||||
import { CssVarsDeclarationPlugin } from "./css-vars-declaration-plugin";
|
||||
|
||||
interface CssVarConfig {
|
||||
path: string;
|
||||
staticVars?: string[];
|
||||
sources?: Array<{
|
||||
file?: string;
|
||||
type: "objectKeys" | "unionType";
|
||||
names: string[];
|
||||
varPrefix?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Create a temp directory with a tsconfig.json and the given source files. */
|
||||
function createFixture(files: Record<string, string>) {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "css-vars-test-"));
|
||||
const srcDir = path.join(rootDir, "src");
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "tsconfig.json"),
|
||||
JSON.stringify({ compilerOptions: { strict: true } }),
|
||||
);
|
||||
|
||||
for (const [relPath, content] of Object.entries(files)) {
|
||||
const filePath = path.join(srcDir, relPath);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, content);
|
||||
}
|
||||
|
||||
return { rootDir, srcDir };
|
||||
}
|
||||
|
||||
function readOutput(srcDir: string, configPath: string) {
|
||||
const outputPath = path.join(srcDir, configPath.replace(/\.ts$/, ".d.css"));
|
||||
return fs.readFileSync(outputPath, "utf-8");
|
||||
}
|
||||
|
||||
function runPlugin({
|
||||
configs,
|
||||
files,
|
||||
}: {
|
||||
configs: CssVarConfig[];
|
||||
files: Record<string, string>;
|
||||
}) {
|
||||
const { rootDir, srcDir } = createFixture(files);
|
||||
|
||||
const plugin = new CssVarsDeclarationPlugin({
|
||||
frontendSrcPath: srcDir,
|
||||
rootPath: rootDir,
|
||||
configs,
|
||||
});
|
||||
|
||||
const tapCallback = jest.fn();
|
||||
const mockCompiler = {
|
||||
hooks: {
|
||||
environment: {
|
||||
tap: (_name: string, fn: () => void) =>
|
||||
tapCallback.mockImplementation(fn),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
plugin.apply(mockCompiler as any);
|
||||
tapCallback();
|
||||
|
||||
return { srcDir };
|
||||
}
|
||||
|
||||
describe("CssVarsDeclarationPlugin", () => {
|
||||
describe("objectKeys extraction", () => {
|
||||
it("extracts --mb-* keys from a plain object", () => {
|
||||
const configPath = "theme/vars.ts";
|
||||
const { srcDir } = runPlugin({
|
||||
files: {
|
||||
[configPath]: `
|
||||
export const MY_VARS = {
|
||||
"--mb-color-bg": "colors.bg",
|
||||
"--mb-color-fg": "colors.fg",
|
||||
"not-a-css-var": "ignored",
|
||||
};
|
||||
`,
|
||||
},
|
||||
configs: [
|
||||
{
|
||||
path: configPath,
|
||||
sources: [{ type: "objectKeys", names: ["MY_VARS"] }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const output = readOutput(srcDir, configPath);
|
||||
expect(output).toContain("--mb-color-bg: ;");
|
||||
expect(output).toContain("--mb-color-fg: ;");
|
||||
expect(output).not.toContain("not-a-css-var");
|
||||
});
|
||||
|
||||
it("extracts keys from objects with satisfies expression", () => {
|
||||
const configPath = "theme/vars.ts";
|
||||
const { srcDir } = runPlugin({
|
||||
files: {
|
||||
[configPath]: `
|
||||
type ThemeMap = Record<string, string>;
|
||||
export const MY_VARS = {
|
||||
"--mb-overlay-z-index": "zIndex",
|
||||
} satisfies ThemeMap;
|
||||
`,
|
||||
},
|
||||
configs: [
|
||||
{
|
||||
path: configPath,
|
||||
sources: [{ type: "objectKeys", names: ["MY_VARS"] }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const output = readOutput(srcDir, configPath);
|
||||
expect(output).toContain("--mb-overlay-z-index: ;");
|
||||
});
|
||||
|
||||
it("extracts keys from objects with as const expression", () => {
|
||||
const configPath = "theme/vars.ts";
|
||||
const { srcDir } = runPlugin({
|
||||
files: {
|
||||
[configPath]: `
|
||||
export const MY_VARS = {
|
||||
"--mb-font-size": "fontSize",
|
||||
} as const;
|
||||
`,
|
||||
},
|
||||
configs: [
|
||||
{
|
||||
path: configPath,
|
||||
sources: [{ type: "objectKeys", names: ["MY_VARS"] }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const output = readOutput(srcDir, configPath);
|
||||
expect(output).toContain("--mb-font-size: ;");
|
||||
});
|
||||
|
||||
it("extracts from multiple variables in the same file", () => {
|
||||
const configPath = "theme/vars.ts";
|
||||
const { srcDir } = runPlugin({
|
||||
files: {
|
||||
[configPath]: `
|
||||
export const MAP_A = { "--mb-a": "a" };
|
||||
export const MAP_B = { "--mb-b": "b" };
|
||||
`,
|
||||
},
|
||||
configs: [
|
||||
{
|
||||
path: configPath,
|
||||
sources: [{ type: "objectKeys", names: ["MAP_A", "MAP_B"] }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const output = readOutput(srcDir, configPath);
|
||||
expect(output).toContain("--mb-a: ;");
|
||||
expect(output).toContain("--mb-b: ;");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unionType extraction", () => {
|
||||
it("extracts string literals from a union type", () => {
|
||||
const configPath = "types/keys.ts";
|
||||
const { srcDir } = runPlugin({
|
||||
files: {
|
||||
[configPath]: `
|
||||
export type ColorKey = "brand" | "danger" | "success";
|
||||
`,
|
||||
},
|
||||
configs: [
|
||||
{
|
||||
path: configPath,
|
||||
sources: [
|
||||
{
|
||||
type: "unionType",
|
||||
names: ["ColorKey"],
|
||||
varPrefix: "--mb-color-",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const output = readOutput(srcDir, configPath);
|
||||
expect(output).toContain("--mb-color-brand: ;");
|
||||
expect(output).toContain("--mb-color-danger: ;");
|
||||
expect(output).toContain("--mb-color-success: ;");
|
||||
});
|
||||
|
||||
it("resolves nested type references in union types", () => {
|
||||
const configPath = "types/keys.ts";
|
||||
const { srcDir } = runPlugin({
|
||||
files: {
|
||||
[configPath]: `
|
||||
type Nested = "nested-a" | "nested-b";
|
||||
export type All = Nested | "direct";
|
||||
`,
|
||||
},
|
||||
configs: [
|
||||
{
|
||||
path: configPath,
|
||||
sources: [{ type: "unionType", names: ["All"] }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const output = readOutput(srcDir, configPath);
|
||||
expect(output).toContain("nested-a: ;");
|
||||
expect(output).toContain("nested-b: ;");
|
||||
expect(output).toContain("direct: ;");
|
||||
});
|
||||
|
||||
it("resolves indexed access types like (typeof arr)[number]", () => {
|
||||
const configPath = "types/keys.ts";
|
||||
const { srcDir } = runPlugin({
|
||||
files: {
|
||||
"constants/names.ts": `
|
||||
export const ALL_NAMES = ["alpha", "beta", "gamma"] as const;
|
||||
`,
|
||||
[configPath]: `
|
||||
import type { ALL_NAMES } from "../constants/names";
|
||||
export type NameKey = (typeof ALL_NAMES)[number];
|
||||
`,
|
||||
},
|
||||
configs: [
|
||||
{
|
||||
path: configPath,
|
||||
sources: [
|
||||
{
|
||||
type: "unionType",
|
||||
names: ["NameKey"],
|
||||
varPrefix: "--mb-",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const output = readOutput(srcDir, configPath);
|
||||
expect(output).toContain("--mb-alpha: ;");
|
||||
expect(output).toContain("--mb-beta: ;");
|
||||
expect(output).toContain("--mb-gamma: ;");
|
||||
});
|
||||
|
||||
it("resolves deeply nested union with mixed type references and indexed access", () => {
|
||||
const configPath = "types/all.ts";
|
||||
const { srcDir } = runPlugin({
|
||||
files: {
|
||||
"constants/accents.ts": `
|
||||
export const ACCENT_NAMES = ["accent0", "accent1"] as const;
|
||||
`,
|
||||
[configPath]: `
|
||||
import type { ACCENT_NAMES } from "../constants/accents";
|
||||
type AccentKey = (typeof ACCENT_NAMES)[number];
|
||||
type ProtectedKey = "admin" | "internal";
|
||||
export type AllKeys = AccentKey | ProtectedKey | "brand";
|
||||
`,
|
||||
},
|
||||
configs: [
|
||||
{
|
||||
path: configPath,
|
||||
sources: [
|
||||
{
|
||||
type: "unionType",
|
||||
names: ["AllKeys"],
|
||||
varPrefix: "--mb-color-",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const output = readOutput(srcDir, configPath);
|
||||
expect(output).toContain("--mb-color-accent0: ;");
|
||||
expect(output).toContain("--mb-color-accent1: ;");
|
||||
expect(output).toContain("--mb-color-admin: ;");
|
||||
expect(output).toContain("--mb-color-internal: ;");
|
||||
expect(output).toContain("--mb-color-brand: ;");
|
||||
});
|
||||
});
|
||||
|
||||
describe("staticVars", () => {
|
||||
it("includes static variables without any sources", () => {
|
||||
const configPath = "theme/static.ts";
|
||||
const { srcDir } = runPlugin({
|
||||
files: {
|
||||
[configPath]: `export default {};`,
|
||||
},
|
||||
configs: [
|
||||
{
|
||||
path: configPath,
|
||||
staticVars: ["--mb-font-family", "--mb-font-mono"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const output = readOutput(srcDir, configPath);
|
||||
expect(output).toContain("--mb-font-family: ;");
|
||||
expect(output).toContain("--mb-font-mono: ;");
|
||||
});
|
||||
|
||||
it("combines static variables with extracted sources", () => {
|
||||
const configPath = "theme/combined.ts";
|
||||
const { srcDir } = runPlugin({
|
||||
files: {
|
||||
[configPath]: `
|
||||
export const VARS = { "--mb-dynamic": "val" };
|
||||
`,
|
||||
},
|
||||
configs: [
|
||||
{
|
||||
path: configPath,
|
||||
staticVars: ["--mb-static"],
|
||||
sources: [{ type: "objectKeys", names: ["VARS"] }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const output = readOutput(srcDir, configPath);
|
||||
expect(output).toContain("--mb-static: ;");
|
||||
expect(output).toContain("--mb-dynamic: ;");
|
||||
});
|
||||
});
|
||||
|
||||
describe("source file override", () => {
|
||||
it("reads from a different file when source.file is specified", () => {
|
||||
const configPath = "theme/output.ts";
|
||||
const { srcDir } = runPlugin({
|
||||
files: {
|
||||
[configPath]: `export default {};`,
|
||||
"other/source.ts": `
|
||||
export type Keys = "a" | "b";
|
||||
`,
|
||||
},
|
||||
configs: [
|
||||
{
|
||||
path: configPath,
|
||||
sources: [
|
||||
{
|
||||
file: "other/source.ts",
|
||||
type: "unionType",
|
||||
names: ["Keys"],
|
||||
varPrefix: "--mb-",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const output = readOutput(srcDir, configPath);
|
||||
expect(output).toContain("--mb-a: ;");
|
||||
expect(output).toContain("--mb-b: ;");
|
||||
});
|
||||
});
|
||||
|
||||
describe("output format", () => {
|
||||
it("sorts variables alphabetically", () => {
|
||||
const configPath = "theme/vars.ts";
|
||||
const { srcDir } = runPlugin({
|
||||
files: {
|
||||
[configPath]: `
|
||||
export const VARS = {
|
||||
"--mb-z-var": "z",
|
||||
"--mb-a-var": "a",
|
||||
"--mb-m-var": "m",
|
||||
};
|
||||
`,
|
||||
},
|
||||
configs: [
|
||||
{
|
||||
path: configPath,
|
||||
sources: [{ type: "objectKeys", names: ["VARS"] }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const output = readOutput(srcDir, configPath);
|
||||
const lines = output.split("\n").filter((l) => l.includes("--mb-"));
|
||||
expect(lines[0]).toContain("--mb-a-var");
|
||||
expect(lines[1]).toContain("--mb-m-var");
|
||||
expect(lines[2]).toContain("--mb-z-var");
|
||||
});
|
||||
|
||||
it("includes the auto-generated header comment", () => {
|
||||
const configPath = "theme/vars.ts";
|
||||
const { srcDir } = runPlugin({
|
||||
files: { [configPath]: `export const V = { "--mb-x": "x" };` },
|
||||
configs: [
|
||||
{
|
||||
path: configPath,
|
||||
sources: [{ type: "objectKeys", names: ["V"] }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const output = readOutput(srcDir, configPath);
|
||||
expect(output).toMatch(
|
||||
/^\/\* Auto-generated by CssVarsDeclarationPlugin/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("warnings", () => {
|
||||
it("warns when a source file does not exist", () => {
|
||||
const warnSpy = jest.spyOn(console, "warn").mockImplementation();
|
||||
|
||||
const configPath = "theme/output.ts";
|
||||
runPlugin({
|
||||
files: {
|
||||
[configPath]: `export default {};`,
|
||||
},
|
||||
configs: [
|
||||
{
|
||||
path: configPath,
|
||||
sources: [
|
||||
{
|
||||
file: "nonexistent/file.ts",
|
||||
type: "objectKeys",
|
||||
names: ["VARS"],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Source file not found: nonexistent/file.ts"),
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("warns when a variable name is not found in the source file", () => {
|
||||
const warnSpy = jest.spyOn(console, "warn").mockImplementation();
|
||||
|
||||
const configPath = "theme/vars.ts";
|
||||
runPlugin({
|
||||
files: {
|
||||
[configPath]: `export const OTHER = { "--mb-x": "x" };`,
|
||||
},
|
||||
configs: [
|
||||
{
|
||||
path: configPath,
|
||||
sources: [{ type: "objectKeys", names: ["MISSING_VAR"] }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Variable "MISSING_VAR" not found'),
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("warns when a type alias is not found in the source file", () => {
|
||||
const warnSpy = jest.spyOn(console, "warn").mockImplementation();
|
||||
|
||||
const configPath = "types/keys.ts";
|
||||
runPlugin({
|
||||
files: {
|
||||
[configPath]: `export type Other = "a";`,
|
||||
},
|
||||
configs: [
|
||||
{
|
||||
path: configPath,
|
||||
sources: [{ type: "unionType", names: ["MissingType"] }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Type alias "MissingType" not found'),
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("compiler hook", () => {
|
||||
it("registers on the environment hook", () => {
|
||||
const plugin = new CssVarsDeclarationPlugin({
|
||||
frontendSrcPath: "/tmp",
|
||||
rootPath: "/tmp",
|
||||
configs: [],
|
||||
});
|
||||
|
||||
const tapFn = jest.fn();
|
||||
const mockCompiler = {
|
||||
hooks: { environment: { tap: tapFn } },
|
||||
};
|
||||
|
||||
plugin.apply(mockCompiler as any);
|
||||
expect(tapFn).toHaveBeenCalledWith(
|
||||
"CssVarsDeclarationPlugin",
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,9 @@ const { CSS_CONFIG } = require("./frontend/build/shared/rspack/css-config");
|
||||
const {
|
||||
getBannerOptions,
|
||||
} = require("./frontend/build/shared/rspack/get-banner-options");
|
||||
const {
|
||||
CssVarsDeclarationPlugin,
|
||||
} = require("./frontend/build/shared/rspack/plugins/CssVarsDeclarationPlugin/css-vars-declaration-plugin");
|
||||
const {
|
||||
RESOLVE_ALIASES,
|
||||
} = require("./frontend/build/shared/rspack/resolve-aliases");
|
||||
@@ -368,6 +371,10 @@ if (isDevMode) {
|
||||
excludeWarnings: true,
|
||||
skipFirstNotification: true,
|
||||
}),
|
||||
new CssVarsDeclarationPlugin({
|
||||
frontendSrcPath: __dirname + "/frontend/src",
|
||||
rootPath: __dirname,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user