ecij 0.1.1 → 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.
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
 
@@ -99,6 +102,20 @@ The `ecij()` plugin accepts an optional configuration object:
99
102
 
100
103
  ```ts
101
104
  export interface Configuration {
105
+ /**
106
+ * Include patterns for files to process.
107
+ * Can be a string, RegExp, or array of strings/RegExp.
108
+ * @default /\.[cm]?[jt]sx?$/
109
+ */
110
+ include?: string | RegExp | ReadonlyArray<string | RegExp>;
111
+
112
+ /**
113
+ * Exclude patterns for files to skip.
114
+ * Can be a string, RegExp, or array of strings/RegExp.
115
+ * @default [/\/node_modules\//, /\.d\.ts$/]
116
+ */
117
+ exclude?: string | RegExp | ReadonlyArray<string | RegExp>;
118
+
102
119
  /**
103
120
  * Prefix for generated CSS class names.
104
121
  * Should not be empty, as generated hashes may start with a digit, resulting in invalid CSS class names.
@@ -116,11 +133,45 @@ ecij({
116
133
  });
117
134
  ```
118
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
+
119
171
  ## TODO
120
172
 
121
- - Tests
122
- - Support `include`/`exclude` configuration
173
+ - Log CSS extraction failures
123
174
  - Scope handling
124
- - Validate that the `css` used refers to
175
+ - Validate that the `css` used refers to the ecij export
125
176
  - Full import/export handling (default/namespace import/export)
126
177
  - Sourcemaps
package/dist/index.d.ts CHANGED
@@ -2,6 +2,18 @@ import { Plugin } from "rolldown";
2
2
 
3
3
  //#region src/index.d.ts
