Skip to content

Commit

Permalink
Migrate @emotion/eslint-plugin to TypeScript (#2568)
Browse files Browse the repository at this point in the history
* feat(eslint-plugin): add standard utils & dependencies for typescript

* feat(eslint-plugin): convert `no-vanilla` to typescript

* feat(eslint-plugin): convert `styled-import` to typescript

* feat(eslint-plugin): convert `import-from-emotion` to typescript

* feat(eslint-plugin): convert `syntax-preference` to typescript

* feat(eslint-plugin): convert `pkg-renaming` to typescript

* feat(eslint-plugin): convert `jsx-import` to typescript

* feat(eslint-plugin): move index to typescript

* feat(eslint-plugin): add changeset

* fix(eslint-plugin): use `require` to get package version

* fix(eslint-plugin): adjust rule description

Co-authored-by: Sam Magura <srmagura@gmail.com>

* fix(eslint-plugin): use `REPO_URL` constant everywhere

* chore(eslint-plugin): add change sets for other bugs

* nit

* Tweak changeset

* Report empty css attribute as invalid in syntax-preference rule

* Tweak changesets

Co-authored-by: Sam Magura <srmagura@gmail.com>
Co-authored-by: Mateusz Burzyński <mateuszburzynski@gmail.com>
  • Loading branch information
3 people authored Apr 11, 2022
1 parent f99981b commit 304f7e3
Show file tree
Hide file tree
Showing 25 changed files with 616 additions and 347 deletions.
5 changes: 5 additions & 0 deletions .changeset/lovely-zebras-admire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@emotion/eslint-plugin': patch
---

An empty css prop (`<div css />`) will now raise an error in the `@emotion/syntax-preference` rule instead of crashing on this case.
5 changes: 5 additions & 0 deletions .changeset/modern-penguins-play.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@emotion/eslint-plugin': minor
---

Source code has been migrated to TypeScript. From now on type declarations will be emitted based on that, instead of being hand-written.
5 changes: 5 additions & 0 deletions .changeset/sharp-trees-smell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@emotion/eslint-plugin': patch
---

Fixed a crash on empty css prop (`<div css />`) in the `@emotion/jsx-import` rule.
7 changes: 6 additions & 1 deletion packages/eslint-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@
"peerDependencies": {
"eslint": "6 || 7 || 8"
},
"dependencies": {
"@typescript-eslint/experimental-utils": "^4.30.0"
},
"devDependencies": {
"eslint": "^7.10.0"
"@types/eslint": "^7.0.0",
"eslint": "^7.10.0",
"resolve-from": "^5.0.0"
}
}
File renamed without changes.
42 changes: 0 additions & 42 deletions packages/eslint-plugin/src/rules/import-from-emotion.js

This file was deleted.

68 changes: 68 additions & 0 deletions packages/eslint-plugin/src/rules/import-from-emotion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/experimental-utils'
import { createRule } from '../utils'

export default createRule({
name: __filename,
meta: {
docs: {
category: 'Best Practices',
description: 'Ensure styled is imported from @emotion/styled',
recommended: false
},
fixable: 'code',
messages: {
incorrectImport: `emotion's exports should be imported directly from emotion rather than from react-emotion`
},
schema: [],
type: 'problem'
},
defaultOptions: [],
create(context) {
return {
ImportDeclaration(node) {
if (
node.source.value === 'react-emotion' &&
node.specifiers.some(
x => x.type !== AST_NODE_TYPES.ImportDefaultSpecifier
)
) {
context.report({
node: node.source,
messageId: 'incorrectImport',
fix(fixer) {
if (
node.specifiers[0].type ===
AST_NODE_TYPES.ImportNamespaceSpecifier
) {
return null
}
// default specifiers are always first
if (
node.specifiers[0].type ===
AST_NODE_TYPES.ImportDefaultSpecifier
) {
return fixer.replaceText(
node,
`import ${
node.specifiers[0].local.name
} from '@emotion/styled';\nimport { ${node.specifiers
.filter(
(x): x is TSESTree.ImportSpecifier =>
x.type === AST_NODE_TYPES.ImportSpecifier
)
.map(x =>
x.local.name === x.imported.name
? x.local.name
: `${x.imported.name} as ${x.local.name}`
)
.join(', ')} } from 'emotion';`
)
}
return fixer.replaceText(node.source, "'emotion'")
}
})
}
}
}
}
})
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/experimental-utils'
import { createRule, REPO_URL } from '../utils'

