├── .eslintignore ├── .eslintrc.js ├── .github ├── actions │ └── setup-node │ │ └── action.yml └── workflows │ └── ci.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── LICENSE ├── README.md ├── compile.js ├── components.ts ├── package-lock.json ├── package.json ├── runtests.sh ├── test ├── .gitignore ├── Compatibility.spec.js ├── Tsx.spec.js ├── helpers │ ├── browser-env.js │ └── compare-inner-html-test.js └── src │ ├── choose.compat.jsx │ ├── choose.tsx │ ├── for.compat.jsx │ ├── for.tsx │ ├── if.compat.jsx │ ├── if.tsx │ ├── mixed-nested.tsx │ ├── with.compat.jsx │ └── with.tsx ├── transformer.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | transformer/test/build/ 3 | transformer/test/babel/ 4 | transformer/transformer.js 5 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', // Specifies the ESLint parser 3 | parserOptions: { 4 | ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features 5 | sourceType: 'module', // Allows for the use of imports 6 | ecmaFeatures: { 7 | jsx: true // Allows for the parsing of JSX 8 | } 9 | }, 10 | settings: { 11 | react: { 12 | version: 'detect' // Tells eslint-plugin-react to automatically detect the version of React to use 13 | } 14 | }, 15 | plugins: ['prettier'], 16 | extends: [ 17 | 'plugin:react/recommended', // Uses the recommended rules from @eslint-plugin-react 18 | 'plugin:@typescript-eslint/recommended', // Uses the recommended rules from @typescript-eslint/eslint-plugin 19 | 'prettier/@typescript-eslint', // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier 20 | 'prettier' 21 | ], 22 | rules: { 23 | 'prettier/prettier': 2, 24 | '@typescript-eslint/ban-ts-comment': 0, 25 | 'react/display-name': 0, 26 | 'react/prop-types': 1 27 | // Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs 28 | // e.g. "@typescript-eslint/explicit-function-return-type": "off", 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /.github/actions/setup-node/action.yml: -------------------------------------------------------------------------------- 1 | name: "Node env prep" 2 | description: "Checkout, setup node and run npm i" 3 | runs: 4 | using: "composite" 5 | steps: 6 | - uses: actions/checkout@v3 7 | 8 | - uses: actions/setup-node@v3 9 | with: 10 | node-version: "16" 11 | cache: "npm" 12 | 13 | - name: Install 14 | run: npm i 15 | shell: bash 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: push 4 | 5 | jobs: 6 | tsc: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v3 11 | - uses: ./.github/actions/setup-node 12 | 13 | - name: Build 14 | run: npm run build 15 | 16 | - name: Tests 17 | run: ./runtests.sh 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | transformer.js 3 | transformer.d.ts 4 | dist/ 5 | .nyc_output/ 6 | coverage/ 7 | transformer/test/tsx-cases/*.js 8 | *.patch 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test/ 2 | dist/ 3 | transformer.d.ts 4 | tsconfig.json 5 | node_modules/ 6 | coverage/ 7 | .nyc_output/ 8 | test-output/ 9 | runtests.sh 10 | bump-tsc.sh 11 | .github/ 12 | compile.js 13 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "none", 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "printWidth": 100, 7 | "arrowParens": "avoid" 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 2 | Version 2, December 2004 3 | 4 | Copyright (C) 2004 Sam Hocevar 5 | 6 | Everyone is permitted to copy and distribute verbatim or modified 7 | copies of this license document, and changing it is allowed as long 8 | as the name is changed. 9 | 10 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 11 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 12 | 13 | 0. You just DO WHAT THE FUCK YOU WANT TO. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tsx-control-statements 2 | 3 | [![CI](https://github.com/KonstantinSimeonov/tsx-control-statements/actions/workflows/ci.yml/badge.svg)](https://github.com/KonstantinSimeonov/tsx-control-statements/actions/workflows/ci.yml) [![Coverage Status](https://coveralls.io/repos/github/KonstantinSimeonov/tsx-control-statements/badge.svg?branch=master)](https://coveralls.io/github/KonstantinSimeonov/tsx-control-statements?branch=master) 4 | 5 | [![NPM](https://nodei.co/npm/tsx-control-statements.png)](https://npmjs.org/package/tsx-control-statements) 6 | 7 | Basically [jsx-control-statements](https://www.npmjs.com/package/babel-plugin-jsx-control-statements), but for the typescript compiler toolchain. **Works for both javascript and typescript.** 8 | 9 | | Typescript version range | `tsx-control-statements` version | 10 | |:------------------------:|:-------------------------------------------| 11 | | `2.4.x` - `3.3.x` | `v3.3.x` | 12 | | `3.4.x` - `4.6.x` | `v4.x` | 13 | | `4.9` | `v5.0` | 14 | | `5.x` | `>= v5.1` | 15 | 16 | ## Drop-in replacement for jsx control statements 17 | - No need to rewrite anything 18 | - Compile control statements in typescript `.tsx` files 19 | - Control statements transpile to type-correct typescript before type checking 20 | - Compile control statements in javascript `.js` and `.jsx` files 21 | - `"allowJs"` should be set to `true` in your typescript configuration 22 | - Run the test suite: `npm i && npm run build && npm run test`. It includes: 23 | - Compatibility tests with `jsx-control-statements` (i.e. both produce the same output html) 24 | - Tests for correct transpilation 25 | - Tests for typechecking 26 | 27 | ## Zero dependencies apart from typescript 28 | - Pick any typescript version equal to or above `2.4.x` 29 | - Can be used with Vue, React or just plain jsx/tsx 30 | 31 | ## Known limitations: 32 | - **[js, ts]** I haven't found any way of integrating this into `create-react-app` scaffold project without ejecting the scripts and modifying them 33 | - **[js, ts]** Various CLIs (`tsc`, `ts-register`, `ts-node`) feature no flag (that I know of) that allows for addition of custom transformers 34 | - ~~**[ts]** The `isolatedModules` flag currently causes build errors for typescript files, since the typings currently live in a namespace~~ 35 | - `isolatedModules` is supported since the module `tsx-control-statements/components` contains stub definitions which can be imported `import { For, If } from 'tsx-control-statements/components'` 36 | - **[ts]** Cannot work with various "smart" plugins that instead of invoking the typescript compiler rather strip the types and handle the code as javascript. This includes tools like: 37 | - `@babel/preset-typescript` 38 | - `@babel/plugin-transform-typescript` 39 | 40 | ## What are the control statements transpiled to? 41 | 42 | ### If - Ternary operators 43 | 44 | ```tsx 45 | import { If } from 'tsx-control-statements/components'; 46 | 47 | const SongRelatedThingy = ({ songList }: { songList: string[] }) => ( 48 |

49 | 50 | good taste in music 51 | 52 |

53 | ); 54 | 55 | // will transpile to 56 | const SongRelatedThingy = ({ songList }) => ( 57 |

58 | {songList.includes('Gery-Nikol - Im the Queen') 59 | ? 'good taste in music' 60 | : null} 61 |

62 | ); 63 | ``` 64 | 65 | ### With - Immediately invoked function expression 66 | 67 | ```tsx 68 | import { With } from 'tsx-control-statements/components'; 69 | 70 | const Sum = () => ( 71 |

72 | 73 | {a + b + c} 74 | 75 |

76 | ); 77 | 78 | // becomes 79 | const Sum = () =>