4
4
  interface Configuration {
5
+ /**
6
+ * Include patterns for files to process.
7
+ * Can be a string, RegExp, or array of strings/RegExp.
8
+ * @default /\.[cm]?[jt]sx?$/
9
+ */
10
+ include?: string | RegExp | ReadonlyArray<string | RegExp>;
11
+ /**
12
+ * Exclude patterns for files to skip.
13
+ * Can be a string, RegExp, or array of strings/RegExp.
14
+ * @default [/\/node_modules\//, /\.d\.ts$/]
15
+ */
16
+ exclude?: string | RegExp | ReadonlyArray<string | RegExp>;
5
17
  /**
6
18
  * Prefix for generated CSS class names.
7
19
  * Should not be empty, as generated hashes may start with a digit, resulting in invalid CSS class names.
@@ -10,6 +22,8 @@ interface Configuration {
10
22
  classPrefix?: string;
11
23
  }
12
24
  declare function ecij({
25
+ include,
26
+ exclude,
13
27
  classPrefix
14
28
  }?: Configuration): Plugin;
15
29
  //#endregion
package/dist/index.js CHANGED
@@ -2,62 +2,75 @@ import { createHash } from "node:crypto";
2
2
  import { relative } from "node:path";
3
3
  import { cwd } from "node:process";
4
4
  import { Visitor, parseSync } from "oxc-parser";
5
+ import { makeIdFiltersToMatchWithQuery } from "@rolldown/pluginutils";
5
6
 
6
7
  //#region src/index.ts
7
- const JS_TS_FILE_REGEX = /\.[jtc]sx?$/;
8
+ const JS_TS_FILE_REGEX = /\.[cm]?[jt]sx?$/;
9
+ const NODE_MODULES_REGEX = /\/node_modules\//;
10
+ const D_TS_FILE_REGEX = /\.d\.ts$/;
8
11
  const PROJECT_ROOT = cwd();
9
12
  function hashText(text) {
10
13
  return createHash("md5").update(text).digest("hex").slice(0, 8);
11
14
  }
12
- /**
13
- * Removes the import statement for 'ecij'
14
- */
15
- function removeImport(code) {
16
- return code.replace(/import\s+{\s*css\s*}\s+from\s+['"]ecij['"];?\s*/g, "");
17
- }
18
- /**
19
- * Adds import for CSS module at the top of the file
20
- */
21
- function addCssImport(code, cssModuleId) {
22
- return `import ${JSON.stringify(cssModuleId)};\n\n${code}`;
23
- }
24
- function ecij({ classPrefix = "css-" } = {}) {
15
+ function ecij({ include = JS_TS_FILE_REGEX, exclude = [NODE_MODULES_REGEX, D_TS_FILE_REGEX], classPrefix = "css-" } = {}) {
16
+ const parsedFileInfoCache = /* @__PURE__ */ new Map();
25
17
  const extractedCssPerFile = /* @__PURE__ */ new Map();
26
- const importedClassNameCache = /* @__PURE__ */ new Map();
27
- /**
28
- * Generates a consistent, unique class name based on file path and variable name or index
29
- */
30
- function generateClassName(filePath, index, variableName) {
31
- return `${classPrefix}${hashText(`${relative(PROJECT_ROOT, filePath).replaceAll("\\", "/")}:${index}:${variableName}`)}`;
32
- }
33
18
  /**
34
- * 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
35
20
  */
36
- async function resolveImportedClassName(context, importerPath, importSource, exportedName) {
37
- const resolvedId = await context.resolve(importSource, importerPath);
38
- if (resolvedId == null) return;
39
- const resolvedPath = resolvedId.id;
40
- const cacheKey = `${resolvedPath}:${exportedName}`;
41
- if (importedClassNameCache.has(cacheKey)) return importedClassNameCache.get(cacheKey);
42
- 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();
43
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
+ }
44
49
  for (const staticExport of parseResult.module.staticExports) for (const entry of staticExport.entries) {
45
50
  if (entry.importName.kind !== "None") continue;
46
51
  if (entry.exportName.kind === "Name" && entry.localName.kind === "Name") {
47
52
  const localName = entry.localName.name;
48
- const exportedName$1 = entry.exportName.name;
49
- localNameToExportedNameMap.set(localName, exportedName$1);
53
+ const exportedName = entry.exportName.name;
54
+ localNameToExportedNameMap.set(localName, exportedName);
50
55
  }
51
56
  }
52
- const declarations = [];
53
- const taggedTemplateExpressionFromVariableDeclarator = /* @__PURE__ */ new Set();
54
- function handleTaggedTemplateExpression(varName, node) {
55
- if (node.tag.type === "Identifier" && node.tag.name === "css") declarations.push({
56
- index: declarations.length,
57
- 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,
58
70
  node,
59
- hasInterpolations: node.quasi.expressions.length > 0
71
+ hasInterpolations: node.quasi.expressions.length !== 0
60
72
  });
73
+ if (localName !== void 0) recordIdentifierWithValue(localName, className);
61
74
  }
62
75
  new Visitor({
63
76
  VariableDeclarator(node) {
@@ -66,85 +79,48 @@ function ecij({ classPrefix = "css-" } = {}) {
66
79
  if (node.init.type === "TaggedTemplateExpression") {
67
80
  taggedTemplateExpressionFromVariableDeclarator.add(node.init);
68
81
  handleTaggedTemplateExpression(localName, node.init);
69
- } else if (node.init.type === "Literal" && typeof node.init.value === "string") {
70
- const exportedName$1 = localNameToExportedNameMap.get(localName);
71
- if (exportedName$1 === void 0) return;
72
- const cacheKey$1 = `${resolvedPath}:${exportedName$1}`;
73
- importedClassNameCache.set(cacheKey$1, node.init.value);
74
- }
82
+ } else if (node.init.type === "Literal" && (typeof node.init.value === "string" || typeof node.init.value === "number")) recordIdentifierWithValue(localName, String(node.init.value));
75
83
  },
76
84
  TaggedTemplateExpression(node) {
77
- if (taggedTemplateExpressionFromVariableDeclarator.has(node)) return;
78
- handleTaggedTemplateExpression(void 0, node);
85
+ if (!taggedTemplateExpressionFromVariableDeclarator.has(node)) handleTaggedTemplateExpression(void 0, node);
79
86
  }
80
87
  }).visit(parseResult.program);
81
- for (const declaration of declarations) {
82
- const localName = declaration.varName;
83
- if (localName === void 0) continue;
84
- const exportedName$1 = localNameToExportedNameMap.get(localName);
85
- if (exportedName$1 === void 0) continue;
86
- const cacheKey$1 = `${resolvedPath}:${exportedName$1}`;
87
- const className = generateClassName(resolvedPath, declaration.index, localName);
88
- importedClassNameCache.set(cacheKey$1, className);
89
- }
90
- return importedClassNameCache.get(cacheKey);
88
+ return parsedInfo;
91
89
  }
92
90
  /**
93
91
  * Extracts CSS from template literals in the source code using AST parsing
94
- * Supports interpolations of class names (both local and imported)
92
+ * Supports interpolations of strings and numbers (both local and imported)
95
93
  */
96
94
  async function extractCssFromCode(context, code, filePath) {
95
+ const { declarations, localIdentifiers, importedIdentifiers } = await parseFile(context, filePath, code);
97
96
  const cssExtractions = [];
98
97
  const replacements = [];
99
- const localClassNames = /* @__PURE__ */ new Map();
100
- const imports = /* @__PURE__ */ new Map();
101
- function resolveClassName(identifierName) {
102
- if (localClassNames.has(identifierName)) return localClassNames.get(identifierName);
103
- if (imports.has(identifierName)) {
104
- const { source, imported } = imports.get(identifierName);
105
- 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
+ }
106
112
  }
107
113
  }
108
- const parseResult = parseSync(filePath, code);
109
- for (const staticImport of parseResult.module.staticImports) for (const entry of staticImport.entries) if (entry.importName.kind === "Name") imports.set(entry.localName.value, {
110
- source: staticImport.moduleRequest.value,
111
- imported: entry.importName.name
112
- });
113
- const declarations = [];
114
- const taggedTemplateExpressionFromVariableDeclarator = /* @__PURE__ */ new Set();
115
- function handleTaggedTemplateExpression(varName, node) {
116
- if (node.tag.type === "Identifier" && node.tag.name === "css") declarations.push({
117
- index: declarations.length,
118
- varName,
119
- node,
120
- hasInterpolations: node.quasi.expressions.length > 0
121
- });
122
- }
123
- new Visitor({
124
- VariableDeclarator(node) {
125
- if (node.init === null || node.id.type !== "Identifier") return;
126
- const localName = node.id.name;
127
- if (node.init.type === "TaggedTemplateExpression") {
128
- taggedTemplateExpressionFromVariableDeclarator.add(node.init);
129
- handleTaggedTemplateExpression(localName, node.init);
130
- } else if (node.init.type === "Literal" && typeof node.init.value === "string") localClassNames.set(localName, node.init.value);
131
- },
132
- TaggedTemplateExpression(node) {
133
- if (taggedTemplateExpressionFromVariableDeclarator.has(node)) return;
134
- handleTaggedTemplateExpression(void 0, node);
135
- }
136
- }).visit(parseResult.program);
137
114
  function addProcessedDeclaration(declaration, cssContent$1) {
138
- const className = generateClassName(filePath, declaration.index, declaration.varName);
139
- if (declaration.varName) localClassNames.set(declaration.varName, className);
115
+ const { className, node } = declaration;
140
116
  cssExtractions.push({
141
117
  className,
142
118
  cssContent: cssContent$1.trim(),
143
- sourcePosition: declaration.node.start
119
+ sourcePosition: node.start
144
120
  });
145
121
  replacements.push({
146
- start: declaration.node.start,
147
- end: declaration.node.end,
122
+ start: node.start,
123
+ end: node.end,
148
124
  className
149
125
  });
150
126
  }
@@ -167,12 +143,12 @@ function ecij({ classPrefix = "css-" } = {}) {
167
143
  break;
168
144
  }
169
145
  const identifierName = expression.name;
170
- const resolvedClassName = await resolveClassName(identifierName);
171
- if (resolvedClassName === void 0) {
146
+ const resolvedValue = await resolveValue(identifierName);
147
+ if (resolvedValue === void 0) {
172
148
  allResolved = false;
173
149
  break;
174
150
  }
175
- cssContent$1 += resolvedClassName;
151
+ cssContent$1 += resolvedValue;
176
152
  }
177
153
  }
178
154
  if (allResolved) addProcessedDeclaration(declaration, cssContent$1);
@@ -181,12 +157,11 @@ function ecij({ classPrefix = "css-" } = {}) {
181
157
  transformedCode: code,
182
158
  hasExtractions: false,
183
159
  cssContent: "",
184
- hasUnprocessedCssBlocks: false
160
+ modulesWithSideEffects
185
161
  };
186
162
  replacements.sort((a, b) => b.start - a.start);
187
163
  let transformedCode = code;
188
164
  for (const { start, end, className } of replacements) transformedCode = `${transformedCode.slice(0, start)}'${className}'${transformedCode.slice(end)}`;
189
- const hasUnprocessedCssBlocks = declarations.length > replacements.length;
190
165
  cssExtractions.sort((a, b) => a.sourcePosition - b.sourcePosition);
191
166
  const cssBlocks = [];
192
167
  for (const { className, cssContent: cssContent$1 } of cssExtractions) if (cssContent$1 !== "") cssBlocks.push(`.${className} {\n ${cssContent$1}\n}`);
@@ -195,38 +170,43 @@ function ecij({ classPrefix = "css-" } = {}) {
195
170
  transformedCode,
196
171
  hasExtractions: true,
197
172
  cssContent,
198
- hasUnprocessedCssBlocks
173
+ modulesWithSideEffects
199
174
  };
200
175
  }
201
176
  return {
202
177
  name: "ecij",
203
- buildStart() {
178
+ buildEnd() {
179
+ parsedFileInfoCache.clear();
204
180
  extractedCssPerFile.clear();
205
- importedClassNameCache.clear();
206
181
  },
207
182
  resolveId(id) {
208
- 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;
209
185
  return null;
210
186
  },
211
187
  load(id) {
212
188
  if (extractedCssPerFile.has(id)) return extractedCssPerFile.get(id);
213
189
  return null;
214
190
  },
215
- async transform(code, id) {
216
- const queryIndex = id.indexOf("?");
217
- const cleanId = queryIndex === -1 ? id : id.slice(0, queryIndex);
218
- if (!JS_TS_FILE_REGEX.test(cleanId)) return null;
219
- if (!code.includes("ecij")) return null;
220
- const { transformedCode, hasExtractions, cssContent, hasUnprocessedCssBlocks } = await extractCssFromCode(this, code, cleanId);
221
- if (!hasExtractions) return null;
222
- let finalCode = transformedCode;
223
- if (cssContent !== "") {
191
+ transform: {
192
+ filter: { id: {
193
+ include: makeIdFiltersToMatchWithQuery(include),
194
+ exclude: makeIdFiltersToMatchWithQuery(exclude)
195
+ } },
196
+ async handler(code, id) {
197
+ if (!code.includes("ecij")) return null;
198
+ const queryIndex = id.indexOf("?");
199
+ const cleanId = queryIndex === -1 ? id : id.slice(0, queryIndex);
200
+ const { transformedCode, hasExtractions, cssContent, modulesWithSideEffects } = await extractCssFromCode(this, code, cleanId);
201
+ if (!hasExtractions) return null;
202
+ if (cssContent === "") return transformedCode;
224
203
  const cssModuleId = `${cleanId}.${hashText(cssContent)}.css`;
225
204
  extractedCssPerFile.set(cssModuleId, cssContent);
226
- finalCode = addCssImport(finalCode, cssModuleId);
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}`;
227
209
  }
228
- if (!hasUnprocessedCssBlocks) finalCode = removeImport(finalCode);
229
- return finalCode;
230
210
  }
231
211
  };
232
212
  }
package/index.d.ts CHANGED
@@ -16,5 +16,5 @@
16
16
  */
17
17
  export function css(
18
18
  strings: TemplateStringsArray,
19
- ...expressions: Array<string>
19
+ ...expressions: Array<string | number>
20
20
  ): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ecij",
3
- "version": "0.1.1",
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"
@@ -16,6 +16,7 @@
16
16
  "license": "MIT",
17
17
  "author": "Nicolas Stepien",
18
18
  "type": "module",
19
+ "sideEffects": false,
19
20
  "exports": {
20
21
  ".": {
21
22
  "types": "./index.d.ts",
@@ -35,16 +36,25 @@
35
36
  "build": "rolldown -c",
36
37
  "format": "prettier --write .",
37
38
  "format:check": "prettier --check .",
38
- "typecheck": "tsc"
39
+ "typecheck": "tsc --build",
40
+ "test": "vitest run",
41
+ "test:coverage": "vitest run --coverage"
39
42
  },
40
43
  "dependencies": {
41
- "oxc-parser": "^0.97.0"
44
+ "@rolldown/pluginutils": "^1.0.0-beta.53",
45
+ "oxc-parser": "^0.102.0"
42
46
  },
43
47
  "devDependencies": {
44
48
  "@types/node": "^24.10.1",
45
- "prettier": "^3.6.2",
46
- "rolldown": "^1.0.0-beta.50",
47
- "rolldown-plugin-dts": "^0.17.7",
48
- "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"
49
59
  }
50
60
  }