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
142 changes: 59 additions & 83 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,13 @@ interface Declaration {
}

interface ParsedFileInfo {
declarations: Declaration[];
localIdentifiers: ReadonlyMap<string, string>;
importedIdentifiers: ReadonlyMap<
readonly declarations: readonly Declaration[];
readonly localIdentifiers: ReadonlyMap<string, string>;
readonly importedIdentifiers: ReadonlyMap<
string,
{ source: string; imported: string }
>;
exportNameToValueMap: ReadonlyMap<string, string>;
readonly exportNameToValueMap: ReadonlyMap<string, string>;
}

// allow .js, .cjs, .mjs, .ts, .cts, .mts, .jsx, .tsx files
Expand All @@ -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],
Expand Down Expand Up @@ -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<string, string>();
const importedIdentifiers = new Map<
string,
{ source: string; imported: string }
>();
const exportNameToValueMap = new Map<string, string>();
const localNameToExportedNameMap = new Map<string, string>();
const taggedTemplateExpressionFromVariableDeclarator =
new Set<TaggedTemplateExpression>();
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<string, string>();

// Collect exports
for (const staticExport of parseResult.module.staticExports) {
for (const entry of staticExport.entries) {
Expand All @@ -149,13 +151,6 @@ export function ecij({
}
}

const declarations: Declaration[] = [];
const localIdentifiers = new Map<string, string>();
const exportNameToValueMap = new Map<string, string>();

const taggedTemplateExpressionFromVariableDeclarator =
new Set<TaggedTemplateExpression>();

function recordIdentifierWithValue(localName: string, value: string) {
localIdentifiers.set(localName, value);

Expand All @@ -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;
}

Expand Down Expand Up @@ -225,15 +226,6 @@ export function ecij({

visitor.visit(parseResult.program);

const parsedInfo: ParsedFileInfo = {
declarations,
localIdentifiers,
importedIdentifiers,
exportNameToValueMap,
};

parsedFileInfoCache.set(filePath, parsedInfo);

return parsedInfo;
}

Expand All @@ -249,7 +241,6 @@ export function ecij({
transformedCode: string;
hasExtractions: boolean;
cssContent: string;
hasUnprocessedCssBlocks: boolean;
}> {
const { declarations, localIdentifiers, importedIdentifiers } =
await parseFile(context, filePath, code);
Expand Down Expand Up @@ -368,7 +359,6 @@ export function ecij({
transformedCode: code,
hasExtractions: false,
cssContent: '',
hasUnprocessedCssBlocks: false,
};
}

Expand All @@ -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);

Expand All @@ -405,7 +391,6 @@ export function ecij({
transformedCode,
hasExtractions: true,
cssContent,
hasUnprocessedCssBlocks,
};
}

Expand Down Expand Up @@ -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}`;
},
},
};
Expand Down
10 changes: 10 additions & 0 deletions test/fixtures/comprehensive.input.ts
Original file line number Diff line number Diff line change
@@ -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`
Expand All @@ -19,19 +20,28 @@ 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
export const importedClass = css`
/* imported */
background: ${background};
width: ${width}px;
font-size: ${fontSize}px;
font-weight: ${fontWeight};

&.${redClass} {
border-color: red;
Expand Down
3 changes: 3 additions & 0 deletions test/fixtures/empty-css.input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { css } from 'ecij';

export const emptyClass = css``;
3 changes: 3 additions & 0 deletions test/fixtures/imported-literals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const fontSize = 16;

export const fontWeight = 'bold';
7 changes: 7 additions & 0 deletions test/fixtures/no-ecij.input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,10 @@ export const buttonClass = css`
color: blue;
padding: 10px;
`;

export function getButtonClass() {
return css`
background: green;
padding: 8px 16px;
`;
}
Loading