├── .eslintrc.js ├── .github └── workflows │ ├── codeql-analysis.yml │ └── main.yml ├── .gitignore ├── .husky ├── .gitignore ├── commit-msg └── pre-commit ├── .prettierrc ├── LICENSE ├── README.md ├── buildPack.js ├── commitlint.config.js ├── package.json ├── src ├── EasyType.ts ├── index.spec.ts ├── index.ts └── transform │ ├── baseReplacer │ ├── baseReplacer.spec.ts │ ├── extractor.ts │ ├── generateEasyTailwindRegex.spec.ts │ ├── generateEasyTailwindRegex.ts │ └── index.ts │ ├── index.ts │ ├── react │ ├── index.ts │ └── reactReplacer.spec.ts │ └── transform.spec.ts ├── tsconfig.build.json ├── tsconfig.json ├── tsconfig.nocomments.json └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module', 6 | }, 7 | plugins: ['@typescript-eslint/eslint-plugin'], 8 | extends: [ 9 | 'plugin:@typescript-eslint/recommended', 10 | 'plugin:prettier/recommended', 11 | ], 12 | root: true, 13 | env: { 14 | node: true, 15 | jest: true, 16 | }, 17 | ignorePatterns: [ 18 | '.eslintrc.js', 'node_modules/', 'coverage/', 'dist/', 'performance/' 19 | ], 20 | rules: { 21 | '@typescript-eslint/interface-name-prefix': 'off', 22 | '@typescript-eslint/explicit-function-return-type': 'off', 23 | '@typescript-eslint/explicit-module-boundary-types': 'off', 24 | '@typescript-eslint/no-explicit-any': 'off', 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | # At 00:00 on day-of-month 1. 22 | - cron: '0 0 1 * *' 23 | 24 | jobs: 25 | analyze: 26 | name: Analyze 27 | runs-on: ubuntu-latest 28 | permissions: 29 | actions: read 30 | contents: read 31 | security-events: write 32 | 33 | strategy: 34 | fail-fast: false 35 | matrix: 36 | language: [ 'javascript' ] 37 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 38 | # Learn more: 39 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 40 | 41 | steps: 42 | - name: Checkout repository 43 | uses: actions/checkout@v2 44 | 45 | # Initializes the CodeQL tools for scanning. 46 | - name: Initialize CodeQL 47 | uses: github/codeql-action/init@v1 48 | with: 49 | languages: ${{ matrix.language }} 50 | # If you wish to specify custom queries, you can do so here or in a config file. 51 | # By default, queries listed here will override any specified in a config file. 52 | # Prefix the list here with "+" to use these queries and those in the config file. 53 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 54 | 55 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 56 | # If this step fails, then you should remove it and run the build manually (see below) 57 | - name: Autobuild 58 | uses: github/codeql-action/autobuild@v1 59 | 60 | # ℹ️ Command-line programs to run using the OS shell. 61 | # 📚 https://git.io/JvXDl 62 | 63 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 64 | # and modify them (or add more) to build your code if your project 65 | # uses a compiled language 66 | 67 | #- run: | 68 | # make bootstrap 69 | # make release 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v1 73 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | types: [ opened, synchronize ] 6 | 7 | jobs: 8 | build: 9 | name: Build 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Setup 15 | uses: actions/setup-node@v3 16 | with: 17 | node-version: 18 18 | cache: 'yarn' 19 | 20 | - name: Install 21 | run: yarn install --prefer-offline 22 | - name: Linter 23 | run: yarn lint 24 | - name: Tests 25 | run: yarn test:ci 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### JetBrains template 3 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 4 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 5 | 6 | # User-specific stuff: 7 | .idea/**/workspace.xml 8 | .idea/**/tasks.xml 9 | .idea/dictionaries 10 | 11 | # Sensitive or high-churn files: 12 | .idea/**/dataSources/ 13 | .idea/**/dataSources.ids 14 | .idea/**/dataSources.xml 15 | .idea/**/dataSources.local.xml 16 | .idea/**/sqlDataSources.xml 17 | .idea/**/dynamic.xml 18 | .idea/**/uiDesigner.xml 19 | 20 | # Gradle: 21 | .idea/**/gradle.xml 22 | .idea/**/libraries 23 | 24 | # CMake 25 | cmake-build-debug/ 26 | 27 | # Mongo Explorer plugin: 28 | .idea/**/mongoSettings.xml 29 | 30 | ## File-based project format: 31 | *.iws 32 | 33 | ## Plugin-specific files: 34 | 35 | # IntelliJ 36 | out/ 37 | 38 | # mpeltonen/sbt-idea plugin 39 | .idea_modules/ 40 | 41 | # JIRA plugin 42 | atlassian-ide-plugin.xml 43 | 44 | # Cursive Clojure plugin 45 | .idea/replstate.xml 46 | 47 | # Crashlytics plugin (for Android Studio and IntelliJ) 48 | com_crashlytics_export_strings.xml 49 | crashlytics.properties 50 | crashlytics-build.properties 51 | fabric.properties 52 | ### VisualStudio template 53 | ## Ignore Visual Studio temporary files, build results, and 54 | ## files generated by popular Visual Studio add-ons. 55 | ## 56 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 57 | 58 | # User-specific files 59 | *.suo 60 | *.user 61 | *.userosscache 62 | *.sln.docstates 63 | 64 | # User-specific files (MonoDevelop/Xamarin Studio) 65 | *.userprefs 66 | 67 | # Build results 68 | [Dd]ebug/ 69 | [Dd]ebugPublic/ 70 | [Rr]elease/ 71 | [Rr]eleases/ 72 | x64/ 73 | x86/ 74 | bld/ 75 | [Bb]in/ 76 | [Oo]bj/ 77 | [Ll]og/ 78 | 79 | # Visual Studio 2015 cache/options directory 80 | .vs/ 81 | # Uncomment if you have tasks that create the project's static files in wwwroot 82 | #wwwroot/ 83 | 84 | # MSTest test Results 85 | [Tt]est[Rr]esult*/ 86 | [Bb]uild[Ll]og.* 87 | 88 | # NUNIT 89 | *.VisualState.xml 90 | TestResult.xml 91 | 92 | # Build Results of an ATL Project 93 | [Dd]ebugPS/ 94 | [Rr]eleasePS/ 95 | dlldata.c 96 | 97 | # Benchmark Results 98 | BenchmarkDotNet.Artifacts/ 99 | 100 | # .NET Core 101 | project.lock.json 102 | project.fragment.lock.json 103 | artifacts/ 104 | **/Properties/launchSettings.json 105 | 106 | *_i.c 107 | *_p.c 108 | *_i.h 109 | *.ilk 110 | *.meta 111 | *.obj 112 | *.pch 113 | *.pdb 114 | *.pgc 115 | *.pgd 116 | *.rsp 117 | *.sbr 118 | *.tlb 119 | *.tli 120 | *.tlh 121 | *.tmp 122 | *.tmp_proj 123 | *.log 124 | *.vspscc 125 | *.vssscc 126 | .builds 127 | *.pidb 128 | *.svclog 129 | *.scc 130 | 131 | # Chutzpah Test files 132 | _Chutzpah* 133 | 134 | # Visual C++ cache files 135 | ipch/ 136 | *.aps 137 | *.ncb 138 | *.opendb 139 | *.opensdf 140 | *.sdf 141 | *.cachefile 142 | *.VC.db 143 | *.VC.VC.opendb 144 | 145 | # Visual Studio profiler 146 | *.psess 147 | *.vsp 148 | *.vspx 149 | *.sap 150 | 151 | # Visual Studio Trace Files 152 | *.e2e 153 | 154 | # TFS 2012 Local Workspace 155 | $tf/ 156 | 157 | # Guidance Automation Toolkit 158 | *.gpState 159 | 160 | # ReSharper is a .NET coding add-in 161 | _ReSharper*/ 162 | *.[Rr]e[Ss]harper 163 | *.DotSettings.user 164 | 165 | # JustCode is a .NET coding add-in 166 | .JustCode 167 | 168 | # TeamCity is a build add-in 169 | _TeamCity* 170 | 171 | # DotCover is a Code Coverage Tool 172 | *.dotCover 173 | 174 | # AxoCover is a Code Coverage Tool 175 | .axoCover/* 176 | !.axoCover/settings.json 177 | 178 | # Visual Studio code coverage results 179 | *.coverage 180 | *.coveragexml 181 | 182 | # NCrunch 183 | _NCrunch_* 184 | .*crunch*.local.xml 185 | nCrunchTemp_* 186 | 187 | # MightyMoose 188 | *.mm.* 189 | AutoTest.Net/ 190 | 191 | # Web workbench (sass) 192 | .sass-cache/ 193 | 194 | # Installshield output folder 195 | [Ee]xpress/ 196 | 197 | # DocProject is a documentation generator add-in 198 | DocProject/buildhelp/ 199 | DocProject/Help/*.HxT 200 | DocProject/Help/*.HxC 201 | DocProject/Help/*.hhc 202 | DocProject/Help/*.hhk 203 | DocProject/Help/*.hhp 204 | DocProject/Help/Html2 205 | DocProject/Help/html 206 | 207 | # Click-Once directory 208 | publish/ 209 | 210 | # Publish Web Output 211 | *.[Pp]ublish.xml 212 | *.azurePubxml 213 | # Note: Comment the next line if you want to checkin your web deploy settings, 214 | # but database connection strings (with potential passwords) will be unencrypted 215 | *.pubxml 216 | *.publishproj 217 | 218 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 219 | # checkin your Azure Web App publish settings, but sensitive information contained 220 | # in these scripts will be unencrypted 221 | PublishScripts/ 222 | 223 | # NuGet Packages 224 | *.nupkg 225 | # The packages folder can be ignored because of Package Restore 226 | **/[Pp]ackages/* 227 | # except build/, which is used as an MSBuild target. 228 | !**/[Pp]ackages/build/ 229 | # Uncomment if necessary however generally it will be regenerated when needed 230 | #!**/[Pp]ackages/repositories.config 231 | # NuGet v3's project.json files produces more ignorable files 232 | *.nuget.props 233 | *.nuget.targets 234 | 235 | # Microsoft Azure Build Output 236 | csx/ 237 | *.build.csdef 238 | 239 | # Microsoft Azure Emulator 240 | ecf/ 241 | rcf/ 242 | 243 | # Windows Store app package directories and files 244 | AppPackages/ 245 | BundleArtifacts/ 246 | Package.StoreAssociation.xml 247 | _pkginfo.txt 248 | *.appx 249 | 250 | # Visual Studio cache files 251 | # files ending in .cache can be ignored 252 | *.[Cc]ache 253 | # but keep track of directories ending in .cache 254 | !*.[Cc]ache/ 255 | 256 | # Others 257 | ClientBin/ 258 | ~$* 259 | *~ 260 | *.dbmdl 261 | *.dbproj.schemaview 262 | *.jfm 263 | *.pfx 264 | *.publishsettings 265 | orleans.codegen.cs 266 | 267 | # Since there are multiple workflows, uncomment next line to ignore bower_components 268 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 269 | #bower_components/ 270 | 271 | # RIA/Silverlight projects 272 | Generated_Code/ 273 | 274 | # Backup & report files from converting an old project file 275 | # to a newer Visual Studio version. Backup files are not needed, 276 | # because we have git ;-) 277 | _UpgradeReport_Files/ 278 | Backup*/ 279 | UpgradeLog*.XML 280 | UpgradeLog*.htm 281 | 282 | # SQL Server files 283 | *.mdf 284 | *.ldf 285 | *.ndf 286 | 287 | # Business Intelligence projects 288 | *.rdl.data 289 | *.bim.layout 290 | *.bim_*.settings 291 | 292 | # Microsoft Fakes 293 | FakesAssemblies/ 294 | 295 | # GhostDoc plugin setting file 296 | *.GhostDoc.xml 297 | 298 | # Node.js Tools for Visual Studio 299 | .ntvs_analysis.dat 300 | node_modules/ 301 | 302 | # Typescript v1 declaration files 303 | typings/ 304 | 305 | # Visual Studio 6 build log 306 | *.plg 307 | 308 | # Visual Studio 6 workspace options file 309 | *.opt 310 | 311 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 312 | *.vbw 313 | 314 | # Visual Studio LightSwitch build output 315 | **/*.HTMLClient/GeneratedArtifacts 316 | **/*.DesktopClient/GeneratedArtifacts 317 | **/*.DesktopClient/ModelManifest.xml 318 | **/*.Server/GeneratedArtifacts 319 | **/*.Server/ModelManifest.xml 320 | _Pvt_Extensions 321 | 322 | # Paket dependency manager 323 | .paket/paket.exe 324 | paket-files/ 325 | 326 | # FAKE - F# Make 327 | .fake/ 328 | 329 | # JetBrains Rider 330 | .idea/ 331 | *.sln.iml 332 | 333 | # IDE - VSCode 334 | .vscode/* 335 | !.vscode/settings.json 336 | !.vscode/tasks.json 337 | !.vscode/launch.json 338 | !.vscode/extensions.json 339 | 340 | # CodeRush 341 | .cr/ 342 | 343 | # Python Tools for Visual Studio (PTVS) 344 | __pycache__/ 345 | *.pyc 346 | 347 | # Cake - Uncomment if you are using it 348 | # tools/** 349 | # !tools/packages.config 350 | 351 | # Tabs Studio 352 | *.tss 353 | 354 | # Telerik's JustMock configuration file 355 | *.jmconfig 356 | 357 | # BizTalk build output 358 | *.btp.cs 359 | *.btm.cs 360 | *.odx.cs 361 | *.xsd.cs 362 | 363 | # OpenCover UI analysis results 364 | OpenCover/ 365 | coverage/ 366 | 367 | ### macOS template 368 | # General 369 | .DS_Store 370 | .AppleDouble 371 | .LSOverride 372 | 373 | # Icon must end with two \r 374 | Icon 375 | 376 | # Thumbnails 377 | ._* 378 | 379 | # Files that might appear in the root of a volume 380 | .DocumentRevisions-V100 381 | .fseventsd 382 | .Spotlight-V100 383 | .TemporaryItems 384 | .Trashes 385 | .VolumeIcon.icns 386 | .com.apple.timemachine.donotpresent 387 | 388 | # Directories potentially created on remote AFP share 389 | .AppleDB 390 | .AppleDesktop 391 | Network Trash Folder 392 | Temporary Items 393 | .apdisk 394 | 395 | ======= 396 | # Local 397 | .env 398 | dist 399 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn commitlint --edit "$1" 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint-staged 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Bruno Noriller 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Easy Tailwind 2 | 3 | An easier way of writing Tailwind classes. 4 | 5 | [Example to play around](https://stackblitz.com/edit/easy-tailwind?file=src/App.jsx) 6 | 7 | ## Table of Contents 8 | 9 | - [What this is and what this isn't](#what-this-is-and-what-this-isnt) 10 | - [Installation](#installation) 11 | - [Setup](#setup) 12 | - [How to Use](#how-to-use) 13 | - [Break Lines](#break-lines) 14 | - [Use Objects](#use-objects) 15 | - [Conditional Classes](#conditional-classes) 16 | - [Rules for it to Work](#rules-for-it-to-work) 17 | - [Does it Support XYZ?](#does-it-support-xyz) 18 | - [Final Considerations](#final-considerations) 19 | - [Why "Easy" Tailwind?](#why-easy-tailwind) 20 | 21 | ## What this is and what this isn't 22 | 23 | ### What this isn't 24 | 25 | This is not [`WindiCSS`](https://github.com/windicss/windicss), [`UnoCSS`](https://github.com/unocss/unocss) or any other CSS lib or framework. This isn't meant to replace them. 26 | 27 | This is meant to be used with Tailwind. So, if you're not using Tailwind, you don't want this. 28 | 29 | ### What this might be 30 | 31 | If you use [`classnames`](https://github.com/JedWatson/classnames), [`clsx`](https://github.com/lukeed/clsx/) and other utilities to have conditional classes, then this might be a replacement for them. 32 | 33 | This doesn't cover all cases they do, and you could use all of them in conjunction if you want (they would wrap `e`/`etw` functions). 34 | 35 | But if you just use them for class toggling and use Tailwind, then you might want to consider replacing them with this. 36 | 37 | ### What this is 38 | 39 | This is a utility to be used with Tailwind. If you're using Tailwind, you want to consider using this. 40 | 41 | This is a tool to increase Developer Experience. The Tailwind world-class extension still works, even while writing with EasyTailwind. (It doesn't show the whole CSS class generated when using the modifiers, but it shows the important part.) 42 | 43 | This is a tool for cleaner code. You might not agree, but I developed that in mind. 44 | 45 | This is like "table salt", salt is good but you don't want to cover everything in it. 46 | 47 | If you have just a couple of classes then there's no need to call it. Call it when you have multiple classes, especially with modifiers or when you need to toggle classes. 48 | 49 | Go to: [Table of Contents](#table-of-contents) 50 | 51 | ## Installation 52 | 53 | Install with your preferred manager: 54 | 55 | ```bash 56 | npm i easy-tailwind 57 | ``` 58 | 59 | ```bash 60 | yarn add easy-tailwind 61 | ``` 62 | 63 | ```bash 64 | pnpm add easy-tailwind 65 | ``` 66 | 67 | Go to: [Table of Contents](#table-of-contents) 68 | 69 | ## Setup 70 | 71 | The configuration file is usually located in the `root` of the application and looks something like `tailwind.config.cjs`. The basic configuration you need is: 72 | 73 | ```js 74 | // tailwind.config.cjs 75 | const { content } = require('easy-tailwind/transform'); 76 | /** @type {import('tailwindcss').Config} */ 77 | module.exports = { 78 | content, 79 | // ... 80 | theme: { 81 | // ... 82 | }, 83 | plugins: [ 84 | // ... 85 | ], 86 | }; 87 | ``` 88 | 89 | If you need to override something in the `content` config: 90 | 91 | ```js 92 | // tailwind.config.cjs 93 | const { replacer } = require('easy-tailwind/transform'); 94 | /** @type {import('tailwindcss').Config} */ 95 | module.exports = { 96 | content: { 97 | files: [ 98 | '!node_modules', 99 | './**/*.{js,ts,jsx,tsx,html,vue,svelte,astro}' // here you can specify your own files and directories 100 | ], 101 | transform: { 102 | DEFAULT: replacer, // default is applied to all files types 103 | }, 104 | } 105 | // ... 106 | theme: { 107 | // ... 108 | }, 109 | plugins: [ 110 | // ... 111 | ], 112 | }; 113 | ``` 114 | 115 | ### Specific configuration for frameworks 116 | 117 | Right now, the only specific one is `React`, instead of importing from `easy-tailwind/transform`, import from `easy-tailwind/transform/react` 118 | 119 | In the example for `React`: 120 | 121 | ```js 122 | // tailwind.config.cjs 123 | const { content } = require('easy-tailwind/transform/react'); 124 | /** @type {import('tailwindcss').Config} */ 125 | module.exports = { 126 | content, 127 | // ... 128 | theme: { 129 | // ... 130 | }, 131 | plugins: [ 132 | // ... 133 | ], 134 | }; 135 | ``` 136 | 137 | Where `content` is equivalent to: 138 | 139 | ```js 140 | // tailwind.config.cjs 141 | const { replacer } = require('easy-tailwind/transform'); 142 | /** @type {import('tailwindcss').Config} */ 143 | module.exports = { 144 | content: { 145 | files: ['!node_modules', './**/*.{js,ts,jsx,tsx,html}'], 146 | transform: { 147 | DEFAULT: replacer, 148 | }, 149 | } 150 | // ... 151 | theme: { 152 | // ... 153 | }, 154 | plugins: [ 155 | // ... 156 | ], 157 | }; 158 | ``` 159 | 160 | Go to: [Table of Contents](#table-of-contents) 161 | 162 | ### Renaming the exports 163 | 164 | If you rename the exports to something other than `e` or `etw`, or maybe you want to use only one because you're already using another function with the same name, then you need to change the `replacer` with the `customNameReplacer` (exported from `easy-tailwind/transform`). 165 | 166 | Example: 167 | 168 | ```js 169 | // tailwind.config.cjs 170 | const { customNameReplacer } = require('easy-tailwind/transform'); 171 | /** @type {import('tailwindcss').Config} */ 172 | module.exports = { 173 | content: { 174 | files: [ 175 | '!node_modules', 176 | './**/*.{js,ts,jsx,tsx,html,vue,svelte,astro}' // here you can specify your own files and directories 177 | ], 178 | transform: { 179 | DEFAULT: customNameReplacer('newFuncName1', 'newFuncName2'), // default is applied to all files types 180 | 'some-file-extension': customNameReplacer('etw'), // this one you know you're only using `etw` 181 | 'some-other-file-extension': customNameReplacer('newFuncName'), // this one you know you're only using `newFuncName' 182 | }, 183 | } 184 | // ... 185 | theme: { 186 | // ... 187 | }, 188 | plugins: [ 189 | // ... 190 | ], 191 | }; 192 | ``` 193 | 194 | Go to: [Table of Contents](#table-of-contents) 195 | 196 | ## How to use 197 | 198 | First, import `e` or `etw`: 199 | 200 | ```js 201 | import { e } from 'easy-tailwind'; 202 | // e and etw resolve to the same function 203 | ``` 204 | 205 | This is a pure function, so you can be sure that it will always return the same values as you pass the same values: 206 | 207 | ```js 208 | e( 209 | 'some base classes here', 210 | 'breaking the line because', 211 | 'it was getting too long', 212 | { 213 | mod1: 'classes with mod1', 214 | mod2: [ 215 | 'classes with only mod2', 216 | { 217 | subMod1: 'nested classes with both', 218 | }, 219 | ], 220 | }, 221 | ); 222 | // this will return: 223 | 'some base classes here breaking the line because it was getting too long mod1:classes mod1:with mod1:mod1 mod2:classes mod2:with mod2:only mod2:mod2 mod2:subMod1:nested mod2:subMod1:classes mod2:subMod1:with mod2:subMod1:both'; 224 | ``` 225 | 226 | Now use it where you would use the Tailwind classes. 227 | 228 | Example below will use the React syntax, but as long as you can call it, it will probably work: 229 | 230 | ```js 231 |
251 | EasyTailwind!!! 252 |
253 | ``` 254 | 255 | Which is way faster and easier to understand, maintain and debug than: 256 | 257 | ```js 258 | "text-lg font-medium text-black hover:underline hover:decoration-black sm:text-base sm:text-blue-500 sm:hover:decoration-cyan-500 lg:text-2xl lg:text-green-500 lg:hover:decoration-amber-500" 259 | ``` 260 | 261 | > ℹ️ Sense of style not included. 🤣 262 | 263 | Go to: [Table of Contents](#table-of-contents) 264 | 265 | ### Break lines 266 | 267 | One of the uses is to "break lines" of the styles. 268 | 269 | For this, just split the classes into multiple strings and put each one in a single line. 270 | 271 | Example: 272 | 273 | ```js 274 |
283 | Multiple lines! 284 |
285 | ``` 286 | 287 | Go to: [Table of Contents](#table-of-contents) 288 | 289 | ### Use Objects 290 | 291 | This is what you were waiting for, objects where the keys are applied to whatever the value is, even nested structures. 292 | 293 | Example: 294 | 295 | ```js 296 |
305 | Objects! 306 |
307 | ``` 308 | 309 | This will create exactly what you would expect: 310 | 311 | ```js 312 | "sm:text-sm sm:text-blue-500 md:text-base md:text-green-500 lg:text-lg lg:text-black lg:hover:text-red-500" 313 | ``` 314 | 315 | Each key (sm, md, lg, [&_li], group-hover, ...) will apply to everything inside the value. 316 | 317 | Each value can be a string, another object, or an array with strings and/or objects. 318 | 319 | > ⚠️ The ordering is applied in the order of the object. 320 | > 321 | > Some Tailwind classes don't work properly depending on the order, 322 | > 323 | > so always check if what you will be building is valid. 324 | 325 | Go to: [Table of Contents](#table-of-contents) 326 | 327 | ### Conditional classes 328 | 329 | One thing we usually need is conditional classes, we got you covered! 330 | 331 | As long as you follow [the rules](#rules-for-it-to-work): 332 | 333 | - Use boolean values for conditional expressions (ternary, &&, ||, ??, etc...) 334 | - Don't add variables other than the boolean for the conditional expressions 335 | 336 | Example: 337 | 338 | ```js 339 | const boolean = Math.random() > 0.5; 340 | // ... 341 |
348 | Conditional classes! 349 |
350 | ``` 351 | 352 | Go to: [Table of Contents](#table-of-contents) 353 | 354 | ## Rules for it to work 355 | 356 | 1. Use boolean values for conditional expressions (ternary, &&, ||, ??, etc...) 357 | 2. Don't add variables other than the boolean for the conditional expressions 358 | 359 | Go to: [Table of Contents](#table-of-contents) 360 | 361 | ### Known limitations 362 | 363 | Those limitations are about the `replacer` in the transform. 364 | 365 | Right now, depending on what you try to use in the `e` function, it will not work and you will be given a warning (check the terminal where you're running your `dev` or `build` script). 366 | 367 | When you're inspecting it in the browser, it will show the classes as normal, but it won't work (the function works as normal, but the transformer won't be able to parse it and the classes won't be added and sent to the browser). 368 | 369 | In those cases, you can append them separately from those you use with `EasyTailwind`. 370 | 371 | Examples: 372 | 373 | ```js 374 | className={`${variable} ${Math.random() > 0.5 ? "more" : "less"} ${e("here goes the safe to parse classes")}`} 375 | ``` 376 | 377 | ### Why is this necessary? 378 | 379 | Tailwind works with the JIT compiler that can create new classes on the fly and inject them. 380 | 381 | For it to work, they need to scan all the files looking for the classes, but when you use `EasyTailwind`, 382 | you're basically compressing many of the classes you're trying to use. So we need to add an extra step to Tailwind. 383 | 384 | In the Tailwind config (`tailwind.config.cjs`) you add the files it will scan for tailwind classes and a transform that uses a function that will resolve ahead of time what `EasyTailwind` can produce, so Tailwind can inject ahead of time all possible classes. 385 | 386 | However, the more complicated and inclusive you want it to scan for, the more you lose performance (for running in dev mode and for build). 387 | 388 | The best balance to be able to accept having conditional classes while minimizing the impact on performance is to simplify this, looking for only a boolean variable and not something that can be as simple as a variable or as complex as complex can be. 389 | 390 | See more at [Tailwind "Transforming source files"](https://tailwindcss.com/docs/content-configuration#transforming-source-files). 391 | 392 | Go to: [Table of Contents](#table-of-contents) 393 | 394 | ## Does it Support XYZ? 395 | 396 | Probably. 397 | 398 | If you can use `e('tw classes')` and it generates the classes (even if they don't actually work), then just follow the setup part. 399 | 400 | If you want me to add a custom `content` for the framework you're using, feel free to open a PR. =D 401 | 402 | Go to: [Table of Contents](#table-of-contents) 403 | 404 | ## Final Considerations 405 | 406 | These are mostly 'pure' functions, so we don't need to worry about getting "stale". 407 | 408 | More functionalities are welcome, but ultimately this package can have months between any updates. 409 | This doesn't mean that it's "dead", just that it's doing what it needs. 410 | 411 | Today it works with Tailwind v3, I'm not sure if with lower versions or for higher versions. 412 | As long as the `content` part doesn't change, then you can just import and use it. 413 | If it changes, you have the `replacer` for the transformations (as long as it supports it) but expect updates as soon as possible. 414 | 415 | Go to: [Table of Contents](#table-of-contents) 416 | 417 | ### Why "Easy" Tailwind? 418 | 419 | I'm lazy. 420 | 421 | And while I'm already productive using Tailwind, I don't like to keep repeating the same modifiers over and over again. 422 | 423 | So, it's "easy" to type. 424 | 425 | Another thing is about reading the classes. It's easy to get a long string with all classes jumbled together, even with extensions sorting and linting them it's hard to keep in mind everything that's happening at once. 426 | 427 | So, it's "easy" to read. 428 | 429 | And well, naming is hard and I went with the first thing I thought about. =p 430 | 431 | Go to: [Table of Contents](#table-of-contents) 432 | 433 | ## Work with me 434 | 435 | 436 | 437 | ### Hit me up at Discord 438 | 439 | 440 | 441 | ### Or Donate 442 | 443 | - [$5 Nice job! Keep it up.](https://www.paypal.com/donate/?business=VWNG7KZD9SS4S&no_recurring=0¤cy_code=USD&amount=5) 444 | - [$10 I really liked that, thank you!](https://www.paypal.com/donate/?business=VWNG7KZD9SS4S&no_recurring=0¤cy_code=USD&amount=10) 445 | - [$42 This is exactly what I was looking for.](https://www.paypal.com/donate/?business=VWNG7KZD9SS4S&no_recurring=0¤cy_code=USD&amount=42) 446 | - [$1K WOW. Did not know javascript could do that!](https://www.paypal.com/donate/?business=VWNG7KZD9SS4S&no_recurring=0¤cy_code=USD&amount=1000) 447 | - [$5K I need something done ASAP! Can you do it for yesterday?](https://www.paypal.com/donate/?business=VWNG7KZD9SS4S&no_recurring=0¤cy_code=USD&amount=5000) 448 | - [$10K Please consider this: quit your job and work with me!](https://www.paypal.com/donate/?business=VWNG7KZD9SS4S&no_recurring=0¤cy_code=USD&amount=10000) 449 | - [$??? Shut up and take my money!](https://www.paypal.com/donate/?business=VWNG7KZD9SS4S&no_recurring=0¤cy_code=USD) 450 | 451 | ## That’s it! 👏 452 | -------------------------------------------------------------------------------- /buildPack.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const fs = require('fs'); 3 | const os = require('os'); 4 | const exec = require('child_process').execSync; 5 | 6 | // create temp directory 7 | const tempDirectory = fs.mkdtempSync(`${os.tmpdir()}/your-project-tarball-`); 8 | const packageDirectory = `${tempDirectory}/package`; 9 | 10 | // create subfolder package 11 | fs.mkdirSync(packageDirectory); 12 | 13 | // get current directory 14 | const currentDirectory = exec('pwd') 15 | .toString() 16 | .replace(/(\r\n|\n|\r)/gm, '') 17 | .trim(); 18 | 19 | // clean output folder 20 | exec(`npx rimraf out/*`); 21 | 22 | // read existing package.json 23 | const packageJSON = require('./package.json'); 24 | 25 | // copy all necessary files 26 | exec(`copyfiles README.md CHANGELOG.md LICENSE ${packageDirectory}`); 27 | exec(`copyfiles -u 1 "dist/**/*" ${packageDirectory}`); 28 | 29 | // modify package.json 30 | Reflect.deleteProperty(packageJSON, 'scripts'); 31 | Reflect.deleteProperty(packageJSON, 'devDependencies'); 32 | // and save it in temp folder 33 | fs.writeFileSync( 34 | `${packageDirectory}/package.json`, 35 | JSON.stringify(packageJSON, null, 2), 36 | ); 37 | 38 | // pack everything and send to out folder 39 | exec( 40 | `cd ${packageDirectory} && yarn pack --out %s-%v.tgz && npx copyfiles *.tgz ${currentDirectory}/out`, 41 | ); 42 | 43 | // clean after 44 | exec(`npx rimraf ${tempDirectory}`); 45 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "easy-tailwind", 3 | "private": false, 4 | "version": "1.1.0", 5 | "description": "An easier way of writing Tailwind classes.", 6 | "main": "index.js", 7 | "types": "index.d.ts", 8 | "license": "MIT", 9 | "scripts": { 10 | "lint": "eslint --ignore-path .gitignore --fix .", 11 | "test": "jest --watch --verbose", 12 | "test:ci": "jest --verbose", 13 | "test:cov": "jest --coverage --silent --watchAll", 14 | "prepare": "husky install", 15 | "cleanDist": "npx rimraf dist/*", 16 | "build": "tsc", 17 | "prebuild:prod": "$npm_execpath run cleanDist", 18 | "build:prod": "tsc -p ./tsconfig.build.json && tsc -p ./tsconfig.nocomments.json", 19 | "prepackPublish": "$npm_execpath run build:prod", 20 | "packPublish": "node buildPack.js", 21 | "packPublish:dryRun": "$npm_execpath run packPublish && npm publish ./out/*.tgz --dry-run", 22 | "packPublish:publish": "$npm_execpath run packPublish && $npm_execpath publish ./out/*.tgz" 23 | }, 24 | "devDependencies": { 25 | "@commitlint/cli": "^12.1.4", 26 | "@commitlint/config-conventional": "^12.1.4", 27 | "@types/jest": "^26.0.22", 28 | "@types/node": "^16.0.0", 29 | "@typescript-eslint/eslint-plugin": "^4.19.0", 30 | "@typescript-eslint/parser": "^4.19.0", 31 | "copyfiles": "^2.4.1", 32 | "eslint": "^7.22.0", 33 | "eslint-config-prettier": "^8.1.0", 34 | "eslint-plugin-prettier": "^3.3.1", 35 | "husky": "^7.0.0", 36 | "jest": "^27.0.6", 37 | "lint-staged": "^11.0.0", 38 | "prettier": "^2.2.1", 39 | "ts-jest": "^27.0.3", 40 | "ts-loader": "^9.2.3", 41 | "ts-node": "^10.0.0", 42 | "tsconfig-paths": "^3.9.0", 43 | "typescript": "^4.2.3" 44 | }, 45 | "author": "Bruno Noriller (https://github.com/Noriller)", 46 | "bugs": { 47 | "url": "https://github.com/Noriller/easy-tailwind/issues" 48 | }, 49 | "homepage": "https://github.com/Noriller/easy-tailwind", 50 | "keywords": [ 51 | "tailwind", 52 | "chakra", 53 | "class", 54 | "clsx", 55 | "classnames" 56 | ], 57 | "jest": { 58 | "moduleFileExtensions": [ 59 | "js", 60 | "json", 61 | "ts" 62 | ], 63 | "rootDir": "src", 64 | "testRegex": ".*\\.spec\\.ts$", 65 | "transform": { 66 | "^.+\\.(t|j)s$": "ts-jest" 67 | }, 68 | "collectCoverageFrom": [ 69 | "**/*.ts" 70 | ], 71 | "coverageDirectory": "../coverage", 72 | "coverageThreshold": { 73 | "global": { 74 | "statements": 100, 75 | "branches": 100, 76 | "functions": 100, 77 | "lines": 100 78 | } 79 | }, 80 | "testEnvironment": "node" 81 | }, 82 | "lint-staged": { 83 | "*.{js,ts}": "yarn lint" 84 | }, 85 | "repository": { 86 | "type": "git", 87 | "url": "git+https://github.com/Noriller/easy-tailwind.git" 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/EasyType.ts: -------------------------------------------------------------------------------- 1 | // I couldn't make this type work yet. 2 | // But if possible, I would like to create it 3 | // then, if it's possible, augment it to check for problems 4 | // that might happen in the transform phase 5 | export type EasyType = unknown; 6 | -------------------------------------------------------------------------------- /src/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { e, etw } from './'; 2 | 3 | describe('EasyTW', () => { 4 | it('exports both "e" and "etw" as options', () => { 5 | expect(e).toBe(etw); 6 | }); 7 | 8 | it.each([ 9 | { args: [], expected: '' }, 10 | { args: [null], expected: '' }, 11 | { args: [undefined], expected: '' }, 12 | { args: [null, undefined], expected: '' }, 13 | { args: [''], expected: '' }, 14 | { args: ['class'], expected: 'class' }, 15 | { args: ['multiple classes'], expected: 'multiple classes' }, 16 | { 17 | args: [['multiple classes', 'in', 'one array']], 18 | expected: 'multiple classes in one array', 19 | }, 20 | { 21 | args: [['multiple classes'], ['in'], ['multiples array']], 22 | expected: 'multiple classes in multiples array', 23 | }, 24 | { 25 | args: [ 26 | 'multiple classes', 27 | [true && 'in', true && 'array', false && 'never'], 28 | ], 29 | expected: 'multiple classes in array', 30 | }, 31 | { 32 | args: ['multiple classes', { mod: 'class' }], 33 | expected: 'multiple classes mod:class', 34 | }, 35 | { 36 | args: ['multiple classes', { mod: 'class with mods' }], 37 | expected: 'multiple classes mod:class mod:with mod:mods', 38 | }, 39 | { 40 | args: ['multiple classes', { mod: ['base', 'other classes'] }], 41 | expected: 'multiple classes mod:base mod:other mod:classes', 42 | }, 43 | { 44 | args: [ 45 | 'multiple classes', 46 | { 47 | mod1: ['base', 'other classes'], 48 | mod2: ['base', { 'additional-mod': 'other classes' }], 49 | }, 50 | ], 51 | expected: 52 | 'multiple classes mod1:base mod1:other mod1:classes mod2:base mod2:additional-mod:other mod2:additional-mod:classes', 53 | }, 54 | { 55 | args: [ 56 | 'lorem ipsum dolor', 57 | ['amet', 'consectetur adipiscing elit'], 58 | ['Sed sit', 'amet ligula', ['ex', 'Ut']], 59 | ], 60 | expected: 61 | 'lorem ipsum dolor amet consectetur adipiscing elit Sed sit amet ligula ex Ut', 62 | }, 63 | { 64 | args: [ 65 | { 66 | mod1: [ 67 | 'amet', 68 | 'consectetur adipiscing elit', 69 | ['Sed sit', 'amet ligula', ['ex ut', 'lorem ipsum']], 70 | ], 71 | }, 72 | ], 73 | expected: 74 | 'mod1:amet mod1:consectetur mod1:adipiscing mod1:elit mod1:Sed mod1:sit mod1:amet mod1:ligula mod1:ex mod1:ut mod1:lorem mod1:ipsum', 75 | }, 76 | ])('return $expected when $args', ({ args, expected }) => { 77 | expect(e(...args)).toBe(expected); 78 | }); 79 | 80 | it('handles a really complex object', () => { 81 | expect( 82 | e( 83 | 'Lorem ipsum', 84 | 'dolor sit', 85 | ['amet', 'consectetur adipiscing elit'], 86 | ['Sed sit', 'amet ligula', ['ex', 'Ut']], 87 | { 88 | mod1: 'in suscipit metus', 89 | mod2: [ 90 | 'vel accumsan', 91 | 'orci', 92 | ['Vivamus sapien', 'neque', ['dictum vel', 'felis maximus']], 93 | ], 94 | mod3: ['luctus', { mod4: 'lorem' }], 95 | mod5: [ 96 | 'Fusce malesuada massa', 97 | ['eu turpis finibus'], 98 | { 99 | mod6: [ 100 | 'mollis', 101 | { 102 | mod7: [ 103 | 'In augue tortor', 104 | { 105 | mod8: [ 106 | 'porta eu erat sit amet', 107 | ['tristique', 'ullamcorper', 'arcu'], 108 | ], 109 | }, 110 | ], 111 | }, 112 | ], 113 | }, 114 | ], 115 | }, 116 | ), 117 | ).toBe( 118 | 'Lorem ipsum dolor sit amet consectetur adipiscing elit Sed sit amet ligula ex Ut mod1:in mod1:suscipit mod1:metus mod2:vel mod2:accumsan mod2:orci mod2:Vivamus mod2:sapien mod2:neque mod2:dictum mod2:vel mod2:felis mod2:maximus mod3:luctus mod3:mod4:lorem mod5:Fusce mod5:malesuada mod5:massa mod5:eu mod5:turpis mod5:finibus mod5:mod6:mollis mod5:mod6:mod7:In mod5:mod6:mod7:augue mod5:mod6:mod7:tortor mod5:mod6:mod7:mod8:porta mod5:mod6:mod7:mod8:eu mod5:mod6:mod7:mod8:erat mod5:mod6:mod7:mod8:sit mod5:mod6:mod7:mod8:amet mod5:mod6:mod7:mod8:tristique mod5:mod6:mod7:mod8:ullamcorper mod5:mod6:mod7:mod8:arcu', 119 | ); 120 | }); 121 | 122 | it.each([false && 'anything', undefined && 'anything', null && 'anything'])( 123 | 'handles falsy values', 124 | (falsy) => { 125 | expect(e(falsy)).toBe(''); 126 | }, 127 | ); 128 | 129 | it('accepts alternatives syntax', () => { 130 | // this syntax don't work with the replacers, so it shouldn't be used directly 131 | // (I've learned about having to transform the files afterwards and had a different idea on how it could be used) 132 | // it's possible I'll refactor it since there's no gain to having it 133 | 134 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 135 | // @ts-ignore 136 | expect(e.mod('classes', 'and more classes')).toEqual([ 137 | 'mod:classes', 138 | 'mod:and', 139 | 'mod:more', 140 | 'mod:classes', 141 | ]); 142 | expect(e['special-mod']('classes', 'and more classes')).toEqual([ 143 | 'special-mod:classes', 144 | 'special-mod:and', 145 | 'special-mod:more', 146 | 'special-mod:classes', 147 | ]); 148 | }); 149 | 150 | it('handles an edge case of empty string', () => { 151 | // this should not happen, but when a "prop" of the 152 | // proxy handle is falsy, it defaults to the unproxied object 153 | expect(e['']('anything')).toEqual('anything'); 154 | }); 155 | 156 | describe('examples from TW docs', () => { 157 | it.each([ 158 | { 159 | args: [ 160 | 'bg-slate-100 rounded-xl p-8', 161 | { 162 | md: 'flex p-0', 163 | dark: 'bg-slate-800', 164 | }, 165 | ], 166 | expected: 167 | 'bg-slate-100 rounded-xl p-8 md:flex md:p-0 dark:bg-slate-800', 168 | }, 169 | { 170 | args: [ 171 | 'w-24 h-24 rounded-full mx-auto', 172 | { 173 | md: 'w-48 h-auto rounded-none', 174 | }, 175 | ], 176 | expected: 177 | 'w-24 h-24 rounded-full mx-auto md:w-48 md:h-auto md:rounded-none', 178 | }, 179 | { 180 | args: [ 181 | 'pt-6 text-center space-y-4', 182 | { 183 | md: 'p-8 text-left', 184 | }, 185 | ], 186 | expected: 'pt-6 text-center space-y-4 md:p-8 md:text-left', 187 | }, 188 | { 189 | args: ['text-sky-500', { dark: 'text-sky-400' }], 190 | expected: 'text-sky-500 dark:text-sky-400', 191 | }, 192 | { 193 | args: ['text-slate-700', { dark: 'text-slate-500' }], 194 | expected: 'text-slate-700 dark:text-slate-500', 195 | }, 196 | { 197 | args: [ 198 | 'flex-none w-48 mb-10 relative z-10', 199 | { 200 | before: 'absolute top-1 left-1 w-full h-full bg-teal-400', 201 | }, 202 | ], 203 | expected: 204 | 'flex-none w-48 mb-10 relative z-10 before:absolute before:top-1 before:left-1 before:w-full before:h-full before:bg-teal-400', 205 | }, 206 | { 207 | args: [ 208 | 'relative flex flex-wrap items-baseline pb-6', 209 | { 210 | before: 'bg-black absolute -top-6 bottom-0 -left-60 -right-6', 211 | }, 212 | ], 213 | expected: 214 | 'relative flex flex-wrap items-baseline pb-6 before:bg-black before:absolute before:-top-6 before:bottom-0 before:-left-60 before:-right-6', 215 | }, 216 | { 217 | args: [ 218 | 'relative w-10 h-10 flex items-center justify-center text-black', 219 | { 220 | before: 'absolute z-[-1] top-0.5 left-0.5 w-full h-full', 221 | 'peer-checked': ['bg-black text-white', { before: 'bg-teal-400' }], 222 | }, 223 | ], 224 | expected: 225 | 'relative w-10 h-10 flex items-center justify-center text-black before:absolute before:z-[-1] before:top-0.5 before:left-0.5 before:w-full before:h-full peer-checked:bg-black peer-checked:text-white peer-checked:before:bg-teal-400', 226 | }, 227 | { 228 | args: [ 229 | 'py-6 px-4', 230 | { 231 | sm: 'p-6', 232 | md: 'py-10 px-8', 233 | }, 234 | ], 235 | expected: 'py-6 px-4 sm:p-6 md:py-10 md:px-8', 236 | }, 237 | { 238 | args: [ 239 | 'max-w-4xl mx-auto grid grid-cols-1', 240 | { 241 | lg: 'max-w-5xl gap-x-20 grid-cols-2', 242 | }, 243 | ], 244 | expected: 245 | 'max-w-4xl mx-auto grid grid-cols-1 lg:max-w-5xl lg:gap-x-20 lg:grid-cols-2', 246 | }, 247 | { 248 | args: [ 249 | 'relative p-3 col-start-1 row-start-1 flex flex-col-reverse rounded-lg bg-gradient-to-t from-black/75 via-black/0', 250 | { 251 | sm: 'bg-none row-start-2 p-0', 252 | lg: 'row-start-1', 253 | }, 254 | ], 255 | expected: 256 | 'relative p-3 col-start-1 row-start-1 flex flex-col-reverse rounded-lg bg-gradient-to-t from-black/75 via-black/0 sm:bg-none sm:row-start-2 sm:p-0 lg:row-start-1', 257 | }, 258 | { 259 | args: [ 260 | 'mt-1 text-lg font-semibold text-white', 261 | { 262 | sm: ['text-slate-900', { dark: 'text-white' }], 263 | md: 'text-2xl', 264 | }, 265 | ], 266 | expected: 267 | 'mt-1 text-lg font-semibold text-white sm:text-slate-900 sm:dark:text-white md:text-2xl', 268 | }, 269 | { 270 | args: [ 271 | 'text-sm leading-4 font-medium text-white', 272 | { 273 | sm: ['text-slate-500', { dark: 'text-slate-400' }], 274 | }, 275 | ], 276 | expected: 277 | 'text-sm leading-4 font-medium text-white sm:text-slate-500 sm:dark:text-slate-400', 278 | }, 279 | { 280 | args: [ 281 | 'grid gap-4 col-start-1 col-end-3 row-start-1', 282 | { 283 | sm: 'mb-6 grid-cols-4', 284 | lg: 'gap-6 col-start-2 row-end-6 row-span-6 mb-0', 285 | }, 286 | ], 287 | expected: 288 | 'grid gap-4 col-start-1 col-end-3 row-start-1 sm:mb-6 sm:grid-cols-4 lg:gap-6 lg:col-start-2 lg:row-end-6 lg:row-span-6 lg:mb-0', 289 | }, 290 | { 291 | args: [ 292 | 'w-full h-60 object-cover rounded-lg', 293 | { 294 | sm: 'h-52 col-span-2', 295 | lg: 'col-span-full', 296 | }, 297 | ], 298 | expected: 299 | 'w-full h-60 object-cover rounded-lg sm:h-52 sm:col-span-2 lg:col-span-full', 300 | }, 301 | { 302 | args: [ 303 | 'hidden w-full h-52 object-cover rounded-lg', 304 | { 305 | sm: 'block col-span-2', 306 | md: 'col-span-1', 307 | lg: 'row-start-2 col-span-2 h-32', 308 | }, 309 | ], 310 | expected: 311 | 'hidden w-full h-52 object-cover rounded-lg sm:block sm:col-span-2 md:col-span-1 lg:row-start-2 lg:col-span-2 lg:h-32', 312 | }, 313 | { 314 | args: [ 315 | 'hidden w-full h-52 object-cover rounded-lg', 316 | { 317 | md: 'block', 318 | lg: 'row-start-2 col-span-2 h-32', 319 | }, 320 | ], 321 | expected: 322 | 'hidden w-full h-52 object-cover rounded-lg md:block lg:row-start-2 lg:col-span-2 lg:h-32', 323 | }, 324 | { 325 | args: [ 326 | 'mt-4 text-xs font-medium flex items-center row-start-2', 327 | { 328 | sm: 'mt-1 row-start-3', 329 | md: 'mt-2.5', 330 | lg: 'row-start-2', 331 | }, 332 | ], 333 | expected: 334 | 'mt-4 text-xs font-medium flex items-center row-start-2 sm:mt-1 sm:row-start-3 md:mt-2.5 lg:row-start-2', 335 | }, 336 | { 337 | args: [ 338 | 'text-indigo-600 flex items-center', 339 | { dark: 'text-indigo-400' }, 340 | ], 341 | expected: 'text-indigo-600 flex items-center dark:text-indigo-400', 342 | }, 343 | { 344 | args: ['mr-1 stroke-current', { dark: 'stroke-indigo-500' }], 345 | expected: 'mr-1 stroke-current dark:stroke-indigo-500', 346 | }, 347 | { 348 | args: ['mr-1 text-slate-400', { dark: 'text-slate-500' }], 349 | expected: 'mr-1 text-slate-400 dark:text-slate-500', 350 | }, 351 | { 352 | args: [ 353 | 'mt-4 col-start-1 row-start-3 self-center', 354 | { 355 | sm: 'mt-0 col-start-2 row-start-2 row-span-2', 356 | lg: 'mt-6 col-start-1 row-start-3 row-end-4', 357 | }, 358 | ], 359 | expected: 360 | 'mt-4 col-start-1 row-start-3 self-center sm:mt-0 sm:col-start-2 sm:row-start-2 sm:row-span-2 lg:mt-6 lg:col-start-1 lg:row-start-3 lg:row-end-4', 361 | }, 362 | { 363 | args: [ 364 | 'mt-4 text-sm leading-6 col-start-1', 365 | { 366 | sm: 'col-span-2', 367 | lg: 'mt-6 row-start-4 col-span-1', 368 | dark: 'text-slate-400', 369 | }, 370 | ], 371 | expected: 372 | 'mt-4 text-sm leading-6 col-start-1 sm:col-span-2 lg:mt-6 lg:row-start-4 lg:col-span-1 dark:text-slate-400', 373 | }, 374 | { 375 | args: [ 376 | 'bg-white space-y-4 p-4', 377 | { 378 | sm: 'px-8 py-6', 379 | lg: 'p-4', 380 | xl: 'px-8 py-6', 381 | }, 382 | ], 383 | expected: 384 | 'bg-white space-y-4 p-4 sm:px-8 sm:py-6 lg:p-4 xl:px-8 xl:py-6', 385 | }, 386 | { 387 | args: [ 388 | { 389 | hover: 'bg-blue-400', 390 | }, 391 | 'group flex items-center rounded-md bg-blue-500 text-white text-sm font-medium pl-2 pr-3 py-2 shadow-sm', 392 | ], 393 | expected: 394 | 'hover:bg-blue-400 group flex items-center rounded-md bg-blue-500 text-white text-sm font-medium pl-2 pr-3 py-2 shadow-sm', 395 | }, 396 | { 397 | args: [ 398 | 'absolute left-3 top-1/2 -mt-2.5 text-slate-400 pointer-events-none', 399 | { 400 | 'group-focus-within': 'text-blue-500', 401 | }, 402 | ], 403 | expected: 404 | 'absolute left-3 top-1/2 -mt-2.5 text-slate-400 pointer-events-none group-focus-within:text-blue-500', 405 | }, 406 | { 407 | args: [ 408 | { focus: 'ring-2 ring-blue-500 outline-none' }, 409 | 'appearance-none w-full text-sm leading-6 text-slate-900 placeholder-slate-400 rounded-md py-2 pl-10 ring-1 ring-slate-200 shadow-sm', 410 | ], 411 | expected: 412 | 'focus:ring-2 focus:ring-blue-500 focus:outline-none appearance-none w-full text-sm leading-6 text-slate-900 placeholder-slate-400 rounded-md py-2 pl-10 ring-1 ring-slate-200 shadow-sm', 413 | }, 414 | { 415 | args: [ 416 | 'bg-slate-50 p-4 grid grid-cols-1 gap-4 text-sm leading-6', 417 | { 418 | sm: 'px-8 pt-6 pb-8 grid-cols-2', 419 | lg: 'p-4 grid-cols-1', 420 | xl: 'px-8 pt-6 pb-8 grid-cols-2', 421 | }, 422 | ], 423 | expected: 424 | 'bg-slate-50 p-4 grid grid-cols-1 gap-4 text-sm leading-6 sm:px-8 sm:pt-6 sm:pb-8 sm:grid-cols-2 lg:p-4 lg:grid-cols-1 xl:px-8 xl:pt-6 xl:pb-8 xl:grid-cols-2', 425 | }, 426 | { 427 | args: [ 428 | { hover: 'bg-blue-500 ring-blue-500 shadow-md' }, 429 | 'group rounded-md p-3 bg-white ring-1 ring-slate-200 shadow-sm', 430 | ], 431 | expected: 432 | 'hover:bg-blue-500 hover:ring-blue-500 hover:shadow-md group rounded-md p-3 bg-white ring-1 ring-slate-200 shadow-sm', 433 | }, 434 | { 435 | args: [ 436 | 'grid grid-cols-2 grid-rows-2 items-center', 437 | { 438 | sm: 'block', 439 | lg: 'grid', 440 | xl: 'block', 441 | }, 442 | ], 443 | expected: 444 | 'grid grid-cols-2 grid-rows-2 items-center sm:block lg:grid xl:block', 445 | }, 446 | { 447 | args: [{ 'group-hover': 'text-white' }, 'font-semibold text-slate-900'], 448 | expected: 'group-hover:text-white font-semibold text-slate-900', 449 | }, 450 | { 451 | args: [ 452 | 'col-start-2 row-start-1 row-end-3', 453 | { 454 | sm: 'mt-4', 455 | lg: 'mt-0', 456 | xl: 'mt-4', 457 | }, 458 | ], 459 | expected: 'col-start-2 row-start-1 row-end-3 sm:mt-4 lg:mt-0 xl:mt-4', 460 | }, 461 | { 462 | args: [ 463 | 'flex justify-end -space-x-1.5', 464 | { 465 | sm: 'justify-start', 466 | lg: 'justify-end', 467 | xl: 'justify-start', 468 | }, 469 | ], 470 | expected: 471 | 'flex justify-end -space-x-1.5 sm:justify-start lg:justify-end xl:justify-start', 472 | }, 473 | { 474 | args: [ 475 | { hover: 'border-blue-500 border-solid bg-white text-blue-500' }, 476 | 'group w-full flex flex-col items-center justify-center rounded-md border-2 border-dashed border-slate-300 text-sm leading-6 text-slate-900 font-medium py-3', 477 | ], 478 | expected: 479 | 'hover:border-blue-500 hover:border-solid hover:bg-white hover:text-blue-500 group w-full flex flex-col items-center justify-center rounded-md border-2 border-dashed border-slate-300 text-sm leading-6 text-slate-900 font-medium py-3', 480 | }, 481 | { 482 | args: [ 483 | 'bg-white border-slate-100 border-b rounded-t-xl p-4 pb-6 space-y-6', 484 | { 485 | dark: 'bg-slate-800 border-slate-500', 486 | sm: 'p-10 pb-8 space-y-8', 487 | lg: 'p-6 space-y-6', 488 | xl: 'p-10 pb-8 space-y-8', 489 | }, 490 | ], 491 | expected: 492 | 'bg-white border-slate-100 border-b rounded-t-xl p-4 pb-6 space-y-6 dark:bg-slate-800 dark:border-slate-500 sm:p-10 sm:pb-8 sm:space-y-8 lg:p-6 lg:space-y-6 xl:p-10 xl:pb-8 xl:space-y-8', 493 | }, 494 | { 495 | args: [ 496 | 'hidden', 497 | { 498 | sm: 'block', 499 | lg: 'hidden', 500 | xl: 'block', 501 | }, 502 | ], 503 | expected: 'hidden sm:block lg:hidden xl:block', 504 | }, 505 | { 506 | args: [ 507 | 'rounded-lg text-xs leading-6 font-semibold px-2 ring-2 ring-inset ring-slate-500 text-slate-500', 508 | { 509 | dark: 'text-slate-100 ring-0 bg-slate-500', 510 | }, 511 | ], 512 | expected: 513 | 'rounded-lg text-xs leading-6 font-semibold px-2 ring-2 ring-inset ring-slate-500 text-slate-500 dark:text-slate-100 dark:ring-0 dark:bg-slate-500', 514 | }, 515 | { 516 | args: [ 517 | 'relative -mr-px w-0 flex-1 inline-flex items-center justify-center py-4', 518 | 'text-sm text-slate-700 font-medium border border-transparent rounded-bl-lg', 519 | { 520 | hover: 'text-slate-500', 521 | focus: 'outline-none shadow-outline-blue border-blue-300 z-10', 522 | }, 523 | 'transition ease-in-out duration-150', 524 | ], 525 | expected: 526 | 'relative -mr-px w-0 flex-1 inline-flex items-center justify-center py-4 text-sm text-slate-700 font-medium border border-transparent rounded-bl-lg hover:text-slate-500 focus:outline-none focus:shadow-outline-blue focus:border-blue-300 focus:z-10 transition ease-in-out duration-150', 527 | }, 528 | ])('return $expected when $args', ({ args, expected }) => { 529 | expect(e(...args)).toBe(expected); 530 | }); 531 | }); 532 | }); 533 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { EasyType } from './EasyType'; 2 | 3 | function easyTW(...args: EasyType[]) { 4 | return twReducer(args).join(' '); 5 | } 6 | 7 | /** 8 | * EasyTailwind main function. (to be imported as `e` or `etw`) 9 | * 10 | * Check the docs for the full version. 11 | * 12 | * It accepts any number of args: 13 | * - strings 14 | * - The following also accept nesting: 15 | * - arrays 16 | * - objects 17 | * - The keys used will be applied as a modifier to all nested values 18 | * - All of them also accepts conditionals 19 | * 20 | * You can use it as a way to have more readable TW classes, spliting them into lines: 21 | * ```js 22 | * e( 23 | * "my tw classes line 1", 24 | * "my tw classes line 2", 25 | * "my tw classes line 3", 26 | * ) 27 | * ``` 28 | * 29 | * You can also use to have conditional classes: 30 | * ```js 31 | * e( 32 | * true && "my tw classes line 1", 33 | * false && "my tw classes line 2", 34 | * "my tw classes line 3", 35 | * ) 36 | * ``` 37 | * 38 | * Finally, you can use to better organize and reduce repetition: 39 | * ```js 40 | * e( 41 | * "my tw classes line 1", 42 | * { 43 | * mod1: "classes with the mod1", 44 | * mod2: [ 45 | * "classes with the mod2", 46 | * { 47 | * subMod: "clases with both mod2 and subMod" 48 | * } 49 | * ] 50 | * } 51 | * ) 52 | * 53 | * // this will return: 54 | * "my tw classes line 1 mod1:classes mod1:with mod1:the mod1:mod1 mod2:classes mod2:with mod2:the mod2:mod2 mod2:subMod:clases mod2:subMod:with mod2:subMod:both mod2:subMod:mod2 mod2:subMod:and mod2:subMod:subMod" 55 | * ``` 56 | */ 57 | export const e: (...args: EasyType[]) => string = new Proxy(easyTW, { 58 | get: function (obj, prop: string) { 59 | return prop 60 | ? (...args: EasyType[]) => twReducer(args).map((c) => `${prop}:${c}`) 61 | : obj; 62 | }, 63 | }); 64 | 65 | function twReducer(args: EasyType[]) { 66 | return ( 67 | args.reduce((acc: string[], cur) => { 68 | if (cur === undefined || cur === null || cur === false) return acc; 69 | 70 | if (Array.isArray(cur)) { 71 | acc.push(...cur.filter(Boolean)); 72 | } else if (typeof cur === 'object') { 73 | Object.entries(cur).forEach(([key, value]) => { 74 | if (Array.isArray(value)) { 75 | value.flat(Infinity).forEach((v) => { 76 | acc.push(e[key](v)); 77 | }); 78 | } else { 79 | acc.push(e[key](value)); 80 | } 81 | }); 82 | } else { 83 | acc.push(...String(cur).split(' ')); 84 | } 85 | 86 | return acc; 87 | }, []) as string[] 88 | ).flat(Infinity); 89 | } 90 | 91 | /** 92 | * @namespace 93 | * @borrows e as etw 94 | */ 95 | export { e as etw }; 96 | -------------------------------------------------------------------------------- /src/transform/baseReplacer/baseReplacer.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, jest, it, describe } from '@jest/globals'; 2 | import { baseReplacer } from '.'; 3 | 4 | describe('.baseReplacer()', () => { 5 | const replacer = baseReplacer(/e\(./gis); 6 | 7 | const consoleErrorSpy = jest.spyOn( 8 | globalThis.console, 9 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 10 | // @ts-ignore 11 | globalThis.console.error.name, 12 | ); 13 | 14 | beforeEach(() => { 15 | jest.resetAllMocks(); 16 | }); 17 | 18 | it('returns nothing when nothing is passed', () => { 19 | expect(replacer('')).toBe(''); 20 | }); 21 | 22 | it('return the just the content when no matches are found', () => { 23 | expect(replacer(`"content"`)).toEqual('"content"'); 24 | }); 25 | 26 | it.each([ 27 | `boolean ? "my" : "classes"`, 28 | `!boolean ? "my" : "classes"`, 29 | `!!boolean ? "my" : "classes"`, 30 | ])('replaces a ternary: %s', (content) => { 31 | expect(replacer(`e(${content})`)).toEqual(`e('my classes')`); 32 | }); 33 | 34 | it.each([ 35 | `boolean && "my classes"`, 36 | `boolean || "my classes"`, 37 | `boolean ?? "my classes"`, 38 | `!boolean && "my classes"`, 39 | `!boolean || "my classes"`, 40 | `!boolean ?? "my classes"`, 41 | `!!boolean && "my classes"`, 42 | `!!boolean || "my classes"`, 43 | `!!boolean ?? "my classes"`, 44 | ])('replaces conditionals: %s', (content) => { 45 | expect(replacer(`e(${content})`)).toEqual(`e('my classes')`); 46 | }); 47 | 48 | it.each([ 49 | { content: `boolean && ["my", "classes"]`, result: 'my classes' }, 50 | { 51 | content: `boolean && { 52 | mod:"my classes" 53 | }`, 54 | result: 'mod:my mod:classes', 55 | }, 56 | { 57 | content: `{ my: boolean && [ { mod:"classes" } ] }`, 58 | result: 'my:mod:classes', 59 | }, 60 | ])( 61 | 'replaces conditionals with objects: $content | return $result', 62 | ({ content, result }) => { 63 | expect(replacer(`e(${content})`)).toEqual(`e('${result}')`); 64 | }, 65 | ); 66 | 67 | it.each([ 68 | `(my > boolean) ? "something" : "else"`, 69 | `(my > boolean) && "something else"`, 70 | `(my > boolean) || "something else"`, 71 | `(my > boolean) ?? "something else"`, 72 | ])('throws when content dont follow etw rules: %s', (content) => { 73 | expect(replacer(`e(${content})`)).toEqual(`e(${content})`); 74 | expect(consoleErrorSpy).toHaveBeenCalledTimes(1); 75 | expect(consoleErrorSpy).toHaveBeenCalledWith( 76 | `\nAre you following EasyTailwind rules?\n\nmy is not defined in\n${content}\n\nTrying to be transformed into:\n${content}\n`, 77 | ); 78 | }); 79 | 80 | it('returns the content without throwing when no matchs are found', () => { 81 | const content = 'content'; 82 | expect(baseReplacer(/no match/g)(content)).toEqual(content); 83 | expect(consoleErrorSpy).toHaveBeenCalledTimes(0); 84 | }); 85 | 86 | it('throws when using regex without global flag', () => { 87 | const content = 'content'; 88 | expect(baseReplacer(/no match/)(content)).toEqual(content); 89 | expect(consoleErrorSpy).toHaveBeenCalledTimes(1); 90 | expect(consoleErrorSpy).toHaveBeenCalledWith( 91 | `\nAre you following EasyTailwind rules?\n\nString.prototype.matchAll called with a non-global RegExp argument in file\ncontent\n`, 92 | ); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /src/transform/baseReplacer/extractor.ts: -------------------------------------------------------------------------------- 1 | type Quote = '"' | "'" | '`'; 2 | 3 | /** 4 | * Takes arguments from a RegExp match that finds the use of EasyTailwind. 5 | * From there it iterates the string until finding the closing bracket and 6 | * returns the string. 7 | * 8 | * In other words, this returns whatever was passed as arguments to `e` 9 | */ 10 | export function extractArgumentsIndex( 11 | matchIndex: number, 12 | matchLen: number, 13 | wholeText: string, 14 | ) { 15 | const initialIndex = matchIndex + matchLen - 1; 16 | const getQuote = (index): Quote | false => 17 | ['"', "'", '`'].includes(wholeText[index]) 18 | ? (wholeText[index] as Quote) 19 | : false; 20 | 21 | // check if inside a string 22 | let isInString: Quote | false = false; 23 | 24 | // balance round brackets to know when to stop consuming 25 | // 1 => initial bracket 26 | let balanceBracketCheck = 1; 27 | 28 | // check for escapes 29 | const lastChar: string = null; 30 | 31 | // to be found 32 | let finalIndex: number = null; 33 | 34 | for (let i = initialIndex; i < wholeText.length; i++) { 35 | const isQuote = getQuote(i); 36 | if (isQuote) { 37 | if (isInString === isQuote && lastChar !== '\\') { 38 | isInString = false; 39 | } else { 40 | isInString = isQuote; 41 | } 42 | continue; 43 | } 44 | 45 | if (wholeText[i] === '(') { 46 | balanceBracketCheck++; 47 | continue; 48 | } 49 | 50 | if (wholeText[i] === ')') { 51 | if (--balanceBracketCheck === 0) { 52 | finalIndex = i; 53 | break; 54 | } 55 | } 56 | } 57 | 58 | return wholeText.slice(initialIndex, finalIndex); 59 | } 60 | -------------------------------------------------------------------------------- /src/transform/baseReplacer/generateEasyTailwindRegex.spec.ts: -------------------------------------------------------------------------------- 1 | import { baseReplacer } from '.'; 2 | import { generateEasyTailwindRegex } from './generateEasyTailwindRegex'; 3 | 4 | describe('.generateEasyTailwindRegex()', () => { 5 | describe('using a custom name', () => { 6 | const replacer = baseReplacer(generateEasyTailwindRegex('myFuncName')); 7 | it('matches "myFuncName"', () => { 8 | const fixture = ` 9 | export function MyComponent() { 10 | return
anything
; 11 | } 12 | `; 13 | 14 | expect(replacer(fixture)).toMatchInlineSnapshot(` 15 | " 16 | export function MyComponent() { 17 | return
anything
; 18 | } 19 | " 20 | `); 21 | }); 22 | 23 | it("doesn't match 'e'", async () => { 24 | const fixture = ` 25 | export function MyComponent() { 26 | return
anything
; 27 | } 28 | `; 29 | 30 | expect(replacer(fixture)).toMatchInlineSnapshot(` 31 | " 32 | export function MyComponent() { 33 | return
anything
; 34 | } 35 | " 36 | `); 37 | }); 38 | }); 39 | 40 | describe('when not passing a function name', () => { 41 | it('throws', () => { 42 | expect(() => generateEasyTailwindRegex()).toThrowError( 43 | 'Please provide at least one function name when using this.', 44 | ); 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/transform/baseReplacer/generateEasyTailwindRegex.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This generates a RegExp based on a custom name of the export you use. 3 | */ 4 | export const generateEasyTailwindRegex = (...functionName: string[]) => { 5 | if (functionName.length === 0) { 6 | throw new Error( 7 | 'Please provide at least one function name when using this.', 8 | ); 9 | } 10 | 11 | return new RegExp( 12 | `\\W(?:${functionName 13 | .map((s) => s.trim()) 14 | .filter(Boolean) 15 | .join('|')})\\(.`, 16 | 'gis', 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/transform/baseReplacer/index.ts: -------------------------------------------------------------------------------- 1 | import { e } from '../..'; 2 | import { extractArgumentsIndex } from './extractor'; 3 | 4 | /** 5 | * This matches the start of any use of EasyTailwind 6 | */ 7 | const genericRegex = /\W(?:e|etw)\(./gis; 8 | 9 | /** 10 | * This matches the &&, ||, ?? operators that are used for conditional classes. 11 | * The first group is the whole match. 12 | */ 13 | const replaceAndOr = /((!*?\w+)\s*?(&&|\|\||\?\?))/gis; 14 | 15 | /** 16 | * This matches the ternary style operator and returns as the 2 groups the true and false matches. 17 | */ 18 | const replaceTernary = 19 | /(?:!*?\w+)\s*\?\s*(?['"`])(?.*?)\k\s*:\s*(?['"`])(?.*?)\k/gis; 20 | 21 | /** 22 | * If any of the available transforms aren't suited for your needs, 23 | * then you can use this baseReplacer function to create a replacer 24 | * that will work for you. 25 | * 26 | * This accepts a RegExp where the first group is the args to be passed to `e`/`etw` function (`EasyTailwind` main function). 27 | * 28 | * Then in `tailwind.config.cjs` 29 | * 30 | *```js 31 | module.exports = { 32 | content: { 33 | // ... 34 | transform: { 35 | // ..., 36 | 'my-file-type': baseReplacer(/my regex/), 37 | DEFAULT: baseReplacer(/my regex/), // default is a catch all 38 | } 39 | }, 40 | // ... 41 | } 42 | * ``` 43 | */ 44 | export const baseReplacer = (easyTailwindRegex: RegExp = genericRegex) => { 45 | return (content: string) => { 46 | try { 47 | return [...content.matchAll(easyTailwindRegex)].reduce( 48 | (acc: string, matchArr: RegExpMatchArray) => { 49 | const extractedArgs = extractArgumentsIndex( 50 | matchArr.index, 51 | matchArr[0].length, 52 | content, 53 | ); 54 | 55 | const currentTransform = extractedArgs 56 | .replace(replaceAndOr, '') 57 | .replace(replaceTernary, '"$ $"') 58 | .trim(); 59 | 60 | try { 61 | const parsedArgs = e( 62 | ...new Function(`return [${currentTransform}]`)(), 63 | ); 64 | 65 | return acc.replace(extractedArgs, `'${parsedArgs}'`); 66 | } catch (errorParsing) { 67 | console.error( 68 | `\nAre you following EasyTailwind rules?\n\n${errorParsing.message} in\n${extractedArgs}\n\nTrying to be transformed into:\n${currentTransform}\n`, 69 | ); 70 | return acc; 71 | } 72 | }, 73 | content, 74 | ); 75 | } catch (errorOnFile) { 76 | console.error( 77 | `\nAre you following EasyTailwind rules?\n\n${errorOnFile.message} in file\n${content}\n`, 78 | ); 79 | return content; 80 | } 81 | }; 82 | }; 83 | -------------------------------------------------------------------------------- /src/transform/index.ts: -------------------------------------------------------------------------------- 1 | import { baseReplacer } from './baseReplacer'; 2 | import { generateEasyTailwindRegex } from './baseReplacer/generateEasyTailwindRegex'; 3 | 4 | /** 5 | * If you need to change something in the `content`, add or remove file extensions or 6 | * use other transformers, then you can use this. 7 | * 8 | * Then in `tailwind.config.cjs` 9 | * 10 | *```js 11 | module.exports = { 12 | content: { 13 | // ... 14 | transform: { 15 | // ..., 16 | 'my-file-type': replacer, 17 | DEFAULT: replacer, // default is a catch all 18 | } 19 | }, 20 | // ... 21 | } 22 | * ``` 23 | */ 24 | const replacer = baseReplacer(); 25 | 26 | /** 27 | * If you're renaming or have collisions with other functions named `e` or `etw` 28 | * 29 | * You can add here all the names you're using. By default it's `e` and `etw` 30 | * 31 | * Example: 32 | * 33 | * ```js 34 | * const replacer = customNameReplacer('e', 'etw'); 35 | * ``` 36 | */ 37 | const customNameReplacer = (...modifiedNames: string[]) => { 38 | // removes empty values 39 | const trimmedNames = modifiedNames.filter(Boolean); 40 | 41 | // returns a string array 42 | const names = 43 | trimmedNames.length === 0 ? ['e', 'etw'] : modifiedNames.map(String); 44 | 45 | return baseReplacer(generateEasyTailwindRegex(...names)); 46 | }; 47 | 48 | /** 49 | * Catch all `content` key with the most common file extensions. 50 | * 51 | * Import in `tailwind.config.cjs`: 52 | * 53 | * `const { content } = require('easy-tailwind/transform');` 54 | * 55 | * And then: 56 | * ```js 57 | module.exports = { 58 | content, 59 | // ... 60 | } 61 | * ``` 62 | * 63 | * It simply returns: 64 | * ```js 65 | { 66 | files: ['!node_modules', '.\/**\/*.{js,ts,jsx,tsx,html,vue,svelte,astro}'], 67 | transform: { 68 | DEFAULT: replacer, 69 | } 70 | } 71 | * ``` 72 | * 73 | * Where the `replacer` is also exported from this folder and returns whats needed 74 | * for it to be able to work properly. 75 | * 76 | * If you're renaming the default names, then use `customNameReplacer`. 77 | * 78 | * "DEFAULT" is used in all file types, but you can override each file type by extension name 79 | */ 80 | const content = { 81 | files: ['!node_modules', './**/*.{js,ts,jsx,tsx,html,vue,svelte,astro}'], 82 | transform: { 83 | DEFAULT: replacer, 84 | }, 85 | }; 86 | 87 | export { customNameReplacer, replacer, content }; 88 | -------------------------------------------------------------------------------- /src/transform/react/index.ts: -------------------------------------------------------------------------------- 1 | // istanbul ignore file 2 | import { replacer } from '..'; 3 | 4 | /** 5 | * `content` key for use in React apps with Tailwind. 6 | * 7 | * Import in `tailwind.config.cjs`: 8 | * 9 | * `const { content } = require('easy-tailwind/transform/react');` 10 | * 11 | * And then: 12 | * ```js 13 | module.exports = { 14 | content, 15 | // ... 16 | } 17 | * ``` 18 | * 19 | * It simply returns: 20 | * ```js 21 | { 22 | files: ['!node_modules', '.\/**\/*.{js,ts,jsx,tsx,html}'], 23 | transform: { 24 | DEFAULT: replacer, 25 | } 26 | } 27 | * ``` 28 | * 29 | * Where the `replacer` is exported from the index of the `/transform` folder. 30 | * 31 | * "DEFAULT" is used in all file types, but you can override each file type by extension name 32 | */ 33 | export const content = { 34 | files: ['!node_modules', './**/*.{js,ts,jsx,tsx,html}'], 35 | transform: { 36 | DEFAULT: replacer, 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /src/transform/react/reactReplacer.spec.ts: -------------------------------------------------------------------------------- 1 | import { replacer } from '..'; 2 | 3 | describe('react| .replacer()', () => { 4 | const consoleErrorSpy = jest.spyOn( 5 | globalThis.console, 6 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 7 | // @ts-ignore 8 | globalThis.console.error.name, 9 | ); 10 | 11 | beforeEach(() => { 12 | jest.resetAllMocks(); 13 | }); 14 | 15 | it('handles a file without matches', () => { 16 | const fixtureWithoutMatch = ` 17 | export function MyComponent() { 18 | return
anything
; 19 | } 20 | `; 21 | 22 | expect(replacer(fixtureWithoutMatch)).toBe(fixtureWithoutMatch); 23 | }); 24 | 25 | it('handles a file with simple matches', async () => { 26 | const fixtureWithSimpleMatches = ` 27 | export function MyComponentWithMatches() { 28 | return ( 29 |
30 |
anything1
31 |
anything2
32 |
33 | ); 34 | } 35 | `; 36 | 37 | expect(replacer(fixtureWithSimpleMatches)).toMatchInlineSnapshot(` 38 | " 39 | export function MyComponentWithMatches() { 40 | return ( 41 |
42 |
anything1
43 |
anything2
44 |
45 | ); 46 | } 47 | " 48 | `); 49 | }); 50 | 51 | it('handles string with linebreaks', async () => { 52 | const fixtureWithSimpleMatches = ` 53 | export function MyComponentWithMatches() { 54 | return ( 55 |
56 |
anything1
63 |
64 | ); 65 | } 66 | `; 67 | 68 | expect(replacer(fixtureWithSimpleMatches)).toMatchInlineSnapshot(` 69 | " 70 | export function MyComponentWithMatches() { 71 | return ( 72 |
73 |
anything1
77 |
78 | ); 79 | } 80 | " 81 | `); 82 | }); 83 | 84 | it('handles a file with a complex usage', () => { 85 | const fixtureWithComplexUsage = ` 86 | export function MyComponentWithMatches() { 87 | const random = Math.random() > 0.5; 88 | return ( 89 |
90 |
anything1
106 |
107 | ) 108 | } 109 | `; 110 | expect(replacer(fixtureWithComplexUsage)).toMatchInlineSnapshot(` 111 | " 112 | export function MyComponentWithMatches() { 113 | const random = Math.random() > 0.5; 114 | return ( 115 |
116 |
anything1
119 |
120 | ) 121 | } 122 | " 123 | `); 124 | }); 125 | 126 | it('handles a file that dont follow etw rules', () => { 127 | const fixtureWithError = ` 128 | export function MyComponentWithError() { 129 | return ( 130 |
131 |
0.5 ? 'using with e' : 'but with error' 134 | ) 135 | }>anything1
136 |
137 | ) 138 | } 139 | `; 140 | expect(replacer(fixtureWithError)).toEqual(fixtureWithError); 141 | expect(consoleErrorSpy).toHaveBeenCalledTimes(1); 142 | expect(consoleErrorSpy).toHaveBeenCalledWith(` 143 | Are you following EasyTailwind rules? 144 | 145 | Unexpected string in 146 | 147 | Math.random() > 0.5 ? \'using with e\' : \'but with error\' 148 | 149 | 150 | Trying to be transformed into: 151 | Math.random() > 0."using with e but with error" 152 | `); 153 | }); 154 | 155 | it('handles example from playground', () => { 156 | const fixtureWithMatches = ` 157 | export function MyComponentWithWeirdThings() { 158 | return ( 159 |
166 | ) 167 | } 168 | `; 169 | expect(replacer(fixtureWithMatches)).toMatchInlineSnapshot(` 170 | " 171 | export function MyComponentWithWeirdThings() { 172 | return ( 173 |
176 | ) 177 | } 178 | " 179 | `); 180 | }); 181 | 182 | it('handles unbalanced brackets', () => { 183 | const fixtureWithImbalance = ` 184 | export function MyComponentWithWeirdThings() { 185 | return ( 186 |
197 | ) 198 | } 199 | `; 200 | expect(replacer(fixtureWithImbalance)).toMatchInlineSnapshot(` 201 | " 202 | export function MyComponentWithWeirdThings() { 203 | return ( 204 |
215 | ) 216 | } 217 | " 218 | `); 219 | }); 220 | 221 | it('handles etw inside a string template', () => { 222 | const fixtureWithTemplate = ` 223 | export function MyComponentWithWeirdThings() { 224 | return ( 225 |
233 | ) 234 | } 235 | `; 236 | expect(replacer(fixtureWithTemplate)).toMatchInlineSnapshot(` 237 | " 238 | export function MyComponentWithWeirdThings() { 239 | return ( 240 |
245 | ) 246 | } 247 | " 248 | `); 249 | }); 250 | }); 251 | -------------------------------------------------------------------------------- /src/transform/transform.spec.ts: -------------------------------------------------------------------------------- 1 | import { customNameReplacer } from '.'; 2 | 3 | describe('.customNameReplacer()', () => { 4 | describe('using a custom name', () => { 5 | const replacer = customNameReplacer('myFuncName'); 6 | 7 | it('it generates a replacer with the myFuncName', () => { 8 | const fixture = ` 9 | export function MyComponent() { 10 | return ( 11 |
12 |
anything
13 |
anything
14 |
anything
15 |
16 | ); 17 | } 18 | `; 19 | 20 | expect(replacer(fixture)).toMatchInlineSnapshot(` 21 | " 22 | export function MyComponent() { 23 | return ( 24 |
25 |
anything
26 |
anything
27 |
anything
28 |
29 | ); 30 | } 31 | " 32 | `); 33 | }); 34 | 35 | it("doesn't match 'e' or 'etw'", async () => { 36 | const fixture = ` 37 | export function MyComponent() { 38 | return ( 39 |
40 |
anything
41 |
anything
42 |
anything
43 |
44 | ); 45 | } 46 | `; 47 | 48 | expect(replacer(fixture)).toMatchInlineSnapshot(` 49 | " 50 | export function MyComponent() { 51 | return ( 52 |
53 |
anything
54 |
anything
55 |
anything
56 |
57 | ); 58 | } 59 | " 60 | `); 61 | }); 62 | }); 63 | 64 | describe('without passing a custom name', () => { 65 | const replacer = customNameReplacer(); 66 | 67 | it('it generates a replacer for the default names', () => { 68 | const fixture = ` 69 | export function MyComponent() { 70 | return ( 71 |
72 |
anything
73 |
anything
74 |
anything
75 |
76 | ); 77 | } 78 | `; 79 | 80 | expect(replacer(fixture)).toMatchInlineSnapshot(` 81 | " 82 | export function MyComponent() { 83 | return ( 84 |
85 |
anything
86 |
anything
87 |
anything
88 |
89 | ); 90 | } 91 | " 92 | `); 93 | }); 94 | }); 95 | 96 | describe('passing an empty custom name', () => { 97 | const replacer = customNameReplacer(''); 98 | 99 | it('it generates a replacer for the default names', () => { 100 | const fixture = ` 101 | export function MyComponent() { 102 | return ( 103 |
104 |
anything
105 |
anything
106 |
anything
107 |
108 | ); 109 | } 110 | `; 111 | 112 | expect(replacer(fixture)).toMatchInlineSnapshot(` 113 | " 114 | export function MyComponent() { 115 | return ( 116 |
117 |
anything
118 |
anything
119 |
anything
120 |
121 | ); 122 | } 123 | " 124 | `); 125 | }); 126 | }); 127 | }); 128 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "dist", "performance","test", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": false, 6 | "emitDecoratorMetadata": false, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "lib": [ 11 | "ES2019" 12 | ], 13 | "sourceMap": true, 14 | "outDir": "./dist", 15 | "baseUrl": "./", 16 | "incremental": false, 17 | "noEmit": false, 18 | "noEmitHelpers": false, 19 | }, 20 | "exclude": [ 21 | "node_modules", 22 | "performance", 23 | "dist", 24 | ], 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.nocomments.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | "compilerOptions": { 4 | "declaration": false, 5 | "removeComments": true, 6 | "sourceMap": false, 7 | } 8 | } --------------------------------------------------------------------------------