diff --git a/.travis.yml b/.travis.yml index aa31aa4d..9653ff8f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,6 +13,7 @@ before_install: script: - set -e - yarn lint + - yarn test - yarn build before_deploy: diff --git a/README.md b/README.md index fc350ebc..6df914a3 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) -> [ESLint](https://eslint.org/) Parser/Plugin for [MDX](https://github.com/mdx-js/mdx), helps you lint all ES syntaxes excluding `code` block of course. +> [ESLint] Parser/Plugin for [MDX], helps you lint all ES syntaxes excluding `code` block of course. > Work perfectly with `eslint-plugin-import`, `eslint-plugin-prettier` or any other eslint plugins. ## Install @@ -57,6 +57,7 @@ npm i -D @rxts/eslint-plugin-mdx "parser": "@rxts/eslint-plugin-mdx", "plugins": ["@rxts/mdx"], "rules": { + "@rxts/mdx/no-jsx-html-comments": 2, "@rxts/mdx/no-unused-expressions": 2, "no-unused-expressions": 0, "react/react-in-jsx-scope": 0 @@ -72,7 +73,7 @@ npm i -D @rxts/eslint-plugin-mdx eslint . --ext js,mdx ``` -3. Custom parser for ES syntax is also supported: +3. Custom parser for ES syntax is also supported, although `babel-eslint` will be detected automatically what means you actually do not need to do this: ```json { @@ -88,9 +89,27 @@ npm i -D @rxts/eslint-plugin-mdx } ``` +## FAQ + +### Why I need to use `overrides`? + +This parser/plugin should only affects `.mdx` files, of course you manually config it on your own risk. + +## Rules + +### @rxts/mdx/no-jsx-html-comments + +HTML style comments in jsx block is invalid, this rule will help you to fix it by transforming it to JSX style comments. + +### @rxts/mdx/no-unused-expressions + +`MDX` can render `jsx` block automatically without exporting them, but `eslint` will report `no-unused-expressions` issue which could be unexpected, this rule is a replacement of it, so make sure that you've turned off the original `no-unused-expressions` rule. + ## Limitation -> This parser/plugin can only handle ES syntaxes for you, markdown related syntaxes will just be ignored, you can use [markdownlint](https://github.com/markdownlint/markdownlint) to lint that part. +> This parser/plugin can only handle ES syntaxes for you, markdown related syntaxes will just be ignored, you can use [markdownlint] or [remake-lint] to lint that part. + +I have a very preliminary idea to integrate with [remake-lint]. ## Changelog @@ -98,4 +117,10 @@ Detailed changes for each release are documented in [CHANGELOG.md](./CHANGELOG.m ## License -[MIT](http://opensource.org/licenses/MIT) +[MIT] + +[eslint]: https://eslint.org +[mdx]: https://github.com/mdx-js/mdx +[mit]: http://opensource.org/licenses/MIT +[markdownlint]: https://github.com/markdownlint/markdownlint +[remake-lint]: https://github.com/remarkjs/remark-lint diff --git a/src/index.ts b/src/index.ts index 450c65a3..c381c787 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,6 @@ import path from 'path' export * from './helper' export * from './normalizer' export * from './parser' -export * from './processors' export * from './regexp' export * from './rules' export * from './traverse' @@ -13,6 +12,7 @@ export const configs = { parser: path.resolve(__dirname, 'parser'), plugins: ['@rxts/mdx'], rules: { + '@rxts/mdx/no-jsx-html-comments': 2, '@rxts/mdx/no-unused-expressions': 2, 'no-unused-expressions': 0, 'react/react-in-jsx-scope': 0, diff --git a/src/normalizer.ts b/src/normalizer.ts index ee6635ee..1a30ce40 100644 --- a/src/normalizer.ts +++ b/src/normalizer.ts @@ -1,46 +1,27 @@ import { isComment, COMMENT_CONTENT_REGEX } from './regexp' -import { mdxProcessor } from './parser' -import { Node, Parent } from 'unist' +import { Node } from 'unist' export const normalizeJsxNode = (node: Node) => { - const rawText = node.value as string - if (!isComment(rawText)) { - const matched = rawText.match(COMMENT_CONTENT_REGEX) - if (matched) { - node.value = rawText.replace( - COMMENT_CONTENT_REGEX, - (_matched, $0) => `{/*${$0}*/}`, - ) - } + let rawText = node.value as string + + if (isComment(rawText)) { + return node } - return node -} -export const normalizeMdx = (source: string) => { - const lines = source.split('\n').length - const { children } = mdxProcessor.parse(source) as Parent - let lastLine: number - return children.reduce((result, node, index) => { - const { - position: { start, end }, - } = node - const startLine = start.line - const endLine = end.line - if (lastLine != null && lastLine !== startLine) { - result += '\n'.repeat(startLine - lastLine) - } - if (node.type === 'jsx') { - result += normalizeJsxNode(node).value - } else { - result += source.slice(start.offset, end.offset) - } + const matched = rawText.match(COMMENT_CONTENT_REGEX) - if (index === children.length - 1 && endLine < lines) { - result += '\n'.repeat(lines - endLine) - } + if (!matched) { + return node + } + + node.jsxType = 'JSXElementWithHTMLComments' + node.raw = rawText + rawText = node.value = rawText.replace( + COMMENT_CONTENT_REGEX, + (_matched, $0, $1, $2) => + `{/${'*'.repeat($0.length - 1)}${$1}${'*'.repeat($2.length - 1)}/}`, + ) - lastLine = endLine - return result - }, '') + return node } diff --git a/src/parser.ts b/src/parser.ts index 15776296..1857d40a 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -7,12 +7,13 @@ import remarkParse from 'remark-parse' import remarkStringify from 'remark-stringify' import unified from 'unified' +import { normalizeJsxNode } from './normalizer' import { normalizePosition, restoreNodeLocation } from './helper' import { isComment } from './regexp' import { traverse } from './traverse' import { AST, Linter } from 'eslint' -import { Parent } from 'unist' +import { Parent, Node } from 'unist' export const AST_PROPS = ['body', 'comments', 'tokens'] as const export const ES_NODE_TYPES = ['export', 'import', 'jsx'] as const @@ -28,26 +29,29 @@ export const mdxProcessor = unified() export const parseForESLint = ( code: string, options: Linter.ParserOptions = {}, -): Linter.ESLintParseResult => { +) => { let { parser } = options if (parser) { if (typeof parser === 'string') { - parser = require(parser).parse - } else { - if (typeof parser === 'object') { - parser = parser.parseForESLint || parser.parse - } - if (typeof parser !== 'function') { - throw new TypeError( - `Invalid custom parser for \`eslint-plugin-mdx\`: ${parser}`, - ) - } + parser = require(parser) + } + + if (typeof parser === 'object') { + parser = parser.parseForESLint || parser.parse + } + + if (typeof parser !== 'function') { + throw new TypeError( + `Invalid custom parser for \`eslint-plugin-mdx\`: ${options.parser}`, + ) } } else { try { // try to load babel-eslint automatically - parser = require(require.resolve('babel-eslint')).parse + // eslint-disable-next-line @typescript-eslint/no-var-requires + const babelEslint = require('babel-eslint') + parser = babelEslint.parseForESLint || babelEslint.parse } catch (e) { parser = esParse } @@ -63,6 +67,9 @@ export const parseForESLint = ( comments: [], tokens: [], } + const services = { + JSXElementsWithHTMLComments: [] as Node[], + } traverse(root, { enter(node) { @@ -70,6 +77,12 @@ export const parseForESLint = ( return } + normalizeJsxNode(node) + + if (node.jsxType === 'JSXElementWithHTMLComments') { + services.JSXElementsWithHTMLComments.push(node) + } + const rawText = node.value as string // fix #4 @@ -80,7 +93,7 @@ export const parseForESLint = ( const { loc, start } = normalizePosition(node.position) const startLine = loc.start.line - 1 //! line is 1-indexed, change to 0-indexed to simplify usage - let program: AST.Program + let program: AST.Program | Linter.ESLintParseResult try { program = parser(rawText, options) @@ -94,6 +107,10 @@ export const parseForESLint = ( throw e } + if ('ast' in program) { + program = program.ast + } + const offset = start - program.range[0] AST_PROPS.forEach(prop => @@ -110,5 +127,7 @@ export const parseForESLint = ( return { ast, - } + parserServices: services, + services, + } as Linter.ESLintParseResult } diff --git a/src/processors.ts b/src/processors.ts deleted file mode 100644 index 297863cb..00000000 --- a/src/processors.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { normalizeMdx } from './normalizer' - -export const processors = { - '.mdx': { - preprocess(code: string) { - return [normalizeMdx(code)] - }, - supportsAutofix: true, - }, -} diff --git a/src/regexp.ts b/src/regexp.ts index 5a8250d3..7801a7a4 100644 --- a/src/regexp.ts +++ b/src/regexp.ts @@ -23,8 +23,8 @@ const openTag = '<[A-Za-z]*[A-Za-z0-9\\.\\-]*' + attribute + '*\\s*>' const closeTag = '<\\s*\\/[A-Za-z]*[A-Za-z0-9\\.\\-]*\\s*>' const selfClosingTag = '<[A-Za-z]*[A-Za-z0-9\\.\\-]*' + attribute + '*\\s*\\/?>' const comment = '|' -const commentOpen = '' +const commentOpen = '<(!---*)' +const commentClose = '(-*--)>' export const OPEN_TAG_REGEX = new RegExp(`^(?:${openTag})$`) export const CLOSE_TAG_REGEX = new RegExp(`^(?:${closeTag})$`) diff --git a/src/rules/index.ts b/src/rules/index.ts index a1ca5d29..83da392d 100644 --- a/src/rules/index.ts +++ b/src/rules/index.ts @@ -1,5 +1,9 @@ +import { noJsxHtmlComments } from './no-jsx-html-comments' import { noUnUsedExpressions } from './no-unused-expressions' +export { noJsxHtmlComments, noUnUsedExpressions } + export const rules = { + 'no-jsx-html-comments': noJsxHtmlComments, 'no-unused-expressions': noUnUsedExpressions, } diff --git a/src/rules/no-jsx-html-comments.ts b/src/rules/no-jsx-html-comments.ts new file mode 100644 index 00000000..b91989f8 --- /dev/null +++ b/src/rules/no-jsx-html-comments.ts @@ -0,0 +1,63 @@ +import { ExpressionStatementWithParent, JSX_TYPES, JsxType } from './types' + +import { Rule } from 'eslint' +import { Node } from 'unist' + +export const noJsxHtmlComments: Rule.RuleModule = { + meta: { + type: 'problem', + docs: { + description: 'Forbid invalid html style comments in jsx block', + category: 'SyntaxError', + recommended: true, + }, + messages: { + jdxHtmlComments: 'html style comments are invalid in jsx: {{ raw }}', + }, + fixable: 'code', + schema: [], + }, + create(context) { + return { + ExpressionStatement(node: ExpressionStatementWithParent) { + const invalidNodes: Node[] = + context.parserServices.JSXElementsWithHTMLComments + + if ( + !JSX_TYPES.includes(node.expression.type as JsxType) || + node.parent.type !== 'Program' || + !invalidNodes || + !invalidNodes.length + ) { + return + } + + const invalidNode = invalidNodes.shift() + // unist column is 1-indexed, but estree is 0-indexed... + const { start, end } = invalidNode.position + context.report({ + messageId: 'jdxHtmlComments', + data: { + raw: invalidNode.raw as string, + }, + loc: { + start: { + ...start, + column: start.column - 1, + }, + end: { + ...end, + column: end.column - 1, + }, + }, + fix(fixer) { + return fixer.replaceTextRange( + [start.offset, end.offset], + invalidNode.value as string, + ) + }, + }) + }, + } + }, +} as Rule.RuleModule diff --git a/src/rules/no-unused-expressions.ts b/src/rules/no-unused-expressions.ts index dadbe1ca..fc1cdaa1 100644 --- a/src/rules/no-unused-expressions.ts +++ b/src/rules/no-unused-expressions.ts @@ -3,18 +3,9 @@ import esLintNoUnUsedExpressions from 'eslint/lib/rules/no-unused-expressions' -import { Rule } from 'eslint' -import { ExpressionStatement, Node } from 'estree' - -export const JSX_TYPES = ['JSXElement', 'JSXFragment'] as const - -export type JsxType = (typeof JSX_TYPES)[number] +import { ExpressionStatementWithParent, JSX_TYPES, JsxType } from './types' -export interface ExpressionStatementWithParent extends ExpressionStatement { - parent?: { - type: Node['type'] - } -} +import { Rule } from 'eslint' export const noUnUsedExpressions: Rule.RuleModule = { ...esLintNoUnUsedExpressions, diff --git a/src/rules/types.ts b/src/rules/types.ts new file mode 100644 index 00000000..eae84ea9 --- /dev/null +++ b/src/rules/types.ts @@ -0,0 +1,11 @@ +import { ExpressionStatement, Node } from 'estree' + +export const JSX_TYPES = ['JSXElement', 'JSXFragment'] as const + +export type JsxType = (typeof JSX_TYPES)[number] + +export interface ExpressionStatementWithParent extends ExpressionStatement { + parent?: { + type: Node['type'] + } +} diff --git a/test/fixture1.mdx b/test/fixture1.mdx index 090d2f42..e0a78a46 100644 --- a/test/fixture1.mdx +++ b/test/fixture1.mdx @@ -18,7 +18,9 @@ Lorem ipsum dolor sit amet, consectetur adipiscing **elit**. Ut ac lobortis v {/* This is a comment */} -In JSX! + + {/** This is a comment */}In JSX!{/** This is a comment */} + ## Subtitle diff --git a/test/normalizer.test.ts b/test/normalizer.test.ts index 18b7eb2f..8f1c59a4 100644 --- a/test/normalizer.test.ts +++ b/test/normalizer.test.ts @@ -1,21 +1,14 @@ -import { normalizeMdx } from '../src' +import { normalizeJsxNode, mdxProcessor } from '../src' + +import { Parent } from 'unist' describe('normalizer', () => { it('should transform html style comment in jsx into jsx comment', () => { - const sourceText = `--- -name ---- - - -` - - const expectText = `--- -name ---- - -{/* JSX Comment */}{/* JSX Comment */} -` - - expect(normalizeMdx(sourceText)).toBe(expectText) + const sourceText = `` + const expectText = `{/** JSX Comment */}{/** JSX Comment */}` + expect( + normalizeJsxNode((mdxProcessor.parse(sourceText) as Parent).children[0]) + .value, + ).toBe(expectText) }) })