Skip to content

Commit

Permalink
Add support for multiple bundlers and module systems (#3)
Browse files Browse the repository at this point in the history
To add support for multiple module systems, the project needs to be compiled in both Commonjs and ESM formats. tsup will now be used for all compilation purposes. The setup for compilation during development and production is different to minimize workflow disruptions. In development, we only compile to ESM while for the package distribution, we compile to both module systems together using tsup's multi-format transpilation.

This project is now an ESM module, however, we still support commonjs projects and have been tested to work with Nextjs build tools. One can both require or import the project's package without issues.

Multi-bundler support will now be possible with unplugin. For starters, we only support webpack, but in the future support for other bundlers will added.
  • Loading branch information
tusharsnx authored Jun 10, 2024
1 parent b8d10cc commit 0c46b07
Show file tree
Hide file tree
Showing 14 changed files with 4,594 additions and 3,141 deletions.
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
**/node_modules/
dev/
demo-project/

# test projects for testing and prototyping
test-projects/

.vscode/
**/coverage/
dist/
13 changes: 0 additions & 13 deletions .npmignore

This file was deleted.

21 changes: 8 additions & 13 deletions jest.config.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,9 @@
/*
* For a detailed explanation regarding each configuration property and type check, visit:
* https://jestjs.io/docs/configuration
*/
import type { Config } from "jest";

export default {
preset: "ts-jest",
globals: {
"ts-jest": {
tsconfig: "tsconfig.json",
},
},
testPathIgnorePatterns: ["/node_modules/", "dev/"],
}
const config: Config = {
preset: "ts-jest/presets/default-esm",
testEnvironment: "node",
extensionsToTreatAsEsm: [".ts"],
};

export default config;
6,839 changes: 4,117 additions & 2,722 deletions package-lock.json

Large diffs are not rendered by default.

62 changes: 43 additions & 19 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,32 +1,56 @@
{
"name": "jsx-css-module-transforms",
"version": "0.2.2-beta",
"version": "0.3.2-beta",
"description": "A babel plugin to transform string classnames into css-module classnames",
"exports": "./dist/index.js",
"license": "MIT",
"author": "Tushar Singh",
"repository": "github:tusharsnn/jsx-css-module-transforms",
"type": "module",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"main": "dist/index.cjs",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
},
"files": [
"dist"
],
"keywords": [
"module-classname-transforms"
],
"author": "Tushar Singh",
"license": "MIT",
"repository": "github:tusharsnn/jsx-css-module-transforms",
"scripts": {
"build": "tsc -p tsconfig.build.json",
"test": "jest --verbose",
"test:cov": "jest --verbose --coverage",
"dev": "tsc -p tsconfig.json"
"build": "NODE_ENV=production npx tsup-node --config tsup.build.config.ts",
"dev": "npx tsup-node --watch",
"dev:nowatch": "npx tsup-node",
"test": "npm run dev:nowatch && NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest --verbose",
"test:cov": "npm run dev:nowatch && NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest --verbose --coverage"
},
"dependencies": {
"@babel/core": "^7.17.10",
"chalk": "^4.1.2"
"@babel/core": "^7.24.3",
"chalk": "^4.1.2",
"unplugin": "^1.10.1"
},
"peerDependencies": {
"@babel/plugin-syntax-jsx": "^7.24.1",
"webpack": "^5.91.0"
},
"peerDependenciesMeta": {
"webpack": {
"optional": true
}
},
"devDependencies": {
"@babel/plugin-syntax-jsx": "^7.17.12",
"@types/babel__core": "^7.1.19",
"@types/jest": "^27.5.0",
"jest": "^28.1.0",
"ts-jest": "^28.0.2",
"ts-node": "^10.8.0",
"ts-node-dev": "^1.1.8",
"typescript": "^4.6.4"
"@types/babel__core": "^7.20.5",
"@types/jest": "^29.5.12",
"@types/node": "^20.14.2",
"jest": "^29.7.0",
"ts-jest": "^29.1.4",
"tsup": "^8.0.2",
"typescript": "^4.9.5"
}
}
}
149 changes: 24 additions & 125 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,134 +1,33 @@
import type { NodePath, PluginObj } from "@babel/core";
import { types as t } from "@babel/core";
import type babel from "@babel/core";
import chalk from "chalk";
import { transformAsync } from "@babel/core";
import { createWebpackPlugin } from "unplugin";
import type { UnpluginOptions } from "unplugin";
import plugin from "./plugin.js";
import { CSSModuleError } from "./utils.js";

import { getImportInfo, getTemplFromStrCls } from "./transforms";
import { CSSModuleError } from "./utils";

