├── .gitignore ├── package-lock.json ├── package.json ├── readme.md └── src ├── index.js └── tests ├── isCompName.test.js ├── tester.js └── validation.test.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "babel-plugin-validate-jsx-nesting", 3 | "version": "1.0.2", 4 | "description": "compile time JSX nesting validation", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "test": "jest" 8 | }, 9 | "keywords": [ 10 | "jsx validator", 11 | "dom validator", 12 | "html validator" 13 | ], 14 | "author": "Manan Tank", 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/MananTank/validate-jsx-nesting.git" 18 | }, 19 | "license": "ISC", 20 | "devDependencies": { 21 | "@babel/plugin-syntax-jsx": "^7.16.7", 22 | "@types/jest": "^27.5.1", 23 | "jest": "^28.1.0" 24 | }, 25 | "dependencies": { 26 | "@babel/core": "^7.17.10", 27 | "validate-html-nesting": "^1.2.2" 28 | }, 29 | "peerDependencies": { 30 | "@babel/plugin-syntax-jsx": "latest" 31 | } 32 | } -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # babel-plugin-validate-jsx-nesting 2 | 3 | ### Compile time JSX nesting validation 4 | 5 | Example 6 | 7 | ```html 8 | Failed to Compile. 9 | 10 | Error: Invalid HTML nesting:
can not be child of

11 | 1 |

12 | 2 | Hello 13 | > 3 |


14 | | ^^^^^^ 15 | 4 | World 16 | 5 |

17 | 6 | 18 | ``` 19 | 20 |
21 | 22 | ### Why this validation is important? 23 | 24 | without such validation, when JSX is converted to HTML and rendered in the DOM, the Browser will try to fix the invalid nestings ( such as `
` inside `

` ) and thus the rendered DOM will have a different structure than JSX structure. 25 | 26 | This is a big issue for frameworks that rely on JSX rendering the exact same elements in DOM. This can lead to unexpected behaviors. 27 | 28 |
29 | 30 | ### Validation library 31 | 32 | This babel plugin uses the [validate-html-nesting](https://github.com/MananTank/validate-html-nesting) library for validating HTML element nesting 33 | 34 |
35 | 36 | ## Install 37 | 38 | ``` 39 | npm i -D babel-plugin-validate-jsx-nesting 40 | ``` 41 | 42 |
43 | 44 | ## babel config 45 | 46 | Refer to the [babel config](https://babeljs.io/docs/en/configuration) guide to learn about configuring babel 47 | 48 | ## no options 49 | 50 | with this config, the plugin throws an error when invalid JSX nesting is found 51 | 52 | ```json 53 | { 54 | "plugins": ["validate-jsx-nesting"] 55 | } 56 | ``` 57 | 58 |
59 | 60 | ### with `warnOnly: true` option 61 | 62 | With this config, the plugin logs a warning when invalid JSX nesting is found 63 | 64 | ```json 65 | { 66 | "plugins": [["validate-jsx-nesting", { "warnOnly": true }]] 67 | } 68 | ``` 69 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const { isValidHTMLNesting } = require('validate-html-nesting'); 2 | 3 | /** @typedef {{ warnOnly: boolean} | undefined} PluginOptions */ 4 | 5 | /** 6 | * returns true if the tag is component's name 7 | * @param {string} tag 8 | * @returns 9 | */ 10 | 11 | function isCompName(tag) { 12 | return tag[0] === tag[0].toUpperCase(); 13 | } 14 | 15 | /** 16 | * jsx nesting validator plugin 17 | * @param {import('@babel/core')} babel 18 | */ 19 | module.exports = function (babel) { 20 | const { types: t } = babel; 21 | 22 | /** 23 | * @type {import('@babel/core').Visitor<{}>} path 24 | */ 25 | const domValidator = { 26 | JSXElement(path, state) { 27 | const elName = path.node.openingElement.name; 28 | if (t.isJSXIdentifier(elName)) { 29 | const elTagName = elName.name; 30 | // if not a component 31 | if (!isCompName(elTagName)) { 32 | const parent = path.parent; 33 | if (t.isJSXElement(parent)) { 34 | const parentElName = parent.openingElement.name; 35 | if (t.isJSXIdentifier(parentElName)) { 36 | const parentElTagName = parentElName.name; 37 | // if parent is not a component 38 | if (!isCompName(parentElTagName)) { 39 | if (!isValidHTMLNesting(parentElName.name, elName.name)) { 40 | // @ts-ignore 41 | const pluginOptions = /** @type {PluginOptions} */ (state && state.opts); 42 | 43 | const error = path.buildCodeFrameError( 44 | `Invalid HTML nesting: <${elName.name}> can not be child of <${parentElName.name}>` 45 | ); 46 | 47 | if (pluginOptions && pluginOptions.warnOnly) { 48 | console.warn(error.message); 49 | } else { 50 | throw error; 51 | } 52 | } 53 | } 54 | } 55 | } 56 | } 57 | } 58 | }, 59 | }; 60 | 61 | return { 62 | name: 'validate-jsx-nesting', // not required 63 | visitor: domValidator, 64 | }; 65 | }; 66 | 67 | module.exports.isCompName = isCompName; 68 | -------------------------------------------------------------------------------- /src/tests/isCompName.test.js: -------------------------------------------------------------------------------- 1 | const { isCompName } = require('../index'); 2 | 3 | test('isCompName', () => { 4 | expect(isCompName('Foo')).toBe(true); 5 | expect(isCompName('foo')).toBe(false); 6 | }); 7 | -------------------------------------------------------------------------------- /src/tests/tester.js: -------------------------------------------------------------------------------- 1 | const { transform } = require('@babel/core'); 2 | const syntaxJSX = require('@babel/plugin-syntax-jsx'); 3 | const plugin = require('../index'); 4 | 5 | /** 6 | * @param {string} inputCode 7 | * @returns 8 | */ 9 | function testPlugin(inputCode) { 10 | return transform(inputCode, { 11 | plugins: [syntaxJSX, plugin], 12 | }).code; 13 | } 14 | 15 | /** 16 | * @param {string} inputCode 17 | * @returns 18 | */ 19 | function testPluginWarnOnly(inputCode) { 20 | return transform(inputCode, { 21 | plugins: [syntaxJSX, [plugin, { warnOnly: true }]], 22 | }).code; 23 | } 24 | 25 | module.exports = { 26 | testPlugin, 27 | testPluginWarnOnly, 28 | }; 29 | -------------------------------------------------------------------------------- /src/tests/validation.test.js: -------------------------------------------------------------------------------- 1 | const { testPlugin, testPluginWarnOnly } = require('./tester'); 2 | 3 | // validation logic is tested in `validate-html-nesting` package 4 | 5 | test('elements are tested', () => { 6 | expect(() => testPlugin('

')).toThrowError( 7 | '
can not be child of ' 8 | ); 9 | 10 | expect(() => testPlugin('


')).toThrowError('
can not be child of

'); 11 | expect(() => testPlugin('

')).not.toThrow(); 12 | }); 13 | 14 | test('components are ignored', () => { 15 | expect(() => testPlugin('
')).not.toThrow(); 16 | expect(() => testPlugin('
')).not.toThrow(); 17 | }); 18 | 19 | test('warn only mode', () => { 20 | jest.spyOn(console, 'warn').mockImplementation(); 21 | 22 | expect(() => testPluginWarnOnly('
')).not.toThrow(); 23 | 24 | expect(console.warn).toHaveBeenCalledWith( 25 | expect.stringContaining('
can not be child of ') 26 | ); 27 | }); 28 | --------------------------------------------------------------------------------