Skip to content
Open
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
13 changes: 13 additions & 0 deletions .changeset/prevent-css-name-overwrites.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@clickhouse/click-ui': patch
---

Add validation to prevent CSS file name collisions at build time.

**Why this matters:**

When a component uses CSS Modules (e.g., `Button.module.css`), the build process generates a processed CSS file (e.g., `.css-modules-temp/components/Button.css`). If a regular CSS file with the same name exists in the source (e.g., `src/components/Button.css`), both would attempt to write to `dist/components/Button.css`, causing ambiguity about which file should take precedence.

**What changed:**

Added early validation (`preventCssNameOverwrites`) that throws a clear error if such a naming conflict is detected, rather than silently overwriting files. The check runs once before the build since it validates source file names, which are format-independent.
25 changes: 25 additions & 0 deletions .llm/CONVENTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,30 @@ When using CSS Modules (migration in progress from styled-components):
- Use CSS custom properties from theme tokens: `var(--click-button-basic-color-primary-background-default)`
- Always include `:focus-visible` styles for keyboard accessibility, never use `outline: none` without replacement

### CSS File Naming (Prevent Overwrites)

⚠️ **CRITICAL**: Never have both `{name}.module.css` and `{name}.css` in the same directory.

**Context**: Click UI uses CSS colocation, each component ships with its CSS alongside it. CSS Modules (`.module.css`) are preprocessed during build into standard CSS files and both are copied to `dist/` maintaining the same directory structure. This enables bundlers to discover and include CSS automatically when importing components.

**Why**: During build, `.module.css` files are processed and written as `.css` to the output. If a regular `.css` file exists with the same name, both would attempt to write to the same destination path in `dist/`, causing ambiguity.

**Wrong:**
```
src/components/Button/
├── Button.module.css ← Processed to dist/components/Button.css
└── Button.css ← Also writes to dist/components/Button.css
```

**Correct:**
```
src/components/Button/
├── Button.module.css ← CSS Modules (scoped)
└── Button.theme.css ← Different name, distinct output
```

The build will throw an error if this collision is detected, but it's better to prevent it at the source.

### Accessibility (Mandatory)

- Interactive elements need `role`, `aria-label`, `aria-describedby`
Expand Down Expand Up @@ -122,3 +146,4 @@ src/components/
- `React.FC` or explicit `children` in props (use `React.ReactNode`)
- Circular imports via barrel files
- Untyped event handlers
- Having both `{name}.module.css` and `{name}.css` in same directory (causes dist overwrite collision)
9 changes: 9 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,5 +125,14 @@ export default tseslint.config(
'@typescript-eslint/no-unused-expressions': 'off',
},
},
{
files: ['plugins/**/*.ts'],
languageOptions: {
parserOptions: {
project: './tsconfig.node.json',
tsconfigRootDir: import.meta.dirname,
},
},
},
...storybook.configs['flat/recommended']
);
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -379,8 +379,8 @@
"generate:tokens": "node ./.scripts/js/generate-tokens.js && yarn format:fix src/theme/tokens/**/*.ts",
"lint": "yarn lint:code && yarn lint:css",
"lint:fix": "yarn lint:code:fix && yarn lint:css:fix",
"lint:code": "eslint src --report-unused-disable-directives",
"lint:code:fix": "eslint src --report-unused-disable-directives --fix",
"lint:code": "eslint src plugins --report-unused-disable-directives",
"lint:code:fix": "eslint src plugins --report-unused-disable-directives --fix",
"lint:css": "stylelint \"src/**/*.css\"",
"lint:css:fix": "stylelint \"src/**/*.css\" --fix",
"prepare": "husky",
Expand Down
4 changes: 2 additions & 2 deletions plugins/css-colocate/css-preprocess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import postcss from 'postcss';
import postcssModules from 'postcss-modules';
import { getTempDir, findFiles, generateScopedName } from './utils';

