mirror of
https://github.com/reactjs/react.dev.git
synced 2026-02-21 19:31:57 +00:00
Add local eslint rule to validate markdown codeblocks with React Compiler (#7988)
In https://github.com/facebook/react/pull/34462 for example, we found an issue where the compiler was incorrectly validating an example straight from the docs. In order to find more issues like this + also provide more feedback to doc authors on valid/invalid patterns, this PR adds a new local eslint rule which validates all markdown codeblocks containing components/hooks with React Compiler. An autofixer is also provided. To express that a codeblock has an expected error, we can use the following metadata: ```ts // pseudo type def type MarkdownCodeBlockMetadata = { expectedErrors?: { 'react-compiler'?: number[]; }; }; ``` and can be used like so: ```` ```js {expectedErrors: {'react-compiler': [4]}} // ❌ setState directly in render function Component({value}) { const [count, setCount] = useState(0); setCount(value); // error on L4 return <div>{count}</div>; } ``` ```` Because this is defined as a local rule, we don't have the same granular reporting that `eslint-plugin-react-hooks` yet. I can look into that later but for now this first PR just sets us up with something basic.
This commit is contained in:
24
.eslintrc
24
.eslintrc
@@ -2,18 +2,36 @@
|
||||
"root": true,
|
||||
"extends": "next/core-web-vitals",
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint", "eslint-plugin-react-compiler"],
|
||||
"plugins": ["@typescript-eslint", "eslint-plugin-react-compiler", "local-rules"],
|
||||
"rules": {
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", {"varsIgnorePattern": "^_"}],
|
||||
"react-hooks/exhaustive-deps": "error",
|
||||
"react/no-unknown-property": ["error", {"ignore": ["meta"]}],
|
||||
"react-compiler/react-compiler": "error"
|
||||
"react-compiler/react-compiler": "error",
|
||||
"local-rules/lint-markdown-code-blocks": "error"
|
||||
},
|
||||
"env": {
|
||||
"node": true,
|
||||
"commonjs": true,
|
||||
"browser": true,
|
||||
"es6": true
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["src/content/**/*.md"],
|
||||
"parser": "./eslint-local-rules/parser",
|
||||
"parserOptions": {
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"react-hooks/exhaustive-deps": "off",
|
||||
"react/no-unknown-property": "off",
|
||||
"react-compiler/react-compiler": "off",
|
||||
"local-rules/lint-markdown-code-blocks": "error"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
3
.github/workflows/site_lint.yml
vendored
3
.github/workflows/site_lint.yml
vendored
@@ -32,6 +32,9 @@ jobs:
|
||||
|
||||
- name: Install deps
|
||||
run: yarn install --frozen-lockfile
|
||||
- name: Install deps (eslint-local-rules)
|
||||
run: yarn install --frozen-lockfile
|
||||
working-directory: eslint-local-rules
|
||||
|
||||
- name: Lint codebase
|
||||
run: yarn ci-check
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,7 +1,7 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
```jsx
|
||||
import {useState} from 'react';
|
||||
function Counter() {
|
||||
const [count, setCount] = useState(0);
|
||||
setCount(count + 1);
|
||||
return <div>{count}</div>;
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,8 @@
|
||||
```jsx title="Counter" {expectedErrors: {'react-compiler': [99]}} {expectedErrors: {'react-compiler': [2]}}
|
||||
import {useState} from 'react';
|
||||
function Counter() {
|
||||
const [count, setCount] = useState(0);
|
||||
setCount(count + 1);
|
||||
return <div>{count}</div>;
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,8 @@
|
||||
```jsx {expectedErrors: {'react-compiler': 'invalid'}}
|
||||
import {useState} from 'react';
|
||||
function Counter() {
|
||||
const [count, setCount] = useState(0);
|
||||
setCount(count + 1);
|
||||
return <div>{count}</div>;
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,7 @@
|
||||
```bash
|
||||
setCount()
|
||||
```
|
||||
|
||||
```txt
|
||||
import {useState} from 'react';
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
```jsx {expectedErrors: {'react-compiler': [3]}}
|
||||
function Hello() {
|
||||
return <h1>Hello</h1>;
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,8 @@
|
||||
```jsx {expectedErrors: {'react-compiler': [4]}}
|
||||
import {useState} from 'react';
|
||||
function Counter() {
|
||||
const [count, setCount] = useState(0);
|
||||
setCount(count + 1);
|
||||
return <div>{count}</div>;
|
||||
}
|
||||
```
|
||||
131
eslint-local-rules/__tests__/lint-markdown-code-blocks.test.js
Normal file
131
eslint-local-rules/__tests__/lint-markdown-code-blocks.test.js
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const {ESLint} = require('eslint');
|
||||
const plugin = require('..');
|
||||
|
||||
const FIXTURES_DIR = path.join(
|
||||
__dirname,
|
||||
'fixtures',
|
||||
'src',
|
||||
'content'
|
||||
);
|
||||
const PARSER_PATH = path.join(__dirname, '..', 'parser.js');
|
||||
|
||||
function createESLint({fix = false} = {}) {
|
||||
return new ESLint({
|
||||
useEslintrc: false,
|
||||
fix,
|
||||
plugins: {
|
||||
'local-rules': plugin,
|
||||
},
|
||||
overrideConfig: {
|
||||
parser: PARSER_PATH,
|
||||
plugins: ['local-rules'],
|
||||
rules: {
|
||||
'local-rules/lint-markdown-code-blocks': 'error',
|
||||
},
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function readFixture(name) {
|
||||
return fs.readFileSync(path.join(FIXTURES_DIR, name), 'utf8');
|
||||
}
|
||||
|
||||
async function lintFixture(name, {fix = false} = {}) {
|
||||
const eslint = createESLint({fix});
|
||||
const filePath = path.join(FIXTURES_DIR, name);
|
||||
const markdown = readFixture(name);
|
||||
const [result] = await eslint.lintText(markdown, {filePath});
|
||||
return result;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const basicResult = await lintFixture('basic-error.md');
|
||||
assert.strictEqual(
|
||||
basicResult.messages.length,
|
||||
1,
|
||||
'expected one diagnostic'
|
||||
);
|
||||
assert(
|
||||
basicResult.messages[0].message.includes('Calling setState during render'),
|
||||
'expected message to mention setState during render'
|
||||
);
|
||||
|
||||
const suppressedResult = await lintFixture('suppressed-error.md');
|
||||
assert.strictEqual(
|
||||
suppressedResult.messages.length,
|
||||
0,
|
||||
'expected suppression metadata to silence diagnostic'
|
||||
);
|
||||
|
||||
const staleResult = await lintFixture('stale-expected-error.md');
|
||||
assert.strictEqual(
|
||||
staleResult.messages.length,
|
||||
1,
|
||||
'expected stale metadata error'
|
||||
);
|
||||
assert.strictEqual(
|
||||
staleResult.messages[0].message,
|
||||
'React Compiler expected error on line 3 was not triggered'
|
||||
);
|
||||
|
||||
const duplicateResult = await lintFixture('duplicate-metadata.md');
|
||||
assert.strictEqual(
|
||||
duplicateResult.messages.length,
|
||||
2,
|
||||
'expected duplicate metadata to surface compiler diagnostic and stale metadata notice'
|
||||
);
|
||||
const duplicateFixed = await lintFixture('duplicate-metadata.md', {
|
||||
fix: true,
|
||||
});
|
||||
assert(
|
||||
duplicateFixed.output.includes(
|
||||
"{expectedErrors: {'react-compiler': [4]}}"
|
||||
),
|
||||
'expected duplicates to be rewritten to a single canonical block'
|
||||
);
|
||||
assert(
|
||||
!duplicateFixed.output.includes('[99]'),
|
||||
'expected stale line numbers to be removed from metadata'
|
||||
);
|
||||
|
||||
const mixedLanguageResult = await lintFixture('mixed-language.md');
|
||||
assert.strictEqual(
|
||||
mixedLanguageResult.messages.length,
|
||||
0,
|
||||
'expected non-js code fences to be ignored'
|
||||
);
|
||||
|
||||
const malformedResult = await lintFixture('malformed-metadata.md');
|
||||
assert.strictEqual(
|
||||
malformedResult.messages.length,
|
||||
1,
|
||||
'expected malformed metadata to fall back to compiler diagnostics'
|
||||
);
|
||||
const malformedFixed = await lintFixture('malformed-metadata.md', {
|
||||
fix: true,
|
||||
});
|
||||
assert(
|
||||
malformedFixed.output.includes(
|
||||
"{expectedErrors: {'react-compiler': [4]}}"
|
||||
),
|
||||
'expected malformed metadata to be replaced with canonical form'
|
||||
);
|
||||
}
|
||||
|
||||
run().catch(error => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
14
eslint-local-rules/index.js
Normal file
14
eslint-local-rules/index.js
Normal file
@@ -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.
|
||||
*/
|
||||
|
||||
const lintMarkdownCodeBlocks = require('./rules/lint-markdown-code-blocks');
|
||||
|
||||
module.exports = {
|
||||
rules: {
|
||||
'lint-markdown-code-blocks': lintMarkdownCodeBlocks,
|
||||
},
|
||||
};
|
||||
12
eslint-local-rules/package.json
Normal file
12
eslint-local-rules/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "eslint-plugin-local-rules",
|
||||
"version": "0.0.0",
|
||||
"main": "index.js",
|
||||
"private": "true",
|
||||
"scripts": {
|
||||
"test": "node __tests__/lint-markdown-code-blocks.test.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint-mdx": "^2"
|
||||
}
|
||||
}
|
||||
8
eslint-local-rules/parser.js
Normal file
8
eslint-local-rules/parser.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
module.exports = require('eslint-mdx');
|
||||
77
eslint-local-rules/rules/diagnostics.js
Normal file
77
eslint-local-rules/rules/diagnostics.js
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
function getRelativeLine(loc) {
|
||||
return loc?.start?.line ?? loc?.line ?? 1;
|
||||
}
|
||||
|
||||
function getRelativeColumn(loc) {
|
||||
return loc?.start?.column ?? loc?.column ?? 0;
|
||||
}
|
||||
|
||||
function getRelativeEndLine(loc, fallbackLine) {
|
||||
if (loc?.end?.line != null) {
|
||||
return loc.end.line;
|
||||
}
|
||||
if (loc?.line != null) {
|
||||
return loc.line;
|
||||
}
|
||||
return fallbackLine;
|
||||
}
|
||||
|
||||
function getRelativeEndColumn(loc, fallbackColumn) {
|
||||
if (loc?.end?.column != null) {
|
||||
return loc.end.column;
|
||||
}
|
||||
if (loc?.column != null) {
|
||||
return loc.column;
|
||||
}
|
||||
return fallbackColumn;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('./markdown').MarkdownCodeBlock} block
|
||||
* @param {Array<{detail: any, loc: any, message: string}>} diagnostics
|
||||
* @returns {Array<{detail: any, message: string, relativeStartLine: number, markdownLoc: {start: {line: number, column: number}, end: {line: number, column: number}}}>}
|
||||
*/
|
||||
function normalizeDiagnostics(block, diagnostics) {
|
||||
return diagnostics.map(({detail, loc, message}) => {
|
||||
const relativeStartLine = Math.max(getRelativeLine(loc), 1);
|
||||
const relativeStartColumn = Math.max(getRelativeColumn(loc), 0);
|
||||
const relativeEndLine = Math.max(
|
||||
getRelativeEndLine(loc, relativeStartLine),
|
||||
relativeStartLine
|
||||
);
|
||||
const relativeEndColumn = Math.max(
|
||||
getRelativeEndColumn(loc, relativeStartColumn),
|
||||
relativeStartColumn
|
||||
);
|
||||
|
||||
const markdownStartLine = block.codeStartLine + relativeStartLine - 1;
|
||||
const markdownEndLine = block.codeStartLine + relativeEndLine - 1;
|
||||
|
||||
return {
|
||||
detail,
|
||||
message,
|
||||
relativeStartLine,
|
||||
markdownLoc: {
|
||||
start: {
|
||||
line: markdownStartLine,
|
||||
column: relativeStartColumn,
|
||||
},
|
||||
end: {
|
||||
line: markdownEndLine,
|
||||
column: relativeEndColumn,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeDiagnostics,
|
||||
};
|
||||
178
eslint-local-rules/rules/lint-markdown-code-blocks.js
Normal file
178
eslint-local-rules/rules/lint-markdown-code-blocks.js
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const {
|
||||
buildFenceLine,
|
||||
getCompilerExpectedLines,
|
||||
getSortedUniqueNumbers,
|
||||
hasCompilerEntry,
|
||||
metadataEquals,
|
||||
metadataHasExpectedErrorsToken,
|
||||
removeCompilerExpectedLines,
|
||||
setCompilerExpectedLines,
|
||||
} = require('./metadata');
|
||||
const {normalizeDiagnostics} = require('./diagnostics');
|
||||
const {parseMarkdownFile} = require('./markdown');
|
||||
const {runReactCompiler} = require('./react-compiler');
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Run React Compiler on markdown code blocks',
|
||||
category: 'Possible Errors',
|
||||
},
|
||||
fixable: 'code',
|
||||
hasSuggestions: true,
|
||||
schema: [],
|
||||
},
|
||||
|
||||
create(context) {
|
||||
return {
|
||||
Program(node) {
|
||||
const filename = context.getFilename();
|
||||
if (!filename.endsWith('.md') || !filename.includes('src/content')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceCode = context.getSourceCode();
|
||||
const {blocks} = parseMarkdownFile(sourceCode.text, filename);
|
||||
// For each supported code block, run the compiler and reconcile metadata.
|
||||
for (const block of blocks) {
|
||||
const compilerResult = runReactCompiler(
|
||||
block.code,
|
||||
`${filename}#codeblock`
|
||||
);
|
||||
|
||||
const expectedLines = getCompilerExpectedLines(block.metadata);
|
||||
const expectedLineSet = new Set(expectedLines);
|
||||
const diagnostics = normalizeDiagnostics(
|
||||
block,
|
||||
compilerResult.diagnostics
|
||||
);
|
||||
|
||||
const errorLines = new Set();
|
||||
const unexpectedDiagnostics = [];
|
||||
|
||||
for (const diagnostic of diagnostics) {
|
||||
const line = diagnostic.relativeStartLine;
|
||||
errorLines.add(line);
|
||||
if (!expectedLineSet.has(line)) {
|
||||
unexpectedDiagnostics.push(diagnostic);
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedErrorLines = getSortedUniqueNumbers(
|
||||
Array.from(errorLines)
|
||||
);
|
||||
const missingExpectedLines = expectedLines.filter(
|
||||
(line) => !errorLines.has(line)
|
||||
);
|
||||
|
||||
const desiredMetadata = normalizedErrorLines.length
|
||||
? setCompilerExpectedLines(block.metadata, normalizedErrorLines)
|
||||
: removeCompilerExpectedLines(block.metadata);
|
||||
|
||||
// Compute canonical metadata and attach an autofix when it differs.
|
||||
const metadataChanged = !metadataEquals(
|
||||
block.metadata,
|
||||
desiredMetadata
|
||||
);
|
||||
const replacementLine = buildFenceLine(block.lang, desiredMetadata);
|
||||
const replacementDiffers = block.fence.rawText !== replacementLine;
|
||||
const applyReplacementFix = replacementDiffers
|
||||
? (fixer) =>
|
||||
fixer.replaceTextRange(block.fence.range, replacementLine)
|
||||
: null;
|
||||
|
||||
const hasDuplicateMetadata =
|
||||
block.metadata.hadDuplicateExpectedErrors;
|
||||
const hasExpectedErrorsMetadata = metadataHasExpectedErrorsToken(
|
||||
block.metadata
|
||||
);
|
||||
|
||||
const shouldFixUnexpected =
|
||||
Boolean(applyReplacementFix) &&
|
||||
normalizedErrorLines.length > 0 &&
|
||||
(metadataChanged ||
|
||||
hasDuplicateMetadata ||
|
||||
!hasExpectedErrorsMetadata);
|
||||
|
||||
let fixAlreadyAttached = false;
|
||||
|
||||
for (const diagnostic of unexpectedDiagnostics) {
|
||||
const reportData = {
|
||||
node,
|
||||
loc: diagnostic.markdownLoc,
|
||||
message: diagnostic.message,
|
||||
};
|
||||
|
||||
if (
|
||||
shouldFixUnexpected &&
|
||||
applyReplacementFix &&
|
||||
!fixAlreadyAttached
|
||||
) {
|
||||
reportData.fix = applyReplacementFix;
|
||||
reportData.suggest = [
|
||||
{
|
||||
desc: 'Add expectedErrors metadata to suppress these errors',
|
||||
fix: applyReplacementFix,
|
||||
},
|
||||
];
|
||||
fixAlreadyAttached = true;
|
||||
}
|
||||
|
||||
context.report(reportData);
|
||||
}
|
||||
|
||||
// Assert that expectedErrors is actually needed
|
||||
if (
|
||||
Boolean(applyReplacementFix) &&
|
||||
missingExpectedLines.length > 0 &&
|
||||
hasCompilerEntry(block.metadata)
|
||||
) {
|
||||
const plural = missingExpectedLines.length > 1;
|
||||
const message = plural
|
||||
? `React Compiler expected errors on lines ${missingExpectedLines.join(
|
||||
', '
|
||||
)} were not triggered`
|
||||
: `React Compiler expected error on line ${missingExpectedLines[0]} was not triggered`;
|
||||
|
||||
const reportData = {
|
||||
node,
|
||||
loc: {
|
||||
start: {
|
||||
line: block.position.start.line,
|
||||
column: 0,
|
||||
},
|
||||
end: {
|
||||
line: block.position.start.line,
|
||||
column: block.fence.rawText.length,
|
||||
},
|
||||
},
|
||||
message,
|
||||
};
|
||||
|
||||
if (!fixAlreadyAttached && applyReplacementFix) {
|
||||
reportData.fix = applyReplacementFix;
|
||||
fixAlreadyAttached = true;
|
||||
} else if (applyReplacementFix) {
|
||||
reportData.suggest = [
|
||||
{
|
||||
desc: 'Remove stale expectedErrors metadata',
|
||||
fix: applyReplacementFix,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
context.report(reportData);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
124
eslint-local-rules/rules/markdown.js
Normal file
124
eslint-local-rules/rules/markdown.js
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const remark = require('remark');
|
||||
const {parseFenceMetadata} = require('./metadata');
|
||||
|
||||
/**
|
||||
* @typedef {Object} MarkdownCodeBlock
|
||||
* @property {string} code
|
||||
* @property {number} codeStartLine
|
||||
* @property {{start: {line: number, column: number}, end: {line: number, column: number}}} position
|
||||
* @property {{lineIndex: number, rawText: string, metaText: string, range: [number, number]}} fence
|
||||
* @property {string} filePath
|
||||
* @property {string} lang
|
||||
* @property {import('./metadata').FenceMetadata} metadata
|
||||
*/
|
||||
|
||||
const SUPPORTED_LANGUAGES = new Set([
|
||||
'js',
|
||||
'jsx',
|
||||
'javascript',
|
||||
'ts',
|
||||
'tsx',
|
||||
'typescript',
|
||||
]);
|
||||
|
||||
function computeLineOffsets(lines) {
|
||||
const offsets = [];
|
||||
let currentOffset = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
offsets.push(currentOffset);
|
||||
currentOffset += line.length + 1;
|
||||
}
|
||||
|
||||
return offsets;
|
||||
}
|
||||
|
||||
function parseMarkdownFile(content, filePath) {
|
||||
const tree = remark().parse(content);
|
||||
const lines = content.split('\n');
|
||||
const lineOffsets = computeLineOffsets(lines);
|
||||
const blocks = [];
|
||||
|
||||
function traverse(node) {
|
||||
if (!node || typeof node !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.type === 'code') {
|
||||
const rawLang = node.lang || '';
|
||||
const normalizedLang = rawLang.toLowerCase();
|
||||
if (!normalizedLang || !SUPPORTED_LANGUAGES.has(normalizedLang)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fenceLineIndex = (node.position?.start?.line ?? 1) - 1;
|
||||
const fenceStartOffset = node.position?.start?.offset ?? 0;
|
||||
const fenceLine = lines[fenceLineIndex] ?? '';
|
||||
const fenceEndOffset = fenceStartOffset + fenceLine.length;
|
||||
|
||||
let metaText = '';
|
||||
if (fenceLine) {
|
||||
const prefixMatch = fenceLine.match(/^`{3,}\s*/);
|
||||
const prefixLength = prefixMatch ? prefixMatch[0].length : 3;
|
||||
metaText = fenceLine.slice(prefixLength + rawLang.length);
|
||||
} else if (node.meta) {
|
||||
metaText = ` ${node.meta}`;
|
||||
}
|
||||
|
||||
const metadata = parseFenceMetadata(metaText);
|
||||
|
||||
blocks.push({
|
||||
lang: rawLang || normalizedLang,
|
||||
metadata,
|
||||
filePath,
|
||||
code: node.value || '',
|
||||
codeStartLine: (node.position?.start?.line ?? 1) + 1,
|
||||
position: {
|
||||
start: {
|
||||
line: fenceLineIndex + 1,
|
||||
column: (node.position?.start?.column ?? 1) - 1,
|
||||
},
|
||||
end: {
|
||||
line: fenceLineIndex + 1,
|
||||
column: (node.position?.start?.column ?? 1) - 1 + fenceLine.length,
|
||||
},
|
||||
},
|
||||
fence: {
|
||||
lineIndex: fenceLineIndex,
|
||||
rawText: fenceLine,
|
||||
metaText,
|
||||
range: [fenceStartOffset, fenceEndOffset],
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if ('children' in node && Array.isArray(node.children)) {
|
||||
for (const child of node.children) {
|
||||
traverse(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverse(tree);
|
||||
|
||||
return {
|
||||
content,
|
||||
blocks,
|
||||
lines,
|
||||
lineOffsets,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SUPPORTED_LANGUAGES,
|
||||
computeLineOffsets,
|
||||
parseMarkdownFile,
|
||||
};
|
||||
377
eslint-local-rules/rules/metadata.js
Normal file
377
eslint-local-rules/rules/metadata.js
Normal file
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{type: 'text', raw: string}} TextToken
|
||||
* @typedef {{
|
||||
* type: 'expectedErrors',
|
||||
* entries: Record<string, Array<number>>,
|
||||
* raw?: string,
|
||||
* }} ExpectedErrorsToken
|
||||
* @typedef {TextToken | ExpectedErrorsToken} MetadataToken
|
||||
*
|
||||
* @typedef {{
|
||||
* leading: string,
|
||||
* trailing: string,
|
||||
* tokens: Array<MetadataToken>,
|
||||
* parseError: boolean,
|
||||
* hadDuplicateExpectedErrors: boolean,
|
||||
* }} FenceMetadata
|
||||
*/
|
||||
|
||||
const EXPECTED_ERRORS_BLOCK_REGEX = /\{\s*expectedErrors\s*:/;
|
||||
const REACT_COMPILER_KEY = 'react-compiler';
|
||||
|
||||
function getSortedUniqueNumbers(values) {
|
||||
return Array.from(new Set(values))
|
||||
.filter((value) => typeof value === 'number' && !Number.isNaN(value))
|
||||
.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function tokenizeMeta(body) {
|
||||
if (!body) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tokens = [];
|
||||
let current = '';
|
||||
let depth = 0;
|
||||
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const char = body[i];
|
||||
if (char === '{') {
|
||||
depth++;
|
||||
} else if (char === '}') {
|
||||
depth = Math.max(depth - 1, 0);
|
||||
}
|
||||
|
||||
if (char === ' ' && depth === 0) {
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
current = '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
current += char;
|
||||
}
|
||||
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function normalizeEntryValues(values) {
|
||||
if (!Array.isArray(values)) {
|
||||
return [];
|
||||
}
|
||||
return getSortedUniqueNumbers(values);
|
||||
}
|
||||
|
||||
function parseExpectedErrorsEntries(rawEntries) {
|
||||
const normalized = rawEntries
|
||||
.replace(/([{,]\s*)([a-zA-Z_$][\w$]*)\s*:/g, '$1"$2":')
|
||||
.replace(/'([^']*)'/g, '"$1"');
|
||||
|
||||
const parsed = JSON.parse(normalized);
|
||||
const entries = {};
|
||||
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
entries[key] = normalizeEntryValues(Array.isArray(value) ? value.flat() : value);
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function parseExpectedErrorsToken(tokenText) {
|
||||
const match = tokenText.match(/^\{\s*expectedErrors\s*:\s*(\{[\s\S]*\})\s*\}$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entriesSource = match[1];
|
||||
let parseError = false;
|
||||
let entries;
|
||||
|
||||
try {
|
||||
entries = parseExpectedErrorsEntries(entriesSource);
|
||||
} catch (error) {
|
||||
parseError = true;
|
||||
entries = {};
|
||||
}
|
||||
|
||||
return {
|
||||
token: {
|
||||
type: 'expectedErrors',
|
||||
entries,
|
||||
raw: tokenText,
|
||||
},
|
||||
parseError,
|
||||
};
|
||||
}
|
||||
|
||||
function parseFenceMetadata(metaText) {
|
||||
if (!metaText) {
|
||||
return {
|
||||
leading: '',
|
||||
trailing: '',
|
||||
tokens: [],
|
||||
parseError: false,
|
||||
hadDuplicateExpectedErrors: false,
|
||||
};
|
||||
}
|
||||
|
||||
const leading = metaText.match(/^\s*/)?.[0] ?? '';
|
||||
const trailing = metaText.match(/\s*$/)?.[0] ?? '';
|
||||
const bodyStart = leading.length;
|
||||
const bodyEnd = metaText.length - trailing.length;
|
||||
const body = metaText.slice(bodyStart, bodyEnd).trim();
|
||||
|
||||
if (!body) {
|
||||
return {
|
||||
leading,
|
||||
trailing,
|
||||
tokens: [],
|
||||
parseError: false,
|
||||
hadDuplicateExpectedErrors: false,
|
||||
};
|
||||
}
|
||||
|
||||
const tokens = [];
|
||||
let parseError = false;
|
||||
let sawExpectedErrors = false;
|
||||
let hadDuplicateExpectedErrors = false;
|
||||
|
||||
for (const rawToken of tokenizeMeta(body)) {
|
||||
const normalizedToken = rawToken.trim();
|
||||
if (!normalizedToken) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (EXPECTED_ERRORS_BLOCK_REGEX.test(normalizedToken)) {
|
||||
const parsed = parseExpectedErrorsToken(normalizedToken);
|
||||
if (parsed) {
|
||||
if (sawExpectedErrors) {
|
||||
hadDuplicateExpectedErrors = true;
|
||||
// Drop duplicates. We'll rebuild the canonical block on write.
|
||||
continue;
|
||||
}
|
||||
tokens.push(parsed.token);
|
||||
parseError = parseError || parsed.parseError;
|
||||
sawExpectedErrors = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
tokens.push({type: 'text', raw: normalizedToken});
|
||||
}
|
||||
|
||||
return {
|
||||
leading,
|
||||
trailing,
|
||||
tokens,
|
||||
parseError,
|
||||
hadDuplicateExpectedErrors,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneMetadata(metadata) {
|
||||
return {
|
||||
leading: metadata.leading,
|
||||
trailing: metadata.trailing,
|
||||
parseError: metadata.parseError,
|
||||
hadDuplicateExpectedErrors: metadata.hadDuplicateExpectedErrors,
|
||||
tokens: metadata.tokens.map((token) => {
|
||||
if (token.type === 'expectedErrors') {
|
||||
const clonedEntries = {};
|
||||
for (const [key, value] of Object.entries(token.entries)) {
|
||||
clonedEntries[key] = [...value];
|
||||
}
|
||||
return {type: 'expectedErrors', entries: clonedEntries};
|
||||
}
|
||||
return {type: 'text', raw: token.raw};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function findExpectedErrorsToken(metadata) {
|
||||
return metadata.tokens.find((token) => token.type === 'expectedErrors') || null;
|
||||
}
|
||||
|
||||
function getCompilerExpectedLines(metadata) {
|
||||
const token = findExpectedErrorsToken(metadata);
|
||||
if (!token) {
|
||||
return [];
|
||||
}
|
||||
return getSortedUniqueNumbers(token.entries[REACT_COMPILER_KEY] || []);
|
||||
}
|
||||
|
||||
function hasCompilerEntry(metadata) {
|
||||
const token = findExpectedErrorsToken(metadata);
|
||||
return Boolean(token && token.entries[REACT_COMPILER_KEY]?.length);
|
||||
}
|
||||
|
||||
function metadataHasExpectedErrorsToken(metadata) {
|
||||
return Boolean(findExpectedErrorsToken(metadata));
|
||||
}
|
||||
|
||||
function stringifyExpectedErrorsToken(token) {
|
||||
const entries = token.entries || {};
|
||||
const keys = Object.keys(entries).filter((key) => entries[key].length > 0);
|
||||
if (keys.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
keys.sort();
|
||||
|
||||
const segments = keys.map((key) => {
|
||||
const values = entries[key];
|
||||
return `'${key}': [${values.join(', ')}]`;
|
||||
});
|
||||
|
||||
return `{expectedErrors: {${segments.join(', ')}}}`;
|
||||
}
|
||||
|
||||
function stringifyFenceMetadata(metadata) {
|
||||
if (!metadata.tokens.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parts = metadata.tokens
|
||||
.map((token) => {
|
||||
if (token.type === 'expectedErrors') {
|
||||
return stringifyExpectedErrorsToken(token);
|
||||
}
|
||||
return token.raw;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (!parts.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const leading = metadata.leading || ' ';
|
||||
const trailing = metadata.trailing ? metadata.trailing.trimEnd() : '';
|
||||
const body = parts.join(' ');
|
||||
return `${leading}${body}${trailing}`;
|
||||
}
|
||||
|
||||
function buildFenceLine(lang, metadata) {
|
||||
const meta = stringifyFenceMetadata(metadata);
|
||||
return meta ? `\`\`\`${lang}${meta}` : `\`\`\`${lang}`;
|
||||
}
|
||||
|
||||
function metadataEquals(a, b) {
|
||||
if (a.leading !== b.leading || a.trailing !== b.trailing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (a.tokens.length !== b.tokens.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < a.tokens.length; i++) {
|
||||
const left = a.tokens[i];
|
||||
const right = b.tokens[i];
|
||||
if (left.type !== right.type) {
|
||||
return false;
|
||||
}
|
||||
if (left.type === 'text') {
|
||||
if (left.raw !== right.raw) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
const leftKeys = Object.keys(left.entries).sort();
|
||||
const rightKeys = Object.keys(right.entries).sort();
|
||||
if (leftKeys.length !== rightKeys.length) {
|
||||
return false;
|
||||
}
|
||||
for (let j = 0; j < leftKeys.length; j++) {
|
||||
if (leftKeys[j] !== rightKeys[j]) {
|
||||
return false;
|
||||
}
|
||||
const lValues = getSortedUniqueNumbers(left.entries[leftKeys[j]]);
|
||||
const rValues = getSortedUniqueNumbers(right.entries[rightKeys[j]]);
|
||||
if (lValues.length !== rValues.length) {
|
||||
return false;
|
||||
}
|
||||
for (let k = 0; k < lValues.length; k++) {
|
||||
if (lValues[k] !== rValues[k]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizeMetadata(metadata) {
|
||||
const normalized = cloneMetadata(metadata);
|
||||
normalized.hadDuplicateExpectedErrors = false;
|
||||
normalized.parseError = false;
|
||||
if (!normalized.tokens.length) {
|
||||
normalized.leading = '';
|
||||
normalized.trailing = '';
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function setCompilerExpectedLines(metadata, lines) {
|
||||
const normalizedLines = getSortedUniqueNumbers(lines);
|
||||
if (normalizedLines.length === 0) {
|
||||
return removeCompilerExpectedLines(metadata);
|
||||
}
|
||||
|
||||
const next = cloneMetadata(metadata);
|
||||
let token = findExpectedErrorsToken(next);
|
||||
if (!token) {
|
||||
token = {type: 'expectedErrors', entries: {}};
|
||||
next.tokens = [token, ...next.tokens];
|
||||
}
|
||||
|
||||
token.entries[REACT_COMPILER_KEY] = normalizedLines;
|
||||
return normalizeMetadata(next);
|
||||
}
|
||||
|
||||
function removeCompilerExpectedLines(metadata) {
|
||||
const next = cloneMetadata(metadata);
|
||||
const token = findExpectedErrorsToken(next);
|
||||
if (!token) {
|
||||
return normalizeMetadata(next);
|
||||
}
|
||||
|
||||
delete token.entries[REACT_COMPILER_KEY];
|
||||
|
||||
const hasEntries = Object.values(token.entries).some(
|
||||
(value) => Array.isArray(value) && value.length > 0
|
||||
);
|
||||
|
||||
if (!hasEntries) {
|
||||
next.tokens = next.tokens.filter((item) => item !== token);
|
||||
}
|
||||
|
||||
return normalizeMetadata(next);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildFenceLine,
|
||||
getCompilerExpectedLines,
|
||||
getSortedUniqueNumbers,
|
||||
hasCompilerEntry,
|
||||
metadataEquals,
|
||||
metadataHasExpectedErrorsToken,
|
||||
parseFenceMetadata,
|
||||
removeCompilerExpectedLines,
|
||||
setCompilerExpectedLines,
|
||||
stringifyFenceMetadata,
|
||||
};
|
||||
122
eslint-local-rules/rules/react-compiler.js
vendored
Normal file
122
eslint-local-rules/rules/react-compiler.js
vendored
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const {transformFromAstSync} = require('@babel/core');
|
||||
const {parse: babelParse} = require('@babel/parser');
|
||||
const BabelPluginReactCompiler = require('babel-plugin-react-compiler').default;
|
||||
const {
|
||||
parsePluginOptions,
|
||||
validateEnvironmentConfig,
|
||||
} = require('babel-plugin-react-compiler');
|
||||
|
||||
const COMPILER_OPTIONS = {
|
||||
noEmit: true,
|
||||
panicThreshold: 'none',
|
||||
environment: validateEnvironmentConfig({
|
||||
validateRefAccessDuringRender: true,
|
||||
validateNoSetStateInRender: true,
|
||||
validateNoSetStateInEffects: true,
|
||||
validateNoJSXInTryStatements: true,
|
||||
validateNoImpureFunctionsInRender: true,
|
||||
validateStaticComponents: true,
|
||||
validateNoFreezingKnownMutableFunctions: true,
|
||||
validateNoVoidUseMemo: true,
|
||||
validateNoCapitalizedCalls: [],
|
||||
validateHooksUsage: true,
|
||||
validateNoDerivedComputationsInEffects: true,
|
||||
}),
|
||||
};
|
||||
|
||||
function hasRelevantCode(code) {
|
||||
const functionPattern = /^(export\s+)?(default\s+)?function\s+\w+/m;
|
||||
const arrowPattern =
|
||||
/^(export\s+)?(const|let|var)\s+\w+\s*=\s*(\([^)]*\)|\w+)\s*=>/m;
|
||||
const hasImports = /^import\s+/m.test(code);
|
||||
|
||||
return functionPattern.test(code) || arrowPattern.test(code) || hasImports;
|
||||
}
|
||||
|
||||
function runReactCompiler(code, filename) {
|
||||
const result = {
|
||||
sourceCode: code,
|
||||
events: [],
|
||||
};
|
||||
|
||||
if (!hasRelevantCode(code)) {
|
||||
return {...result, diagnostics: []};
|
||||
}
|
||||
|
||||
const options = parsePluginOptions({
|
||||
...COMPILER_OPTIONS,
|
||||
});
|
||||
|
||||
options.logger = {
|
||||
logEvent: (_, event) => {
|
||||
if (event.kind === 'CompileError') {
|
||||
const category = event.detail?.category;
|
||||
if (category === 'Todo' || category === 'Invariant') {
|
||||
return;
|
||||
}
|
||||
result.events.push(event);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const ast = babelParse(code, {
|
||||
sourceFilename: filename,
|
||||
sourceType: 'module',
|
||||
plugins: ['jsx', 'typescript'],
|
||||
});
|
||||
|
||||
transformFromAstSync(ast, code, {
|
||||
filename,
|
||||
highlightCode: false,
|
||||
retainLines: true,
|
||||
plugins: [[BabelPluginReactCompiler, options]],
|
||||
sourceType: 'module',
|
||||
configFile: false,
|
||||
babelrc: false,
|
||||
});
|
||||
} catch (error) {
|
||||
return {...result, diagnostics: []};
|
||||
}
|
||||
|
||||
const diagnostics = [];
|
||||
|
||||
for (const event of result.events) {
|
||||
if (event.kind !== 'CompileError') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const detail = event.detail;
|
||||
if (!detail) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const loc = typeof detail.primaryLocation === 'function'
|
||||
? detail.primaryLocation()
|
||||
: null;
|
||||
|
||||
if (loc == null || typeof loc === 'symbol') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const message = typeof detail.printErrorMessage === 'function'
|
||||
? detail.printErrorMessage(result.sourceCode, {eslint: true})
|
||||
: detail.description || 'Unknown React Compiler error';
|
||||
|
||||
diagnostics.push({detail, loc, message});
|
||||
}
|
||||
|
||||
return {...result, diagnostics};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hasRelevantCode,
|
||||
runReactCompiler,
|
||||
};
|
||||
1421
eslint-local-rules/yarn.lock
Normal file
1421
eslint-local-rules/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,8 +7,8 @@
|
||||
"analyze": "ANALYZE=true next build",
|
||||
"dev": "next-remote-watch ./src/content",
|
||||
"build": "next build && node --experimental-modules ./scripts/downloadFonts.mjs",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "next lint --fix",
|
||||
"lint": "next lint && eslint \"src/content/**/*.md\"",
|
||||
"lint:fix": "next lint --fix && eslint \"src/content/**/*.md\" --fix",
|
||||
"format:source": "prettier --config .prettierrc --write \"{plugins,src}/**/*.{js,ts,jsx,tsx,css}\"",
|
||||
"nit:source": "prettier --config .prettierrc --list-different \"{plugins,src}/**/*.{js,ts,jsx,tsx,css}\"",
|
||||
"prettier": "yarn format:source",
|
||||
@@ -22,7 +22,8 @@
|
||||
"check-all": "npm-run-all prettier lint:fix tsc rss",
|
||||
"rss": "node scripts/generateRss.js",
|
||||
"deadlinks": "node scripts/deadLinkChecker.js",
|
||||
"copyright": "node scripts/copyright.js"
|
||||
"copyright": "node scripts/copyright.js",
|
||||
"test:eslint-local-rules": "yarn --cwd eslint-local-rules test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codesandbox/sandpack-react": "2.13.5",
|
||||
@@ -70,6 +71,7 @@
|
||||
"eslint-plugin-flowtype": "4.x",
|
||||
"eslint-plugin-import": "2.x",
|
||||
"eslint-plugin-jsx-a11y": "6.x",
|
||||
"eslint-plugin-local-rules": "link:eslint-local-rules",
|
||||
"eslint-plugin-react": "7.x",
|
||||
"eslint-plugin-react-compiler": "^19.0.0-beta-e552027-20250112",
|
||||
"eslint-plugin-react-hooks": "^0.0.0-experimental-fabef7a6b-20221215",
|
||||
|
||||
@@ -3405,6 +3405,10 @@ eslint-plugin-jsx-a11y@^6.4.1:
|
||||
safe-regex-test "^1.0.3"
|
||||
string.prototype.includes "^2.0.0"
|
||||
|
||||
"eslint-plugin-local-rules@link:eslint-local-rules":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
eslint-plugin-react-compiler@^19.0.0-beta-e552027-20250112:
|
||||
version "19.0.0-beta-e552027-20250112"
|
||||
resolved "https://registry.yarnpkg.com/eslint-plugin-react-compiler/-/eslint-plugin-react-compiler-19.0.0-beta-e552027-20250112.tgz#f4ad9cebe47615ebf6097a8084a30d761ee164f4"
|
||||
|
||||
Reference in New Issue
Block a user