Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/eslint-plugin-react-pug/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
56 changes: 53 additions & 3 deletions packages/eslint-plugin-react-pug/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ interface EslintReactPugProcessorOptions {
classShorthandMerge?: ClassMergeOption;
startupjsCssxjs?: StartupjsCssxjsOption;
componentPathFromUppercaseClassShorthand?: boolean;
jsxInJsFiles?: 'auto' | 'always';
}

interface EslintLintMessage {
Expand Down Expand Up @@ -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<string, unknown>;
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);
Expand Down Expand Up @@ -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),
}];
},
Expand Down
29 changes: 29 additions & 0 deletions packages/eslint-plugin-react-pug/test/unit/processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <BreedPage breed='domestic' /> }\n",
'file.js',
);
expect(block).toMatchInlineSnapshot(`
{
"filename": "../../../pug-react.jsx",
"text": "import BreedPage from './-breed'

export default function Domestic () { return <BreedPage breed='domestic' /> }
",
}
`);
});

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 = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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<ReturnType<ESLint['lintText']>>): string {
const lines: string[] = [];
let totalErrors = 0;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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."`);
});
});
3 changes: 3 additions & 0 deletions test/fixtures/regression/domestic.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import BreedPage from './-breed'

export default function Domestic () { return <BreedPage breed='domestic' /> }