diff --git a/.eslintrc.js b/.eslintrc.js index 22b36bec..a5073c36 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -33,6 +33,7 @@ module.exports = { // related to /~https://github.com/eslint/eslint/issues/14207 rules: { 'prettier/prettier': 0, + 'react/no-unescaped-entities': 1, 'unicorn/filename-case': 0, }, settings: { diff --git a/package.json b/package.json index 36f6ba47..b5346a68 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,7 @@ ] }, "typeCoverage": { - "atLeast": 99.7, + "atLeast": 99.92, "cache": true, "detail": true, "ignoreAsAssertion": true, diff --git a/packages/eslint-mdx/src/helpers.ts b/packages/eslint-mdx/src/helpers.ts index 2d733982..cc68f031 100644 --- a/packages/eslint-mdx/src/helpers.ts +++ b/packages/eslint-mdx/src/helpers.ts @@ -1,10 +1,16 @@ /// import type { Linter } from 'eslint' -import type { SourceLocation } from 'estree' -import type { Position } from 'unist' +import type { Position as ESPosition, SourceLocation } from 'estree' +import type { Point, Position } from 'unist' -import type { Arrayable, JsxNode, ParserFn, ParserOptions } from './types' +import type { + Arrayable, + JsxNode, + ParserFn, + ParserOptions, + ValueOf, +} from './types' export const FALLBACK_PARSERS = [ '@typescript-eslint/parser', @@ -90,14 +96,29 @@ export const hasProperties = ( obj && properties.every(property => property in obj) -export const restoreNodeLocation = ( - node: T, - startLine: number, - offset: number, -): T => { +// fix #292 +export const getPositionAt = (code: string, offset: number): ESPosition => { + let currOffset = 0 + + for (const [index, { length }] of code.split('\n').entries()) { + const line = index + 1 + const nextOffset = currOffset + length + + if (nextOffset >= offset) { + return { + line, + column: offset - currOffset, + } + } + + currOffset = nextOffset + 1 // add a line break `\n` offset + } +} + +export const restoreNodeLocation = (node: T, point: Point): T => { if (node && typeof node === 'object') { - for (const value of Object.values(node)) { - restoreNodeLocation(value, startLine, offset) + for (const value of Object.values(node) as Array>) { + restoreNodeLocation(value, point) } } @@ -105,28 +126,25 @@ export const restoreNodeLocation = ( return node } - const { + let { loc: { start: startLoc, end: endLoc }, - range, + range: [start, end], } = node - const start = range[0] + offset - const end = range[1] + offset - const restoredStartLine = startLine + startLoc.line - const restoredEndLine = startLine + endLoc.line + const range = [(start += point.offset), (end += point.offset)] as const return Object.assign(node, { start, end, - range: [start, end], + range, loc: { start: { - line: restoredStartLine, - column: startLoc.column + (restoredStartLine === 1 ? offset : 0), + line: point.line + startLoc.line, + column: startLoc.column + (startLoc.line === 1 ? point.column : 0), }, end: { - line: restoredEndLine, - column: endLoc.column + (restoredEndLine === 1 ? offset : 0), + line: point.line + endLoc.line, + column: endLoc.column + (endLoc.line === 1 ? point.column : 0), }, }, }) diff --git a/packages/eslint-mdx/src/parser.ts b/packages/eslint-mdx/src/parser.ts index 8eeb4ee2..56a0e04d 100644 --- a/packages/eslint-mdx/src/parser.ts +++ b/packages/eslint-mdx/src/parser.ts @@ -9,6 +9,7 @@ import type { Node, Parent } from 'unist' import { arrayify, + getPositionAt, hasProperties, isJsxNode, last, @@ -193,7 +194,7 @@ export class Parser { for (const normalizedNode of arrayify( this.normalizeJsxNode(node, parent, options), )) { - this._nodeToAst(normalizedNode, options) + this._nodeToAst(code, normalizedNode, options) } }, }) @@ -333,7 +334,7 @@ export class Parser { } // @internal - private _nodeToAst(node: Node, options: ParserOptions) { + private _nodeToAst(code: string, node: Node, options: ParserOptions) { if (node.data && node.data.jsxType === 'JSXElementWithHTMLComments') { this._services.JSXElementsWithHTMLComments.push(node) } @@ -371,13 +372,18 @@ export class Parser { throw e } - const offset = start - program.range[0] + const startPoint = { + line: startLine, + // #279 related + column: getPositionAt(code, start).column, + offset: start, + } for (const prop of AST_PROPS) this._ast[prop].push( // ts doesn't understand the mixed type ...program[prop].map((item: never) => - restoreNodeLocation(item, startLine, offset), + restoreNodeLocation(item, startPoint), ), ) } diff --git a/packages/eslint-mdx/src/types.ts b/packages/eslint-mdx/src/types.ts index a87c9433..c39c7e16 100644 --- a/packages/eslint-mdx/src/types.ts +++ b/packages/eslint-mdx/src/types.ts @@ -2,10 +2,20 @@ import type { JSXElement, JSXFragment } from '@babel/types' import type { AST, Linter } from 'eslint' import type { Node, Parent, Point } from 'unist' -export type JsxNode = (JSXElement | JSXFragment) & { range: [number, number] } - export type Arrayable = T[] | readonly T[] +export declare type ValueOf = T extends { + [key: string]: infer M +} + ? M + : T extends { + [key: number]: infer N + } + ? N + : never + +export type JsxNode = (JSXElement | JSXFragment) & { range: [number, number] } + export type ParserFn = ( code: string, options: Linter.ParserOptions, diff --git a/packages/eslint-plugin-mdx/src/processors/markdown.ts b/packages/eslint-plugin-mdx/src/processors/markdown.ts index 2cb6977e..86ac6b0e 100644 --- a/packages/eslint-plugin-mdx/src/processors/markdown.ts +++ b/packages/eslint-plugin-mdx/src/processors/markdown.ts @@ -242,7 +242,7 @@ function preprocess(text: string, filename: string): ESLinterProcessorFile[] { traverse(ast, { code(node, parent) { - const comments = [] + const comments: string[] = [] if (node.lang) { let index = parent.children.indexOf(node) - 1 diff --git a/packages/eslint-plugin-mdx/src/processors/types.ts b/packages/eslint-plugin-mdx/src/processors/types.ts index a569978c..75fc102a 100644 --- a/packages/eslint-plugin-mdx/src/processors/types.ts +++ b/packages/eslint-plugin-mdx/src/processors/types.ts @@ -18,7 +18,7 @@ export interface ESLintProcessor< } export interface ESLintMdxSettings { - 'mdx/code-blocks': boolean + 'mdx/code-blocks'?: boolean 'mdx/language-mapper'?: false | Record } diff --git a/test/__snapshots__/fixtures.test.ts.snap b/test/__snapshots__/fixtures.test.ts.snap index 97f6422a..494296f5 100644 --- a/test/__snapshots__/fixtures.test.ts.snap +++ b/test/__snapshots__/fixtures.test.ts.snap @@ -109,6 +109,8 @@ Array [ exports[`fixtures should match all snapshots: 287.mdx 1`] = `Array []`; +exports[`fixtures should match all snapshots: 292.mdx 1`] = `Array []`; + exports[`fixtures should match all snapshots: adjacent.mdx 1`] = `Array []`; exports[`fixtures should match all snapshots: basic.mdx 1`] = `Array []`; @@ -169,6 +171,14 @@ Array [ "ruleId": "remark-lint-no-multiple-toplevel-headings", "severity": 1, }, + Object { + "column": 7, + "line": 35, + "message": "\`'\` can be escaped with \`'\`, \`‘\`, \`'\`, \`’\`.", + "nodeType": "JSXText", + "ruleId": "react/no-unescaped-entities", + "severity": 1, + }, Object { "column": 2, "endColumn": 9, @@ -221,7 +231,26 @@ Array [ ] `; -exports[`fixtures should match all snapshots: details.mdx 1`] = `Array []`; +exports[`fixtures should match all snapshots: details.mdx 1`] = ` +Array [ + Object { + "column": 290, + "line": 3, + "message": "\`'\` can be escaped with \`'\`, \`‘\`, \`'\`, \`’\`.", + "nodeType": "JSXText", + "ruleId": "react/no-unescaped-entities", + "severity": 1, + }, + Object { + "column": 5, + "line": 5, + "message": "\`'\` can be escaped with \`'\`, \`‘\`, \`'\`, \`’\`.", + "nodeType": "JSXText", + "ruleId": "react/no-unescaped-entities", + "severity": 1, + }, +] +`; exports[`fixtures should match all snapshots: jsx-in-list.mdx 1`] = `Array []`; @@ -231,7 +260,26 @@ exports[`fixtures should match all snapshots: markdown.md 1`] = `Array []`; exports[`fixtures should match all snapshots: no-jsx-html-comments.mdx 1`] = `Array []`; -exports[`fixtures should match all snapshots: no-unescaped-entities.mdx 1`] = `Array []`; +exports[`fixtures should match all snapshots: no-unescaped-entities.mdx 1`] = ` +Array [ + Object { + "column": 8, + "line": 2, + "message": "\`>\` can be escaped with \`>\`.", + "nodeType": "JSXText", + "ruleId": "react/no-unescaped-entities", + "severity": 1, + }, + Object { + "column": 13, + "line": 5, + "message": "\`>\` can be escaped with \`>\`.", + "nodeType": "JSXText", + "ruleId": "react/no-unescaped-entities", + "severity": 1, + }, +] +`; exports[`fixtures should match all snapshots: processor.mdx 1`] = ` Array [ diff --git a/test/__snapshots__/helpers.test.ts.snap b/test/__snapshots__/helpers.test.ts.snap new file mode 100644 index 00000000..1b35ebbc --- /dev/null +++ b/test/__snapshots__/helpers.test.ts.snap @@ -0,0 +1,22 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Helpers should get correct loc range range 1`] = ` +Object { + "column": 2, + "line": 1, +} +`; + +exports[`Helpers should get correct loc range range 2`] = ` +Object { + "column": 13, + "line": 4, +} +`; + +exports[`Helpers should get correct loc range range 3`] = ` +Object { + "column": 19, + "line": 4, +} +`; diff --git a/test/fixtures.test.ts b/test/fixtures.test.ts index 12eb8039..8fe9aa99 100644 --- a/test/fixtures.test.ts +++ b/test/fixtures.test.ts @@ -15,6 +15,7 @@ const getCli = (lintCodeBlocks = false) => extends: ['plugin:mdx/recommended'], plugins: ['react', 'unicorn', 'prettier'], rules: { + 'react/no-unescaped-entities': 1, 'unicorn/prefer-spread': 2, }, overrides: lintCodeBlocks diff --git a/test/fixtures/292.mdx b/test/fixtures/292.mdx new file mode 100644 index 00000000..963fa36d --- /dev/null +++ b/test/fixtures/292.mdx @@ -0,0 +1,8 @@ +# Header + +paragraph
content
+ +- -
+ Vuetify preset +
+ : some extra text describing the preset diff --git a/test/helpers.test.ts b/test/helpers.test.ts index db9fc580..0c22657e 100644 --- a/test/helpers.test.ts +++ b/test/helpers.test.ts @@ -1,6 +1,6 @@ import path from 'path' -import { arrayify } from 'eslint-mdx' +import { arrayify, getPositionAt } from 'eslint-mdx' import { getGlobals, getShortLang, requirePkg } from 'eslint-plugin-mdx' describe('Helpers', () => { @@ -18,6 +18,19 @@ describe('Helpers', () => { expect(getShortLang('4.Markdown', { markdown: 'mkdn' })).toBe('mkdn') }) + it('should get correct loc range range', () => { + const code = ` +# Header + +- jsx in list
+ content +
+ `.trim() + expect(getPositionAt(code, code.indexOf('Header'))).toMatchSnapshot() + expect(getPositionAt(code, code.indexOf('link'))).toMatchSnapshot() + expect(getPositionAt(code, code.indexOf('content'))).toMatchSnapshot() + }) + it('should resolve globals correctly', () => { expect(getGlobals({})).toEqual({}) expect(getGlobals(['a', 'b'])).toEqual({