diff --git a/src/index.ts b/src/index.ts index ec7b41b..450d130 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,13 +35,13 @@ interface Declaration { } interface ParsedFileInfo { - declarations: Declaration[]; - localIdentifiers: ReadonlyMap; - importedIdentifiers: ReadonlyMap< + readonly declarations: readonly Declaration[]; + readonly localIdentifiers: ReadonlyMap; + readonly importedIdentifiers: ReadonlyMap< string, { source: string; imported: string } >; - exportNameToValueMap: ReadonlyMap; + readonly exportNameToValueMap: ReadonlyMap; } // allow .js, .cjs, .mjs, .ts, .cts, .mts, .jsx, .tsx files @@ -58,23 +58,6 @@ function hashText(text: string): string { return createHash('md5').update(text).digest('hex').slice(0, 8); } -/** - * Removes the import statement for 'ecij' - */ -function removeImport(code: string): string { - // TODO: remove via ast - // Remove import { css } from 'ecij'; - return code.replace(/import\s+{\s*css\s*}\s+from\s+['"]ecij['"];?\s*/g, ''); -} - -/** - * Adds import for CSS module at the top of the file - */ -function addCssImport(code: string, cssModuleId: string): string { - // use JSON.stringify to properly escape the module ID, including \ delimiters on Windows - return `import ${JSON.stringify(cssModuleId)};\n\n${code}`; -} - export function ecij({ include = JS_TS_FILE_REGEX, exclude = [NODE_MODULES_REGEX, D_TS_FILE_REGEX], @@ -111,26 +94,45 @@ export function ecij({ code ?? (await context.fs.readFile(filePath, { encoding: 'utf8' })); const parseResult = parseSync(filePath, sourceText); + const declarations: Declaration[] = []; + const localIdentifiers = new Map(); const importedIdentifiers = new Map< string, { source: string; imported: string } >(); + const exportNameToValueMap = new Map(); + const localNameToExportedNameMap = new Map(); + const taggedTemplateExpressionFromVariableDeclarator = + new Set(); + let hasCSSTagImport = false; + + const parsedInfo: ParsedFileInfo = { + declarations, + localIdentifiers, + importedIdentifiers, + exportNameToValueMap, + }; + + parsedFileInfoCache.set(filePath, parsedInfo); // Collect imports for (const staticImport of parseResult.module.staticImports) { for (const entry of staticImport.entries) { // TODO: support default and namespace imports if (entry.importName.kind === 'Name') { - importedIdentifiers.set(entry.localName.value, { - source: staticImport.moduleRequest.value, - imported: entry.importName.name!, - }); + const source = staticImport.moduleRequest.value; + const imported = entry.importName.name!; + const localName = entry.localName.value; + + if (source === 'ecij' && imported === 'css' && localName === 'css') { + hasCSSTagImport = true; + } + + importedIdentifiers.set(localName, { source, imported }); } } } - const localNameToExportedNameMap = new Map(); - // Collect exports for (const staticExport of parseResult.module.staticExports) { for (const entry of staticExport.entries) { @@ -149,13 +151,6 @@ export function ecij({ } } - const declarations: Declaration[] = []; - const localIdentifiers = new Map(); - const exportNameToValueMap = new Map(); - - const taggedTemplateExpressionFromVariableDeclarator = - new Set(); - function recordIdentifierWithValue(localName: string, value: string) { localIdentifiers.set(localName, value); @@ -169,7 +164,13 @@ export function ecij({ localName: string | undefined, node: TaggedTemplateExpression, ) { - if (!(node.tag.type === 'Identifier' && node.tag.name === 'css')) { + if ( + !( + hasCSSTagImport && + node.tag.type === 'Identifier' && + node.tag.name === 'css' + ) + ) { return; } @@ -225,15 +226,6 @@ export function ecij({ visitor.visit(parseResult.program); - const parsedInfo: ParsedFileInfo = { - declarations, - localIdentifiers, - importedIdentifiers, - exportNameToValueMap, - }; - - parsedFileInfoCache.set(filePath, parsedInfo); - return parsedInfo; } @@ -249,7 +241,6 @@ export function ecij({ transformedCode: string; hasExtractions: boolean; cssContent: string; - hasUnprocessedCssBlocks: boolean; }> { const { declarations, localIdentifiers, importedIdentifiers } = await parseFile(context, filePath, code); @@ -368,7 +359,6 @@ export function ecij({ transformedCode: code, hasExtractions: false, cssContent: '', - hasUnprocessedCssBlocks: false, }; } @@ -383,10 +373,6 @@ export function ecij({ transformedCode = `${transformedCode.slice(0, start)}'${className}'${transformedCode.slice(end)}`; } - // If we have any css`` blocks that couldn't be processed (skipped due to unresolved interpolations), - // we shouldn't remove the css import - const hasUnprocessedCssBlocks = declarations.length > replacements.length; - // Sort CSS extractions by source position to maintain original order cssExtractions.sort((a, b) => a.sourcePosition - b.sourcePosition); @@ -405,7 +391,6 @@ export function ecij({ transformedCode, hasExtractions: true, cssContent, - hasUnprocessedCssBlocks, }; } @@ -443,53 +428,44 @@ export function ecij({ }, }, async handler(code, id) { - // Remove query parameters from the ID - const queryIndex = id.indexOf('?'); - const cleanId = queryIndex === -1 ? id : id.slice(0, queryIndex); - // Check if the file references 'ecij' if (!code.includes('ecij')) { return null; } + // Remove query parameters from the ID + const queryIndex = id.indexOf('?'); + const cleanId = queryIndex === -1 ? id : id.slice(0, queryIndex); + // Extract CSS from the code - const { - transformedCode, - hasExtractions, - cssContent, - hasUnprocessedCssBlocks, - } = await extractCssFromCode(this, code, cleanId); + const { transformedCode, hasExtractions, cssContent } = + await extractCssFromCode(this, code, cleanId); if (!hasExtractions) { return null; } - let finalCode = transformedCode; - // Avoid outputing empty CSS modules - if (cssContent !== '') { - // Generate CSS module ID for this file - // A hash of the CSS content is created to make HMR work - // Use the original file path with .css extension - // e.g., /src/components/Button.tsx -> /src/components/Button.tsx.hash.css - const hash = hashText(cssContent); - const cssModuleId = `${cleanId}.${hash}.css`; - - // Store the CSS extractions for this file - extractedCssPerFile.set(cssModuleId, cssContent); - - // Add CSS module import - finalCode = addCssImport(finalCode, cssModuleId); + if (cssContent === '') { + return transformedCode; } - // TODO: let rolldown tree-shake it? - // Only remove the css import if we processed all css`` blocks - if (!hasUnprocessedCssBlocks) { - finalCode = removeImport(finalCode); - } + // Generate CSS module ID for this file + // A hash of the CSS content is created to make HMR work + // Use the original file path with .css extension + // e.g., /src/components/Button.tsx -> /src/components/Button.tsx.hash.css + const hash = hashText(cssContent); + const cssModuleId = `${cleanId}.${hash}.css`; + + // Store the CSS extractions for this file + extractedCssPerFile.set(cssModuleId, cssContent); + + // use JSON.stringify to properly escape the module ID, + // including \ delimiters on Windows. + const importStatement = `import ${JSON.stringify(cssModuleId)};`; - // TODO return sourcemaps - return finalCode; + // Add CSS module import at the top of the file. + return `${importStatement}\n${transformedCode}`; }, }, }; diff --git a/test/fixtures/comprehensive.input.ts b/test/fixtures/comprehensive.input.ts index 5ee02c1..964fd6f 100644 --- a/test/fixtures/comprehensive.input.ts +++ b/test/fixtures/comprehensive.input.ts @@ -1,5 +1,6 @@ import { css } from 'ecij'; import { bgColor as background, redClass, width } from './imported-style'; +import { fontSize, fontWeight } from './imported-literals'; // Basic CSS transformation export const buttonClass = css` @@ -19,12 +20,19 @@ export const secondaryClass = css` color: green; `; +// empty CSS blocks do not produce any CSS output +const emptyClass = css``; + // Local variable interpolation const baseColor = 'red'; const highlightedClass = css` /* highlighted */ color: ${baseColor}; + + &.${emptyClass} { + font-weight: bold; + } `; // Imported variable and class name interpolation @@ -32,6 +40,8 @@ export const importedClass = css` /* imported */ background: ${background}; width: ${width}px; + font-size: ${fontSize}px; + font-weight: ${fontWeight}; &.${redClass} { border-color: red; diff --git a/test/fixtures/empty-css.input.ts b/test/fixtures/empty-css.input.ts new file mode 100644 index 0000000..2f2198e --- /dev/null +++ b/test/fixtures/empty-css.input.ts @@ -0,0 +1,3 @@ +import { css } from 'ecij'; + +export const emptyClass = css``; diff --git a/test/fixtures/imported-literals.ts b/test/fixtures/imported-literals.ts new file mode 100644 index 0000000..3d93408 --- /dev/null +++ b/test/fixtures/imported-literals.ts @@ -0,0 +1,3 @@ +export const fontSize = 16; + +export const fontWeight = 'bold'; diff --git a/test/fixtures/no-ecij.input.ts b/test/fixtures/no-ecij.input.ts index 8f950c2..1532200 100644 --- a/test/fixtures/no-ecij.input.ts +++ b/test/fixtures/no-ecij.input.ts @@ -9,3 +9,10 @@ export const buttonClass = css` color: blue; padding: 10px; `; + +export function getButtonClass() { + return css` + background: green; + padding: 8px 16px; + `; +} diff --git a/test/plugin.test.ts b/test/plugin.test.ts index f8df178..ae18c03 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -39,10 +39,10 @@ test('comprehensive CSS-in-JS patterns', async () => { const buttonClass = "css-39ccb25d"; const primaryClass = "css-7a998145"; const secondaryClass = "css-6c03a746"; - const importedClass = "css-873c0af7"; - const nestedClass = "css-558a1973"; + const importedClass = "css-4f842925"; + const nestedClass = "css-234be203"; function getButtonClass() { - return "css-05de2aa1"; + return "css-6c89bbd7"; } //#endregion @@ -65,31 +65,37 @@ test('comprehensive CSS-in-JS patterns', async () => { color: green; } - .css-51df74aa { + .css-f67b7304 { /* highlighted */ color: red; + + &.css-af173032 { + font-weight: bold; + } } - .css-873c0af7 { + .css-4f842925 { /* imported */ background: white; width: 40.123px; + font-size: 16px; + font-weight: bold; &.css-348273b1 { border-color: red; } } - .css-558a1973 { + .css-234be203 { /* nested */ background: gray; - &.css-51df74aa { + &.css-f67b7304 { color: red; } } - .css-05de2aa1 { + .css-6c89bbd7 { /* inline css */ background: blue; padding: 8px 16px; @@ -124,13 +130,15 @@ test('generate hash based on file path relative to root and file name to avoid n `); }); -// TODO -test.fails('ignore non-ecij css tag functions', async () => { +test('ignore non-ecij css tag functions', async () => { const fixturePath = import.meta.resolve('./fixtures/no-ecij.input.ts'); const result = await buildWithPlugin(fixturePath); expect(result.js).toMatchInlineSnapshot(` "//#region test/fixtures/fake.ts + function css(_) { + return ""; + } function unrelated(_) { return ""; } @@ -138,10 +146,19 @@ test.fails('ignore non-ecij css tag functions', async () => { //#endregion //#region test/fixtures/no-ecij.input.ts const unknown = unrelated\`this is not css\`; - const buttonClass = "css-25e9670b"; + const buttonClass = css\` + color: blue; + padding: 10px; + \`; + function getButtonClass() { + return css\` + background: green; + padding: 8px 16px; + \`; + } //#endregion - export { buttonClass, unknown };" + export { buttonClass, getButtonClass, unknown };" `); // No CSS should be generated @@ -178,6 +195,22 @@ test('skip css blocks with complex interpolations', async () => { expect(result.css).toBeUndefined(); }); +test('skip empty css blocks', async () => { + const fixturePath = import.meta.resolve('./fixtures/empty-css.input.ts'); + const result = await buildWithPlugin(fixturePath); + + expect(result.js).toMatchInlineSnapshot(` + "//#region test/fixtures/empty-css.input.ts + const emptyClass = "css-f993173e"; + + //#endregion + export { emptyClass };" + `); + + // No CSS should be generated + expect(result.css).toBeUndefined(); +}); + test('classPrefix setting', async () => { const fixturePath = import.meta.resolve('./fixtures/basic.input.ts'); const result = await buildWithPlugin(fixturePath, {