ecij 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Nicolas Stepien
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,126 @@
1
+ # ecij
2
+
3
+ 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
+
5
+ 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.
6
+
7
+ The plugin does not process the CSS in any way whatsoever, it is merely output in virtual CSS files for Rolldown and Vite to handle. Separate plugins may be used to process these virtual CSS files.
8
+
9
+ ## Installation
10
+
11
+ ```
12
+ npm install -D ecij
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ Source input:
18
+
19
+ ```ts
20
+ /* main.ts */
21
+ import { css } from 'ecij';
22
+ import { redClassname } from './styles';
23
+
24
+ const myButtonClassname = css`
25
+ border: 1px solid blue;
26
+
27
+ &.${redClassname} {
28
+ border-color: red;
29
+ }
30
+ `;
31
+ ```
32
+
33
+ ```ts
34
+ /* styles.ts */
35
+ import { css } from 'ecij';
36
+
37
+ const color = 'red';
38
+
39
+ export const redClassname = css`
40
+ color: ${color};
41
+ `;
42
+ ```
43
+
44
+ Build output:
45
+
46
+ ```js
47
+ /* js */
48
+ const color = 'red';
49
+
50
+ const redClassname = 'css-a1b2c3d4';
51
+
52
+ const myButtonClassname = 'css-1d2c3b4a';
53
+ ```
54
+
55
+ ```css
56
+ /* css */
57
+ .css-a1b2c3d4 {
58
+ color: red;
59
+ }
60
+
61
+ .css-1d2c3b4a {
62
+ border: 1px solid blue;
63
+
64
+ &.css-a1b2c3d4 {
65
+ border-color: red;
66
+ }
67
+ }
68
+ ```
69
+
70
+ ## Set up
71
+
72
+ In `rolldown.config.ts`:
73
+
74
+ ```ts
75
+ import { defineConfig } from 'rolldown';
76
+ import { ecij } from 'ecij/plugin';
77
+
78
+ export default defineConfig({
79
+ // ...
80
+ plugins: [ecij()],
81
+ });
82
+ ```
83
+
84
+ In `vite.config.ts`:
85
+
86
+ ```ts
87
+ import { defineConfig } from 'vite';
88
+ import { ecij } from 'ecij/plugin';
89
+
90
+ export default defineConfig({
91
+ // ...
92
+ plugins: [ecij()],
93
+ });
94
+ ```
95
+
96
+ ## Configuration
97
+
98
+ The `ecij()` plugin accepts an optional configuration object:
99
+
100
+ ```ts
101
+ export interface Configuration {
102
+ /**
103
+ * Prefix for generated CSS class names.
104
+ * Should not be empty, as generated hashes may start with a digit, resulting in invalid CSS class names.
105
+ * @default 'css-'
106
+ */
107
+ classPrefix?: string;
108
+ }
109
+ ```
110
+
111
+ **Example:**
112
+
113
+ ```ts
114
+ ecij({
115
+ classPrefix: 'lib-',
116
+ });
117
+ ```
118
+
119
+ ## TODO
120
+
121
+ - Tests
122
+ - Support `include`/`exclude` configuration
123
+ - Scope handling
124
+ - Validate that the `css` used refers to
125
+ - Full import/export handling (default/namespace import/export)
126
+ - Sourcemaps
@@ -0,0 +1,16 @@
1
+ import { Plugin } from "rolldown";
2
+
3
+ //#region src/index.d.ts
4
+ interface Configuration {
5
+ /**
6
+ * Prefix for generated CSS class names.
7
+ * Should not be empty, as generated hashes may start with a digit, resulting in invalid CSS class names.
8
+ * @default 'css-'
9
+ */
10
+ classPrefix?: string;
11
+ }
12
+ declare function ecij({
13
+ classPrefix
14
+ }?: Configuration): Plugin;
15
+ //#endregion
16
+ export { Configuration, ecij };
package/dist/index.js ADDED
@@ -0,0 +1,235 @@
1
+ import { createHash } from "node:crypto";
2
+ import { relative } from "node:path";
3
+ import { cwd } from "node:process";
4
+ import { Visitor, parseSync } from "oxc-parser";
5
+
6
+ //#region src/index.ts
7
+ const JS_TS_FILE_REGEX = /\.[jtc]sx?$/;
8
+ const PROJECT_ROOT = cwd();
9
+ function hashText(text) {
10
+ return createHash("md5").update(text).digest("hex").slice(0, 8);
11
+ }
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-" } = {}) {
25
+ 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
+ /**
34
+ * Resolves an imported class name by reading and processing the source file
35
+ */
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" }));
43
+ const localNameToExportedNameMap = /* @__PURE__ */ new Map();
44
+ for (const staticExport of parseResult.module.staticExports) for (const entry of staticExport.entries) {
45
+ if (entry.importName.kind !== "None") continue;
46
+ if (entry.exportName.kind === "Name" && entry.localName.kind === "Name") {
47
+ const localName = entry.localName.name;
48
+ const exportedName$1 = entry.exportName.name;
49
+ localNameToExportedNameMap.set(localName, exportedName$1);
50
+ }
51
+ }
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,
58
+ node,
59
+ hasInterpolations: node.quasi.expressions.length > 0
60
+ });
61
+ }
62
+ new Visitor({
63
+ VariableDeclarator(node) {
64
+ if (node.init === null || node.id.type !== "Identifier") return;
65
+ const localName = node.id.name;
66
+ if (node.init.type === "TaggedTemplateExpression") {
67
+ taggedTemplateExpressionFromVariableDeclarator.add(node.init);
68
+ 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
+ }
75
+ },
76
+ TaggedTemplateExpression(node) {
77
+ if (taggedTemplateExpressionFromVariableDeclarator.has(node)) return;
78
+ handleTaggedTemplateExpression(void 0, node);
79
+ }
80
+ }).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);
91
+ }
92
+ /**
93
+ * Extracts CSS from template literals in the source code using AST parsing
94
+ * Supports interpolations of class names (both local and imported)
95
+ */
96
+ async function extractCssFromCode(context, code, filePath) {
97
+ const cssExtractions = [];
98
+ 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);
106
+ }
107
+ }
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
+ function addProcessedDeclaration(declaration, cssContent$1) {
138
+ const className = generateClassName(filePath, declaration.index, declaration.varName);
139
+ if (declaration.varName) localClassNames.set(declaration.varName, className);
140
+ cssExtractions.push({
141
+ className,
142
+ cssContent: cssContent$1.trim(),
143
+ sourcePosition: declaration.node.start
144
+ });
145
+ replacements.push({
146
+ start: declaration.node.start,
147
+ end: declaration.node.end,
148
+ className
149
+ });
150
+ }
151
+ for (const declaration of declarations) {
152
+ if (declaration.hasInterpolations) continue;
153
+ const cssContent$1 = declaration.node.quasi.quasis[0].value.raw;
154
+ addProcessedDeclaration(declaration, cssContent$1);
155
+ }
156
+ for (const declaration of declarations) {
157
+ if (!declaration.hasInterpolations) continue;
158
+ const { quasis, expressions } = declaration.node.quasi;
159
+ let cssContent$1 = "";
160
+ let allResolved = true;
161
+ for (let i = 0; i < quasis.length; i++) {
162
+ cssContent$1 += quasis[i].value.raw;
163
+ if (i < expressions.length) {
164
+ const expression = expressions[i];
165
+ if (expression.type !== "Identifier") {
166
+ allResolved = false;
167
+ break;
168
+ }
169
+ const identifierName = expression.name;
170
+ const resolvedClassName = await resolveClassName(identifierName);
171
+ if (resolvedClassName === void 0) {
172
+ allResolved = false;
173
+ break;
174
+ }
175
+ cssContent$1 += resolvedClassName;
176
+ }
177
+ }
178
+ if (allResolved) addProcessedDeclaration(declaration, cssContent$1);
179
+ }
180
+ if (replacements.length === 0) return {
181
+ transformedCode: code,
182
+ hasExtractions: false,
183
+ cssContent: "",
184
+ hasUnprocessedCssBlocks: false
185
+ };
186
+ replacements.sort((a, b) => b.start - a.start);
187
+ let transformedCode = code;
188
+ for (const { start, end, className } of replacements) transformedCode = `${transformedCode.slice(0, start)}'${className}'${transformedCode.slice(end)}`;
189
+ const hasUnprocessedCssBlocks = declarations.length > replacements.length;
190
+ cssExtractions.sort((a, b) => a.sourcePosition - b.sourcePosition);
191
+ const cssBlocks = [];
192
+ for (const { className, cssContent: cssContent$1 } of cssExtractions) if (cssContent$1 !== "") cssBlocks.push(`.${className} {\n ${cssContent$1}\n}`);
193
+ const cssContent = cssBlocks.join("\n\n");
194
+ return {
195
+ transformedCode,
196
+ hasExtractions: true,
197
+ cssContent,
198
+ hasUnprocessedCssBlocks
199
+ };
200
+ }
201
+ return {
202
+ name: "ecij",
203
+ buildStart() {
204
+ extractedCssPerFile.clear();
205
+ importedClassNameCache.clear();
206
+ },
207
+ resolveId(id) {
208
+ if (extractedCssPerFile.has(id)) return { id };
209
+ return null;
210
+ },
211
+ load(id) {
212
+ if (extractedCssPerFile.has(id)) return extractedCssPerFile.get(id);
213
+ return null;
214
+ },
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 !== "") {
224
+ const cssModuleId = `${cleanId}.${hashText(cssContent)}.css`;
225
+ extractedCssPerFile.set(cssModuleId, cssContent);
226
+ finalCode = addCssImport(finalCode, cssModuleId);
227
+ }
228
+ if (!hasUnprocessedCssBlocks) finalCode = removeImport(finalCode);
229
+ return finalCode;
230
+ }
231
+ };
232
+ }
233
+
234
+ //#endregion
235
+ export { ecij };
package/index.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @example
3
+ * input:
4
+ * ```js
5
+ * import { css } from 'ecij';
6
+ *
7
+ * const myClass = css`
8
+ * color: red;
9
+ * `;
10
+ * ```
11
+ *
12
+ * output:
13
+ * ```js
14
+ * const myClass = 'css-a1b2c3d4';
15
+ * ```
16
+ */
17
+ export function css(
18
+ strings: TemplateStringsArray,
19
+ ...expressions: Array<string>
20
+ ): string;
package/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export function css() {
2
+ throw new Error('css`` should have been transformed by the ecij plugin');
3
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "ecij",
3
+ "version": "0.1.1",
4
+ "description": "Rolldown and Vite plugin to Extract CSS-in-JS",
5
+ "keywords": [
6
+ "css-in-js"
7
+ ],
8
+ "homepage": "https://github.com/nstepien/ecij#readme",
9
+ "bugs": {
10
+ "url": "https://github.com/nstepien/ecij/issues"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/nstepien/ecij.git"
15
+ },
16
+ "license": "MIT",
17
+ "author": "Nicolas Stepien",
18
+ "type": "module",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./index.d.ts",
22
+ "default": "./index.js"
23
+ },
24
+ "./plugin": {
25
+ "types": "./dist/index.d.ts",
26
+ "default": "./dist/index.js"
27
+ }
28
+ },
29
+ "main": "index.js",
30
+ "files": [
31
+ "dist",
32
+ "index.d.ts"
33
+ ],
34
+ "scripts": {
35
+ "build": "rolldown -c",
36
+ "format": "prettier --write .",
37
+ "format:check": "prettier --check .",
38
+ "typecheck": "tsc"
39
+ },
40
+ "dependencies": {
41
+ "oxc-parser": "^0.97.0"
42
+ },
43
+ "devDependencies": {
44
+ "@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
+ }
50
+ }