export async function preprocessCssModules(rootDir: string): Promise<void> {
export const preprocessCssModules = async (rootDir: string): Promise<void> => {
const srcDir = path.join(rootDir, 'src');
const tempDir = getTempDir(rootDir);

Expand Down Expand Up @@ -50,4 +50,4 @@ export async function preprocessCssModules(rootDir: string): Promise<void> {
console.log(
`\n✅ Pre-processing complete: ${files.length} file(s), ${totalClasses} class(es)\n`
);
}
};
10 changes: 5 additions & 5 deletions plugins/css-colocate/import-inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ const copyAndResolveCss = async (
jsOutputFile: string
): Promise<string | null> => {
const cssSourcePath = resolveCssPath(cssImportPath, sourceFile, srcDir);
if (!cssSourcePath || !(await fileExists(cssSourcePath))) return null;
if (!cssSourcePath || !(await fileExists(cssSourcePath))) {return null;}

const cssRelativeToSrc = path.relative(srcDir, cssSourcePath);
const cssOutputPath = path.join(distDir, cssRelativeToSrc);
Expand Down Expand Up @@ -111,10 +111,10 @@ export const injectComponentCss = async (
const component = path.basename(dir);
const cssFile = path.join(dir, `${component}.css`);

if (!(await fileExists(cssFile))) continue;
if (!(await fileExists(cssFile))) {continue;}

const content = await fs.readFile(jsFile, 'utf-8');
if (content.includes(`${component}.css`)) continue;
if (content.includes(`${component}.css`)) {continue;}

const importStmt = createImportStatement(`./${component}.css`, format) + '\n';
const updated = insertAtTop(content, importStmt);
Expand All @@ -133,7 +133,7 @@ export const injectRegularCssImports = async (
distDir: string,
format: 'esm' | 'cjs'
): Promise<void> => {
if (trackedImports.length === 0) return;
if (trackedImports.length === 0) {return;}

const srcDir = path.join(rootDir, 'src');

Expand All @@ -144,7 +144,7 @@ export const injectRegularCssImports = async (
relativeToSrc.replace(/\.tsx?$/, `.${format === 'esm' ? 'js' : 'cjs'}`)
);

if (!(await fileExists(jsOutputFile))) continue;
if (!(await fileExists(jsOutputFile))) {continue;}

let content = await fs.readFile(jsOutputFile, 'utf-8');

Expand Down
23 changes: 11 additions & 12 deletions plugins/css-colocate/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Plugin, ResolvedConfig } from 'vite';
import { preprocessCssModules } from './css-preprocess';
import { resolveCssModule, loadCssModule } from './virtual-modules';
import { injectComponentCss, injectRegularCssImports } from './import-inject';
import { copyCssFiles } from './utils';
import { copyCssFiles, preventCssNameOverwrites } from './utils';
import path from 'path';

interface TrackedCssImport {
Expand All @@ -27,29 +27,28 @@ export const cssColocatePlugin = (): Plugin => {
name: 'vite-plugin-css-colocate',
apply: 'build',

async buildStart() {
buildStart: async () => {
// WARN: Reset between rebuilds
// to prevent future build:watch transform to keep
// appending, causing duplicate CSS imports
// on each rebuild
trackedImports.length = 0;

await preprocessCssModules(config.root);
},

configResolved(resolvedConfig) {
configResolved: resolvedConfig => {
config = resolvedConfig;
},

async resolveId(id, importer) {
return resolveCssModule(id, importer, config.root);
},
resolveId: async (id, importer) => resolveCssModule(id, importer, config.root),

async load(id) {
return loadCssModule(id, this, config.root);
},

transform(code, id) {
if (id.includes('node_modules') || !/\.[jt]sx?$/.test(id)) return null;
transform: (code, id) => {
if (id.includes('node_modules') || !/\.[jt]sx?$/.test(id)) {return null;}

// Track regular CSS imports (not .module.css)
const cssImports: string[] = [];
Expand All @@ -69,22 +68,22 @@ export const cssColocatePlugin = (): Plugin => {
return null;
},

async closeBundle() {
closeBundle: async () => {
const formats = [
{ format: 'esm' as const, ext: 'js' as const },
{ format: 'cjs' as const, ext: 'cjs' as const },
];

// WARN: Prevents CSS file name collisions between processed .module.css files and regular .css files. E.g. throws an error if a .module.css file would produce the same output name as a .css file. This check is format-independent because it validates source files, not output.
await preventCssNameOverwrites(config.root);

for (const { format, ext } of formats) {
const distDir = path.join(config.root, 'dist', format);

// Copy all CSS files (from temp and src)
await copyCssFiles(config.root, distDir);

// Inject CSS imports into component files
await injectComponentCss(distDir, format, ext);

// Inject CSS imports into files with tracked imports
await injectRegularCssImports(trackedImports, config.root, distDir, format);
}
},
Expand Down
48 changes: 32 additions & 16 deletions plugins/css-colocate/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,40 @@ export const createImportStatement = (
return format === 'esm' ? `import "${importPath}";` : `require("${importPath}");`;
};

export const copyCssFiles = async (rootDir: string, distDir: string): Promise<void> => {
/**
* Prevents CSS file name collisions between processed .module.css files and regular .css files.
* Throws an error if a .module.css file would produce the same output name as a .css file.
* This check is format-independent because it validates source files, not output.
*/
export const preventCssNameOverwrites = async (rootDir: string): Promise<void> => {
const tempDir = getTempDir(rootDir);
const srcDir = path.join(rootDir, 'src');

// Track processed CSS files to detect naming collisions with regular CSS
const copiedFromTemp = new Set<string>();
const processedCssDestPaths = new Set<string>();

const tempFiles = await findFiles(tempDir, '**/*.css');
for (const file of tempFiles) {
const destPath = path.relative(tempDir, file);
processedCssDestPaths.add(destPath);
}

const srcFiles = await findFiles(srcDir, '**/*.css');
for (const file of srcFiles) {
if (file.endsWith('.module.css')) {continue;}

const destPath = path.relative(srcDir, file);

if (processedCssDestPaths.has(destPath)) {
throw new Error(
`👹 Oops! CSS naming collision detected: "${destPath}" exists as both a processed .module.css file and a regular .css file. Please rename one of them to avoid ambiguity.`
);
}
}
};

export const copyCssFiles = async (rootDir: string, distDir: string): Promise<void> => {
const tempDir = getTempDir(rootDir);
const srcDir = path.join(rootDir, 'src');

// Copy processed CSS from temp (generated from .module.css files)
// These are processed through postcss-modules with hashed class names
Expand All @@ -47,27 +75,15 @@ export const copyCssFiles = async (rootDir: string, distDir: string): Promise<vo
const dest = path.join(distDir, path.relative(tempDir, file));
await fs.ensureDir(path.dirname(dest));
await fs.copy(file, dest, { overwrite: true });
copiedFromTemp.add(dest);
})
);

// Copy regular CSS from src (non-module CSS files)
// Throws if there's a naming collision with processed CSS files
const srcFiles = await findFiles(srcDir, '**/*.css');
await Promise.all(
srcFiles.map(async file => {
if (file.endsWith('.module.css')) return;
if (file.endsWith('.module.css')) {return;}
const dest = path.join(distDir, path.relative(srcDir, file));

// Check for naming collision with processed CSS
if (copiedFromTemp.has(dest)) {
const relativePath = path.relative(distDir, dest);
throw new Error(
`CSS naming collision detected: "${relativePath}" exists as both a processed .module.css file and a regular .css file. ` +
`Please rename one of them to avoid ambiguity.`
);
}

await fs.ensureDir(path.dirname(dest));
await fs.copy(file, dest, { overwrite: true });
})
Expand Down
29 changes: 18 additions & 11 deletions plugins/css-colocate/virtual-modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import { getTempDir } from './utils';

const VIRTUAL_PREFIX = 'virtual:css-module:';

export async function resolveCssModule(
export const resolveCssModule = async (
id: string,
importer: string | undefined,
rootDir: string
): Promise<string | null> {
if (!id.endsWith('.module.css') || !importer) return null;
): Promise<string | null> => {
if (!id.endsWith('.module.css') || !importer) {
return null;
}

const resolved = path.resolve(path.dirname(importer), id);
const relative = path.relative(path.join(rootDir, 'src'), resolved);
Expand All @@ -19,17 +21,21 @@ export async function resolveCssModule(
relative.replace('.module.css', '.module.json')
);

if (!(await fs.pathExists(jsonPath))) return null;
if (!(await fs.pathExists(jsonPath))) {
return null;
}

return VIRTUAL_PREFIX + relative;
}
};

export async function loadCssModule(
export const loadCssModule = async (
id: string,
ctx: PluginContext,
rootDir: string
): Promise<string | null> {
if (!id.startsWith(VIRTUAL_PREFIX)) return null;
): Promise<string | null> => {
if (!id.startsWith(VIRTUAL_PREFIX)) {
return null;
}

const relative = id.slice(VIRTUAL_PREFIX.length);
const jsonPath = path.join(
Expand All @@ -48,7 +54,8 @@ export async function loadCssModule(
})
.join(',\n ');
return `export default {\n ${exports}\n};`;
} catch (e: any) {
ctx.error(`Failed to load CSS module from ${jsonPath}: ${e.message}`);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
ctx.error(`Failed to load CSS module from ${jsonPath}: ${msg}`);
}
}
};
Loading