From f80d0ccdfe8bc79a6ea7fc46d795579f33235ae1 Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Sat, 14 Mar 2026 00:55:03 +0000 Subject: [PATCH] fix: handle jsx-in-js eslint processor files --- packages/eslint-plugin-react-pug/README.md | 5 ++ packages/eslint-plugin-react-pug/src/index.ts | 56 +++++++++++++- .../test/unit/processor.test.ts | 29 ++++++++ .../real-project-compiler-snapshots.test.ts | 74 ++++++++++++------- test/fixtures/regression/domestic.js | 3 + 5 files changed, 139 insertions(+), 28 deletions(-) create mode 100644 test/fixtures/regression/domestic.js diff --git a/packages/eslint-plugin-react-pug/README.md b/packages/eslint-plugin-react-pug/README.md index f00434b..0d06c80 100644 --- a/packages/eslint-plugin-react-pug/README.md +++ b/packages/eslint-plugin-react-pug/README.md @@ -32,6 +32,11 @@ Use `createReactPugProcessor(...)` when you need custom options: - `classShorthandMerge` - `startupjsCssxjs` - `componentPathFromUppercaseClassShorthand` +- `jsxInJsFiles` + +`jsxInJsFiles: 'always'` forces `.js` / `.mjs` / `.cjs` files onto the +processor's virtual `.jsx` lint path. Use this if your ESLint config already +treats JS files as JSX-capable and you want to skip JSX auto-detection. Used `pug` import bindings are removed from the processor's transformed view automatically. diff --git a/packages/eslint-plugin-react-pug/src/index.ts b/packages/eslint-plugin-react-pug/src/index.ts index 27ce955..6f92751 100644 --- a/packages/eslint-plugin-react-pug/src/index.ts +++ b/packages/eslint-plugin-react-pug/src/index.ts @@ -20,6 +20,7 @@ interface EslintReactPugProcessorOptions { classShorthandMerge?: ClassMergeOption; startupjsCssxjs?: StartupjsCssxjsOption; componentPathFromUppercaseClassShorthand?: boolean; + jsxInJsFiles?: 'auto' | 'always'; } interface EslintLintMessage { @@ -108,11 +109,48 @@ function isTypeScriptLikeFilename(filename: string): boolean { return /\.(?:ts|tsx|mts|cts)$/i.test(filename); } +function isJavaScriptLikeFilename(filename: string): boolean { + return /\.(?:js|jsx|mjs|cjs)$/i.test(filename); +} + function getVirtualLintFilename(filename: string): string { if (isTypeScriptLikeFilename(filename)) return '../../../pug-react.tsx'; return '../../../pug-react.jsx'; } +function astContainsJsx(node: unknown): boolean { + if (node == null) return false; + if (Array.isArray(node)) return node.some(astContainsJsx); + if (typeof node !== 'object') return false; + + const record = node as Record; + if (record.type === 'JSXElement' || record.type === 'JSXFragment') return true; + + for (const [key, value] of Object.entries(record)) { + if (key === 'loc' || key === 'start' || key === 'end' || key === 'extra') continue; + if (astContainsJsx(value)) return true; + } + + return false; +} + +function containsJsxSyntax(text: string, filename: string): boolean { + try { + const ast = parse(text, { + sourceType: 'module', + plugins: [ + 'jsx', + 'decorators-legacy', + ...(isTypeScriptLikeFilename(filename) ? ['typescript'] : []), + ] as any, + errorRecovery: false, + }) as any; + return astContainsJsx(ast.program); + } catch { + return false; + } +} + function getLineIndent(text: string, offset: number): string { const lineStart = text.lastIndexOf('\n', Math.max(0, offset - 1)) + 1; const lineText = text.slice(lineStart, text.indexOf('\n', lineStart) >= 0 ? text.indexOf('\n', lineStart) : text.length); @@ -465,11 +503,23 @@ function createReactPugProcessor( startupjsCssxjs: options.startupjsCssxjs ?? 'auto', componentPathFromUppercaseClassShorthand: options.componentPathFromUppercaseClassShorthand ?? true, }); - const formatted = formatLintCode(transformed, filename); + const hasTransformedPug = transformed.regions.length > 0; + const jsLikeFilename = isJavaScriptLikeFilename(filename); + const shouldAlwaysVirtualizeJs = ( + options.jsxInJsFiles === 'always' + && jsLikeFilename + && !isTypeScriptLikeFilename(filename) + ); + const shouldUseVirtualJsxFilename = ( + hasTransformedPug + || shouldAlwaysVirtualizeJs + || containsJsxSyntax(text, filename) + ); + const formatted = hasTransformedPug ? formatLintCode(transformed, filename) : null; cache.set(filename, { transformed, formatted }); - if (transformed.regions.length === 0) return [transformed.code]; + if (!shouldUseVirtualJsxFilename) return [transformed.code]; return [{ - text: formatted?.code ?? transformed.code, + text: hasTransformedPug ? (formatted?.code ?? transformed.code) : transformed.code, filename: getVirtualLintFilename(filename), }]; }, diff --git a/packages/eslint-plugin-react-pug/test/unit/processor.test.ts b/packages/eslint-plugin-react-pug/test/unit/processor.test.ts index af50c64..3ccc179 100644 --- a/packages/eslint-plugin-react-pug/test/unit/processor.test.ts +++ b/packages/eslint-plugin-react-pug/test/unit/processor.test.ts @@ -76,6 +76,35 @@ describe('eslint-plugin-react-pug processor', () => { `); }); + it('uses a JSX virtual filename for plain .js files that already contain JSX', () => { + const processor = createReactPugProcessor(); + const [block] = processor.preprocess( + "import BreedPage from './-breed'\n\nexport default function Domestic () { return }\n", + 'file.js', + ); + expect(block).toMatchInlineSnapshot(` + { + "filename": "../../../pug-react.jsx", + "text": "import BreedPage from './-breed' + + export default function Domestic () { return } + ", + } + `); + }); + + it('can always virtualize .js files to JSX when jsxInJsFiles is forced', () => { + const processor = createReactPugProcessor({ jsxInJsFiles: 'always' }); + const [block] = processor.preprocess('const answer = 42;\n', 'file.js'); + expect(block).toMatchInlineSnapshot(` + { + "filename": "../../../pug-react.jsx", + "text": "const answer = 42; + ", + } + `); + }); + it('preserves surrounding JS formatting while reformatting only pug output', () => { const processor = createReactPugProcessor(); const input = [ diff --git a/packages/react-pug-core/test/integration/real-project-compiler-snapshots.test.ts b/packages/react-pug-core/test/integration/real-project-compiler-snapshots.test.ts index cd642db..0ca7fd1 100644 --- a/packages/react-pug-core/test/integration/real-project-compiler-snapshots.test.ts +++ b/packages/react-pug-core/test/integration/real-project-compiler-snapshots.test.ts @@ -17,6 +17,7 @@ import { lineColumnToOffset } from '../../src/language/diagnosticMapping'; const THIS_FILE = fileURLToPath(import.meta.url); const REPO_ROOT = join(dirname(THIS_FILE), '../../../..'); const FIXTURES_DIR = join(REPO_ROOT, 'test/fixtures/real-project'); +const REGRESSION_FIXTURES_DIR = join(REPO_ROOT, 'test/fixtures/regression'); const SNAPSHOTS_DIR = join(FIXTURES_DIR, 'snapshots'); const FIXTURES = [ @@ -52,6 +53,42 @@ function readFixture(fileName: string): string { return readFileSync(fixturePath(fileName), 'utf8'); } +function regressionFixturePath(fileName: string): string { + return join(REGRESSION_FIXTURES_DIR, fileName); +} + +function readRegressionFixture(fileName: string): string { + return readFileSync(regressionFixturePath(fileName), 'utf8'); +} + +function createNeostandardEslint(): ESLint { + return new ESLint({ + cwd: REPO_ROOT, + ignore: false, + overrideConfigFile: true, + overrideConfig: [ + ...neostandard({ + ts: true, + }), + { + files: ['**/*.js', '**/*.mjs', '**/*.cjs'], + languageOptions: { + parserOptions: { + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + }, + { + plugins: { + 'react-pug': reactPugEslintPlugin as any, + }, + processor: 'react-pug/pug-react', + }, + ] as any, + }); +} + function formatEslintResults(results: Awaited>): string { const lines: string[] = []; let totalErrors = 0; @@ -172,31 +209,7 @@ function countMappingsInsidePugRegions( describe('real project fixtures compiler snapshots', () => { it('matches output snapshots for Babel, SWC, esbuild, ESLint preprocess, and shadow TSX', async () => { mkdirSync(SNAPSHOTS_DIR, { recursive: true }); - const eslintForNeostandard = new ESLint({ - cwd: REPO_ROOT, - ignore: false, - overrideConfigFile: true, - overrideConfig: [ - ...neostandard({ - ts: true, - }), - { - files: ['**/*.js', '**/*.mjs', '**/*.cjs'], - languageOptions: { - parserOptions: { - ecmaFeatures: { jsx: true }, - sourceType: 'module', - }, - }, - }, - { - plugins: { - 'react-pug': reactPugEslintPlugin as any, - }, - processor: 'react-pug/pug-react', - }, - ] as any, - }); + const eslintForNeostandard = createNeostandardEslint(); for (const fileName of FIXTURES) { const source = readFixture(fileName); @@ -328,4 +341,15 @@ describe('real project fixtures compiler snapshots', () => { ); } }); + + it('keeps JSX imports marked as used in plain .js files under the neostandard processor config', async () => { + const eslintForNeostandard = createNeostandardEslint(); + const fileName = 'domestic.js'; + const source = readRegressionFixture(fileName); + const results = await eslintForNeostandard.lintText(source, { + filePath: regressionFixturePath(fileName), + }); + + expect(formatEslintResults(results)).toMatchInlineSnapshot(`"Found 0 errors."`); + }); }); diff --git a/test/fixtures/regression/domestic.js b/test/fixtures/regression/domestic.js new file mode 100644 index 0000000..7b86dda --- /dev/null +++ b/test/fixtures/regression/domestic.js @@ -0,0 +1,3 @@ +import BreedPage from './-breed' + +export default function Domestic () { return }