const JSX_ANNOTATION_REGEX = /\*?\s*@jsx\s+([^\s]+)/
const JSX_IMPORT_SOURCE_REGEX = /\*?\s*@jsxImportSource\s+([^\s]+)/

Expand All @@ -6,9 +9,35 @@ const JSX_IMPORT_SOURCE_REGEX = /\*?\s*@jsxImportSource\s+([^\s]+)/
// to
// <div css={css`color:hotpink;`} /> + import { css }

export default {
declare module '@typescript-eslint/experimental-utils/dist/ts-eslint/Rule' {
export interface SharedConfigurationSettings {
react?: { pragma?: string }
}
}

interface JSXConfig {
runtime: string
importSource?: string
}

type RuleOptions = [(JSXConfig | string)?]

const messages = {
cssProp: `The css prop can only be used if jsxImportSource is set to {{ importSource }}`,
cssPropWithPragma: `The css prop can only be used if jsx from @emotion/react is imported and it is set as the jsx pragma`,
templateLiterals: `Template literals should be replaced with tagged template literals using \`css\` when using the css prop`
}

export default createRule<RuleOptions, keyof typeof messages>({
name: __filename,
meta: {
docs: {
category: 'Possible Errors',
description: 'Ensure jsx from @emotion/react is imported',
recommended: false
},
fixable: 'code',
messages,
schema: {
type: 'array',
items: {
Expand All @@ -29,11 +58,14 @@ export default {
},
uniqueItems: true,
minItems: 0
}
},
type: 'problem'
},
defaultOptions: [],
create(context) {
const jsxRuntimeMode = context.options.find(
option => option && option.runtime === 'automatic'
(option): option is JSXConfig =>
typeof option === 'object' && option.runtime === 'automatic'
)

if (jsxRuntimeMode) {
Expand All @@ -42,15 +74,14 @@ export default {
if (node.name.name !== 'css') {
return
}
const importSource =
(jsxRuntimeMode || {}).importSource || '@emotion/react'
let jsxImportSourcePragmaNode
const importSource = jsxRuntimeMode?.importSource || '@emotion/react'
let jsxImportSourcePragmaComment: TSESTree.Comment | null = null
let jsxImportSourceMatch
let validJsxImportSource = false
let sourceCode = context.getSourceCode()
let pragma = sourceCode.getAllComments().find(node => {
if (JSX_IMPORT_SOURCE_REGEX.test(node.value)) {
jsxImportSourcePragmaNode = node
let pragma = sourceCode.getAllComments().find(comment => {
if (JSX_IMPORT_SOURCE_REGEX.test(comment.value)) {
jsxImportSourcePragmaComment = comment
return true
}
})
Expand All @@ -65,21 +96,30 @@ export default {
if (!jsxImportSourceMatch) {
context.report({
node,
message: `The css prop can only be used if jsxImportSource is set to ${importSource}`,
messageId: 'cssProp',
data: { importSource },
fix(fixer) {
return fixer.insertTextBefore(
sourceCode.ast.body[0],
`/** @jsxImportSource ${importSource} */\n`
)
}
})
} else if (!validJsxImportSource && jsxImportSourcePragmaNode) {
} else if (!validJsxImportSource && jsxImportSourcePragmaComment) {
context.report({
node,
message: `The css prop can only be used if jsxImportSource is set to ${importSource}`,
messageId: 'cssProp',
data: { importSource },
fix(fixer) {
/* istanbul ignore if */
if (jsxImportSourcePragmaComment === null) {
throw new Error(
`Unexpected null when attempting to fix ${context.getFilename()} - please file a github issue at ${REPO_URL}`
)
}

return fixer.replaceText(
jsxImportSourcePragmaNode,
jsxImportSourcePragmaComment,
`/** @jsxImportSource ${importSource} */`
)
}
Expand All @@ -95,26 +135,28 @@ export default {
return
}
let hasJsxImport = false
let emotionCoreNode = null
let local = null
let emotionCoreNode = null as TSESTree.ImportDeclaration | null
let local: string | null = null
let sourceCode = context.getSourceCode()
sourceCode.ast.body.forEach(x => {
if (
x.type === 'ImportDeclaration' &&
x.type === AST_NODE_TYPES.ImportDeclaration &&
(x.source.value === '@emotion/react' ||
x.source.value === '@emotion/core')
) {
emotionCoreNode = x

if (
x.specifiers.length === 1 &&
x.specifiers[0].type === 'ImportNamespaceSpecifier'
x.specifiers[0].type === AST_NODE_TYPES.ImportNamespaceSpecifier
) {
hasJsxImport = true
local = x.specifiers[0].local.name + '.jsx'
} else {
let jsxSpecifier = x.specifiers.find(
x => x.type === 'ImportSpecifier' && x.imported.name === 'jsx'
x =>
x.type === AST_NODE_TYPES.ImportSpecifier &&
x.imported.name === 'jsx'
)
if (jsxSpecifier) {
hasJsxImport = true
Expand All @@ -138,10 +180,16 @@ export default {
if (!hasJsxImport || !hasSetPragma) {
context.report({
node,
message:
'The css prop can only be used if jsx from @emotion/react is imported and it is set as the jsx pragma',
messageId: 'cssPropWithPragma',
fix(fixer) {
if (hasJsxImport) {
/* istanbul ignore if */
if (emotionCoreNode === null) {
throw new Error(
`Unexpected null when attempting to fix ${context.getFilename()} - please file a github issue at ${REPO_URL}`
)
}

return fixer.insertTextBefore(
emotionCoreNode,
`/** @jsx ${local} */\n`
Expand All @@ -154,7 +202,9 @@ export default {
emotionCoreNode.specifiers.length - 1
]

if (lastSpecifier.type === 'ImportDefaultSpecifier') {
if (
lastSpecifier.type === AST_NODE_TYPES.ImportDefaultSpecifier
) {
return fixer.insertTextAfter(lastSpecifier, ', { jsx }')
}

Expand All @@ -174,43 +224,53 @@ export default {
})
return
}

/* istanbul ignore if */
if (emotionCoreNode === null) {
throw new Error(
`Unexpected null when attempting to fix ${context.getFilename()} - please file a github issue at ${REPO_URL}`
)
}

const { specifiers } = emotionCoreNode
const { value } = node

if (
node.value.type === 'JSXExpressionContainer' &&
node.value.expression.type === 'TemplateLiteral'
value &&
value.type === AST_NODE_TYPES.JSXExpressionContainer &&
value.expression.type === AST_NODE_TYPES.TemplateLiteral
) {
let cssSpecifier = emotionCoreNode.specifiers.find(
x => x.imported.name === 'css'
let cssSpecifier = specifiers.find(
x =>
x.type === AST_NODE_TYPES.ImportSpecifier &&
x.imported.name === 'css'
)
context.report({
node,
message:
'Template literals should be replaced with tagged template literals using `css` when using the css prop',
messageId: 'templateLiterals',
fix(fixer) {
if (cssSpecifier) {
return fixer.insertTextBefore(
node.value.expression,
value.expression,
cssSpecifier.local.name
)
}
let lastSpecifier =
emotionCoreNode.specifiers[
emotionCoreNode.specifiers.length - 1
]
let lastSpecifier = specifiers[specifiers.length - 1]

if (context.getScope().variables.some(x => x.name === 'css')) {
return [
fixer.insertTextAfter(lastSpecifier, `, css as _css`),
fixer.insertTextBefore(node.value.expression, '_css')
fixer.insertTextBefore(value.expression, '_css')
]
}
return [
fixer.insertTextAfter(lastSpecifier, `, css`),
fixer.insertTextBefore(node.value.expression, 'css')
fixer.insertTextBefore(value.expression, 'css')
]
}
})
}
}
}
}
}
})
Loading

0 comments on commit 304f7e3

Please sign in to comment.