{((a, b, c) => a + b + c)(3, 5, 6)}

; 80 | ``` 81 | 82 | ### For - `Array.from` calls 83 | More flexible than `[].map`, since it can be provided with an iterator or an array-like as it's first parameter. For non-legacy code, prefer the more type-safe alternative. 84 | ```tsx 85 | import { For } from 'tsx-control-statements/components'; 86 | 87 | // more type-safe for, the typechecker knows 88 | // the types of the "name" and "i" bindings 89 | const Names = ({ names }: { names: string[] }) => ( 90 |
    91 | ( 94 |
  1. 95 | {i} 96 | {name} 97 |
  2. 98 | )} 99 | /> 100 |
101 | ); 102 | 103 | // jsx-control-statements compatible 104 | const Names = ({ names }: { names: string[] }) => ( 105 |
    106 | 107 |
  1. 108 | {i} 109 | {name} 110 |
  2. 111 |
    112 |
113 | ); 114 | 115 | // both of the above will transpile to: 116 | const Names = ({ names }) => ( 117 |
    118 | {Array.from(names, (name, i) => ( 119 |
  1. 120 | {i} 121 | {name} 122 |
  2. 123 | ))} 124 |
125 | ); 126 | ``` 127 | 128 | ### Choose/When/Otherwise - nested ternary operators, emulates switch/case. 129 | 130 | ```tsx 131 | import { 132 | Choose, 133 | When, 134 | Otherwise 135 | } from 'tsx-control-statements/components'; 136 | 137 | const RandomStuff = ({ str }: { str: string }) => ( 138 |
139 | 140 | ivancho 141 | 142 |

yum!

143 |
144 | {/* Otherwise tag is optional, 145 | * if not provided, null will be rendered */} 146 | im the queen da da da da 147 |
148 |
149 | ); 150 | 151 | // transpiles to 152 | const RandomStuff = ({ str }) => ( 153 |
154 | {str === 'ivan' 155 | ? 'ivancho' 156 | : str === 'sarmi' 157 | ? React.createElement('h1', null, 'yum!') 158 | : 'im the queen da da da da'} 159 |
160 | ); 161 | ``` 162 | 163 | ## Cookbook 164 | 165 | #### Bundlers and scaffolding tools 166 | - `webpack` with [`ts-loader`](https://github.com/TypeStrong/ts-loader#getcustomtransformers) 167 | - `rollup` with [typescript plugin](https://github.com/rollup/plugins/tree/master/packages/typescript#transformers) 168 | - `parcel` - [this](https://github.com/coreoz/parcel-transformer-ttypescript) might work but don't count on it 169 | 170 | #### Testing 171 | - `ava`, `mocha` or anything other that can use `ts-node` - `ts-node` supports [programatically adding custom transformers](https://github.com/TypeStrong/ts-node#programmatic-only-options) so it can be used to run test suites. 172 | - `jest` using `ts-jest` like [that](https://kulshekhar.github.io/ts-jest/docs/getting-started/options/astTransformers) 173 | 174 | #### Importing the transformer in your build configs: 175 | ```ts 176 | // commonjs 177 | const transformer = require('tsx-control-statements').default; 178 | 179 | // ts 180 | import transformer from 'tsx-control-statements'; 181 | ``` 182 | 183 | #### Importing type definitions: 184 | 185 | ```ts 186 | import { 187 | For, 188 | If, 189 | With, 190 | Choose, 191 | When, 192 | Otherwise 193 | } from 'tsx-control-statements/components'; 194 | ``` 195 | 196 | ## Reasons to not use any control statements for jsx: 197 | - ~~Hard to statically type~~ 198 | - Has been somewhat adressed, with the exception of `With` 199 | - Not part of the standard 200 | - Not ordinary jsx elements 201 | - Requires extra dependencies to use 202 | - Many typescript tools do not support custom transformers in a convenient way 203 | -------------------------------------------------------------------------------- /compile.js: -------------------------------------------------------------------------------- 1 | const ts = require(`typescript`); 2 | const transformer = require(`./transformer`).default; 3 | const fs = require(`fs`); 4 | 5 | const TEST_DIR = `test` 6 | 7 | /** 8 | * @param {string[]} files - file paths to source files 9 | */ 10 | const compile = files => { 11 | /** @type {ts.CompilerOptions} */ 12 | const opts = { 13 | allowJs: true, 14 | module: ts.ModuleKind.CommonJS, 15 | jsx: ts.JsxEmit.React, 16 | lib: [`es6`, `es2016`], 17 | outDir: `${TEST_DIR}/build`, 18 | target: ts.ScriptTarget.ES2015, 19 | allowSyntheticDefaultImports: true, 20 | noEmit: false 21 | }; 22 | 23 | const host = ts.createCompilerHost(opts); 24 | const prg = ts.createProgram(files, opts, host); 25 | console.log(`compiler options: `, prg.getCompilerOptions()); 26 | 27 | const { emitSkipped, diagnostics } = prg.emit(undefined, undefined, undefined, false, { 28 | before: [transformer(prg)], 29 | after: [] 30 | }); 31 | 32 | if (emitSkipped) { 33 | throw new Error(diagnostics.map(d => d.messageText).join(`\n`)); 34 | } 35 | }; 36 | 37 | compile(fs.readdirSync(`${TEST_DIR}/src`).map(f => `${TEST_DIR}/src/${f}`)); 38 | -------------------------------------------------------------------------------- /components.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Stub definitions that will be replaced by the transformer. 3 | * They're used to provide type definitions for the typescript 4 | * compiler and various static analysis tools. 5 | */ 6 | 7 | type JsxChild = string | boolean | number | null | undefined | JSX.Element; 8 | type JsxChildren = JsxChild | JsxChild[]; 9 | 10 | export function Choose(props: { children: JsxChildren }) { 11 | return props.children as any; 12 | } 13 | 14 | export function When(props: { children: JsxChildren; condition: unknown }) { 15 | return props.children as any; 16 | } 17 | 18 | export function If(props: { children: JsxChildren; condition: unknown }) { 19 | return props.children as any; 20 | } 21 | 22 | type NoBody = { children?: JsxChildren; each: string; of: Iterable; index?: string }; 23 | type WithBody = { 24 | children?: JsxChildren; 25 | of: Iterable; 26 | body: (x: T, index: number) => JsxChildren; 27 | }; 28 | export function For(props: NoBody | WithBody) { 29 | return undefined as any; 30 | } 31 | 32 | export function Otherwise(props: { children: JsxChildren }) { 33 | return props.children as any; 34 | } 35 | 36 | export function With(props: { children: JsxChildren; [id: string]: any }) { 37 | return props.children as any; 38 | } 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tsx-control-statements", 3 | "private": false, 4 | "version": "5.1.1", 5 | "main": "transformer.js", 6 | "ts-main": "transformer.ts", 7 | "author": "Konstantin Simeonov ", 8 | "keywords": [ 9 | "control-statements", 10 | "typescript", 11 | "jsx", 12 | "tsx", 13 | "if", 14 | "loop", 15 | "react " 16 | ], 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/KonstantinSimeonov/tsx-control-statements" 20 | }, 21 | "license": "WTFPL", 22 | "scripts": { 23 | "build": "tsc -P tsconfig.json", 24 | "build:watch": "npm run build -- --watch", 25 | "test": "npm run test:compile && npm run test:run", 26 | "test:ci": "./runtests.sh", 27 | "test:compile": "npm run test:compile-tsc && npm run test:compile-babel", 28 | "test:compile-tsc": "node compile.js", 29 | "test:compile-babel": "babel test/src --presets=@babel/preset-react,@babel/preset-env --plugins=\"jsx-control-statements\" --out-dir=test/babel", 30 | "test:run": "mocha --exit test/helpers/browser-env.js \"test/**/*.spec.js\"", 31 | "test:coverage": "nyc report --reporter=text-lcov | coveralls", 32 | "test:html-report": "nyc report --reporter=html", 33 | "link:self": "npm link && npm link tsx-control-statements", 34 | "format:all": "npx prettier --write './**/*.{ts,js,tsx,jsx}'" 35 | }, 36 | "devDependencies": { 37 | "@babel/cli": "^7.21.0", 38 | "@babel/core": "^7.21.3", 39 | "@babel/preset-env": "^7.20.2", 40 | "@babel/preset-react": "^7.18.6", 41 | "@testing-library/react": "^14.0.0", 42 | "@types/chai": "^4.3.4", 43 | "@types/mocha": "^10.0.1", 44 | "@types/node": "^16.18.16", 45 | "@types/react": "^16.9.55", 46 | "babel-plugin-jsx-control-statements": "^4.1.2", 47 | "chai": "^4.3.7", 48 | "coveralls": "^3.1.1", 49 | "jsdom": "^21.1.1", 50 | "mocha": "^10.2.0", 51 | "nyc": "^15.1.0", 52 | "react": "^18.2.0", 53 | "react-dom": "^18.2.0", 54 | "typescript": "^5.3.3" 55 | }, 56 | "peerDependencies": { 57 | "typescript": ">=5.0" 58 | }, 59 | "nyc": { 60 | "extension": [ 61 | ".ts" 62 | ] 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /runtests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | mkdir -p test-output 4 | 5 | readonly supported_tsc_versions=(5.0 5.1 5.2 5.3) 6 | 7 | exit_code=0 8 | for v in ${supported_tsc_versions[*]}; do 9 | echo "================= TESTING VERSION $v =======================" 10 | git checkout .. 11 | npm i --save-dev typescript@$v 12 | ./node_modules/.bin/tsc --version 13 | npm run link:self 14 | npm run test:compile 15 | npm run test:run 16 | test_exit_code=$? 17 | [[ $test_exit_code != 0 ]] && exit_code=1 18 | 19 | echo "Build and tests for $v exited with code $test_exit_code" 20 | done 21 | 22 | exit $exit_code 23 | -------------------------------------------------------------------------------- /test/.gitignore: -------------------------------------------------------------------------------- 1 | yarn-error.log 2 | build/ 3 | babel/ 4 | -------------------------------------------------------------------------------- /test/Compatibility.spec.js: -------------------------------------------------------------------------------- 1 | const fs = require(`fs`); 2 | const path = require(`path`); 3 | const compareInnerHTMLTest = require(`./helpers/compare-inner-html-test`); 4 | 5 | const testFiles = fs.readdirSync(path.join(__dirname, `babel`)); 6 | 7 | for (const name of testFiles) { 8 | /** @type {Object. 20 | dataSet.forEach(testProps => 21 | compareInnerHTMLTest({ expectedComponent, assertedComponent, ...testProps }) 22 | ) 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /test/Tsx.spec.js: -------------------------------------------------------------------------------- 1 | const fs = require(`fs`); 2 | const path = require(`path`); 3 | const compareInnerHTMLTest = require(`./helpers/compare-inner-html-test`); 4 | 5 | fs.readdirSync(path.join(__dirname, `build`)) 6 | .filter(name => !name.endsWith(`.compat.js`)) 7 | .forEach(name => { 8 | 9 | /** @type {[string, { component: React.FC, dataSet: { props: object }[] }][]} */ 10 | const tests = Object.entries(require(`./build/${name}`)); 11 | for (const [name, suite] of tests) { 12 | const suiteName = name.replace(`default`, name.replace(`.js`, ``)); 13 | const { expected: expectedComponent, actual: assertedComponent, dataSet } = suite; 14 | describe(suiteName, () => { 15 | for (const testProps of dataSet) { 16 | compareInnerHTMLTest({ 17 | ...testProps, 18 | expectedComponent, 19 | assertedComponent 20 | }); 21 | } 22 | }); 23 | } 24 | }); 25 | -------------------------------------------------------------------------------- /test/helpers/browser-env.js: -------------------------------------------------------------------------------- 1 | const { JSDOM } = require('jsdom'); 2 | 3 | const { window } = new JSDOM(` 4 | 5 | 6 | 7 | 8 | 9 | `); 10 | 11 | function copyProperties(src, target) { 12 | const propertiesToDefine = Object.getOwnPropertyNames(src) 13 | .filter(propKey => typeof target[propKey] === 'undefined') 14 | .reduce( 15 | (propMap, propKey) => ({ 16 | ...propMap, 17 | [propKey]: Object.getOwnPropertyDescriptor(src, propKey) 18 | }), 19 | {} 20 | ); 21 | 22 | Object.defineProperties(target, propertiesToDefine); 23 | } 24 | 25 | const setupBrowserEnv = () => { 26 | global.window = window; 27 | global.document = window.document; 28 | global.navigator = { userAgent: 'node.js' }; 29 | copyProperties(window, global); 30 | }; 31 | 32 | setupBrowserEnv(); 33 | -------------------------------------------------------------------------------- /test/helpers/compare-inner-html-test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | const React = require('react'); 3 | const { render } = require(`@testing-library/react`); 4 | 5 | /** 6 | * @description 7 | * Renders the provided components with the given props 8 | * and asserts that they produce equal html. 9 | * 10 | * @param {{ message: string, assertedComponent: React.FC, expectedComponent: React.FC, props: object }} args 11 | */ 12 | const compareInnerHTMLTest = ({ message, assertedComponent, expectedComponent, props }) => 13 | it(message, () => { 14 | const [expectedNode, actualNode] = [expectedComponent, assertedComponent].map( 15 | component => render(React.createElement(component, props)).baseElement 16 | ); 17 | 18 | if (expectedNode !== actualNode) { 19 | expect(actualNode.innerHTML).to.equal(expectedNode.innerHTML); 20 | } 21 | }); 22 | 23 | module.exports = compareInnerHTMLTest 24 | -------------------------------------------------------------------------------- /test/src/choose.compat.jsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export const ChooseNumbers = { 4 | component: ({ n }) => ( 5 |
6 | 7 | 8 |

