Skip to content

Commit

Permalink
feat: @lexical/eslint-plugin - add a rules-of-lexical linter to help …
Browse files Browse the repository at this point in the history
…with $function rules
  • Loading branch information
etrepum committed Apr 27, 2024
1 parent 9df0da1 commit 97d718f
Show file tree
Hide file tree
Showing 20 changed files with 1,178 additions and 10 deletions.
34 changes: 32 additions & 2 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
const restrictedGlobals = require('confusing-browser-globals');

const OFF = 0;
const WARN = 1;
const ERROR = 2;

module.exports = {
Expand Down Expand Up @@ -64,6 +65,7 @@ module.exports = {
'eslint:recommended',
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended',
'plugin:@lexical/all',
],
files: ['**/*.ts', '**/*.tsx'],
parser: '@typescript-eslint/parser',
Expand All @@ -72,15 +74,42 @@ module.exports = {
},
plugins: ['react', '@typescript-eslint', 'header'],
rules: {
'@lexical/rules-of-lexical': [
WARN,
/** @type import('./packages/lexical-eslint-plugin/src').RulesOfLexicalOptions */ ({
isDollarFunction: [
'^INTERNAL_$[a-z_]',
'(NodeTransform$)',
'^convert.*(Element|Node)',
'beginUpdate',
'commitPendingUpdates',
'createBinding',
'createChildrenArray',
'flushRootMutations',
'getNodeFromDOM',
'getNodeFromDOMNode',
'getOrInitCollabNodeFromSharedType',
'isSelectionCapturedInDecoratorInput',
'mergePrevious',
'parseEditorState',
'removeNode',
'syncLocalCursorPosition',
'trimTextContentFromAnchor',
],
isSafeDollarFunction: '$getRoot',
}),
],
'@typescript-eslint/ban-ts-comment': OFF,
'@typescript-eslint/no-this-alias': OFF,
'@typescript-eslint/no-unused-vars': [ERROR, {args: 'none'}],
'header/header': [2, 'scripts/www/headerTemplate.js'],
},
},
{
// These aren't compiled, but they're written in module JS
files: ['packages/lexical-playground/esm/*.mjs'],
files: [
// These aren't compiled, but they're written in module JS
'packages/lexical-playground/esm/*.mjs',
],
parserOptions: {
sourceType: 'module',
},
Expand Down Expand Up @@ -119,6 +148,7 @@ module.exports = {
'react',
'no-only-tests',
'lexical',
'@lexical',
],

// Stop ESLint from looking for a configuration file in parent folders
Expand Down
1 change: 1 addition & 0 deletions .flowconfig
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ module.name_mapper='^@lexical/clipboard$' -> '<PROJECT_ROOT>/packages/lexical-cl
module.name_mapper='^@lexical/code$' -> '<PROJECT_ROOT>/packages/lexical-code/flow/LexicalCode.js.flow'
module.name_mapper='^@lexical/devtools-core$' -> '<PROJECT_ROOT>/packages/lexical-devtools-core/flow/LexicalDevtoolsCore.js.flow'
module.name_mapper='^@lexical/dragon$' -> '<PROJECT_ROOT>/packages/lexical-dragon/flow/LexicalDragon.js.flow'
module.name_mapper='^@lexical/eslint-plugin$' -> '<PROJECT_ROOT>/packages/lexical-eslint-plugin/flow/LexicalEslintPlugin.js.flow'
module.name_mapper='^@lexical/file$' -> '<PROJECT_ROOT>/packages/lexical-file/flow/LexicalFile.js.flow'
module.name_mapper='^@lexical/hashtag$' -> '<PROJECT_ROOT>/packages/lexical-hashtag/flow/LexicalHashtag.js.flow'
module.name_mapper='^@lexical/headless$' -> '<PROJECT_ROOT>/packages/lexical-headless/flow/LexicalHeadless.js.flow'
Expand Down
2 changes: 1 addition & 1 deletion eslint-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@
"description": "ESLint plugin for lexical",
"main": "src/index.js",
"peerDependencies": {
"eslint": ">=4.19.1"
"eslint": "^7.31.0 || ^8.0.0"
}
}
36 changes: 29 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@
"@babel/preset-flow": "^7.14.5",
"@babel/preset-react": "^7.14.5",
"@babel/preset-typescript": "^7.16.7",
"@lexical/eslint-plugin": "file:./packages/lexical-eslint-plugin",
"@playwright/test": "^1.41.2",
"@rollup/plugin-alias": "^3.1.4",
"@rollup/plugin-babel": "^5.3.0",
Expand Down
1 change: 1 addition & 0 deletions packages/lexical-devtools/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@lexical/code": ["../lexical-code/src/index.ts"],
"@lexical/devtools-core": ["../lexical-devtools-core/src/index.ts"],
"@lexical/dragon": ["../lexical-dragon/src/index.ts"],
"@lexical/eslint-plugin": ["../lexical-eslint-plugin/src/index.ts"],
"@lexical/file": ["../lexical-file/src/index.ts"],
"@lexical/hashtag": ["../lexical-hashtag/src/index.ts"],
"@lexical/headless": ["../lexical-headless/src/index.ts"],
Expand Down
14 changes: 14 additions & 0 deletions packages/lexical-eslint-plugin/LexicalEslintPlugin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/

