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.
- package/README.md +40 -2
- package/dist/index.js +87 -115
- package/package.json +16 -8
package/README.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# ecij
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/ecij)
|
|
4
|
+
[](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
|
-
-
|
|
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
|
-
*
|
|
19
|
+
* Parses a file and extracts all relevant information in a single pass
|
|
38
20
|
*/
|
|
39
|
-
async function
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
const
|
|
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
|
|
52
|
-
localNameToExportedNameMap.set(localName, exportedName
|
|
53
|
+
const exportedName = entry.exportName.name;
|
|
54
|
+
localNameToExportedNameMap.set(localName, exportedName);
|
|
53
55
|
}
|
|
54
56
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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))
|
|
81
|
-
handleTaggedTemplateExpression(void 0, node);
|
|
85
|
+
if (!taggedTemplateExpressionFromVariableDeclarator.has(node)) handleTaggedTemplateExpression(void 0, node);
|
|
82
86
|
}
|
|
83
87
|
}).visit(parseResult.program);
|
|
84
|
-
|
|
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
|
|
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
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
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
|
|
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:
|
|
119
|
+
sourcePosition: node.start
|
|
147
120
|
});
|
|
148
121
|
replacements.push({
|
|
149
|
-
start:
|
|
150
|
-
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
|
|
174
|
-
if (
|
|
146
|
+
const resolvedValue = await resolveValue(identifierName);
|
|
147
|
+
if (resolvedValue === void 0) {
|
|
175
148
|
allResolved = false;
|
|
176
149
|
break;
|
|
177
150
|
}
|
|
178
|
-
cssContent$1 +=
|
|
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
|
-
|
|
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
|
-
|
|
173
|
+
modulesWithSideEffects
|
|
202
174
|
};
|
|
203
175
|
}
|
|
204
176
|
return {
|
|
205
177
|
name: "ecij",
|
|
206
|
-
|
|
178
|
+
buildEnd() {
|
|
179
|
+
parsedFileInfoCache.clear();
|
|
207
180
|
extractedCssPerFile.clear();
|
|
208
|
-
importedClassNameCache.clear();
|
|
209
181
|
},
|
|
210
182
|
resolveId(id) {
|
|
211
|
-
if (extractedCssPerFile.has(id)) return
|
|
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
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
}
|
|
235
|
-
|
|
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.
|
|
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.
|
|
43
|
-
"oxc-parser": "^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
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"rolldown
|
|
50
|
-
"
|
|
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
|
}
|