n is 1

9 |
10 | 11 |

n is 2

12 |
13 | 14 | n is 10 15 | 16 |
17 |
18 | ), 19 | dataSet: [ 20 | [1, `renders first When`], 21 | [2, `renders second When`], 22 | [10, `renders third When`], 23 | [42, `does not render any When`] 24 | ].map(([n, message]) => ({ props: { n }, message })) 25 | }; 26 | 27 | export const ChooseWithOtherwise = { 28 | component: ({ name }) => ( 29 |
30 | 31 | gosho in da house 32 | gosho pie bira sig 33 | 34 |
35 | ), 36 | dataSet: [ 37 | [`gosho`, `renders child of When`], 38 | [`pesho`, `renders child of Otherwise`] 39 | ].map(([name, message]) => ({ props: { name }, message })) 40 | }; 41 | 42 | export const ChooseMultipleChildren = { 43 | component: ({ name }) => ( 44 |
45 | 46 | 47 |

kek

48 |

ivan is here

49 | {name + ` is haskell dev`} 50 |
51 | 52 |

topkek

53 |

it is not ivan, but rather {name}

54 | neshto si neshto si 55 |
56 |
57 |
58 | ), 59 | dataSet: [ 60 | [`ivan`, `renders children of When`], 61 | [`hristofor`, `renders children of Otherwise`] 62 | ].map(([name, message]) => ({ props: { name }, message })) 63 | }; 64 | 65 | export const ChooseNested = { 66 | component: ({ name }) => ( 67 |
68 | 69 | 70 | 71 | name cannot be empty 72 | name too short 73 | 74 | 75 | 76 | 77 | 20}>name too long 78 | {name} 79 | 80 | sdf 81 | 82 | 83 |
84 | ), 85 | dataSet: [ 86 | [``, `When -> When`], 87 | [`ja`, `When -> Otherwise`], 88 | [Array.from({ length: 30 }).fill(`a`).join(``), `Otherwise -> When`], 89 | [`horse`, `Otherwise -> Otherwise`] 90 | ].map(([name, message]) => ({ props: { name }, message: `renders ${message}` })) 91 | }; 92 | 93 | export const NoOtherwise = { 94 | component: ({ n }) => ( 95 |
96 | 97 | 98 |

its one!

99 |
100 | 101 |

its two :(

102 | kek 103 |
104 | 105 |

its 3!

106 |
107 | 108 |

{n}

109 |
110 |
111 |
112 | ), 113 | dataSet: [1, 2, 3, 7] 114 | .map(n => ({ props: { n }, message: `renders one of the Whens` })) 115 | .concat([{ props: { n: 10 }, message: `renders nothing` }]) 116 | }; 117 | -------------------------------------------------------------------------------- /test/src/choose.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Choose, When, Otherwise } from 'tsx-control-statements/components'; 3 | 4 | export default { 5 | actual: ({ str }: { str: string }) => ( 6 |
7 | 8 | ivancho 9 | 10 |

yum!

11 |
12 | im the queen da da da da 13 |
14 |
15 | ), 16 | expected: ({ str }: { str: string }) => ( 17 |
18 | {str === `ivan` ? `ivancho` : str === `sarmi` ?

yum!

: `im the queen da da da da`} 19 |
20 | ), 21 | dataSet: [ 22 | [`ivan`, `renders first When`], 23 | [`sarmi`, `renders second When`], 24 | [`banana`, `renders Otherwise`] 25 | ].map(([str, message]) => ({ props: { str }, message })) 26 | }; 27 | 28 | export const MisplacedOtherwise = { 29 | actual: () => ( 30 |
31 | 123 32 | 33 | 1 34 | 5 35 | 2 36 | 3 37 | 38 |
39 | ), 40 | expected: () =>
1232
, 41 | dataSet: [{ props: {}, message: `misplaced otherwise elements are skipped` }] 42 | }; 43 | 44 | export const ChooseFragment = { 45 | actual: () => ( 46 |
47 | 123 48 | 49 | 1{`zdr`} 50 | 2{`anotha one`} 51 | 52 |

9

3 53 |
54 |
55 |
56 | ), 57 | expected: () =>
1232anotha one
, 58 | dataSet: [ 59 | { props: {}, message: `When/Otherwise with more than 1 child should transpile to fragments` } 60 | ] 61 | }; 62 | -------------------------------------------------------------------------------- /test/src/for.compat.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | // defo not overworking myself 3 | const nextKey = () => Math.random() + String(Math.random() * 10000 * Math.random()); 4 | 5 | export const ForChildrenExpressions = { 6 | component: ({ words }) => ( 7 |
8 | 9 | {3 + 4} 10 | {i}. 11 |

