Skip to content

Commit 7aa863a

Browse files
committed
feat(css): support CSS Modules and nested imports
1 parent c810068 commit 7aa863a

File tree

1 file changed

+77
-143
lines changed

1 file changed

+77
-143
lines changed

src/index.ts

Lines changed: 77 additions & 143 deletions
Original file line numberDiff line numberDiff line change
@@ -2,51 +2,78 @@ import type { Plugin, OutputChunk, NormalizedOutputOptions, OutputBundle, Output
22
import type JavaScriptTypes from '@ast-grep/napi/lang/JavaScript';
33
import type { Kinds } from '@ast-grep/napi/types/staticTypes';
44
import { Lang, parse, type SgNode } from '@ast-grep/napi';
5+
import { basename } from 'node:path';
56

67
type CSSFiles = Set<string> | undefined;
78

89
type NodePos = SgNode<JavaScriptTypes> | undefined;
910

1011
/**
11-
* @name extractCssImports
12-
* @description Extract CSS imports from source code
12+
* @name extractStyleImports
13+
* @description Extract CSS imports (including CSS Modules) from source code
1314
* @example
14-
* const s = 'import "./index.css";'
15-
* const arr = extractCssImports(s) // ["./index.css"]
15+
* const s = 'import "./index.css"; import styles from "./Button.module.css";'
16+
* const arr = extractStyleImports(s) // ["./index.css", "./Button.module.css"]
1617
* @param code - The source code to analyze
1718
* @returns An array of CSS import paths
1819
*/
19-
const extractCssImports = (code: string): string[] => {
20-
const cssImports: string[] = [];
20+
const extractStyleImports = (code: string): string[] => {
21+
const styleImports: string[] = [];
2122

22-
// Match CSS import statements
23-
// Handles: import './style.css', import 'style.css', import './style.css?inline'
24-
const importRegex = /import\s+(['"])(.*?\.css(?:\?[^'"]*)?)\1/g;
23+
// Match CSS file extensions (including CSS Modules)
24+
const cssExtensions = /\.css(?:\?[^'"]*)?/;
25+
26+
// Pattern 1: import './style.css' or import 'style.css'
27+
const sideEffectImportRegex = /import\s+(['"])(.*?)\1/g;
28+
29+
// Pattern 2: import styles from './style.module.css'
30+
const namedImportRegex = /import\s+(?:\*\s+as\s+)?(\w+)\s+from\s+(['"])(.*?)\2/g;
31+
32+
// Pattern 3: import { something } from './style.css'
33+
const destructuredImportRegex = /import\s+{[^}]+}\s+from\s+(['"])(.*?)\1/g;
34+
35+
// Check side-effect imports
2536
let match;
37+
while ((match = sideEffectImportRegex.exec(code)) !== null) {
38+
const importPath = match[2];
39+
if (cssExtensions.test(importPath)) {
40+
styleImports.push(importPath);
41+
}
42+
}
2643

27-
while ((match = importRegex.exec(code)) !== null) {
28-
const cssPath = match[2];
29-
cssImports.push(cssPath);
44+
// Check named imports (e.g., CSS modules)
45+
while ((match = namedImportRegex.exec(code)) !== null) {
46+
const importPath = match[3];
47+
if (cssExtensions.test(importPath)) {
48+
styleImports.push(importPath);
49+
}
3050
}
3151

32-
return cssImports;
52+
// Check destructured imports
53+
while ((match = destructuredImportRegex.exec(code)) !== null) {
54+
const importPath = match[2];
55+
if (cssExtensions.test(importPath)) {
56+
styleImports.push(importPath);
57+
}
58+
}
59+
60+
return styleImports;
3361
};
3462

3563
/**
3664
* @description Inject CSS files at the top of each generated chunk file for tsdown builds.
3765
* @return {Plugin} A Rolldown plugin to inject CSS imports into library chunks.
3866
*/
39-
export const injectCssPlugin = (): Plugin => {
40-
// Track CSS imports per module
41-
const cssImportMap = new Map<string, string[]>();
67+
const injectCssPlugin = (): Plugin => {
68+
// Track style imports per module
69+
const styleImportMap = new Map<string, string[]>();
4270
// Track which modules are included in which chunks
4371
const moduleToChunkMap = new Map<string, string>();
4472

4573
return {
4674
name: 'tsdown:lib-inject-css',
4775

4876
// Set default config for better library bundling
49-
// Not sure if this is required
5077
outputOptions(outputOptions: OutputOptions): OutputOptions {
5178
// Prevent hoisting transitive imports to avoid tree-shaking issues
5279
if (typeof outputOptions.hoistTransitiveImports !== 'boolean') {
@@ -59,17 +86,17 @@ export const injectCssPlugin = (): Plugin => {
5986
return outputOptions;
6087
},
6188

62-
// Capture CSS imports before they're stripped by the build
89+
// Capture style imports before they're stripped by the build
6390
transform(code, id) {
6491
// Only process TypeScript/JavaScript files (ignore .d.ts files)
6592
if (!/\.(tsx?|jsx?)$/.test(id)) {
6693
return null;
6794
}
6895

69-
const cssImports = extractCssImports(code);
96+
const styleImports = extractStyleImports(code);
7097

71-
if (cssImports.length > 0) {
72-
cssImportMap.set(id, cssImports);
98+
if (styleImports.length > 0) {
99+
styleImportMap.set(id, styleImports);
73100
}
74101

75102
return null;
@@ -94,9 +121,10 @@ export const injectCssPlugin = (): Plugin => {
94121
}
95122

96123
// Build a map of chunk -> CSS files
124+
// This aggregates ALL style imports from ALL modules in each chunk
97125
const chunkCssMap = new Map<string, Set<string>>();
98126

99-
for (const [moduleId, cssImports] of cssImportMap.entries()) {
127+
for (const [moduleId, styleImports] of styleImportMap.entries()) {
100128
const chunkName = moduleToChunkMap.get(moduleId);
101129

102130
if (chunkName) {
@@ -105,13 +133,29 @@ export const injectCssPlugin = (): Plugin => {
105133
}
106134

107135
const chunkCss = chunkCssMap.get(chunkName)!;
108-
for (const cssImport of cssImports) {
109-
// Normalize CSS import path to match the output file name
110-
const normalizedPath = cssImport.replace(/^\.\//, '');
111-
112-
// Only add CSS files have been bundled
113-
if (outputCssFiles.has(normalizedPath)) {
114-
chunkCss.add(normalizedPath);
136+
for (const styleImport of styleImports) {
137+
// Remove query parameters
138+
const cleanPath = styleImport.split('?')[0];
139+
140+
// Get the base filename
141+
const fileName = basename(cleanPath);
142+
143+
// Try to find matching CSS file in output
144+
const possibleMatches = Array.from(outputCssFiles).filter((cssFile) => {
145+
const cssBaseName = basename(cssFile);
146+
147+
// Exact filename match (including .module.css files)
148+
return cssBaseName === fileName;
149+
});
150+
151+
// If we found exact matches, add them
152+
if (possibleMatches.length > 0) {
153+
possibleMatches.forEach((match) => chunkCss.add(match));
154+
} else if (outputCssFiles.size === 1) {
155+
// If there's only one CSS file in the output, assume all styles
156+
// are bundled into it (common case for libraries)
157+
const [singleCssFile] = Array.from(outputCssFiles);
158+
chunkCss.add(singleCssFile);
115159
}
116160
}
117161
}
@@ -175,122 +219,12 @@ export const injectCssPlugin = (): Plugin => {
175219
code = code.slice(0, position) + injections.join('\n') + '\n' + code.slice(position);
176220
}
177221

178-
// Update code and sourcemap
222+
// Update code
179223
outputChunk.code = code;
180224
}
181225
}
182-
183-
// generateBundle(options: NormalizedOutputOptions, bundle: OutputBundle) {
184-
// // First, get all CSS files that actually exist in the output bundle
185-
// const outputCssFiles = new Set<string>();
186-
// for (const file of Object.keys(bundle)) {
187-
// if (file.endsWith('.css')) {
188-
// outputCssFiles.add(file);
189-
// }
190-
// }
191-
//
192-
// console.log('BOSH: CSS files in output bundle:', Array.from(outputCssFiles));
193-
//
194-
// // Build a map of chunk -> CSS files
195-
// const chunkCssMap = new Map<string, Set<string>>();
196-
//
197-
// for (const [moduleId, cssImports] of cssImportMap.entries()) {
198-
// const chunkName = moduleToChunkMap.get(moduleId);
199-
//
200-
// if (chunkName) {
201-
// if (!chunkCssMap.has(chunkName)) {
202-
// chunkCssMap.set(chunkName, new Set());
203-
// }
204-
//
205-
// const chunkCss = chunkCssMap.get(chunkName)!;
206-
// for (const cssImport of cssImports) {
207-
// // Normalize the CSS import path to match output file names
208-
// const normalizedPath = cssImport.replace(/^\.\//, '');
209-
//
210-
// // Only add CSS files that actually exist in the output bundle
211-
// if (outputCssFiles.has(normalizedPath)) {
212-
// chunkCss.add(normalizedPath);
213-
// console.log(`BOSH: Adding CSS ${normalizedPath} to chunk ${chunkName}`);
214-
// } else {
215-
// console.log(
216-
// `BOSH: Skipping CSS ${normalizedPath} (not in output bundle, likely bundled into another CSS file)`
217-
// );
218-
// }
219-
// }
220-
// }
221-
// }
222-
//
223-
// console.log('BOSH: Chunk CSS Map:', Array.from(chunkCssMap.entries()));
224-
//
225-
// // Inject CSS imports into chunks
226-
// for (const chunk of Object.values(bundle)) {
227-
// if (chunk.type !== 'chunk') {
228-
// continue;
229-
// }
230-
//
231-
// const outputChunk = chunk as OutputChunk;
232-
//
233-
// // Skip non-JavaScript files (like .d.ts files)
234-
// if (
235-
// !outputChunk.fileName.endsWith('.js') &&
236-
// !outputChunk.fileName.endsWith('.mjs') &&
237-
// !outputChunk.fileName.endsWith('.cjs')
238-
// ) {
239-
// continue;
240-
// }
241-
//
242-
// const cssFiles = chunkCssMap.get(outputChunk.fileName);
243-
//
244-
// if (!cssFiles || cssFiles.size === 0) {
245-
// console.log(`BOSH: No CSS imports for ${outputChunk.fileName}`);
246-
// continue;
247-
// }
248-
//
249-
// console.log(`BOSH: Injecting ${cssFiles.size} CSS imports into ${outputChunk.fileName}`);
250-
//
251-
// // Find the position to inject CSS imports
252-
// const node = parse<JavaScriptTypes>(Lang.JavaScript, outputChunk.code)
253-
// .root()
254-
// .children()
255-
// .find((node) => !excludeTokens.includes(node.kind()));
256-
//
257-
// const position = node?.range().start.index ?? 0;
258-
//
259-
// // Inject CSS imports at the top of the chunk
260-
// let code = outputChunk.code;
261-
// const injections: string[] = [];
262-
//
263-
// for (const cssFileName of cssFiles) {
264-
// // Resolve the CSS file path relative to the chunk
265-
// let cssFilePath = cssFileName;
266-
//
267-
// // If it's a relative import, keep it relative
268-
// if (cssFilePath.startsWith('./') || cssFilePath.startsWith('../')) {
269-
// // Already relative, use as-is
270-
// } else {
271-
// // Make it relative
272-
// cssFilePath = `./${cssFilePath}`;
273-
// }
274-
//
275-
// const injection = options.format === 'es' ? `import '${cssFilePath}';` : `require('${cssFilePath}');`;
276-
//
277-
// injections.push(injection);
278-
// }
279-
//
280-
// if (injections.length > 0) {
281-
// code = code.slice(0, position) + injections.join('\n') + '\n' + code.slice(position);
282-
// }
283-
//
284-
// // Update code and sourcemap
285-
// outputChunk.code = code;
286-
//
287-
// if (sourcemap && options.sourcemap) {
288-
// const ms = new MagicString(code);
289-
// outputChunk.map = ms.generateMap({ hires: 'boundary' }) as any;
290-
// }
291-
//
292-
// console.log(`BOSH: Successfully injected CSS into ${outputChunk.fileName}`);
293-
// }
294-
// }
295226
};
296227
};
228+
229+
export default injectCssPlugin;
230+
export { injectCssPlugin };

0 commit comments

Comments
 (0)