'use strict';
/**
* This file is here for bootstrapping reasons so we can use it without
* building anything
*/
module.exports = require('./src/LexicalEslintPlugin.js');
196 changes: 196 additions & 0 deletions packages/lexical-eslint-plugin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
# `@lexical/eslint-plugin`

This ESLint plugin enforces the [Lexical $function convention](https://lexical.dev/docs/intro#reading-and-updating-editor-state).

## Installation

Assuming you already have ESLint installed, run:

```sh
npm install @lexical/eslint-plugin --save-dev
```

Then extend the recommended eslint config:

```js
{
"extends": [
// ...
"plugin:@lexical/recommended"
]
}
```

### Custom Configuration

If you want more fine-grained configuration, you can instead add a snippet like this to your ESLint configuration file:

```js
{
"plugins": [
// ...
"@lexical"
],
"rules": {
// ...
"@lexical/rules-of-lexical": "error"
}
}
```

### Advanced configuration

Most of the heuristics in `@lexical/rules-of-lexical` can be extended with
additional terms or patterns.

The code example below is shown using the default implementations for each
option. When you configure these they are combined with the default
implementations using "OR", the default implementations can not be overridden.
These terms and patterns are only shown for reference and pasting this example
into your project is not useful.

If the string begins with a `"^"` or `"("` then it is treated as a RegExp,
otherwise it will be an exact match. A string may also be used instead
of an array of strings.

```js
{
"plugins": [
// ...
"@lexical"
],
"rules": {
// ...
"@lexical/rules-of-lexical": [
"error",
{
"isDollarFunction": ["^\\$[a-z_]"],
"isLexicalProvider": ["read", "registerCommand", "registerNodeTransform", "update"],
"isSafeDollarFunction": ["^\\$is"]
}
]
}
}
```

#### `isDollarFunction`

*Base case*: `/^\$[a-z_]/`

This defines the \$function convention, which by default is any function that
starts with a dollar sign followed by a lowercase latin letter. You may have a
secondary convention in your codebase, such as non-latin letters, or an
internal prefix that you want to consider (e.g. `"^INTERNAL_\\$"`).

#### `isLexicalProvider`

*Base case*: `/^(read|registerCommand|registerNodeTransform|update)$/`

These are functions that allow their function argument to use Lexical
\$functions.

#### `isSafeDollarFunction`

*Base case*: `/^\$is/`

These \$functions are considered safe to call from anywhere, generally
these functions are runtime type checks that do not depend on any other
state.

## Valid and Invalid Examples

### Valid Examples

\$functions may be called by other \$functions

```js
function $namedCorrectly() {
return $getRoot();
}
```

\$functions may be called in `editor.update` or `editorState.read`

```js
function validUsesEditorOrState(editor) {
editor.update(() => $getRoot());
editor.getLatestState().read(() => $getRoot());
}
```

\$functions may be called from class methods

```js
class CustomNode extends ElementNode {
appendText(string) {
this.appendChild($createTextNode(string));
}
}
```

### Invalid Examples

#### Rename autofix

```js
function invalidFunction() {
return $getRoot();
}
function $callsInvalidFunction() {
return invalidFunction();
}
```

*Autofix:* The function is renamed with a $ prefix. Any references to this
name in this module are also always renamed.

```js
function $invalidFunction() {
return $getRoot();
}
function $callsInvalidFunction() {
return $invalidFunction();
}
```

#### Rename & deprecate autofix

```js
export function exportedInvalidFunction() {
return $getRoot();
}
```

*Autofix:* The exported function is renamed with a $ prefix. The previous name
is also exported and marked deprecated, because automatic renaming of
references to that name is limited to the module's scope.

```js
export function $exportedInvalidFunction() {
return $getRoot();
}
/** @deprecated renamed to $exportedInvalidFunction by @lexical/eslint-plugin rules-of-lexical */
export const exportedInvalidFunction = $exportedInvalidFunction;
```

#### Rename scope conflict

```js
import {$getRoot} from 'lexical';
function InvalidComponent({editor}) {
const [editor] = useLexicalComposerContext();
const getRoot = useCallback(() => $getRoot(), []);
return (<button onClick={() => editor.update(() => getRoot())} />);
}
```

*Autofix:* The function is renamed with a $ prefix and _ suffix since the suggested name was already in scope.

```js
import {$getRoot} from 'lexical';
function InvalidComponent({editor}) {
const [editor] = useLexicalComposerContext();
const $getRoot_ = useCallback(() => $getRoot(), []);
return (<button onClick={() => editor.update(() => $getRoot_())} />);
}
```
Loading

0 comments on commit 97d718f

Please sign in to comment.