{w}

12 |
13 |
14 | ), 15 | dataSet: [ 16 | [[], `same with []`], 17 | [[`gosho`, `pesho`, `gg`], `same with non-empty array`] 18 | ].map(([words, message]) => ({ props: { words }, message })) 19 | }; 20 | 21 | export const ForLongNames = { 22 | component: () => ( 23 |
    24 | 25 |
  • {number * index}
  • 26 |
    27 |
28 | ), 29 | dataSet: [{ props: {}, message: `works with longer bindings names` }] 30 | }; 31 | 32 | export const ForIndex = { 33 | component: () => ( 34 |

35 | 36 | {index} 37 | 38 |

39 | ), 40 | dataSet: [{ props: {}, message: `index binding works` }] 41 | }; 42 | 43 | export const ForKeyIndex = { 44 | component: () => ( 45 |
    46 | 47 |
  • {number * index}
  • 48 |
    49 |
50 | ), 51 | dataSet: [{ props: {}, message: `can use index binding as key` }] 52 | }; 53 | 54 | export const ForEmptyArray = { 55 | component: () => ( 56 |
    57 | 58 |
  • {number * index}
  • 59 |
    60 |
61 | ), 62 | dataSet: [{ props: {}, message: `works with empty array` }] 63 | }; 64 | 65 | export const ForNested = { 66 | component: ({ xs, ys }) => ( 67 |
    68 | 69 |
  1. 70 |
      71 | 72 |
    1. 73 | [{i}, {j}] = ({x}, {y}) 74 |
    2. 75 |
      76 |
    77 |
  2. 78 |
    79 |
80 | ), 81 | dataSet: [ 82 | [[], []], 83 | [[], [1, 2, 3]], 84 | [[1, 2], []], 85 | [ 86 | [4, 2, 1], 87 | [`i`, `hate`, `nested`, `ctrl`, `flow`] 88 | ] 89 | ].map(([xs, ys]) => ({ props: { xs, ys }, message: `works for [${xs}] and [${ys}]` })) 90 | }; 91 | -------------------------------------------------------------------------------- /test/src/for.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { For, Choose, When, Otherwise, If } from 'tsx-control-statements/components'; 3 | 4 | // this is unnecessary for compilation, but fools visuals studio code 5 | // declare var i: number, chap: string; 6 | 7 | export const CanUseControlStatementsInBody = { 8 | actual: ({ words }: { words: string[] }) => ( 9 |
10 | ( 13 | 14 | 15 | {w} 16 | stuff 17 | 18 | {w + ` ` + w} 19 | 20 | )} 21 | /> 22 |
23 | ), 24 | expected: ({ words }: { words: string[] }) => ( 25 |
26 | {words.map((w, i) => (i % 2 === 0 ? w + (w.length <= 3 ? `stuff` : ``) : `${w} ${w}`))} 27 |
28 | ), 29 | dataSet: [ 30 | { 31 | props: { words: [`big`, `papa`, `top`, `kek`] }, 32 | message: `Control statements in for loop body are transformed` 33 | } 34 | ] 35 | }; 36 | 37 | export const NoOf = { 38 | expected: (): null => null, 39 | // @ts-ignore 40 | actual: () => haha, 41 | dataSet: [{ props: {}, message: `renders null` }] 42 | }; 43 | 44 | export const BadBodyProp = { 45 | expected: () =>

123

, 46 | // @ts-ignore 47 | actual: () => ( 48 |

49 | 50 | {i} 51 | 52 |

53 | ), 54 | dataSet: [{ props: {}, message: `uses for children when body is bad` }] 55 | }; 56 | 57 | declare const chap: string; 58 | declare const i: number; 59 | export default { 60 | expected: ({ chaps }: { chaps: string[] }) => ( 61 |
    62 | 63 |
  1. 64 | {i} 65 | {chap} 66 | 10}>a long one! 67 |
  2. 68 |
    69 |
70 | ), 71 | actual: ({ chaps }: { chaps: string[] }) => ( 72 |
    73 | {chaps.map((chap, i) => ( 74 |
  1. 75 | {i} 76 | {chap} 77 | {chap.length > 10 ? `a long one!` : null} 78 |
  2. 79 | ))} 80 |
81 | ), 82 | dataSet: [ 83 | [[], `renders empty ol`], 84 | [[`steeve joobs`, `bil gaytes`, `lightlin naakov`], `renders a li for every chap`] 85 | ].map(([chaps, message]) => ({ props: { chaps }, message })) 86 | }; 87 | 88 | export const EmptyFor = { 89 | expected: ({ peshovci }: { peshovci: any[] }) => ( 90 |
    91 | transformerfactory 92 | 93 |
94 | ), 95 | actual: () =>
    transformerfactory
, 96 | dataSet: [{ props: { peshovci: [1, 2, 3] }, message: `empty for renders nothing` }] 97 | }; 98 | 99 | export const ForWithIterable = { 100 | expected: ({ xs }: { xs: Map }) => ( 101 |
    102 | {Array.from(xs, (kvp, i) => ( 103 | 104 | pair {i} with key {kvp[0]} and value {kvp[1]} 105 | 106 | ))} 107 |
108 | ), 109 | actual: ({ xs }: { xs: Map }) => ( 110 |
    111 | 112 | { 113 | // @ts-ignore 114 | 115 | pair{` `} 116 | { 117 | // @ts-ignore 118 | i 119 | }{` `} 120 | with key{` `} 121 | { 122 | // @ts-ignore 123 | kvp[0] 124 | }{` `} 125 | and value{` `} 126 | { 127 | // @ts-ignore 128 | kvp[1] 129 | } 130 | 131 | } 132 | 133 |
134 | ), 135 | dataSet: [ 136 | { props: { xs: new Map() }, message: `renders no pairs for empty iterator` }, 137 | { 138 | props: { 139 | xs: new Map([ 140 | [`a`, 2], 141 | [`c`, 15], 142 | [`d`, 69] 143 | ]) 144 | }, 145 | message: `uses the elements yielded by the iterator` 146 | } 147 | ] 148 | }; 149 | 150 | export const LoopBody = { 151 | expected: ({ xs }: { xs: number[] }) => ( 152 |
    153 | {Array.from(xs, (x, i) => ( 154 | 155 | {x} 156 |

    {x * i}

    157 |
    158 | ))} 159 |
160 | ), 161 | actual: ({ xs }: { xs: number[] }) => ( 162 |
    163 | ( 166 | 167 | {x} 168 |

    {x * i}

    169 |
    170 | )} 171 | /> 172 |
173 | ), 174 | dataSet: [ 175 | { 176 | props: { xs: [1, 5, 13] }, 177 | message: `executes all iterations when provided with a function for body` 178 | }, 179 | { props: { xs: [] }, message: `does not crash with empty input` }, 180 | { 181 | props: { 182 | get xs() { 183 | // this is called more than once and should not be stateful 184 | return new Map([ 185 | [1, `dsf`], 186 | [2, `zdr`], 187 | [5, `krp`], 188 | [8, `kyp`] 189 | ]).keys(); 190 | } 191 | }, 192 | message: `arrow body works with iterators` 193 | } 194 | ] 195 | }; 196 | -------------------------------------------------------------------------------- /test/src/if.compat.jsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | const ListFive = ({ messages }) => ( 4 |
    5 | {messages.slice(0, 5).map((m, i) => ( 6 |
  • {m}
  • 7 | ))} 8 |
9 | ); 10 | 11 | const selfClosingElements = [ 12 | , 13 | , 14 | 15 | ]; 16 | 17 | export const IfChildElements = { 18 | component: ({ condition }) => ( 19 |
20 |

useful links

21 | 22 | install gentoo 23 | github 24 | 25 |
26 | ), 27 | dataSet: [ 28 | { props: { condition: true }, message: `renders links` }, 29 | { props: { condition: false }, message: `does not render links` } 30 | ] 31 | }; 32 | 33 | export const IfSelfClosingChildElements = { 34 | component: ({ condition }) => ( 35 |
36 | {selfClosingElements} 37 |
38 | ), 39 | dataSet: [ 40 | { props: { condition: true }, message: `renders self-closing children` }, 41 | { props: { condition: false }, message: `does not render self-closing` } 42 | ] 43 | }; 44 | 45 | export const IfChildExpressions = { 46 | component: ({ a, b, condition }) => ( 47 |
48 |

maths

49 | 50 | 3 + 4 = {3 + 4} 51 | {a} + {b} = {a + b} 52 | 53 |
54 | ), 55 | dataSet: [ 56 | [7, 8, false, `does not render child expressions`], 57 | [7, 8, true, `renders child expressions`] 58 | ].map(([a, b, condition, message]) => ({ props: { a, b, condition }, message })) 59 | }; 60 | 61 | export const IfChildExpressionsAndElements = { 62 | component: ({ a, b, condition }) => ( 63 |
64 |

maths

65 | 66 | 3 + 4 = {3 + 4} 67 | install gentoo 68 | github 69 | {a} + {b} = {a + b} 70 | {selfClosingElements} 71 | 72 |
73 | ), 74 | dataSet: JSON.parse(JSON.stringify(IfChildExpressions.dataSet)) 75 | }; 76 | 77 | export const IfConditionIsExpressions = { 78 | component: ({ name1, name2 }) => ( 79 |
80 | 81 |

First: {name1}

82 |
83 | 84 |

Second: {name2}

85 |
86 |
87 | ), 88 | dataSet: [ 89 | [`gosho`, `vancho`, `boolean expressions as conditions work`], 90 | [``, `vancho`, `boolean expressions as conditions work`] 91 | ].map(([name1, name2, message]) => ({ props: { name1, name2 }, message })) 92 | }; 93 | 94 | export const NestedIfs = { 95 | component: ({ a, b }) => ( 96 |
97 | 98 |

a is add

99 | b is odd 100 | b is even 101 |
102 |
103 | ), 104 | dataSet: [ 105 | [0, 1, `does not render nested content`], 106 | [3, 1, `renders nested content correctly`], 107 | [3, 2, `renders nested content correctly`] 108 | ].map(([a, b, message]) => ({ props: { a, b }, message })) 109 | }; 110 | 111 | export const EmptyIfs = { 112 | component: ({ a, b }) => ( 113 |

114 | 115 | 116 |

117 | ), 118 | dataSet: [{ props: {}, message: `renders nothing` }] 119 | }; 120 | 121 | export const EmptyNestedIfs = { 122 | component: ({ a, b }) => ( 123 |

124 | 125 | 126 | 127 | 128 |

129 | ), 130 | dataSet: [ 131 | [0, 1], 132 | [0, 2], 133 | [1, 2], 134 | [1, 3] 135 | ].map(([a, b]) => ({ props: { a, b }, message: `never do this. please` })) 136 | }; 137 | 138 | export const IfMultipleProps = { 139 | component: ({ condition }) => ( 140 |

141 | 142 | kljsdfjklsdfjklsdfjkl 143 | 144 |

145 | ), 146 | dataSet: [ 147 | { props: { condition: true }, message: `renders content` }, 148 | { props: { condition: false }, message: `does not render content` } 149 | ] 150 | }; 151 | -------------------------------------------------------------------------------- /test/src/if.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { If } from 'tsx-control-statements/components'; 3 | 4 | export default { 5 | actual: ({ songList }: { songList: string[] }) => ( 6 |

7 | good taste in music 8 |

9 | ), 10 | expected: ({ songList }: { songList: string[] }) => ( 11 |

{songList.includes(`Gery-Nikol - I'm the Queen`) ? `good taste in music` : null}

12 | ), 13 | dataSet: [ 14 | [[`Iron Maiden - The Monad (Horse & Penguin cover)`, `Britney - Toxic`], `renders text`], 15 | [ 16 | [ 17 | `Iron Maiden - The Monad (Horse & Penguin cover)`, 18 | `Britney - Toxic`, 19 | `Gery-Nikol - I'm the Queen` 20 | ], 21 | `does not render text` 22 | ] 23 | ].map(([songList, message]) => ({ props: { songList }, message })) 24 | }; 25 | 26 | export const WithSelfClosingElementChild = { 27 | actual: ({ n }: { n: number }) => ( 28 |
29 | some text 30 | 2}> 31 | 32 | 33 |
34 | ), 35 | expected: ({ n }: { n: number }) => ( 36 |
37 | some text 38 | {n > 2 ? : null} 39 |
40 | ), 41 | dataSet: [ 42 | { props: { n: 1 }, message: `works with self-closing children when condition is false` } 43 | ] 44 | }; 45 | 46 | export const IfFragment = { 47 | expected: () => ( 48 |
49 | <> 50 |

1

51 |

2

3 52 | 53 |
54 | ), 55 | actual: () => ( 56 |
57 | 58 |

1

59 |

2

3 60 |
61 |
62 | ), 63 | dataSet: [{ message: `If with more than 1 child should transpile to a jsx fragment` }] 64 | }; 65 | 66 | export const IfWithComments = { 67 | expected: () => ( 68 |
69 | 70 | {/*hahahaha*/ 71 | /* haha */} 72 | 73 |
74 | ), 75 | actual: () =>
, 76 | dataSet: [{ message: `Shouldn't crash from comments` }] 77 | }; 78 | -------------------------------------------------------------------------------- /test/src/mixed-nested.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Choose, When, Otherwise, For, If } from 'tsx-control-statements/components'; 3 | 4 | export default { 5 | actual: ({ songList }: { songList: string[] }) => ( 6 |
7 | 8 | Song list is empty! 9 | 10 |
    11 | 12 | 18 |
  • 24 | { 25 | // @ts-ignore 26 | songName 27 | } 28 |
  • 29 |
    30 |
    31 |
32 |
33 |
34 |
35 | ), 36 | expected: ({ songList }: { songList: string[] }) => ( 37 |
38 | {[ 39 | songList.length === 0 ? `Song list is empty!` : null, 40 |
    41 | {songList 42 | .map(songName => (Boolean(songName) ?
  • {songName}
  • : null)) 43 | .filter(Boolean)} 44 |
45 | ].find(Boolean)} 46 |
47 | ), 48 | dataSet: [ 49 | [[], `When`], 50 | [[``, ``], `empty ul`], 51 | [ 52 | [``, `Iron Maiden - The Monad (Horse & Penguin cover)`, `Britney - Toxic`, ``], 53 | `only non-empty song names as lis` 54 | ], 55 | [ 56 | [ 57 | `Iron Maiden - The Monad (Horse & Penguin cover)`, 58 | `Britney - Toxic`, 59 | `Gery-Nikol - I'm the Queen` 60 | ], 61 | `all the names as lis` 62 | ] 63 | ].map(([songList, message]) => ({ props: { songList }, message: `renders ${message}` })) 64 | }; 65 | -------------------------------------------------------------------------------- /test/src/with.compat.jsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export const WithOneVariable = { 4 | component: ({ n }) => ( 5 |
6 | {even ? `kek` : `topkek`} 7 |
8 | ), 9 | dataSet: [ 10 | [2, `binding value is true`], 11 | [1, `binding value is false`] 12 | ].map(([n, message]) => ({ props: { n }, message })) 13 | }; 14 | 15 | export const WithManyVariables = { 16 | component: ({ x, firstName, lastName, people }) => ( 17 |
18 | 19 | {fullName} is a promising young entepreneur from Kazichene. He currently has{` `} 20 | {employeesCount} employees and pays each of them {salary} per month. 21 | 22 |
23 | ), 24 | dataSet: [ 25 | { 26 | props: { 27 | x: 3, 28 | firstName: `remove`, 29 | secondName: `ceiling`, 30 | people: [`penka`, `kaka ginka`, `lightlin naakov`] 31 | }, 32 | message: `works for multiple bindings` 33 | } 34 | ] 35 | }; 36 | 37 | export const WithNoVariables = { 38 | component: ({ thing }) => ( 39 |
40 | This {thing} is idiotic 41 |
42 | ), 43 | dataSet: [{ props: { thing: `control statements thing` }, message: `works with no variables` }] 44 | }; 45 | 46 | export const WithNested = { 47 | component: ({ xs }) => ( 48 |
49 | 50 | {fst + 1} 51 | 52 | {fst + snd} 53 | {last} 54 | sum + n, 0)}> 55 |

{fst}

56 |

{sum}

57 | {snd} 58 |
59 |
60 |
61 |
62 | ), 63 | dataSet: [ 64 | { 65 | props: { xs: [1, 2, 3, 4, 5, 6, 42, 69] }, 66 | message: `works when some idiot nests 3 Withs` 67 | } 68 | ] 69 | }; 70 | -------------------------------------------------------------------------------- /test/src/with.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { With } from 'tsx-control-statements/components'; 3 | 4 | // this is unnecessary for compilation, but fools visuals studio code 5 | // declare var gosho: number, pesho: number, tosho: number; 6 | 7 | export default { 8 | actual: () => ( 9 |

10 | 11 | { 12 | // @ts-ignore 13 | gosho + pesho + tosho 14 | } 15 | 16 |

17 | ), 18 | expected: () =>

{14}

, 19 | dataSet: [ 20 | { props: {}, message: `bindings defined in With are available in the children expressions` } 21 | ] 22 | }; 23 | -------------------------------------------------------------------------------- /transformer.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript'; 2 | 3 | export default function transformer(program: ts.Program): ts.TransformerFactory { 4 | return (context: ts.TransformationContext) => (file: ts.SourceFile) => 5 | visitNodes(file, program, context); 6 | } 7 | 8 | function visitNodes( 9 | file: ts.SourceFile, 10 | program: ts.Program, 11 | context: ts.TransformationContext 12 | ): ts.SourceFile; 13 | function visitNodes(node: ts.Node, program: ts.Program, context: ts.TransformationContext): ts.Node; 14 | 15 | function visitNodes( 16 | node: ts.Node, 17 | program: ts.Program, 18 | context: ts.TransformationContext 19 | ): ts.Node { 20 | const newNode = statements(node, program, context); 21 | if (node !== newNode) { 22 | return newNode; 23 | } 24 | 25 | return ts.visitEachChild(node, childNode => visitNodes(childNode, program, context), context); 26 | } 27 | 28 | const CTRL_NODE_NAMES = Object.freeze({ 29 | CONDITIONAL: 'If', 30 | FOREACH: 'For', 31 | SWITCH: 'Choose', 32 | CASE: 'When', 33 | DEFAULT: 'Otherwise', 34 | WITH: 'With' 35 | }); 36 | 37 | type TS = ( 38 | node: N, 39 | program: ts.Program, 40 | context: ts.TransformationContext 41 | ) => Readonly; 42 | 43 | type Transformation = TS; 44 | type JsxTransformation = TS; 45 | 46 | const warn = (node: ts.Node, ...args: readonly unknown[]) => { 47 | const file = node.getSourceFile() 48 | const pos = file.getLineAndCharacterOfPosition(node.pos) 49 | const location = `${file.fileName}:${pos.line}:${pos.character}` 50 | console.warn(`WARN(tsx-control-statements):`, ...args) 51 | console.warn(location, `\n`) 52 | } 53 | 54 | const isRelevantJsxNode = (node: ts.Node): node is ts.JsxElement => 55 | ts.isJsxElement(node) || 56 | ts.isJsxSelfClosingElement(node) || 57 | ts.isJsxExpression(node) || 58 | (ts.isJsxText(node) && node.getText() !== ''); 59 | 60 | const getTagNameString = (node: ts.JsxElement | ts.JsxSelfClosingElement): string => { 61 | if (ts.isJsxSelfClosingElement(node)) { 62 | return node.tagName.getFullText(); 63 | } 64 | 65 | const maybeOpeningElement = node.getChildAt(0) as ts.JsxOpeningElement; 66 | return maybeOpeningElement.tagName.getFullText(); 67 | }; 68 | 69 | type PropMap = Readonly>; 70 | const getJsxProps = (node: ts.JsxElement): PropMap => { 71 | const isOpening = ts.isJsxOpeningElement(node.getChildAt(0)); 72 | const elementWithProps = isOpening ? node.getChildAt(0) : node; 73 | 74 | const props = elementWithProps 75 | .getChildAt(2) // [tag (<), name (For, If, etc), attributes (...), tag (>)] 76 | .getChildAt(0) // some kinda ts api derp 77 | .getChildren() 78 | .filter(ts.isJsxAttribute) 79 | .map(x => { 80 | const propValue = x.getChildAt(2); 81 | // ts.JSXExpression has 3 children - {, value, } 82 | // string values are just "value", no children 83 | const value = ts.isJsxExpression(propValue) ? propValue.getChildAt(1) : propValue; 84 | return { [x.getChildAt(0).getText()]: value as ts.Expression }; 85 | }); 86 | 87 | return Object.assign({}, ...props); 88 | }; 89 | 90 | const getJsxElementBody = ( 91 | node: ts.Node, 92 | program: ts.Program, 93 | ctx: ts.TransformationContext 94 | ): ts.JsxChild[] => 95 | node 96 | .getChildAt(1) 97 | .getChildren() 98 | .filter(isRelevantJsxNode) 99 | .map(node => (ts.isJsxText(node) ? node : (visitNodes(node, program, ctx) as ts.JsxChild))) 100 | .filter(Boolean); 101 | 102 | const trim = (from: string) => from.replace(/^\r?\n[\s\t]*/, '').replace(/\r?\n[\s\t]*$/, ''); 103 | const nullJsxExpr = (): ts.JsxChild => 104 | ts.factory.createJsxExpression(undefined, ts.factory.createNull()); 105 | 106 | const hasOnlyComments = (expr: ts.JsxExpression) => { 107 | let onlyComments = true; 108 | expr.forEachChild( 109 | c => 110 | (onlyComments = 111 | onlyComments && 112 | [ 113 | ts.SyntaxKind.LastPunctuation, 114 | ts.SyntaxKind.FirstPunctuation, 115 | ts.SyntaxKind.CloseBraceToken 116 | ].includes(c.kind)) 117 | ); 118 | return onlyComments; 119 | }; 120 | 121 | const createConditional = (args: Record<`true` | `false` | `condition`, ts.Expression>) => 122 | ts.factory.createConditionalExpression( 123 | args.condition, 124 | ts.factory.createToken(ts.SyntaxKind.QuestionToken), 125 | args.true, //createExpressionLiteral(body, node), 126 | ts.factory.createToken(ts.SyntaxKind.ColonToken), 127 | args.false //ts.factory.createNull() 128 | ); 129 | 130 | const createExpressionLiteral = ( 131 | expressions: ts.JsxChild[], 132 | node: ts.Node 133 | ): ts.JsxFragment | ts.Expression => { 134 | if (expressions.length === 1) { 135 | const [expr] = expressions; 136 | if (ts.isJsxExpression(expr) && hasOnlyComments(expr)) { 137 | return ts.factory.createNull(); 138 | } 139 | const jsxChild = ts.isJsxText(expr) 140 | ? ts.factory.createStringLiteral(trim(expr.getFullText())) 141 | : expr; 142 | return jsxChild; 143 | } 144 | 145 | return ts.factory.createJsxFragment( 146 | ts.setOriginalNode(ts.factory.createJsxOpeningFragment(), node), 147 | expressions, 148 | ts.setOriginalNode(ts.factory.createJsxJsxClosingFragment(), node) 149 | ); 150 | }; 151 | 152 | const transformIfNode: JsxTransformation = (node, program, ctx) => { 153 | const { condition } = getJsxProps(node); 154 | if (!condition) { 155 | warn(node, `"condition" prop of ${CTRL_NODE_NAMES.CONDITIONAL} is missing`); 156 | return nullJsxExpr(); 157 | } 158 | 159 | const body = getJsxElementBody(node, program, ctx); 160 | 161 | if (body.length === 0) { 162 | warn(node, `Empty ${CTRL_NODE_NAMES.CONDITIONAL}`); 163 | return nullJsxExpr(); 164 | } 165 | 166 | return ts.factory.createJsxExpression( 167 | undefined, 168 | createConditional({ 169 | condition, 170 | true: createExpressionLiteral(body, node), 171 | false: ts.factory.createNull() 172 | }) 173 | ); 174 | }; 175 | 176 | const makeArrayFromCall = (args: ts.Expression[]): ts.JsxExpression => 177 | ts.factory.createJsxExpression( 178 | undefined, 179 | ts.factory.createCallExpression( 180 | ts.factory.createPropertyAccessExpression(ts.factory.createIdentifier('Array'), 'from'), 181 | undefined, 182 | args 183 | ) 184 | ); 185 | 186 | const transformForNode: JsxTransformation = (node, program, ctx) => { 187 | const { each, of, index, body: functionLoopBody } = getJsxProps(node); 188 | 189 | if (!each && !functionLoopBody) { 190 | warn(node, `"each" or "body" property of ${CTRL_NODE_NAMES.FOREACH} is missing`); 191 | return nullJsxExpr(); 192 | } 193 | 194 | if (!of) { 195 | warn(node, `"of" property of ${CTRL_NODE_NAMES.FOREACH} is missing`); 196 | return nullJsxExpr(); 197 | } 198 | 199 | if (functionLoopBody) { 200 | if (ts.isArrowFunction(functionLoopBody) || ts.isFunctionExpression(functionLoopBody)) { 201 | const transformedFunc = visitNodes(functionLoopBody, program, ctx) as ts.FunctionExpression; 202 | return makeArrayFromCall([of, transformedFunc]); 203 | } 204 | } 205 | 206 | const body = getJsxElementBody(node, program, ctx); 207 | if (body.length === 0) { 208 | warn(node, `Empty ${CTRL_NODE_NAMES.FOREACH}`); 209 | return nullJsxExpr(); 210 | } 211 | 212 | const arrowFunctionArgs = [each, index] 213 | .map( 214 | arg => 215 | arg && 216 | ts.factory.createParameterDeclaration(undefined, undefined, arg.getText().slice(1, -1)) 217 | ) 218 | .filter(Boolean); 219 | 220 | const arrowFunction = ts.factory.createArrowFunction( 221 | undefined, 222 | undefined, 223 | arrowFunctionArgs, 224 | undefined, // type 225 | undefined, 226 | createExpressionLiteral(body, node) 227 | ); 228 | 229 | return makeArrayFromCall([of, arrowFunction]); 230 | }; 231 | 232 | const CHOOSE_TAG_NAMES: readonly string[] = [CTRL_NODE_NAMES.CASE, CTRL_NODE_NAMES.DEFAULT]; 233 | 234 | const transformChooseNode: JsxTransformation = (node, program, ctx) => { 235 | const elements = ( 236 | node 237 | .getChildAt(1) 238 | .getChildren() 239 | .filter( 240 | node => isRelevantJsxNode(node) && CHOOSE_TAG_NAMES.includes(getTagNameString(node)) 241 | ) as ts.JsxElement[] 242 | ) 243 | .map(jsxNode => { 244 | const tagName = getTagNameString(jsxNode); 245 | const { condition } = getJsxProps(jsxNode); 246 | const nodeBody = getJsxElementBody(jsxNode, program, ctx); 247 | 248 | return { condition, nodeBody, tagName }; 249 | }) 250 | .filter((parsedNode, index, array) => { 251 | if (parsedNode.nodeBody.length === 0) { 252 | warn(node, `Empty ${CTRL_NODE_NAMES.CASE}`); 253 | return false; 254 | } 255 | 256 | if (!parsedNode.condition && parsedNode.tagName !== CTRL_NODE_NAMES.DEFAULT) { 257 | warn(node, `${CTRL_NODE_NAMES.CASE} without condition won't be rendered`); 258 | return false; 259 | } 260 | 261 | if (parsedNode.tagName === CTRL_NODE_NAMES.DEFAULT && index !== array.length - 1) { 262 | warn(node, 263 | `${CTRL_NODE_NAMES.DEFAULT} must be the last node in a ${CTRL_NODE_NAMES.SWITCH} element!` 264 | ); 265 | return false; 266 | } 267 | 268 | return true; 269 | }); 270 | 271 | if (elements.length === 0) { 272 | warn(node, `tsx-ctrl: Empty ${CTRL_NODE_NAMES.SWITCH}`); 273 | return nullJsxExpr(); 274 | } 275 | 276 | const last = elements[elements.length - 1]; 277 | const [cases, defaultCase] = 278 | last && last.tagName === CTRL_NODE_NAMES.DEFAULT 279 | ? [elements.slice(0, elements.length - 1), last] 280 | : [elements, null]; 281 | const defaultCaseOrNull = defaultCase 282 | ? createExpressionLiteral(defaultCase.nodeBody, node) 283 | : ts.factory.createNull(); 284 | 285 | return ts.factory.createJsxExpression( 286 | undefined, 287 | cases.reduceRight( 288 | (conditionalExpr, { condition, nodeBody }) => 289 | createConditional({ 290 | condition, 291 | true: createExpressionLiteral(nodeBody, node), 292 | false: conditionalExpr 293 | }), 294 | defaultCaseOrNull 295 | ) 296 | ); 297 | }; 298 | 299 | const transformWithNode: JsxTransformation = (node, program, ctx) => { 300 | const props = getJsxProps(node); 301 | const iifeArgs = Object.keys(props).map(key => 302 | ts.factory.createParameterDeclaration(undefined, undefined, key) 303 | ); 304 | const iifeArgValues = Object.values(props); 305 | const body = getJsxElementBody(node, program, ctx); 306 | 307 | return ts.factory.createJsxExpression( 308 | undefined, 309 | ts.factory.createCallExpression( 310 | ts.factory.createArrowFunction( 311 | undefined, 312 | undefined, 313 | iifeArgs, 314 | undefined, 315 | undefined, 316 | createExpressionLiteral(body, node) 317 | ), 318 | undefined, 319 | iifeArgValues 320 | ) 321 | ); 322 | }; 323 | 324 | const STUB_PACKAGE_REGEXP = /("|')tsx-control-statements\/components(.ts)?("|')/; 325 | const getTransformation = (node: ts.Node): JsxTransformation => { 326 | const isStubsImport = 327 | ts.isImportDeclaration(node) && 328 | node.getChildren().some(child => STUB_PACKAGE_REGEXP.test(child.getFullText())); 329 | 330 | if (isStubsImport) { 331 | return ts.factory.createEmptyStatement; 332 | } 333 | 334 | if (!ts.isJsxElement(node) && !ts.isJsxSelfClosingElement(node)) { 335 | return node => node; 336 | } 337 | 338 | const tagName = getTagNameString(node as ts.JsxElement); 339 | switch (tagName) { 340 | case CTRL_NODE_NAMES.CONDITIONAL: 341 | return transformIfNode; 342 | case CTRL_NODE_NAMES.FOREACH: 343 | return transformForNode; 344 | case CTRL_NODE_NAMES.SWITCH: 345 | return transformChooseNode; 346 | case CTRL_NODE_NAMES.WITH: 347 | return transformWithNode; 348 | default: 349 | return node => node; 350 | } 351 | }; 352 | 353 | const statements: Transformation = (node, program, ctx) => 354 | (getTransformation(node) as Transformation)(node, program, ctx); 355 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "alwaysStrict": true, 4 | "declaration": true, 5 | "target": "es6", 6 | "lib": ["es6", "es2017", "es2017.object", "es2016"], 7 | "module": "commonjs", 8 | "noImplicitAny": true, 9 | "noUnusedLocals": true, 10 | "downlevelIteration": true, 11 | "listEmittedFiles": true, 12 | "newLine": "LF", 13 | "removeComments": true, 14 | "strictNullChecks": true, 15 | "strict": true, 16 | "pretty": true, 17 | "outDir": "./", 18 | "skipLibCheck": true 19 | }, 20 | "files": ["./transformer.ts"] 21 | } 22 | --------------------------------------------------------------------------------