function ImportDeclaration(path: NodePath<t.ImportDeclaration>, state: PluginPass) {
// we're only interested in scss/sass/css imports
if (!/.module.(s[ac]ss|css)(:.*)?$/iu.test(path.node.source.value)) {
return;
}

// saving path for error messages
CSSModuleError.path = path;

if (path.node.specifiers.length > 1 && !t.isImportDefaultSpecifier(path.node.specifiers[0])) {
// Syntax: import { classA, classB } from "./m1.module.css"
throw new CSSModuleError(`Import CSS-Module as a default import on '${chalk.cyan(path.node.source.value)}'`);
}
if (path.node.specifiers.length > 1) {
// Syntax: import style, { classA, classB } from "./m1.module.css"
throw new CSSModuleError(`More than one import found on '${chalk.cyan(path.node.source.value)}'`);
}

let moduleInfo = getImportInfo(path.node);
if (moduleInfo.hasSpecifier) {
let importSpecifier = path.node.specifiers[0].local;
if (importSpecifier.name in state.pluginState.modules.namedModules) {
throw new CSSModuleError(`CSS-Module ${chalk.yellow(`'${importSpecifier.name}'`)} has already been declared`);
}

// saving new module
state.pluginState.modules.namedModules[importSpecifier.name] = importSpecifier.name;
} else if (moduleInfo.default) {
if (state.pluginState.modules.defaultModule) {
throw new CSSModuleError(`Only one default css-module import is allowed. Provide names for all except the default module`);
}

let importSpecifier = path.scope.generateUidIdentifier("style");
let newSpecifiers = [t.importDefaultSpecifier(importSpecifier)];
let newImportDeclaration = t.importDeclaration(newSpecifiers, t.stringLiteral(path.node.source.value));
path.replaceWith<t.ImportDeclaration>(newImportDeclaration);

// saving this module as the default module for the current translation unit.
state.pluginState.modules.defaultModule = importSpecifier.name;
} else {
if (moduleInfo.moduleName in state.pluginState.modules.namedModules) {
throw new CSSModuleError(`CSS-Module ${chalk.yellow(`'${moduleInfo.moduleName}'`)} has already been declared`);
}

let importSpecifier = path.scope.generateUidIdentifier(moduleInfo.moduleName);
let newSpecifiers = [t.importDefaultSpecifier(importSpecifier)];
let newImportDeclaration = t.importDeclaration(newSpecifiers, t.stringLiteral(path.node.source.value));
path.replaceWith<t.ImportDeclaration>(newImportDeclaration);

// saving new module
state.pluginState.modules.namedModules[moduleInfo.moduleName] = importSpecifier.name;
}

// strips away module name from the source
path.node.source.value = moduleInfo.moduleSource; // this inplace replacment does not causes any problem with the ast
}

function JSXAttribute(path: NodePath<t.JSXAttribute>, state: PluginPass) {
// we only support className attribute having a string value
if (path.node.name.name != "className" || !t.isStringLiteral(path.node.value)) {
return;
}
// className values should be transformed only if we ever found a css module
if (!state.pluginState.modules.defaultModule && !state.pluginState.modules.namedModules) {
return;
}

// saving path for error messages
CSSModuleError.path = path;
function unpluginFactory(): UnpluginOptions {
return {
name: "jsx-css-module-transforms",

// if no default modules is available, make the first modules as default
if (!state.pluginState.modules.defaultModule) {
let firstNamedModule = getFirstNamedModule(state.pluginState.modules.namedModules);
if (firstNamedModule) {
state.pluginState.modules.defaultModule = state.pluginState.modules.namedModules[firstNamedModule];
}
}
transformInclude(id) {
const result = /\.tsx?$/i.test(id);
return result;
},

let fileCSSModules = state.pluginState.modules;
let templateLiteral = getTemplFromStrCls(path.node.value.value, fileCSSModules);
let jsxExpressionContainer = t.jsxExpressionContainer(templateLiteral);
let newJSXAttr = t.jsxAttribute(t.jsxIdentifier("className"), jsxExpressionContainer);
path.replaceWith(newJSXAttr);
path.skip();
}
async transform(code, id) {
// babel's transformSync cannot be used with ESM based plugin
const result = await transformAsync(code, {
filename: id,
plugins: ["@babel/plugin-syntax-jsx", plugin],
sourceMaps: process.env.NODE_ENV == "production" ? false : "inline",
});

function API({ types: t }: typeof babel): PluginObj<PluginPass> {
/**
* Sets up the initial state of the plugin
*/
function pre(this: PluginPass): void {
this.pluginState = {
modules: {
namedModules: {},
},
};
}
if (!result?.code) {
throw new CSSModuleError(`Could not transform ${id}`);
}

return {
pre,
visitor: {
ImportDeclaration,
JSXAttribute,
return result.code;
},
};
}

export default API;

function getFirstNamedModule(namedModules: Modules["namedModules"]): string | null {
for (let module in namedModules) return module;
return null;
}

export type Modules = {
defaultModule?: string;
namedModules: { [moduleName: string]: string };
};

type PluginState = {
modules: Modules;
};

interface PluginPass extends babel.PluginPass {
pluginState: PluginState;
}
export default createWebpackPlugin(unpluginFactory);
Loading

0 comments on commit 0c46b07

Please sign in to comment.