ecij 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +40 -2
  2. package/dist/index.js +87 -115
  3. package/package.json +16 -8
package/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # ecij
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/ecij)](https://www.npmjs.com/package/ecij)
4
+ [![CI](https://github.com/nstepien/ecij/actions/workflows/ci.yml/badge.svg)](https://github.com/nstepien/ecij/actions/workflows/ci.yml)
5
+
3
6
  ecij (**E**xtract **C**SS-**i**n-**J**S) is a zero-runtime css-in-js plugin for [Rolldown](https://rolldown.rs/) and [Vite](https://vite.dev/).
4
7
 
5
8
  It achieves this via static analysis by using [oxc-parser](https://www.npmjs.com/package/oxc-parser), as such it is limited to static expressions. The plugin will ignore dynamic or complex expressions.
@@ -8,7 +11,7 @@ The plugin does not process the CSS in any way whatsoever, it is merely output i
8
11
 
9
12
  ## Installation
10
13
 
11
- ```
14
+ ```bash
12
15
  npm install -D ecij
13
16
  ```
14
17
 
@@ -130,9 +133,44 @@ ecij({
130
133
  });
131
134
  ```
132
135
 
136
+ ## Development
137
+
138
+ ### Building
139
+
140
+ ```bash
141
+ npm run build
142
+ ```
143
+
144
+ ### Formatting
145
+
146
+ ```bash
147
+ npm run format
148
+ ```
149
+
150
+ ### Type Checking
151
+
152
+ ```bash
153
+ npm run typecheck
154
+ ```
155
+
156
+ ### Running Tests
157
+
158
+ The project uses **integration tests** with **inline snapshot testing** to validate transformations.
159
+
160
+ ```bash
161
+ # Run tests once
162
+ npm test
163
+
164
+ # Run tests with coverage
165
+ npm run test:coverage
166
+
167
+ # Update inline snapshots after intentional changes
168
+ npm test -- -u
169
+ ```
170
+
133
171
  ## TODO
134
172
 
135
- - Tests
173
+ - Log CSS extraction failures
136
174
  - Scope handling
137
175
  - Validate that the `css` used refers to the ecij export
138
176
  - Full import/export handling (default/namespace import/export)
package/dist/index.js CHANGED
@@ -12,55 +12,65 @@ const PROJECT_ROOT = cwd();
12
12
  function hashText(text) {
13
13
  return createHash("md5").update(text).digest("hex").slice(0, 8);
14
14
  }
15
- /**
16
- * Removes the import statement for 'ecij'
17
- */
18
- function removeImport(code) {
19
- return code.replace(/import\s+{\s*css\s*}\s+from\s+['"]ecij['"];?\s*/g, "");
20
- }
21
- /**
22
- * Adds import for CSS module at the top of the file
23
- */
24
- function addCssImport(code, cssModuleId) {
25
- return `import ${JSON.stringify(cssModuleId)};\n\n${code}`;
26
- }
27
15
  function ecij({ include = JS_TS_FILE_REGEX, exclude = [NODE_MODULES_REGEX, D_TS_FILE_REGEX], classPrefix = "css-" } = {}) {
16
+ const parsedFileInfoCache = /* @__PURE__ */ new Map();
28
17
  const extractedCssPerFile = /* @__PURE__ */ new Map();
29
- const importedClassNameCache = /* @__PURE__ */ new Map();
30
- /**
31
- * Generates a consistent, unique class name based on file path and variable name or index
32
- */
33
- function generateClassName(filePath, index, variableName) {
34
- return `${classPrefix}${hashText(`${relative(PROJECT_ROOT, filePath).replaceAll("\\", "/")}:${index}:${variableName}`)}`;
35
- }
36
18
  /**
37
- * Resolves an imported class name by reading and processing the source file
19
+ * Parses a file and extracts all relevant information in a single pass
38
20
  */
39
- async function resolveImportedClassName(context, importerPath, importSource, exportedName) {
40
- const resolvedId = await context.resolve(importSource, importerPath);
41
- if (resolvedId == null) return;
42
- const resolvedPath = resolvedId.id;
43
- const cacheKey = `${resolvedPath}:${exportedName}`;
44
- if (importedClassNameCache.has(cacheKey)) return importedClassNameCache.get(cacheKey);
45
- const parseResult = parseSync(resolvedPath, await context.fs.readFile(resolvedPath, { encoding: "utf8" }));
21
+ async function parseFile(context, filePath, code) {
22
+ if (code === void 0 && parsedFileInfoCache.has(filePath)) return parsedFileInfoCache.get(filePath);
23
+ const relativePath = relative(PROJECT_ROOT, filePath).replaceAll("\\", "/");
24
+ const parseResult = parseSync(filePath, code ?? await context.fs.readFile(filePath, { encoding: "utf8" }));
25
+ const declarations = [];
26
+ const localIdentifiers = /* @__PURE__ */ new Map();
27
+ const importedIdentifiers = /* @__PURE__ */ new Map();
28
+ const exportNameToValueMap = /* @__PURE__ */ new Map();
46
29
  const localNameToExportedNameMap = /* @__PURE__ */ new Map();
30
+ const taggedTemplateExpressionFromVariableDeclarator = /* @__PURE__ */ new Set();
31
+ let hasCSSTagImport = false;
32
+ const parsedInfo = {
33
+ declarations,
34
+ localIdentifiers,
35
+ importedIdentifiers,
36
+ exportNameToValueMap
37
+ };
38
+ parsedFileInfoCache.set(filePath, parsedInfo);
39
+ for (const staticImport of parseResult.module.staticImports) for (const entry of staticImport.entries) if (entry.importName.kind === "Name") {
40
+ const source = staticImport.moduleRequest.value;
41
+ const imported = entry.importName.name;
42
+ const localName = entry.localName.value;
43
+ if (source === "ecij" && imported === "css" && localName === "css") hasCSSTagImport = true;
44
+ importedIdentifiers.set(localName, {
45
+ source,
46
+ imported
47
+ });
48
+ }
47
49
  for (const staticExport of parseResult.module.staticExports) for (const entry of staticExport.entries) {
48
50
  if (entry.importName.kind !== "None") continue;
49
51
  if (entry.exportName.kind === "Name" && entry.localName.kind === "Name") {
50
52
  const localName = entry.localName.name;
51
- const exportedName$1 = entry.exportName.name;
52
- localNameToExportedNameMap.set(localName, exportedName$1);
53
+ const exportedName = entry.exportName.name;
54
+ localNameToExportedNameMap.set(localName, exportedName);
53
55
  }
54
56
  }
55
- const declarations = [];
56
- const taggedTemplateExpressionFromVariableDeclarator = /* @__PURE__ */ new Set();
57
- function handleTaggedTemplateExpression(varName, node) {
58
- if (node.tag.type === "Identifier" && node.tag.name === "css") declarations.push({
59
- index: declarations.length,
60
- varName,
57
+ function recordIdentifierWithValue(localName, value) {
58
+ localIdentifiers.set(localName, value);
59
+ if (localNameToExportedNameMap.has(localName)) {
60
+ const exportedName = localNameToExportedNameMap.get(localName);
61
+ exportNameToValueMap.set(exportedName, value);
62
+ }
63
+ }
64
+ function handleTaggedTemplateExpression(localName, node) {
65
+ if (!(hasCSSTagImport && node.tag.type === "Identifier" && node.tag.name === "css")) return;
66
+ const index = declarations.length;
67
+ const className = `${classPrefix}${hashText(`${relativePath}:${index}:${localName}`)}`;
68
+ declarations.push({
69
+ className,
61
70
  node,
62
- hasInterpolations: node.quasi.expressions.length > 0
71
+ hasInterpolations: node.quasi.expressions.length !== 0
63
72
  });
73
+ if (localName !== void 0) recordIdentifierWithValue(localName, className);
64
74
  }
65
75
  new Visitor({
66
76
  VariableDeclarator(node) {
@@ -69,85 +79,48 @@ function ecij({ include = JS_TS_FILE_REGEX, exclude = [NODE_MODULES_REGEX, D_TS_
69
79
  if (node.init.type === "TaggedTemplateExpression") {
70
80
  taggedTemplateExpressionFromVariableDeclarator.add(node.init);
71
81
  handleTaggedTemplateExpression(localName, node.init);
72
- } else if (node.init.type === "Literal" && (typeof node.init.value === "string" || typeof node.init.value === "number")) {
73
- const exportedName$1 = localNameToExportedNameMap.get(localName);
74
- if (exportedName$1 === void 0) return;
75
- const cacheKey$1 = `${resolvedPath}:${exportedName$1}`;
76
- importedClassNameCache.set(cacheKey$1, String(node.init.value));
77
- }
82
+ } else if (node.init.type === "Literal" && (typeof node.init.value === "string" || typeof node.init.value === "number")) recordIdentifierWithValue(localName, String(node.init.value));
78
83
  },
79
84
  TaggedTemplateExpression(node) {
80
- if (taggedTemplateExpressionFromVariableDeclarator.has(node)) return;
81
- handleTaggedTemplateExpression(void 0, node);
85
+ if (!taggedTemplateExpressionFromVariableDeclarator.has(node)) handleTaggedTemplateExpression(void 0, node);
82
86
  }
83
87
  }).visit(parseResult.program);
84
- for (const declaration of declarations) {
85
- const localName = declaration.varName;
86
- if (localName === void 0) continue;
87
- const exportedName$1 = localNameToExportedNameMap.get(localName);
88
- if (exportedName$1 === void 0) continue;
89
- const cacheKey$1 = `${resolvedPath}:${exportedName$1}`;
90
- const className = generateClassName(resolvedPath, declaration.index, localName);
91
- importedClassNameCache.set(cacheKey$1, className);
92
- }
93
- return importedClassNameCache.get(cacheKey);
88
+ return parsedInfo;
94
89
  }
95
90
  /**
96
91
  * Extracts CSS from template literals in the source code using AST parsing
97
- * Supports interpolations of class names (both local and imported)
92
+ * Supports interpolations of strings and numbers (both local and imported)
98
93
  */
99
94
  async function extractCssFromCode(context, code, filePath) {
95
+ const { declarations, localIdentifiers, importedIdentifiers } = await parseFile(context, filePath, code);
100
96
  const cssExtractions = [];
101
97
  const replacements = [];
102
- const localClassNames = /* @__PURE__ */ new Map();
103
- const imports = /* @__PURE__ */ new Map();
104
- function resolveClassName(identifierName) {
105
- if (localClassNames.has(identifierName)) return localClassNames.get(identifierName);
106
- if (imports.has(identifierName)) {
107
- const { source, imported } = imports.get(identifierName);
108
- return resolveImportedClassName(context, filePath, source, imported);
98
+ const modulesWithSideEffects = /* @__PURE__ */ new Set();
99
+ async function resolveValue(identifierName) {
100
+ if (localIdentifiers.has(identifierName)) return localIdentifiers.get(identifierName);
101
+ if (importedIdentifiers.has(identifierName)) {
102
+ const { source, imported } = importedIdentifiers.get(identifierName);
103
+ const resolvedId = await context.resolve(source, filePath);
104
+ if (resolvedId != null) {
105
+ const { id } = resolvedId;
106
+ const { declarations: declarations$1, exportNameToValueMap } = await parseFile(context, id);
107
+ if (exportNameToValueMap.has(imported)) {
108
+ if (declarations$1.length !== 0) modulesWithSideEffects.add(id);
109
+ return exportNameToValueMap.get(imported);
110
+ }
111
+ }
109
112
  }
110
113
  }
111
- const parseResult = parseSync(filePath, code);
112
- for (const staticImport of parseResult.module.staticImports) for (const entry of staticImport.entries) if (entry.importName.kind === "Name") imports.set(entry.localName.value, {
113
- source: staticImport.moduleRequest.value,
114
- imported: entry.importName.name
115
- });
116
- const declarations = [];
117
- const taggedTemplateExpressionFromVariableDeclarator = /* @__PURE__ */ new Set();
118
- function handleTaggedTemplateExpression(varName, node) {
119
- if (node.tag.type === "Identifier" && node.tag.name === "css") declarations.push({
120
- index: declarations.length,
121
- varName,
122
- node,
123
- hasInterpolations: node.quasi.expressions.length > 0
124
- });
125
- }
126
- new Visitor({
127
- VariableDeclarator(node) {
128
- if (node.init === null || node.id.type !== "Identifier") return;
129
- const localName = node.id.name;
130
- if (node.init.type === "TaggedTemplateExpression") {
131
- taggedTemplateExpressionFromVariableDeclarator.add(node.init);
132
- handleTaggedTemplateExpression(localName, node.init);
133
- } else if (node.init.type === "Literal" && (typeof node.init.value === "string" || typeof node.init.value === "number")) localClassNames.set(localName, String(node.init.value));
134
- },
135
- TaggedTemplateExpression(node) {
136
- if (taggedTemplateExpressionFromVariableDeclarator.has(node)) return;
137
- handleTaggedTemplateExpression(void 0, node);
138
- }
139
- }).visit(parseResult.program);
140
114
  function addProcessedDeclaration(declaration, cssContent$1) {
141
- const className = generateClassName(filePath, declaration.index, declaration.varName);
142
- if (declaration.varName) localClassNames.set(declaration.varName, className);
115
+ const { className, node } = declaration;
143
116
  cssExtractions.push({
144
117
  className,
145
118
  cssContent: cssContent$1.trim(),
146
- sourcePosition: declaration.node.start
119
+ sourcePosition: node.start
147
120
  });
148
121
  replacements.push({
149
- start: declaration.node.start,
150
- end: declaration.node.end,
122
+ start: node.start,
123
+ end: node.end,
151
124
  className
152
125
  });
153
126
  }
@@ -170,12 +143,12 @@ function ecij({ include = JS_TS_FILE_REGEX, exclude = [NODE_MODULES_REGEX, D_TS_
170
143
  break;
171
144
  }
172
145
  const identifierName = expression.name;
173
- const resolvedClassName = await resolveClassName(identifierName);
174
- if (resolvedClassName === void 0) {
146
+ const resolvedValue = await resolveValue(identifierName);
147
+ if (resolvedValue === void 0) {
175
148
  allResolved = false;
176
149
  break;
177
150
  }
178
- cssContent$1 += resolvedClassName;
151
+ cssContent$1 += resolvedValue;
179
152
  }
180
153
  }
181
154
  if (allResolved) addProcessedDeclaration(declaration, cssContent$1);
@@ -184,12 +157,11 @@ function ecij({ include = JS_TS_FILE_REGEX, exclude = [NODE_MODULES_REGEX, D_TS_
184
157
  transformedCode: code,
185
158
  hasExtractions: false,
186
159
  cssContent: "",
187
- hasUnprocessedCssBlocks: false
160
+ modulesWithSideEffects
188
161
  };
189
162
  replacements.sort((a, b) => b.start - a.start);
190
163
  let transformedCode = code;
191
164
  for (const { start, end, className } of replacements) transformedCode = `${transformedCode.slice(0, start)}'${className}'${transformedCode.slice(end)}`;
192
- const hasUnprocessedCssBlocks = declarations.length > replacements.length;
193
165
  cssExtractions.sort((a, b) => a.sourcePosition - b.sourcePosition);
194
166
  const cssBlocks = [];
195
167
  for (const { className, cssContent: cssContent$1 } of cssExtractions) if (cssContent$1 !== "") cssBlocks.push(`.${className} {\n ${cssContent$1}\n}`);
@@ -198,17 +170,18 @@ function ecij({ include = JS_TS_FILE_REGEX, exclude = [NODE_MODULES_REGEX, D_TS_
198
170
  transformedCode,
199
171
  hasExtractions: true,
200
172
  cssContent,
201
- hasUnprocessedCssBlocks
173
+ modulesWithSideEffects
202
174
  };
203
175
  }
204
176
  return {
205
177
  name: "ecij",
206
- buildStart() {
178
+ buildEnd() {
179
+ parsedFileInfoCache.clear();
207
180
  extractedCssPerFile.clear();
208
- importedClassNameCache.clear();
209
181
  },
210
182
  resolveId(id) {
211
- if (extractedCssPerFile.has(id)) return { id };
183
+ if (extractedCssPerFile.has(id)) return id;
184
+ if (parsedFileInfoCache.has(id) && parsedFileInfoCache.get(id).declarations.length !== 0) return id;
212
185
  return null;
213
186
  },
214
187
  load(id) {
@@ -221,19 +194,18 @@ function ecij({ include = JS_TS_FILE_REGEX, exclude = [NODE_MODULES_REGEX, D_TS_
221
194
  exclude: makeIdFiltersToMatchWithQuery(exclude)
222
195
  } },
223
196
  async handler(code, id) {
197
+ if (!code.includes("ecij")) return null;
224
198
  const queryIndex = id.indexOf("?");
225
199
  const cleanId = queryIndex === -1 ? id : id.slice(0, queryIndex);
226
- if (!code.includes("ecij")) return null;
227
- const { transformedCode, hasExtractions, cssContent, hasUnprocessedCssBlocks } = await extractCssFromCode(this, code, cleanId);
200
+ const { transformedCode, hasExtractions, cssContent, modulesWithSideEffects } = await extractCssFromCode(this, code, cleanId);
228
201
  if (!hasExtractions) return null;
229
- let finalCode = transformedCode;
230
- if (cssContent !== "") {
231
- const cssModuleId = `${cleanId}.${hashText(cssContent)}.css`;
232
- extractedCssPerFile.set(cssModuleId, cssContent);
233
- finalCode = addCssImport(finalCode, cssModuleId);
234
- }
235
- if (!hasUnprocessedCssBlocks) finalCode = removeImport(finalCode);
236
- return finalCode;
202
+ if (cssContent === "") return transformedCode;
203
+ const cssModuleId = `${cleanId}.${hashText(cssContent)}.css`;
204
+ extractedCssPerFile.set(cssModuleId, cssContent);
205
+ const importStatements = [];
206
+ for (const id$1 of modulesWithSideEffects) importStatements.push(`import ${JSON.stringify(id$1)};\n`);
207
+ importStatements.push(`import ${JSON.stringify(cssModuleId)}\n;`);
208
+ return `${importStatements.join("")}${transformedCode}`;
237
209
  }
238
210
  }
239
211
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ecij",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Rolldown and Vite plugin to Extract CSS-in-JS",
5
5
  "keywords": [
6
6
  "css-in-js"
@@ -36,17 +36,25 @@
36
36
  "build": "rolldown -c",
37
37
  "format": "prettier --write .",
38
38
  "format:check": "prettier --check .",
39
- "typecheck": "tsc"
39
+ "typecheck": "tsc --build",
40
+ "test": "vitest run",
41
+ "test:coverage": "vitest run --coverage"
40
42
  },
41
43
  "dependencies": {
42
- "@rolldown/pluginutils": "^1.0.0-beta.50",
43
- "oxc-parser": "^0.98.0"
44
+ "@rolldown/pluginutils": "^1.0.0-beta.53",
45
+ "oxc-parser": "^0.102.0"
44
46
  },
45
47
  "devDependencies": {
46
48
  "@types/node": "^24.10.1",
47
- "prettier": "^3.6.2",
48
- "rolldown": "^1.0.0-beta.50",
49
- "rolldown-plugin-dts": "^0.17.8",
50
- "typescript": "^5.9.3"
49
+ "@vitest/coverage-v8": "^4.0.15",
50
+ "prettier": "^3.7.4",
51
+ "rolldown": "^1.0.0-beta.53",
52
+ "rolldown-plugin-dts": "^0.18.3",
53
+ "typescript": "^5.9.3",
54
+ "vite": "npm:rolldown-vite@^7.2.10",
55
+ "vitest": "^4.0.15"
56
+ },
57
+ "overrides": {
58
+ "vite": "$vite"
51
59
  }
52
60
  }