From 120536e885b6e4700bf6ec49cdf8512400883c9a Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Mon, 6 Apr 2026 16:54:51 +0300 Subject: [PATCH 1/7] fix some eslint issues identified by linting startupjs-ui project --- package-lock.json | 4 +- packages/eslint-plugin-react-pug/package.json | 2 + packages/eslint-plugin-react-pug/src/index.ts | 307 +++++++++++++-- .../test/integration/autofix.test.ts | 19 + .../integration/fixture-diagnostics.test.ts | 23 ++ .../startupjs-ui-regressions.test.ts | 109 ++++++ .../syntaxes/pug-template-literal.json | 2 +- .../test/unit/grammar.test.ts | 94 +++++ .../src/StartupjsUiDialogsReadme.js.txt | 1 + .../src/StartupjsUiDraggableReadme.js.txt | 1 + .../src/StartupjsUiMdxComponents.js.txt | 1 + .../src/StartupjsUiTypeCell.js.txt | 1 + .../src/StartupjsUiWrapInput.tsx.txt | 1 + .../fixed/src/StartupjsUiDialogsReadme.js | 16 + .../fixed/src/StartupjsUiDraggableReadme.js | 18 + .../fixed/src/StartupjsUiMdxComponents.js | 320 ++++++++++++++++ .../fixed/src/StartupjsUiTypeCell.js | 27 ++ .../fixed/src/StartupjsUiWrapInput.tsx | 360 ++++++++++++++++++ .../src/StartupjsUiDialogsReadme.js | 16 + .../src/StartupjsUiDraggableReadme.js | 18 + .../src/StartupjsUiMdxComponents.js | 320 ++++++++++++++++ .../src/StartupjsUiTypeCell.js | 27 ++ .../src/StartupjsUiWrapInput.tsx | 360 ++++++++++++++++++ .../snapshots/eslint/CatCard.js.output.jsx | 82 +++- .../snapshots/eslint/CatCard.tsx.output.jsx | 82 +++- .../eslint/event-tabs-breed.js.output.jsx | 7 +- .../eslint/event-tabs-breed.tsx.output.jsx | 7 +- .../eslint/event-tabs-layout.js.output.jsx | 41 +- .../eslint/event-tabs-layout.tsx.output.jsx | 41 +- 29 files changed, 2249 insertions(+), 58 deletions(-) create mode 100644 packages/eslint-plugin-react-pug/test/integration/startupjs-ui-regressions.test.ts create mode 100644 test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiDialogsReadme.js.txt create mode 100644 test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiDraggableReadme.js.txt create mode 100644 test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiMdxComponents.js.txt create mode 100644 test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiTypeCell.js.txt create mode 100644 test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiWrapInput.tsx.txt create mode 100644 test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiDialogsReadme.js create mode 100644 test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiDraggableReadme.js create mode 100644 test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiMdxComponents.js create mode 100644 test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiTypeCell.js create mode 100644 test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiWrapInput.tsx create mode 100644 test/fixtures/example-unformatted/src/StartupjsUiDialogsReadme.js create mode 100644 test/fixtures/example-unformatted/src/StartupjsUiDraggableReadme.js create mode 100644 test/fixtures/example-unformatted/src/StartupjsUiMdxComponents.js create mode 100644 test/fixtures/example-unformatted/src/StartupjsUiTypeCell.js create mode 100644 test/fixtures/example-unformatted/src/StartupjsUiWrapInput.tsx diff --git a/package-lock.json b/package-lock.json index a7b23c5..081b700 100644 --- a/package-lock.json +++ b/package-lock.json @@ -96,7 +96,6 @@ "version": "7.29.1", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.0", @@ -12708,7 +12707,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -21303,7 +21301,9 @@ "name": "@react-pug/eslint-plugin-react-pug", "version": "0.1.12", "dependencies": { + "@babel/generator": "^7.28.3", "@babel/parser": "^7.0.0", + "@babel/types": "^7.28.2", "@prettier/sync": "^0.6.1", "@react-pug/react-pug-core": "^0.1.10", "@stylistic/eslint-plugin": "^5.10.0", diff --git a/packages/eslint-plugin-react-pug/package.json b/packages/eslint-plugin-react-pug/package.json index a5b9c09..0f036ca 100644 --- a/packages/eslint-plugin-react-pug/package.json +++ b/packages/eslint-plugin-react-pug/package.json @@ -11,7 +11,9 @@ "prepublishOnly": "npm run build" }, "dependencies": { + "@babel/generator": "^7.28.3", "@babel/parser": "^7.0.0", + "@babel/types": "^7.28.2", "@prettier/sync": "^0.6.1", "@react-pug/react-pug-core": "^0.1.10", "@stylistic/eslint-plugin": "^5.10.0", diff --git a/packages/eslint-plugin-react-pug/src/index.ts b/packages/eslint-plugin-react-pug/src/index.ts index 3d8af60..258a9d6 100644 --- a/packages/eslint-plugin-react-pug/src/index.ts +++ b/packages/eslint-plugin-react-pug/src/index.ts @@ -8,7 +8,9 @@ import { type StartupjsCssxjsOption, transformSourceFile, } from '@react-pug/react-pug-core'; -import { parse } from '@babel/parser'; +import generate from '@babel/generator'; +import { parse, parseExpression } from '@babel/parser'; +import * as t from '@babel/types'; import { Linter, SourceCode } from 'eslint'; import stylisticPlugin from '@stylistic/eslint-plugin'; import prettier from '@prettier/sync'; @@ -78,6 +80,7 @@ interface CachedLintState { transformed: SourceTransformState | null; formatted: FormattedLintCode | null; legacyStyleStatementRanges: OffsetRange[]; + providerBooleanValueRanges: OffsetRange[]; } interface EslintProcessorLike { @@ -217,11 +220,7 @@ function containsJsxSyntax(text: string, filename: string): boolean { try { const ast = parse(text, { sourceType: 'module', - plugins: [ - 'jsx', - 'decorators-legacy', - ...(isTypeScriptLikeFilename(filename) ? ['typescript'] : []), - ] as any, + plugins: getExpressionParserPlugins(filename), errorRecovery: false, }) as any; return astContainsJsx(ast.program); @@ -234,11 +233,7 @@ function collectLegacyStyleStatementRanges(text: string, filename: string): Offs try { const ast = parse(text, { sourceType: 'module', - plugins: [ - 'jsx', - 'decorators-legacy', - ...(isTypeScriptLikeFilename(filename) ? ['typescript'] : []), - ] as any, + plugins: getExpressionParserPlugins(filename), errorRecovery: false, }) as any; @@ -273,13 +268,244 @@ function collectLegacyStyleStatementRanges(text: string, filename: string): Offs } } +function isProviderElementName(node: any): boolean { + if (!node || typeof node !== 'object') return false; + if (node.type === 'JSXIdentifier') return node.name === 'Provider'; + if (node.type === 'JSXMemberExpression') return isProviderElementName(node.property); + return false; +} + +function collectProviderBooleanValueRanges(text: string, filename: string): OffsetRange[] { + try { + const ast = parse(text, { + sourceType: 'module', + plugins: getExpressionParserPlugins(filename), + errorRecovery: false, + }) as any; + + const ranges: OffsetRange[] = []; + const visit = (node: any) => { + if (!node || typeof node !== 'object') return; + if (Array.isArray(node)) { + for (const child of node) visit(child); + return; + } + + if ( + node.type === 'JSXOpeningElement' + && isProviderElementName(node.name) + && Array.isArray(node.attributes) + ) { + for (const attribute of node.attributes) { + if ( + attribute?.type === 'JSXAttribute' + && attribute.name?.type === 'JSXIdentifier' + && attribute.name.name === 'value' + && attribute.value?.type === 'JSXExpressionContainer' + && attribute.value.expression?.type === 'BooleanLiteral' + && attribute.value.expression.value === true + && typeof attribute.start === 'number' + && typeof attribute.end === 'number' + ) { + ranges.push({ start: attribute.start, end: attribute.end }); + } + } + } + + for (const value of Object.values(node)) visit(value); + }; + + visit(ast.program); + return ranges; + } catch { + return []; + } +} + 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); return lineText.match(/^[ \t]*/)?.[0] ?? ''; } -function indentFormattedRegion(text: string, baseIndent: string): string { +function getExpressionParserPlugins(filename: string): any[] { + return [ + 'jsx', + 'decorators-legacy', + ...(isTypeScriptLikeFilename(filename) ? ['typescript'] : []), + ] as any; +} + +function unwrapLintComparableExpression(node: t.Expression): t.Expression { + if (t.isParenthesizedExpression(node)) return unwrapLintComparableExpression(node.expression as t.Expression); + if (t.isTSAsExpression(node)) return unwrapLintComparableExpression(node.expression as t.Expression); + if (t.isTSTypeAssertion(node)) return unwrapLintComparableExpression(node.expression as t.Expression); + if (t.isTSNonNullExpression(node)) return unwrapLintComparableExpression(node.expression as t.Expression); + return node; +} + +function isRepeatableTruthyExpression(node: t.Expression): boolean { + const unwrapped = unwrapLintComparableExpression(node); + return ( + t.isIdentifier(unwrapped) + || t.isThisExpression(unwrapped) + || t.isSuper(unwrapped) + || t.isMemberExpression(unwrapped) + || t.isOptionalMemberExpression(unwrapped) + ); +} + +function areEquivalentRepeatableExpressions(left: t.Expression, right: t.Expression): boolean { + const a = unwrapLintComparableExpression(left); + const b = unwrapLintComparableExpression(right); + + if (a.type !== b.type) return false; + if (t.isIdentifier(a) && t.isIdentifier(b)) return a.name === b.name; + if (t.isThisExpression(a) && t.isThisExpression(b)) return true; + if (t.isSuper(a) && t.isSuper(b)) return true; + + if (t.isMemberExpression(a) && t.isMemberExpression(b)) { + return ( + a.computed === b.computed + && areEquivalentRepeatableExpressions(a.object as t.Expression, b.object as t.Expression) + && ( + a.computed + ? ( + t.isExpression(a.property) + && t.isExpression(b.property) + && areEquivalentRepeatableExpressions(a.property, b.property) + ) + : ( + t.isIdentifier(a.property) + && t.isIdentifier(b.property) + && a.property.name === b.property.name + ) + ) + ); + } + + if (t.isOptionalMemberExpression(a) && t.isOptionalMemberExpression(b)) { + return ( + a.computed === b.computed + && a.optional === b.optional + && areEquivalentRepeatableExpressions(a.object as t.Expression, b.object as t.Expression) + && ( + a.computed + ? areEquivalentRepeatableExpressions(a.property as t.Expression, b.property as t.Expression) + : ( + t.isIdentifier(a.property) + && t.isIdentifier(b.property) + && a.property.name === b.property.name + ) + ) + ); + } + + return false; +} + +function isFragmentJsxName(name: t.JSXIdentifier | t.JSXMemberExpression | t.JSXNamespacedName): boolean { + return ( + (t.isJSXIdentifier(name) && name.name === 'Fragment') + || ( + t.isJSXMemberExpression(name) + && t.isJSXIdentifier(name.object) + && name.object.name === 'React' + && t.isJSXIdentifier(name.property) + && name.property.name === 'Fragment' + ) + ); +} + +function normalizeLintExpressionAst(node: T): T { + if (t.isConditionalExpression(node)) { + node.test = normalizeLintExpressionAst(node.test); + node.consequent = normalizeLintExpressionAst(node.consequent); + node.alternate = normalizeLintExpressionAst(node.alternate); + + if ( + isRepeatableTruthyExpression(node.test) + && areEquivalentRepeatableExpressions(node.test, node.consequent) + ) { + return t.logicalExpression('||', node.test, node.alternate) as T; + } + + return node; + } + + if (t.isJSXElement(node)) { + node.children = node.children.map(child => normalizeLintExpressionAst(child)); + if ( + isFragmentJsxName(node.openingElement.name) + && node.openingElement.attributes.length === 0 + && !node.openingElement.selfClosing + && node.closingElement + ) { + return t.jsxFragment( + t.jsxOpeningFragment(), + t.jsxClosingFragment(), + node.children, + ) as T; + } + return node; + } + + if (t.isJSXFragment(node)) { + node.children = node.children.map(child => normalizeLintExpressionAst(child)); + return node; + } + + if (t.isJSXExpressionContainer(node)) { + node.expression = normalizeLintExpressionAst(node.expression); + return node; + } + + if (Array.isArray((node as any).children)) { + (node as any).children = (node as any).children.map((child: any) => normalizeLintExpressionAst(child)); + } + + for (const [key, value] of Object.entries(node as any)) { + if (key === 'loc' || key === 'start' || key === 'end' || key === 'extra') continue; + if (Array.isArray(value)) { + (node as any)[key] = value.map(item => { + if (!item || typeof item !== 'object' || typeof (item as any).type !== 'string') return item; + return normalizeLintExpressionAst(item as t.Node); + }); + continue; + } + + if (value && typeof value === 'object' && typeof (value as any).type === 'string') { + (node as any)[key] = normalizeLintExpressionAst(value as t.Node); + } + } + + return node; +} + +function normalizeLintExpression(expr: string, filename: string): string { + try { + const ast = parseExpression(expr, { + plugins: getExpressionParserPlugins(filename), + errorRecovery: false, + }) as t.Expression; + const normalized = normalizeLintExpressionAst(ast); + return generate(normalized, { + comments: true, + jsescOption: { + minimal: true, + }, + }).code; + } catch { + return expr; + } +} + +function indentFormattedRegion( + text: string, + baseIndent: string, + closingIndentOffset = 0, + inlinePrefix = false, +): string { if (baseIndent.length === 0 || text.length === 0) return text; const lines = text.split('\n'); @@ -302,14 +528,14 @@ function indentFormattedRegion(text: string, baseIndent: string): string { return lines .map((line, index) => { - if (index === 0) return line; + if (index === 0) return inlinePrefix ? line.trimStart() : line; if (isStructuredMultilineExpression && index < lines.length - 1) { return `${baseIndent} ${line.slice(structuredBodyIndent)}`; } if (isStructuredMultilineExpression) { - return `${baseIndent}${line.trimStart()}`; + return `${baseIndent}${' '.repeat(closingIndentOffset)}${line.trimStart()}`; } return `${baseIndent}${line}`; @@ -583,6 +809,8 @@ function buildBoundaryMap( function formatPugRegionForLint( expr: string, baseIndent: string, + closingIndentOffset: number, + inlinePrefix: boolean, filename: string, ): { code: string; boundaryMap: number[] } { const lintConfig: any[] = [{ @@ -597,7 +825,8 @@ function formatPugRegionForLint( } : {}), }]; - const wrapped = `${FORMAT_WRAPPER_PREFIX}${expr}\n`; + const normalizedExpr = normalizeLintExpression(expr, filename); + const wrapped = `${FORMAT_WRAPPER_PREFIX}${normalizedExpr}\n`; const prettyWrapped = prettier.format(wrapped, { parser: isTypeScriptLikeFilename(filename) ? 'babel-ts' : 'babel', semi: false, @@ -620,7 +849,16 @@ function formatPugRegionForLint( ).output; body = refixedWrapped.slice(FORMAT_WRAPPER_PREFIX.length); if (body.endsWith('\n')) body = body.slice(0, -1); - body = indentFormattedRegion(body, baseIndent); + body = normalizeTernaryBranchIndent(body); + body = normalizeJsxClosingBracketIndent(body); + const finalWrapped = formatLinter.verifyAndFix( + `${FORMAT_WRAPPER_PREFIX}${body}\n`, + lintConfig, + getFormatterLintFilename(filename), + ).output; + body = finalWrapped.slice(FORMAT_WRAPPER_PREFIX.length); + if (body.endsWith('\n')) body = body.slice(0, -1); + body = indentFormattedRegion(body, baseIndent, closingIndentOffset, inlinePrefix); return { code: body, @@ -655,9 +893,20 @@ function formatLintCode(transformed: SourceTransformState, filename: string): Fo const formattedStart = code.length; const baseIndent = getLineIndent(transformed.code, region.shadowStart); + const lineStart = transformed.code.lastIndexOf('\n', Math.max(0, region.shadowStart - 1)) + 1; + const beforeRegionOnLine = transformed.code.slice(lineStart, region.shadowStart); + const inlinePrefix = beforeRegionOnLine.trim().length > 0; + const closingIndentOffset = ( + /[?:]\s*$/.test(beforeRegionOnLine) + || /^\s*[?:].*=>\s*$/.test(beforeRegionOnLine) + ) + ? 2 + : 0; const formattedRegion = formatPugRegionForLint( transformed.code.slice(region.shadowStart, region.shadowEnd), baseIndent, + closingIndentOffset, + inlinePrefix, filename, ); code += formattedRegion.code; @@ -792,6 +1041,12 @@ function shouldSuppressGeneratedRangeMessage( generatedStart: number, generatedEnd: number, ): boolean { + if ( + message.ruleId === 'react/jsx-boolean-value' + && overlapsRangeList(cached.providerBooleanValueRanges, generatedStart, generatedEnd) + ) { + return true; + } if (isSyntheticStyleCallRange(cached, generatedStart, generatedEnd)) { return true; } @@ -895,15 +1150,16 @@ function createReactPugProcessor( shouldAlwaysVirtualizeJs || containsJsxSyntax(text, filename) ); - if (legacyStyleStatementRanges.length > 0) { - cache.set(filename, { - originalText: text, - transformed: null, - formatted: null, - legacyStyleStatementRanges, - }); - } else { - cache.delete(filename); + if (legacyStyleStatementRanges.length > 0) { + cache.set(filename, { + originalText: text, + transformed: null, + formatted: null, + legacyStyleStatementRanges, + providerBooleanValueRanges: [], + }); + } else { + cache.delete(filename); } if (!shouldUseVirtualJsxFilename) return [text]; return [{ @@ -939,6 +1195,7 @@ function createReactPugProcessor( transformed, formatted, legacyStyleStatementRanges, + providerBooleanValueRanges: collectProviderBooleanValueRanges(transformed.code, filename), }); if (!shouldUseVirtualJsxFilename) return [transformed.code]; return [{ diff --git a/packages/eslint-plugin-react-pug/test/integration/autofix.test.ts b/packages/eslint-plugin-react-pug/test/integration/autofix.test.ts index cd43aa5..454ad21 100644 --- a/packages/eslint-plugin-react-pug/test/integration/autofix.test.ts +++ b/packages/eslint-plugin-react-pug/test/integration/autofix.test.ts @@ -9,6 +9,14 @@ import reactPugPlugin from '../../src/index' const repoRoot = resolve(__dirname, '../../../..') const fixtureRoot = resolve(repoRoot, 'test/fixtures/example-unformatted') const snapshotRoot = resolve(fixtureRoot, 'snapshots/fixed') +const reactHooksStubPlugin = { + rules: { + 'rules-of-hooks': { + meta: { schema: [] }, + create: () => ({}), + }, + }, +} const tempDirs: string[] = [] @@ -19,11 +27,17 @@ function createExampleEslint(cwd: string, fix: boolean): ESLint { ignore: false, overrideConfigFile: true, overrideConfig: [ + { + linterOptions: { + reportUnusedDisableDirectives: 'off', + }, + }, ...neostandard({ ts: true, }), { plugins: { + 'react-hooks': reactHooksStubPlugin as any, 'react-pug': reactPugPlugin as any, }, processor: 'react-pug/react-pug', @@ -75,7 +89,12 @@ describe('eslint --fix integration for react-pug processor', () => { 'src/Card.tsx', 'src/ModalScreen.tsx', 'src/RootLayout.tsx', + 'src/StartupjsUiDialogsReadme.js', + 'src/StartupjsUiDraggableReadme.js', 'src/StartupjsLogin.js', + 'src/StartupjsUiMdxComponents.js', + 'src/StartupjsUiTypeCell.js', + 'src/StartupjsUiWrapInput.tsx', 'src/StartupjsTabThree.js', 'src/TypeScriptErrorsInPug.tsx', 'src/TypeScriptInPug.tsx', diff --git a/packages/eslint-plugin-react-pug/test/integration/fixture-diagnostics.test.ts b/packages/eslint-plugin-react-pug/test/integration/fixture-diagnostics.test.ts index c2e8f6d..d1129df 100644 --- a/packages/eslint-plugin-react-pug/test/integration/fixture-diagnostics.test.ts +++ b/packages/eslint-plugin-react-pug/test/integration/fixture-diagnostics.test.ts @@ -7,6 +7,14 @@ import reactPugPlugin from '../../src/index' const repoRoot = resolve(__dirname, '../../../..') const fixtureRoot = resolve(repoRoot, 'test/fixtures/example-unformatted') const diagnosticsSnapshotRoot = resolve(fixtureRoot, 'snapshots/diagnostics') +const reactHooksStubPlugin = { + rules: { + 'rules-of-hooks': { + meta: { schema: [] }, + create: () => ({}), + }, + }, +} function createExampleEslint(): ESLint { return new ESLint({ @@ -15,11 +23,17 @@ function createExampleEslint(): ESLint { ignore: false, overrideConfigFile: true, overrideConfig: [ + { + linterOptions: { + reportUnusedDisableDirectives: 'off', + }, + }, ...neostandard({ ts: true, }), { plugins: { + 'react-hooks': reactHooksStubPlugin as any, 'react-pug': reactPugPlugin as any, }, processor: 'react-pug/react-pug', @@ -60,5 +74,14 @@ describe('eslint diagnostics for example-unformatted fixture', () => { const startupjsLogin = results.find(result => result.filePath.endsWith('/src/StartupjsLogin.js')) expect(startupjsLogin?.messages.some(message => message.ruleId === '@stylistic/jsx-indent')).toBe(false) + + const startupjsUiDialogsReadme = results.find(result => result.filePath.endsWith('/src/StartupjsUiDialogsReadme.js')) + expect(startupjsUiDialogsReadme?.messages.some(message => message.ruleId === 'react/jsx-fragments')).toBe(false) + + const startupjsUiDraggableReadme = results.find(result => result.filePath.endsWith('/src/StartupjsUiDraggableReadme.js')) + expect(startupjsUiDraggableReadme?.messages.some(message => message.ruleId === 'no-unneeded-ternary')).toBe(false) + + const startupjsUiTypeCell = results.find(result => result.filePath.endsWith('/src/StartupjsUiTypeCell.js')) + expect(startupjsUiTypeCell?.messages.some(message => message.ruleId === '@stylistic/no-multi-spaces')).toBe(false) }, 30000) }) diff --git a/packages/eslint-plugin-react-pug/test/integration/startupjs-ui-regressions.test.ts b/packages/eslint-plugin-react-pug/test/integration/startupjs-ui-regressions.test.ts new file mode 100644 index 0000000..b3be6d8 --- /dev/null +++ b/packages/eslint-plugin-react-pug/test/integration/startupjs-ui-regressions.test.ts @@ -0,0 +1,109 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { ESLint } from 'eslint' +import neostandard from 'neostandard' +import reactPlugin from 'eslint-plugin-react' +import reactPugPlugin from '../../src/index' + +const repoRoot = resolve(__dirname, '../../../..') +const fixtureRoot = resolve(repoRoot, 'test/fixtures/example-unformatted/src') +const reactHooksStubPlugin = { + rules: { + 'rules-of-hooks': { + meta: { schema: [] }, + create: () => ({}), + }, + }, +} + +function createStartupjsUiStyleEslint(fix: boolean): ESLint { + return new ESLint({ + cwd: repoRoot, + fix, + ignore: false, + overrideConfigFile: true, + overrideConfig: [ + { + linterOptions: { + reportUnusedDisableDirectives: 'off', + }, + }, + ...neostandard({ + ts: true, + }), + { + plugins: { + react: reactPlugin as any, + 'react-hooks': reactHooksStubPlugin as any, + 'react-pug': reactPugPlugin as any, + }, + rules: { + 'react/jsx-boolean-value': 'error', + }, + processor: 'react-pug/react-pug', + }, + ] as any, + }) +} + +describe('startupjs-ui regressions', () => { + it('does not report false indent or Provider(value=true) diagnostics for startupjs-ui repros', async () => { + const files = [ + resolve(fixtureRoot, 'StartupjsUiDialogsReadme.js'), + resolve(fixtureRoot, 'StartupjsUiDraggableReadme.js'), + resolve(fixtureRoot, 'StartupjsUiTypeCell.js'), + resolve(fixtureRoot, 'StartupjsUiWrapInput.tsx'), + resolve(fixtureRoot, 'StartupjsUiMdxComponents.js'), + ] + + const results = await createStartupjsUiStyleEslint(false).lintFiles(files) + const messages = results.flatMap(result => ( + result.messages.map(message => ({ + filePath: result.filePath, + ruleId: message.ruleId, + line: message.line, + column: message.column, + message: message.message, + })) + )) + + expect(messages).toEqual([]) + }) + + it('still reports jsx-boolean-value for intrinsic boolean attrs inside pug', async () => { + const filePath = resolve(repoRoot, 'intrinsic-boolean-pug.js') + const input = [ + "import { pug } from 'startupjs'", + '', + 'export default function Demo () {', + ' return pug`', + ' button(disabled=true) Click', + ' `', + '}', + '', + ].join('\n') + + const [result] = await createStartupjsUiStyleEslint(false).lintText(input, { filePath }) + const booleanDiagnostic = result.messages.find(message => message.ruleId === 'react/jsx-boolean-value') + + expect(booleanDiagnostic).toBeTruthy() + expect(booleanDiagnostic?.line).toBe(5) + expect(booleanDiagnostic?.column).toBe(12) + }) + + it('does not rewrite startupjs-ui repros under eslint --fix', async () => { + for (const relativePath of [ + 'StartupjsUiDialogsReadme.js', + 'StartupjsUiDraggableReadme.js', + 'StartupjsUiMdxComponents.js', + 'StartupjsUiTypeCell.js', + 'StartupjsUiWrapInput.tsx', + ]) { + const filePath = resolve(fixtureRoot, relativePath) + const input = readFileSync(filePath, 'utf8') + const [result] = await createStartupjsUiStyleEslint(true).lintText(input, { filePath }) + expect(result.output ?? input).toBe(input) + } + }) +}) diff --git a/packages/vscode-react-pug-tsx/syntaxes/pug-template-literal.json b/packages/vscode-react-pug-tsx/syntaxes/pug-template-literal.json index 1bb9fbc..da0b59b 100644 --- a/packages/vscode-react-pug-tsx/syntaxes/pug-template-literal.json +++ b/packages/vscode-react-pug-tsx/syntaxes/pug-template-literal.json @@ -1,6 +1,6 @@ { "scopeName": "inline.pug-template-literal", - "injectionSelector": "L:source.ts,L:source.tsx,L:source.js,L:source.jsx", + "injectionSelector": "L:source.ts -comment -string, L:source.tsx -comment -string, L:source.js -comment -string, L:source.jsx -comment -string", "patterns": [ { "include": "#pug-tagged-template" diff --git a/packages/vscode-react-pug-tsx/test/unit/grammar.test.ts b/packages/vscode-react-pug-tsx/test/unit/grammar.test.ts index 78d1b61..a147979 100644 --- a/packages/vscode-react-pug-tsx/test/unit/grammar.test.ts +++ b/packages/vscode-react-pug-tsx/test/unit/grammar.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { readFileSync, existsSync } from 'fs'; import { resolve } from 'path'; +import { readdirSync } from 'fs'; // Test checklist: // [x] syntaxes/pug-template-literal.json exists @@ -63,6 +64,8 @@ describe('TextMate grammar file', () => { expect(selector).toContain('source.jsx'); // Should use L: prefix for left-injection expect(selector).toContain('L:'); + expect(selector).toContain('-comment'); + expect(selector).toContain('-string'); }); it('has patterns array referencing pug-tagged-template', () => { @@ -75,6 +78,97 @@ describe('TextMate grammar file', () => { }); }); +describe('tokenization regressions', () => { + function getLatestVsCodeAppRoot() { + const testRoot = resolve(extensionRoot, '../../.vscode-test'); + const entries = readdirSync(testRoot, { withFileTypes: true }) + .filter(entry => entry.isDirectory() && entry.name.startsWith('vscode-darwin-arm64-')) + .map(entry => resolve(testRoot, entry.name, 'Visual Studio Code.app/Contents/Resources/app')); + + if (entries.length === 0) { + throw new Error('No local VS Code test install found under .vscode-test'); + } + + return entries.sort().at(-1)!; + } + + async function createRegistry() { + const vscodeAppRoot = getLatestVsCodeAppRoot(); + const vsctm = require(resolve(vscodeAppRoot, 'node_modules/vscode-textmate/release/main.js')); + const onig = require(resolve(vscodeAppRoot, 'node_modules/vscode-oniguruma/release/main.js')); + const wasmBin = readFileSync(resolve(vscodeAppRoot, 'node_modules/vscode-oniguruma/release/onig.wasm')).buffer; + await onig.loadWASM(wasmBin); + + return new vsctm.Registry({ + onigLib: Promise.resolve({ + createOnigScanner(patterns: string[]) { + return new onig.OnigScanner(patterns); + }, + createOnigString(s: string) { + return new onig.OnigString(s); + }, + }), + loadGrammar(scopeName: string) { + const knownScopes: Record = { + 'source.js': resolve(vscodeAppRoot, 'extensions/javascript/syntaxes/JavaScript.tmLanguage.json'), + 'source.jsx': resolve(vscodeAppRoot, 'extensions/javascript/syntaxes/JavaScriptReact.tmLanguage.json'), + 'source.ts': resolve(vscodeAppRoot, 'extensions/typescript-basics/syntaxes/TypeScript.tmLanguage.json'), + 'source.tsx': resolve(vscodeAppRoot, 'extensions/typescript-basics/syntaxes/TypeScriptReact.tmLanguage.json'), + 'source.pug': resolve(vscodeAppRoot, 'extensions/pug/syntaxes/pug.tmLanguage.json'), + 'inline.pug-template-literal': grammarPath, + }; + const target = knownScopes[scopeName]; + if (!target) return null; + return vsctm.parseRawGrammar(readFileSync(target, 'utf8'), target); + }, + getInjections(scopeName: string) { + if (['source.js', 'source.jsx', 'source.ts', 'source.tsx'].includes(scopeName)) { + return ['inline.pug-template-literal']; + } + return []; + }, + }); + } + + it('does not inject pug tokenization into line comments', async () => { + const registry = await createRegistry(); + const grammar = await registry.loadGrammar('source.js'); + const line = "// const RowComponent = props => pug`Div(...props row)`"; + const result = grammar.tokenizeLine(line); + const injectedTokens = result.tokens.filter((token: any) => ( + token.scopes.includes('meta.embedded.inline.pug') + )); + + expect(injectedTokens).toEqual([]); + }); + + it('does not leak pug tokenization onto the next line after a commented pug template', async () => { + const registry = await createRegistry(); + const grammar = await registry.loadGrammar('source.js'); + const firstLine = "// const RowComponent = props => pug`Div(...props row)`"; + const secondLine = "const ALPHABET = 'abcdefghigklmnopqrstuvwxyz'"; + const firstResult = grammar.tokenizeLine(firstLine); + const secondResult = grammar.tokenizeLine(secondLine, firstResult.ruleStack); + const injectedTokens = secondResult.tokens.filter((token: any) => ( + token.scopes.includes('meta.embedded.inline.pug') + )); + + expect(injectedTokens).toEqual([]); + }); + + it('does not inject pug tokenization inside ordinary string literals', async () => { + const registry = await createRegistry(); + const grammar = await registry.loadGrammar('source.js'); + const line = "const x = 'pug`div hi`'"; + const result = grammar.tokenizeLine(line); + const injectedTokens = result.tokens.filter((token: any) => ( + token.scopes.includes('meta.embedded.inline.pug') + )); + + expect(injectedTokens).toEqual([]); + }); +}); + // ── Grammar repository rule tests ──────────────────────────────── describe('pug-tagged-template grammar rule', () => { diff --git a/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiDialogsReadme.js.txt b/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiDialogsReadme.js.txt new file mode 100644 index 0000000..69e487c --- /dev/null +++ b/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiDialogsReadme.js.txt @@ -0,0 +1 @@ +No diagnostics. diff --git a/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiDraggableReadme.js.txt b/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiDraggableReadme.js.txt new file mode 100644 index 0000000..69e487c --- /dev/null +++ b/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiDraggableReadme.js.txt @@ -0,0 +1 @@ +No diagnostics. diff --git a/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiMdxComponents.js.txt b/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiMdxComponents.js.txt new file mode 100644 index 0000000..69e487c --- /dev/null +++ b/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiMdxComponents.js.txt @@ -0,0 +1 @@ +No diagnostics. diff --git a/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiTypeCell.js.txt b/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiTypeCell.js.txt new file mode 100644 index 0000000..69e487c --- /dev/null +++ b/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiTypeCell.js.txt @@ -0,0 +1 @@ +No diagnostics. diff --git a/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiWrapInput.tsx.txt b/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiWrapInput.tsx.txt new file mode 100644 index 0000000..69e487c --- /dev/null +++ b/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiWrapInput.tsx.txt @@ -0,0 +1 @@ +No diagnostics. diff --git a/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiDialogsReadme.js b/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiDialogsReadme.js new file mode 100644 index 0000000..0a118cd --- /dev/null +++ b/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiDialogsReadme.js @@ -0,0 +1,16 @@ +import { Fragment } from 'react' +import { pug } from 'startupjs' + +const Button = 'Button' +const Br = 'Br' + +export function DialogsProviderSandbox ({ onPressAlert, onPressConfirm, onPressPrompt }) { + return pug` + Fragment + Button(onPress=onPressAlert) Show alert + Br + Button(onPress=onPressConfirm) Show confirm + Br + Button(onPress=onPressPrompt) Show prompt + ` +} diff --git a/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiDraggableReadme.js b/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiDraggableReadme.js new file mode 100644 index 0000000..fbda2f3 --- /dev/null +++ b/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiDraggableReadme.js @@ -0,0 +1,18 @@ +import { pug } from 'startupjs' + +const DragDropProvider = 'DragDropProvider' +const Droppable = 'Droppable' +const Draggable = 'Draggable' +const Span = 'Span' + +export function DragDropProviderSandbox ({ children, ...props }) { + return pug` + DragDropProvider(...props) + if children + = children + else + Droppable(dropId='sandbox-drop') + Draggable(dragId='sandbox-drag') + Span Drag me + ` +} diff --git a/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiMdxComponents.js b/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiMdxComponents.js new file mode 100644 index 0000000..ad0f4c4 --- /dev/null +++ b/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiMdxComponents.js @@ -0,0 +1,320 @@ +import React, { useState, useContext } from 'react' +import { Image, ScrollView, Platform, Text } from 'react-native' +import { pug, observer, $, BASE_URL } from 'startupjs' +import Alert from '@startupjs-ui/alert' +import Div from '@startupjs-ui/div' +import Span from '@startupjs-ui/span' +import Divider from '@startupjs-ui/divider' +import Br from '@startupjs-ui/br' +import Link from '@startupjs-ui/link' +import Icon from '@startupjs-ui/icon' +import { Table, Tbody, Td, Th, Thead, Tr } from '@startupjs-ui/table' +import Collapse from '@startupjs-ui/collapse' +import { setStringAsync } from 'expo-clipboard' +// import { Anchor, scrollTo } from '@startupjs/scrollable-anchors' +// import { faLink } from '@fortawesome/free-solid-svg-icons/faLink' +import { faCode } from '@fortawesome/free-solid-svg-icons/faCode' +import { faCopy } from '@fortawesome/free-solid-svg-icons/faCopy' +// import _kebabCase from 'lodash/kebabCase' +// import _get from 'lodash/get' +import './index.cssx.styl' +import Code from '../Code' + +// const RowComponent = props => pug`Div(...props row)` +const ALPHABET = 'abcdefghigklmnopqrstuvwxyz' +const ListLevelContext = React.createContext() +const BlockquoteContext = React.createContext() +const PreContext = React.createContext() + +function getOrderedListMark (index, level) { + switch (level) { + case 1: + return ALPHABET.charAt(index % ALPHABET.length) + ')' + default: + return '' + (index + 1) + '.' + } +} + +function P (props) { + return pug` + Span.p(style=props.style ...props) + ` +} + +// function getTextChildren (children) { +// const nestedChildren = _get(children, 'props.children') +// if (nestedChildren) { +// return getTextChildren(nestedChildren) +// } + +// return children +// } + +// function MDXAnchor ({ +// children, +// style, +// anchor, +// size +// }) { +// const [hover, setHover] = useState() +// const anchorKebab = _kebabCase(anchor) + +// return pug` +// Anchor.anchor( +// style=style +// id=anchorKebab +// Component=RowComponent +// vAlign='center' +// onMouseEnter=() => setHover(true) +// onMouseLeave=() => setHover() +// ) +// = children +// Link.anchor-link( +// styleName={ hover } +// to='#' + anchorKebab +// ) +// Icon(icon=faLink size=size) +// ` +// } + +export default { + wrapper: ({ children }) => pug` + Div= children + `, + // TODO: MDXAnchor(anchor=getTextChildren(children) size='xl') + h1: ({ children }) => pug` + Span(h2 bold) + = children + `, + // TODO: MDXAnchor.h2(anchor=getTextChildren(children)) + h2: ({ children }) => pug` + Span.h2.h2-text(h5)= children + Div.divider + `, + // TODO: MDXAnchor.h3(anchor=getTextChildren(children) size='s') + h3: ({ children }) => pug` + Span.h3.h3-text(h6 bold)= children + `, + h4: P, + h5: P, + h6: P, + p: ({ children }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const inBlockquote = useContext(BlockquoteContext) + + if (inBlockquote) { + return pug` + Span.blockquoteP= children + ` + } + return pug` + P= children + ` + }, + strong: ({ children }) => pug` + Text(style={ fontWeight: 'bold' })= children + `, + em: ({ children }) => pug` + Text(style={ fontStyle: 'italic' })= children + `, + hr: ({ children }) => pug` + Divider(size='l') + `, + center: ({ children }) => { + return pug` + P.center= children + ` + }, + br: Br, + thematicBreak: P, + table: ({ children }) => { + return pug` + Table(style={ marginTop: 16 })= children + ` + }, + thead: Thead, + tbody: Tbody, + tr: Tr, + td: Td, + th: Th, + delete: P, + a: ({ children, href }) => { + // function onPress (event) { + // const { url, hash } = $root.get('$render') + // const [_url, _hash] = href.split('#') + // if (url === _url && hash === `#${_hash}`) { + // event.preventDefault() + // // scrollTo({ anchorId: _hash }) + // } + // } + + // TODO: handle Anchor click with onPress + return pug` + Link.link( + to=href + size='l' + color='primary' + )= children + ` + }, + ul: ({ children }) => children, + ol: ({ children }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const currentLevel = useContext(ListLevelContext) + const nextLevel = currentLevel == null ? 0 : currentLevel + 1 + const filteredChildren = React.Children + .toArray(children) + .filter(child => child !== '\n') + .map((child, index) => React.cloneElement(child, { index })) + return pug` + ListLevelContext.Provider(value=nextLevel) + Div + = filteredChildren + ` + }, + li: ({ children, index }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const level = useContext(ListLevelContext) + const listIndex = index == null ? '•' : getOrderedListMark(index, level) + let hasTextChild = false + children = React.Children + .toArray(children) + .filter(child => child !== '\n') + .map(child => { + if (typeof child === 'string') { + hasTextChild = true + } + return child + }) + return pug` + Div(row) + Span.listIndex= listIndex + Div.listContent + if hasTextChild + P(size='l')= children + else + = children + ` + }, + blockquote: ({ children }) => { + const filteredChildren = React.Children + .toArray(children) + .filter(child => child !== '\n') + + return pug` + BlockquoteContext.Provider(value=true) + Alert.alert= filteredChildren + ` + }, + img: ({ src }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [style, setStyle] = useState({}) + const isUrl = /^(http|https):\/\//.test(src) + const isLocalUrl = /^\//.test(src) + + if (isLocalUrl) { + src = BASE_URL + src + } else if (!isUrl) { + console.warn('[@startupjs/mdx] Need to provide the url for the image') + return null + } + + function onLayout (e) { + const maxWidth = e.nativeEvent.layout.width + Image.getSize(src, (width, height) => { + const coefficient = maxWidth / width + setStyle({ + width: Math.min(width, maxWidth), + height: coefficient < 1 ? Math.ceil(height * coefficient) : height + }) + }, + error => console.warn(`[@startupjs/mdx], ${error}`) + ) + } + return pug` + Div.img(onLayout=onLayout) + Image(style=style source={ uri: src }) + ` + }, + section: ({ children, noscroll }) => { + const Wrapper = noscroll + ? ({ children }) => pug` + Div.example.padding= children + ` + : ({ children }) => pug` + ScrollView.example( + contentContainerStyleName=['exampleContent', 'padding'] + horizontal + )= children + ` + + return pug` + Wrapper= children + ` + }, + pre: ({ children }) => { + return pug` + PreContext.Provider(value=true) + = children + ` + }, + code: observer(({ children, className, ...props }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const isBlockCode = useContext(PreContext) + + if (!isBlockCode) { + return pug` + Span.inlineCodeWrapper + Span.inlineCodeSpacer   + Span.inlineCode(style={ + fontFamily: Platform.OS === 'ios' ? 'Menlo-Regular' : 'monospace' + })= children + Span.inlineCodeSpacer   + ` + } + + const language = (className || 'language-txt').replace(/language-/, '') + const [open, setOpen] = useState(false) + const $copyText = $('Copy code') + + async function copyHandler () { + await setStringAsync(children) + $copyText.set('Copied') + } + + function onMouseEnter () { + // we need to reutrn default text if it was copied + $copyText.set('Copy code') + } + + let example + + if (typeof children === 'string' && children.includes('[HACK EXAMPLE CODE]')) { + children = children.replace('[HACK EXAMPLE CODE]', '') + example = true + } + + return pug` + Div.code(styleName={ 'code-example': example }) + if example + Collapse.code-collapse(open=open variant='pure') + Collapse.Header.code-collapse-header(icon=false onPress=null) + Div.code-actions(align='right' row) + Div.code-action( + tooltip=open ? 'Hide code' : 'Show code' + onPress=()=> setOpen(!open) + ) + Icon.code-action-collapse(icon=faCode color='error') + Div.code-action( + tooltip=$copyText.get() + onPress=copyHandler + onMouseEnter=onMouseEnter + ) + Icon.code-action-copy(icon=faCopy) + Collapse.Content.code-collapse-content + Code(language=language)= children + else + Code(language=language)= children + ` + }) +} diff --git a/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiTypeCell.js b/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiTypeCell.js new file mode 100644 index 0000000..bd69f11 --- /dev/null +++ b/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiTypeCell.js @@ -0,0 +1,27 @@ +import { pug } from 'startupjs' + +const Button = 'Button' +const Span = 'Span' +const MAX_ITEMS = 10 + +export function TypeCell ({ possibleValues, toggleList, collapsed, type }) { + const values = possibleValues || [] + + function renderButton () { + if (possibleValues?.length <= MAX_ITEMS) return null + return pug` + Span    + Button(onPress=toggleList)= collapsed ? 'More...' : 'Less' + ` + } + + return pug` + if type === 'oneOf' + Span + each value, index in values + Span(key=index)= value + = renderButton() + else + Span= type + ` +} diff --git a/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiWrapInput.tsx b/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiWrapInput.tsx new file mode 100644 index 0000000..a2908cc --- /dev/null +++ b/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiWrapInput.tsx @@ -0,0 +1,360 @@ +import { useEffect, useState, type ReactNode, type RefObject } from 'react' +import { Platform, Text } from 'react-native' +import { pug, styl, observer } from 'startupjs' +import { themed } from '@startupjs-ui/core' +import Div from '@startupjs-ui/div' +import Icon from '@startupjs-ui/icon' +import Span from '@startupjs-ui/span' +import { faExclamationCircle } from '@fortawesome/free-solid-svg-icons/faExclamationCircle' +import merge from 'lodash/merge' +import getInputTestId from './helpers/getInputTestId' +import useLayout from './useLayout' + +export const IS_WRAPPED = Symbol('wrapped into wrapInput()') +const IS_WEB = Platform.OS === 'web' + +export type InputLayout = 'pure' | 'rows' | 'columns' + +export interface InputWrapperLayoutConfiguration { + labelPosition?: 'top' | 'right' + descriptionPosition?: 'top' | 'bottom' + _renderWrapper?: any + [key: string]: any +} + +export interface InputWrapperConfiguration extends InputWrapperLayoutConfiguration { + rows?: InputWrapperLayoutConfiguration + columns?: InputWrapperLayoutConfiguration + isLabelColoredWhenFocusing?: boolean + isLabelClickable?: boolean + _webLabelMode?: 'aria' | 'native' +} + +export interface InputWrapperProps { + label?: string + description?: string + layout?: InputLayout + configuration?: InputWrapperConfiguration + error?: string | string[] + required?: boolean | object + disabled?: boolean + readonly?: boolean + onFocus?: (...args: any[]) => void + onBlur?: (...args: any[]) => void + _onLabelPress?: () => void + ref?: RefObject + style?: any + [key: string]: any +} + +export function isWrapped (Component: any): boolean { + return Component[IS_WRAPPED] +} + +export default function wrapInput (Component: any, configuration: InputWrapperConfiguration = {}): any { + const defaultConfiguration = merge( + { + rows: { + labelPosition: 'top', + descriptionPosition: 'top' + }, + isLabelColoredWhenFocusing: false, + isLabelClickable: false + }, + configuration + ) + + function InputWrapper ({ + label, + description, + layout, + configuration: componentConfiguration, + error, + onFocus, + required, + onBlur, + _onLabelPress, + ref, + ...props + }: InputWrapperProps): ReactNode { + const currentLayout = useLayout({ + layout, + label, + description + }) + + const mergedConfiguration = merge({}, defaultConfiguration, componentConfiguration) + const resolvedConfiguration = merge({}, mergedConfiguration, mergedConfiguration[currentLayout]) + + const { + labelPosition, + descriptionPosition, + isLabelColoredWhenFocusing, + isLabelClickable, + _webLabelMode = 'aria' + } = resolvedConfiguration + + const [focused, setFocused] = useState(false) + const isReadOnlyOrDisabled = [props.readonly, props.disabled].some(Boolean) + + function handleFocus (...args: any[]) { + setFocused(true) + onFocus && onFocus(...args) + } + + function handleBlur (...args: any[]) { + setFocused(false) + onBlur && onBlur(...args) + } + + // NOTE + useEffect(() => { + if (!isLabelColoredWhenFocusing) return + if (focused && isReadOnlyOrDisabled) setFocused(false) + }, [focused, isLabelColoredWhenFocusing, isReadOnlyOrDisabled]) + + const hasError = Array.isArray(error) ? error.length > 0 : !!error + const generatedTestID = props.testID ?? getInputTestId({ + ...props, + label, + description, + testId: props.testID + }) + const semanticBaseId = typeof generatedTestID === 'string' && generatedTestID !== '' + ? generatedTestID + : undefined + const inputId = semanticBaseId ? `${semanticBaseId}-input` : undefined + const labelId = label && semanticBaseId ? `${semanticBaseId}-label` : undefined + const descriptionId = description && semanticBaseId ? `${semanticBaseId}-description` : undefined + const errorId = hasError && semanticBaseId ? `${semanticBaseId}-error` : undefined + const useNativeWebLabel = IS_WEB && _webLabelMode === 'native' && !!inputId + + const labelStyleName = [ + currentLayout, + currentLayout + '-' + labelPosition, + { + focused: isLabelColoredWhenFocusing ? focused : false, + error: hasError + } + ] + const requiredAsterisk = required === true + ? pug` + Text.required(aria-hidden=IS_WEB ? true : undefined)= ' *' + ` + : null + const WebLabelElement = 'label' + const _label = label + ? useNativeWebLabel + ? pug` + WebLabelElement.label( + key='label' + id=labelId + htmlFor=inputId + part='label' + styleName=labelStyleName + ) + = label + = requiredAsterisk + ` + : pug` + Span.label( + key='label' + id=labelId + part='label' + styleName=labelStyleName + onPress=isLabelClickable + ? _onLabelPress + : undefined + ) + = label + = requiredAsterisk + ` + : null + const _description = pug` + if description + Span.description( + key='description' + id=descriptionId + part='description' + styleName=[ + currentLayout, + descriptionPosition, + currentLayout + '-' + descriptionPosition + ] + description + )= description + ` + + const passRef = ref ? { ref } : {} + const inputAccessibilityProps: Record = {} + const describedBy = [descriptionId].filter(Boolean).join(' ') || undefined + + if (props['aria-label'] == null) { + if (props.accessibilityLabel != null) inputAccessibilityProps['aria-label'] = props.accessibilityLabel + else if (label) inputAccessibilityProps['aria-label'] = label + } + + if (inputId) { + inputAccessibilityProps.id = inputId + inputAccessibilityProps.nativeID = inputId + } + if (required === true) inputAccessibilityProps['aria-required'] = true + if (labelId) inputAccessibilityProps['aria-labelledby'] = labelId + if (describedBy) inputAccessibilityProps['aria-describedby'] = describedBy + if (hasError && errorId) { + inputAccessibilityProps['aria-errormessage'] = errorId + inputAccessibilityProps['aria-invalid'] = true + } + + const input = pug` + Component( + key='input' + part='wrapper' + layout=currentLayout + _hasError=hasError + onFocus=handleFocus + onBlur=handleBlur + ...inputAccessibilityProps + ...passRef + ...props + ) + ` + const err = pug` + if hasError + Div.errorContainer( + key='error' + id=errorId + styleName=[ + currentLayout, + currentLayout + '-' + descriptionPosition, + ] + vAlign='center' + row + ) + Icon.errorContainer-icon(icon=faExclamationCircle) + Span.errorContainer-text + each _error, index in (Array.isArray(error) ? error : [error]) + if index + Text= ' ' + = _error + ` + + return pug` + Div.root( + part='root' + styleName=[currentLayout] + ) + if currentLayout === 'rows' + if labelPosition === 'top' + = _label + if descriptionPosition === 'top' + = _description + = err + if labelPosition === 'right' + Div(vAlign='center' row) + = input + = _label + else + = input + if descriptionPosition === 'bottom' + = err + = _description + else if currentLayout === 'columns' + Div.leftBlock + = _label + = _description + Div.rightBlock + = input + = err + else if currentLayout === 'pure' + = input + = err + ` + } + + const componentDisplayName = Component.displayName ?? Component.name + + InputWrapper.displayName = componentDisplayName + 'InputWrapper' + + const ObservedInputWrapper = observer( + themed('InputWrapper', InputWrapper) + ) as any + + ObservedInputWrapper[IS_WRAPPED] = true + + return ObservedInputWrapper +} + +styl` + $errorColor = var(--color-text-error) + $focusedColor = var(--color-text-primary) + + // common + .label + color var(--InputWrapper-label-color) + align-self flex-start + font(body2) + + &.focused + color $focusedColor + + &.error + color $errorColor + + .description + font(caption) + + .required + color $errorColor + font-weight bold + + .errorContainer + margin-top 1u + margin-bottom 0.5u + + &-icon + color $errorColor + + &-text + font(caption) + margin-left 0.5u + color $errorColor + + // rows + .rows + &-top + .label& + margin-bottom 0.5u + + .description& + margin-bottom 1u + + .errorContainer& + margin-top 0 + margin-bottom 1u + + &-right + .label& + margin-left 1u + + &-bottom + .description& + margin-top 0.5u + + // columns + .leftBlock + .rightBlock + flex 1 + + .leftBlock + margin-right 1.5u + + .rightBlock + margin-left 1.5u + + .columns + .root& + flex-direction row + align-items center + +` diff --git a/test/fixtures/example-unformatted/src/StartupjsUiDialogsReadme.js b/test/fixtures/example-unformatted/src/StartupjsUiDialogsReadme.js new file mode 100644 index 0000000..0a118cd --- /dev/null +++ b/test/fixtures/example-unformatted/src/StartupjsUiDialogsReadme.js @@ -0,0 +1,16 @@ +import { Fragment } from 'react' +import { pug } from 'startupjs' + +const Button = 'Button' +const Br = 'Br' + +export function DialogsProviderSandbox ({ onPressAlert, onPressConfirm, onPressPrompt }) { + return pug` + Fragment + Button(onPress=onPressAlert) Show alert + Br + Button(onPress=onPressConfirm) Show confirm + Br + Button(onPress=onPressPrompt) Show prompt + ` +} diff --git a/test/fixtures/example-unformatted/src/StartupjsUiDraggableReadme.js b/test/fixtures/example-unformatted/src/StartupjsUiDraggableReadme.js new file mode 100644 index 0000000..fbda2f3 --- /dev/null +++ b/test/fixtures/example-unformatted/src/StartupjsUiDraggableReadme.js @@ -0,0 +1,18 @@ +import { pug } from 'startupjs' + +const DragDropProvider = 'DragDropProvider' +const Droppable = 'Droppable' +const Draggable = 'Draggable' +const Span = 'Span' + +export function DragDropProviderSandbox ({ children, ...props }) { + return pug` + DragDropProvider(...props) + if children + = children + else + Droppable(dropId='sandbox-drop') + Draggable(dragId='sandbox-drag') + Span Drag me + ` +} diff --git a/test/fixtures/example-unformatted/src/StartupjsUiMdxComponents.js b/test/fixtures/example-unformatted/src/StartupjsUiMdxComponents.js new file mode 100644 index 0000000..ad0f4c4 --- /dev/null +++ b/test/fixtures/example-unformatted/src/StartupjsUiMdxComponents.js @@ -0,0 +1,320 @@ +import React, { useState, useContext } from 'react' +import { Image, ScrollView, Platform, Text } from 'react-native' +import { pug, observer, $, BASE_URL } from 'startupjs' +import Alert from '@startupjs-ui/alert' +import Div from '@startupjs-ui/div' +import Span from '@startupjs-ui/span' +import Divider from '@startupjs-ui/divider' +import Br from '@startupjs-ui/br' +import Link from '@startupjs-ui/link' +import Icon from '@startupjs-ui/icon' +import { Table, Tbody, Td, Th, Thead, Tr } from '@startupjs-ui/table' +import Collapse from '@startupjs-ui/collapse' +import { setStringAsync } from 'expo-clipboard' +// import { Anchor, scrollTo } from '@startupjs/scrollable-anchors' +// import { faLink } from '@fortawesome/free-solid-svg-icons/faLink' +import { faCode } from '@fortawesome/free-solid-svg-icons/faCode' +import { faCopy } from '@fortawesome/free-solid-svg-icons/faCopy' +// import _kebabCase from 'lodash/kebabCase' +// import _get from 'lodash/get' +import './index.cssx.styl' +import Code from '../Code' + +// const RowComponent = props => pug`Div(...props row)` +const ALPHABET = 'abcdefghigklmnopqrstuvwxyz' +const ListLevelContext = React.createContext() +const BlockquoteContext = React.createContext() +const PreContext = React.createContext() + +function getOrderedListMark (index, level) { + switch (level) { + case 1: + return ALPHABET.charAt(index % ALPHABET.length) + ')' + default: + return '' + (index + 1) + '.' + } +} + +function P (props) { + return pug` + Span.p(style=props.style ...props) + ` +} + +// function getTextChildren (children) { +// const nestedChildren = _get(children, 'props.children') +// if (nestedChildren) { +// return getTextChildren(nestedChildren) +// } + +// return children +// } + +// function MDXAnchor ({ +// children, +// style, +// anchor, +// size +// }) { +// const [hover, setHover] = useState() +// const anchorKebab = _kebabCase(anchor) + +// return pug` +// Anchor.anchor( +// style=style +// id=anchorKebab +// Component=RowComponent +// vAlign='center' +// onMouseEnter=() => setHover(true) +// onMouseLeave=() => setHover() +// ) +// = children +// Link.anchor-link( +// styleName={ hover } +// to='#' + anchorKebab +// ) +// Icon(icon=faLink size=size) +// ` +// } + +export default { + wrapper: ({ children }) => pug` + Div= children + `, + // TODO: MDXAnchor(anchor=getTextChildren(children) size='xl') + h1: ({ children }) => pug` + Span(h2 bold) + = children + `, + // TODO: MDXAnchor.h2(anchor=getTextChildren(children)) + h2: ({ children }) => pug` + Span.h2.h2-text(h5)= children + Div.divider + `, + // TODO: MDXAnchor.h3(anchor=getTextChildren(children) size='s') + h3: ({ children }) => pug` + Span.h3.h3-text(h6 bold)= children + `, + h4: P, + h5: P, + h6: P, + p: ({ children }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const inBlockquote = useContext(BlockquoteContext) + + if (inBlockquote) { + return pug` + Span.blockquoteP= children + ` + } + return pug` + P= children + ` + }, + strong: ({ children }) => pug` + Text(style={ fontWeight: 'bold' })= children + `, + em: ({ children }) => pug` + Text(style={ fontStyle: 'italic' })= children + `, + hr: ({ children }) => pug` + Divider(size='l') + `, + center: ({ children }) => { + return pug` + P.center= children + ` + }, + br: Br, + thematicBreak: P, + table: ({ children }) => { + return pug` + Table(style={ marginTop: 16 })= children + ` + }, + thead: Thead, + tbody: Tbody, + tr: Tr, + td: Td, + th: Th, + delete: P, + a: ({ children, href }) => { + // function onPress (event) { + // const { url, hash } = $root.get('$render') + // const [_url, _hash] = href.split('#') + // if (url === _url && hash === `#${_hash}`) { + // event.preventDefault() + // // scrollTo({ anchorId: _hash }) + // } + // } + + // TODO: handle Anchor click with onPress + return pug` + Link.link( + to=href + size='l' + color='primary' + )= children + ` + }, + ul: ({ children }) => children, + ol: ({ children }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const currentLevel = useContext(ListLevelContext) + const nextLevel = currentLevel == null ? 0 : currentLevel + 1 + const filteredChildren = React.Children + .toArray(children) + .filter(child => child !== '\n') + .map((child, index) => React.cloneElement(child, { index })) + return pug` + ListLevelContext.Provider(value=nextLevel) + Div + = filteredChildren + ` + }, + li: ({ children, index }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const level = useContext(ListLevelContext) + const listIndex = index == null ? '•' : getOrderedListMark(index, level) + let hasTextChild = false + children = React.Children + .toArray(children) + .filter(child => child !== '\n') + .map(child => { + if (typeof child === 'string') { + hasTextChild = true + } + return child + }) + return pug` + Div(row) + Span.listIndex= listIndex + Div.listContent + if hasTextChild + P(size='l')= children + else + = children + ` + }, + blockquote: ({ children }) => { + const filteredChildren = React.Children + .toArray(children) + .filter(child => child !== '\n') + + return pug` + BlockquoteContext.Provider(value=true) + Alert.alert= filteredChildren + ` + }, + img: ({ src }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [style, setStyle] = useState({}) + const isUrl = /^(http|https):\/\//.test(src) + const isLocalUrl = /^\//.test(src) + + if (isLocalUrl) { + src = BASE_URL + src + } else if (!isUrl) { + console.warn('[@startupjs/mdx] Need to provide the url for the image') + return null + } + + function onLayout (e) { + const maxWidth = e.nativeEvent.layout.width + Image.getSize(src, (width, height) => { + const coefficient = maxWidth / width + setStyle({ + width: Math.min(width, maxWidth), + height: coefficient < 1 ? Math.ceil(height * coefficient) : height + }) + }, + error => console.warn(`[@startupjs/mdx], ${error}`) + ) + } + return pug` + Div.img(onLayout=onLayout) + Image(style=style source={ uri: src }) + ` + }, + section: ({ children, noscroll }) => { + const Wrapper = noscroll + ? ({ children }) => pug` + Div.example.padding= children + ` + : ({ children }) => pug` + ScrollView.example( + contentContainerStyleName=['exampleContent', 'padding'] + horizontal + )= children + ` + + return pug` + Wrapper= children + ` + }, + pre: ({ children }) => { + return pug` + PreContext.Provider(value=true) + = children + ` + }, + code: observer(({ children, className, ...props }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const isBlockCode = useContext(PreContext) + + if (!isBlockCode) { + return pug` + Span.inlineCodeWrapper + Span.inlineCodeSpacer   + Span.inlineCode(style={ + fontFamily: Platform.OS === 'ios' ? 'Menlo-Regular' : 'monospace' + })= children + Span.inlineCodeSpacer   + ` + } + + const language = (className || 'language-txt').replace(/language-/, '') + const [open, setOpen] = useState(false) + const $copyText = $('Copy code') + + async function copyHandler () { + await setStringAsync(children) + $copyText.set('Copied') + } + + function onMouseEnter () { + // we need to reutrn default text if it was copied + $copyText.set('Copy code') + } + + let example + + if (typeof children === 'string' && children.includes('[HACK EXAMPLE CODE]')) { + children = children.replace('[HACK EXAMPLE CODE]', '') + example = true + } + + return pug` + Div.code(styleName={ 'code-example': example }) + if example + Collapse.code-collapse(open=open variant='pure') + Collapse.Header.code-collapse-header(icon=false onPress=null) + Div.code-actions(align='right' row) + Div.code-action( + tooltip=open ? 'Hide code' : 'Show code' + onPress=()=> setOpen(!open) + ) + Icon.code-action-collapse(icon=faCode color='error') + Div.code-action( + tooltip=$copyText.get() + onPress=copyHandler + onMouseEnter=onMouseEnter + ) + Icon.code-action-copy(icon=faCopy) + Collapse.Content.code-collapse-content + Code(language=language)= children + else + Code(language=language)= children + ` + }) +} diff --git a/test/fixtures/example-unformatted/src/StartupjsUiTypeCell.js b/test/fixtures/example-unformatted/src/StartupjsUiTypeCell.js new file mode 100644 index 0000000..bd69f11 --- /dev/null +++ b/test/fixtures/example-unformatted/src/StartupjsUiTypeCell.js @@ -0,0 +1,27 @@ +import { pug } from 'startupjs' + +const Button = 'Button' +const Span = 'Span' +const MAX_ITEMS = 10 + +export function TypeCell ({ possibleValues, toggleList, collapsed, type }) { + const values = possibleValues || [] + + function renderButton () { + if (possibleValues?.length <= MAX_ITEMS) return null + return pug` + Span    + Button(onPress=toggleList)= collapsed ? 'More...' : 'Less' + ` + } + + return pug` + if type === 'oneOf' + Span + each value, index in values + Span(key=index)= value + = renderButton() + else + Span= type + ` +} diff --git a/test/fixtures/example-unformatted/src/StartupjsUiWrapInput.tsx b/test/fixtures/example-unformatted/src/StartupjsUiWrapInput.tsx new file mode 100644 index 0000000..a2908cc --- /dev/null +++ b/test/fixtures/example-unformatted/src/StartupjsUiWrapInput.tsx @@ -0,0 +1,360 @@ +import { useEffect, useState, type ReactNode, type RefObject } from 'react' +import { Platform, Text } from 'react-native' +import { pug, styl, observer } from 'startupjs' +import { themed } from '@startupjs-ui/core' +import Div from '@startupjs-ui/div' +import Icon from '@startupjs-ui/icon' +import Span from '@startupjs-ui/span' +import { faExclamationCircle } from '@fortawesome/free-solid-svg-icons/faExclamationCircle' +import merge from 'lodash/merge' +import getInputTestId from './helpers/getInputTestId' +import useLayout from './useLayout' + +export const IS_WRAPPED = Symbol('wrapped into wrapInput()') +const IS_WEB = Platform.OS === 'web' + +export type InputLayout = 'pure' | 'rows' | 'columns' + +export interface InputWrapperLayoutConfiguration { + labelPosition?: 'top' | 'right' + descriptionPosition?: 'top' | 'bottom' + _renderWrapper?: any + [key: string]: any +} + +export interface InputWrapperConfiguration extends InputWrapperLayoutConfiguration { + rows?: InputWrapperLayoutConfiguration + columns?: InputWrapperLayoutConfiguration + isLabelColoredWhenFocusing?: boolean + isLabelClickable?: boolean + _webLabelMode?: 'aria' | 'native' +} + +export interface InputWrapperProps { + label?: string + description?: string + layout?: InputLayout + configuration?: InputWrapperConfiguration + error?: string | string[] + required?: boolean | object + disabled?: boolean + readonly?: boolean + onFocus?: (...args: any[]) => void + onBlur?: (...args: any[]) => void + _onLabelPress?: () => void + ref?: RefObject + style?: any + [key: string]: any +} + +export function isWrapped (Component: any): boolean { + return Component[IS_WRAPPED] +} + +export default function wrapInput (Component: any, configuration: InputWrapperConfiguration = {}): any { + const defaultConfiguration = merge( + { + rows: { + labelPosition: 'top', + descriptionPosition: 'top' + }, + isLabelColoredWhenFocusing: false, + isLabelClickable: false + }, + configuration + ) + + function InputWrapper ({ + label, + description, + layout, + configuration: componentConfiguration, + error, + onFocus, + required, + onBlur, + _onLabelPress, + ref, + ...props + }: InputWrapperProps): ReactNode { + const currentLayout = useLayout({ + layout, + label, + description + }) + + const mergedConfiguration = merge({}, defaultConfiguration, componentConfiguration) + const resolvedConfiguration = merge({}, mergedConfiguration, mergedConfiguration[currentLayout]) + + const { + labelPosition, + descriptionPosition, + isLabelColoredWhenFocusing, + isLabelClickable, + _webLabelMode = 'aria' + } = resolvedConfiguration + + const [focused, setFocused] = useState(false) + const isReadOnlyOrDisabled = [props.readonly, props.disabled].some(Boolean) + + function handleFocus (...args: any[]) { + setFocused(true) + onFocus && onFocus(...args) + } + + function handleBlur (...args: any[]) { + setFocused(false) + onBlur && onBlur(...args) + } + + // NOTE + useEffect(() => { + if (!isLabelColoredWhenFocusing) return + if (focused && isReadOnlyOrDisabled) setFocused(false) + }, [focused, isLabelColoredWhenFocusing, isReadOnlyOrDisabled]) + + const hasError = Array.isArray(error) ? error.length > 0 : !!error + const generatedTestID = props.testID ?? getInputTestId({ + ...props, + label, + description, + testId: props.testID + }) + const semanticBaseId = typeof generatedTestID === 'string' && generatedTestID !== '' + ? generatedTestID + : undefined + const inputId = semanticBaseId ? `${semanticBaseId}-input` : undefined + const labelId = label && semanticBaseId ? `${semanticBaseId}-label` : undefined + const descriptionId = description && semanticBaseId ? `${semanticBaseId}-description` : undefined + const errorId = hasError && semanticBaseId ? `${semanticBaseId}-error` : undefined + const useNativeWebLabel = IS_WEB && _webLabelMode === 'native' && !!inputId + + const labelStyleName = [ + currentLayout, + currentLayout + '-' + labelPosition, + { + focused: isLabelColoredWhenFocusing ? focused : false, + error: hasError + } + ] + const requiredAsterisk = required === true + ? pug` + Text.required(aria-hidden=IS_WEB ? true : undefined)= ' *' + ` + : null + const WebLabelElement = 'label' + const _label = label + ? useNativeWebLabel + ? pug` + WebLabelElement.label( + key='label' + id=labelId + htmlFor=inputId + part='label' + styleName=labelStyleName + ) + = label + = requiredAsterisk + ` + : pug` + Span.label( + key='label' + id=labelId + part='label' + styleName=labelStyleName + onPress=isLabelClickable + ? _onLabelPress + : undefined + ) + = label + = requiredAsterisk + ` + : null + const _description = pug` + if description + Span.description( + key='description' + id=descriptionId + part='description' + styleName=[ + currentLayout, + descriptionPosition, + currentLayout + '-' + descriptionPosition + ] + description + )= description + ` + + const passRef = ref ? { ref } : {} + const inputAccessibilityProps: Record = {} + const describedBy = [descriptionId].filter(Boolean).join(' ') || undefined + + if (props['aria-label'] == null) { + if (props.accessibilityLabel != null) inputAccessibilityProps['aria-label'] = props.accessibilityLabel + else if (label) inputAccessibilityProps['aria-label'] = label + } + + if (inputId) { + inputAccessibilityProps.id = inputId + inputAccessibilityProps.nativeID = inputId + } + if (required === true) inputAccessibilityProps['aria-required'] = true + if (labelId) inputAccessibilityProps['aria-labelledby'] = labelId + if (describedBy) inputAccessibilityProps['aria-describedby'] = describedBy + if (hasError && errorId) { + inputAccessibilityProps['aria-errormessage'] = errorId + inputAccessibilityProps['aria-invalid'] = true + } + + const input = pug` + Component( + key='input' + part='wrapper' + layout=currentLayout + _hasError=hasError + onFocus=handleFocus + onBlur=handleBlur + ...inputAccessibilityProps + ...passRef + ...props + ) + ` + const err = pug` + if hasError + Div.errorContainer( + key='error' + id=errorId + styleName=[ + currentLayout, + currentLayout + '-' + descriptionPosition, + ] + vAlign='center' + row + ) + Icon.errorContainer-icon(icon=faExclamationCircle) + Span.errorContainer-text + each _error, index in (Array.isArray(error) ? error : [error]) + if index + Text= ' ' + = _error + ` + + return pug` + Div.root( + part='root' + styleName=[currentLayout] + ) + if currentLayout === 'rows' + if labelPosition === 'top' + = _label + if descriptionPosition === 'top' + = _description + = err + if labelPosition === 'right' + Div(vAlign='center' row) + = input + = _label + else + = input + if descriptionPosition === 'bottom' + = err + = _description + else if currentLayout === 'columns' + Div.leftBlock + = _label + = _description + Div.rightBlock + = input + = err + else if currentLayout === 'pure' + = input + = err + ` + } + + const componentDisplayName = Component.displayName ?? Component.name + + InputWrapper.displayName = componentDisplayName + 'InputWrapper' + + const ObservedInputWrapper = observer( + themed('InputWrapper', InputWrapper) + ) as any + + ObservedInputWrapper[IS_WRAPPED] = true + + return ObservedInputWrapper +} + +styl` + $errorColor = var(--color-text-error) + $focusedColor = var(--color-text-primary) + + // common + .label + color var(--InputWrapper-label-color) + align-self flex-start + font(body2) + + &.focused + color $focusedColor + + &.error + color $errorColor + + .description + font(caption) + + .required + color $errorColor + font-weight bold + + .errorContainer + margin-top 1u + margin-bottom 0.5u + + &-icon + color $errorColor + + &-text + font(caption) + margin-left 0.5u + color $errorColor + + // rows + .rows + &-top + .label& + margin-bottom 0.5u + + .description& + margin-bottom 1u + + .errorContainer& + margin-top 0 + margin-bottom 1u + + &-right + .label& + margin-left 1u + + &-bottom + .description& + margin-top 0.5u + + // columns + .leftBlock + .rightBlock + flex 1 + + .leftBlock + margin-right 1.5u + + .rightBlock + margin-left 1.5u + + .columns + .root& + flex-direction row + align-items center + +` diff --git a/test/fixtures/real-project/snapshots/eslint/CatCard.js.output.jsx b/test/fixtures/real-project/snapshots/eslint/CatCard.js.output.jsx index dbfa36e..5670005 100644 --- a/test/fixtures/real-project/snapshots/eslint/CatCard.js.output.jsx +++ b/test/fixtures/real-project/snapshots/eslint/CatCard.js.output.jsx @@ -22,26 +22,66 @@ export default observer(({ $cat, showPhone, large, small }) => { {photoFileId ? ( ) : ( - {name} + + {name} + )}
- + {(number || 'X') + '. '}
- {name} + + {name} + {showPhone ? ( <> {phone ? ( - + Phone: {phone} @@ -49,10 +89,22 @@ export default observer(({ $cat, showPhone, large, small }) => { : null} {catgram ? ( - + Catgram: {catgram} @@ -62,10 +114,22 @@ export default observer(({ $cat, showPhone, large, small }) => { : null} {phonegram ? ( - + Phonegram: {phonegram} diff --git a/test/fixtures/real-project/snapshots/eslint/CatCard.tsx.output.jsx b/test/fixtures/real-project/snapshots/eslint/CatCard.tsx.output.jsx index dbfa36e..5670005 100644 --- a/test/fixtures/real-project/snapshots/eslint/CatCard.tsx.output.jsx +++ b/test/fixtures/real-project/snapshots/eslint/CatCard.tsx.output.jsx @@ -22,26 +22,66 @@ export default observer(({ $cat, showPhone, large, small }) => { {photoFileId ? ( ) : ( - {name} + + {name} + )}
- + {(number || 'X') + '. '}
- {name} + + {name} + {showPhone ? ( <> {phone ? ( - + Phone: {phone} @@ -49,10 +89,22 @@ export default observer(({ $cat, showPhone, large, small }) => { : null} {catgram ? ( - + Catgram: {catgram} @@ -62,10 +114,22 @@ export default observer(({ $cat, showPhone, large, small }) => { : null} {phonegram ? ( - + Phonegram: {phonegram} diff --git a/test/fixtures/real-project/snapshots/eslint/event-tabs-breed.js.output.jsx b/test/fixtures/real-project/snapshots/eslint/event-tabs-breed.js.output.jsx index c72e1b4..4f38f5b 100644 --- a/test/fixtures/real-project/snapshots/eslint/event-tabs-breed.js.output.jsx +++ b/test/fixtures/real-project/snapshots/eslint/event-tabs-breed.js.output.jsx @@ -195,7 +195,12 @@ const SelectLikes = observer(({ $likes, oppositeBreed, eventId }) => { const catId = $cat.getId() return ( $likes[catId].get() diff --git a/test/fixtures/real-project/snapshots/eslint/event-tabs-breed.tsx.output.jsx b/test/fixtures/real-project/snapshots/eslint/event-tabs-breed.tsx.output.jsx index c72e1b4..4f38f5b 100644 --- a/test/fixtures/real-project/snapshots/eslint/event-tabs-breed.tsx.output.jsx +++ b/test/fixtures/real-project/snapshots/eslint/event-tabs-breed.tsx.output.jsx @@ -195,7 +195,12 @@ const SelectLikes = observer(({ $likes, oppositeBreed, eventId }) => { const catId = $cat.getId() return ( $likes[catId].get() diff --git a/test/fixtures/real-project/snapshots/eslint/event-tabs-layout.js.output.jsx b/test/fixtures/real-project/snapshots/eslint/event-tabs-layout.js.output.jsx index 018ad30..4e00da2 100644 --- a/test/fixtures/real-project/snapshots/eslint/event-tabs-layout.js.output.jsx +++ b/test/fixtures/real-project/snapshots/eslint/event-tabs-layout.js.output.jsx @@ -34,7 +34,10 @@ export default observer(function TabLayout () { renderEditEvent({ $event }) + headerRight: () => + renderEditEvent({ + $event + }) }} /> { function renderWildIcon ({ color, size }) { return ( - + ) } function renderDomesticIcon ({ color, size }) { return ( - + ) } function renderHomeIcon ({ color, size }) { return ( - + ) } function renderTestIcon ({ color, size }) { return ( - + ) } diff --git a/test/fixtures/real-project/snapshots/eslint/event-tabs-layout.tsx.output.jsx b/test/fixtures/real-project/snapshots/eslint/event-tabs-layout.tsx.output.jsx index 018ad30..4e00da2 100644 --- a/test/fixtures/real-project/snapshots/eslint/event-tabs-layout.tsx.output.jsx +++ b/test/fixtures/real-project/snapshots/eslint/event-tabs-layout.tsx.output.jsx @@ -34,7 +34,10 @@ export default observer(function TabLayout () { renderEditEvent({ $event }) + headerRight: () => + renderEditEvent({ + $event + }) }} /> { function renderWildIcon ({ color, size }) { return ( - + ) } function renderDomesticIcon ({ color, size }) { return ( - + ) } function renderHomeIcon ({ color, size }) { return ( - + ) } function renderTestIcon ({ color, size }) { return ( - + ) } From 49fb0609e03a961ba58b310791c73d3168437005 Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Mon, 6 Apr 2026 19:35:27 +0300 Subject: [PATCH 2/7] refactor: move lint shaping contracts into core --- package-lock.json | 4 +- packages/eslint-plugin-react-pug/package.json | 2 - packages/eslint-plugin-react-pug/src/index.ts | 646 ++------------- .../test/integration/autofix.test.ts | 30 +- .../integration/fixture-diagnostics.test.ts | 9 + .../startupjs-ui-regressions.test.ts | 21 +- .../test/unit/processor.test.ts | 54 ++ packages/react-pug-core/package.json | 2 + packages/react-pug-core/src/index.ts | 1 + .../src/language/lintTransform.ts | 764 ++++++++++++++++++ .../test/unit/lintTransform.test.ts | 199 +++++ plan.md | 396 +++++++++ .../src/StartupjsUiMdxComponents.js.txt | 3 +- .../diagnostics/src/StartupjsUiPrompt.tsx.txt | 1 + .../snapshots/fixed/src/StartupjsUiPrompt.tsx | 73 ++ .../src/StartupjsUiPrompt.tsx | 73 ++ .../eslint/cat-profile-link.js.output.jsx | 10 +- .../eslint/cat-profile-link.tsx.output.jsx | 10 +- .../eslint/event-tabs-breed.js.output.jsx | 6 +- .../eslint/event-tabs-breed.tsx.output.jsx | 6 +- 20 files changed, 1723 insertions(+), 587 deletions(-) create mode 100644 packages/react-pug-core/src/language/lintTransform.ts create mode 100644 packages/react-pug-core/test/unit/lintTransform.test.ts create mode 100644 plan.md create mode 100644 test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiPrompt.tsx.txt create mode 100644 test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiPrompt.tsx create mode 100644 test/fixtures/example-unformatted/src/StartupjsUiPrompt.tsx diff --git a/package-lock.json b/package-lock.json index 081b700..b5ebf75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21301,9 +21301,7 @@ "name": "@react-pug/eslint-plugin-react-pug", "version": "0.1.12", "dependencies": { - "@babel/generator": "^7.28.3", "@babel/parser": "^7.0.0", - "@babel/types": "^7.28.2", "@prettier/sync": "^0.6.1", "@react-pug/react-pug-core": "^0.1.10", "@stylistic/eslint-plugin": "^5.10.0", @@ -23037,7 +23035,9 @@ "name": "@react-pug/react-pug-core", "version": "0.1.10", "dependencies": { + "@babel/generator": "^7.29.0", "@babel/parser": "^7.0.0", + "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.13", "@react-pug/pug-lexer": "^0.1.7", "@volar/source-map": "^2.4.28", diff --git a/packages/eslint-plugin-react-pug/package.json b/packages/eslint-plugin-react-pug/package.json index 0f036ca..a5b9c09 100644 --- a/packages/eslint-plugin-react-pug/package.json +++ b/packages/eslint-plugin-react-pug/package.json @@ -11,9 +11,7 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@babel/generator": "^7.28.3", "@babel/parser": "^7.0.0", - "@babel/types": "^7.28.2", "@prettier/sync": "^0.6.1", "@react-pug/react-pug-core": "^0.1.10", "@stylistic/eslint-plugin": "^5.10.0", diff --git a/packages/eslint-plugin-react-pug/src/index.ts b/packages/eslint-plugin-react-pug/src/index.ts index 258a9d6..7327307 100644 --- a/packages/eslint-plugin-react-pug/src/index.ts +++ b/packages/eslint-plugin-react-pug/src/index.ts @@ -1,16 +1,18 @@ import { + buildExpressionBoundaryMap, type ClassAttributeOption, type ClassMergeOption, + createFormattingWrapper, + createLintTransform, + extractFormattedExpressionFromWrapper, hasTagFunctionCall, lineColumnToOffset, mapGeneratedRangeToOriginal, offsetToLineColumn, + type RegionFormattingContext, type StartupjsCssxjsOption, - transformSourceFile, } from '@react-pug/react-pug-core'; -import generate from '@babel/generator'; -import { parse, parseExpression } from '@babel/parser'; -import * as t from '@babel/types'; +import { parse } from '@babel/parser'; import { Linter, SourceCode } from 'eslint'; import stylisticPlugin from '@stylistic/eslint-plugin'; import prettier from '@prettier/sync'; @@ -47,7 +49,7 @@ interface EslintLintMessage { [key: string]: unknown; } -type SourceTransformState = ReturnType; +type LintTransformState = ReturnType; interface FormattedCopySegment { formattedStart: number; @@ -77,10 +79,10 @@ interface OffsetRange { interface CachedLintState { originalText: string; - transformed: SourceTransformState | null; + transformed: LintTransformState | null; formatted: FormattedLintCode | null; legacyStyleStatementRanges: OffsetRange[]; - providerBooleanValueRanges: OffsetRange[]; + syntheticStyleCallRanges: OffsetRange[]; } interface EslintProcessorLike { @@ -92,7 +94,6 @@ interface EslintProcessorLike { supportsAutofix: boolean; } -const FORMAT_WRAPPER_PREFIX = 'const __pug = '; const FLAT_LINT_FILES = ['**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}']; const LEGACY_STYLE_HELPERS = new Set(['styl', 'css', 'sass', 'scss']); const LEGACY_STYLE_SUPPRESSED_RULES = new Set(['no-unused-expressions', 'no-unreachable']); @@ -268,58 +269,18 @@ function collectLegacyStyleStatementRanges(text: string, filename: string): Offs } } -function isProviderElementName(node: any): boolean { - if (!node || typeof node !== 'object') return false; - if (node.type === 'JSXIdentifier') return node.name === 'Provider'; - if (node.type === 'JSXMemberExpression') return isProviderElementName(node.property); - return false; -} - -function collectProviderBooleanValueRanges(text: string, filename: string): OffsetRange[] { - try { - const ast = parse(text, { - sourceType: 'module', - plugins: getExpressionParserPlugins(filename), - errorRecovery: false, - }) as any; - - const ranges: OffsetRange[] = []; - const visit = (node: any) => { - if (!node || typeof node !== 'object') return; - if (Array.isArray(node)) { - for (const child of node) visit(child); - return; - } - - if ( - node.type === 'JSXOpeningElement' - && isProviderElementName(node.name) - && Array.isArray(node.attributes) - ) { - for (const attribute of node.attributes) { - if ( - attribute?.type === 'JSXAttribute' - && attribute.name?.type === 'JSXIdentifier' - && attribute.name.name === 'value' - && attribute.value?.type === 'JSXExpressionContainer' - && attribute.value.expression?.type === 'BooleanLiteral' - && attribute.value.expression.value === true - && typeof attribute.start === 'number' - && typeof attribute.end === 'number' - ) { - ranges.push({ start: attribute.start, end: attribute.end }); - } - } - } +function collectSyntheticStyleCallRanges(transformed: LintTransformState): OffsetRange[] { + const ranges: OffsetRange[] = []; - for (const value of Object.values(node)) visit(value); - }; - - visit(ast.program); - return ranges; - } catch { - return []; + for (const insertion of transformed.baseTransform.document.insertions) { + if (insertion.kind !== 'style-call') continue; + const start = transformed.mapBaseOffsetToRewritten(insertion.shadowStart); + const end = transformed.mapBaseOffsetToRewritten(insertion.shadowEnd); + if (start == null || end == null) continue; + ranges.push({ start, end }); } + + return ranges; } function getLineIndent(text: string, offset: number): string { @@ -336,283 +297,27 @@ function getExpressionParserPlugins(filename: string): any[] { ] as any; } -function unwrapLintComparableExpression(node: t.Expression): t.Expression { - if (t.isParenthesizedExpression(node)) return unwrapLintComparableExpression(node.expression as t.Expression); - if (t.isTSAsExpression(node)) return unwrapLintComparableExpression(node.expression as t.Expression); - if (t.isTSTypeAssertion(node)) return unwrapLintComparableExpression(node.expression as t.Expression); - if (t.isTSNonNullExpression(node)) return unwrapLintComparableExpression(node.expression as t.Expression); - return node; -} - -function isRepeatableTruthyExpression(node: t.Expression): boolean { - const unwrapped = unwrapLintComparableExpression(node); - return ( - t.isIdentifier(unwrapped) - || t.isThisExpression(unwrapped) - || t.isSuper(unwrapped) - || t.isMemberExpression(unwrapped) - || t.isOptionalMemberExpression(unwrapped) - ); -} - -function areEquivalentRepeatableExpressions(left: t.Expression, right: t.Expression): boolean { - const a = unwrapLintComparableExpression(left); - const b = unwrapLintComparableExpression(right); - - if (a.type !== b.type) return false; - if (t.isIdentifier(a) && t.isIdentifier(b)) return a.name === b.name; - if (t.isThisExpression(a) && t.isThisExpression(b)) return true; - if (t.isSuper(a) && t.isSuper(b)) return true; - - if (t.isMemberExpression(a) && t.isMemberExpression(b)) { - return ( - a.computed === b.computed - && areEquivalentRepeatableExpressions(a.object as t.Expression, b.object as t.Expression) - && ( - a.computed - ? ( - t.isExpression(a.property) - && t.isExpression(b.property) - && areEquivalentRepeatableExpressions(a.property, b.property) - ) - : ( - t.isIdentifier(a.property) - && t.isIdentifier(b.property) - && a.property.name === b.property.name - ) - ) - ); - } - - if (t.isOptionalMemberExpression(a) && t.isOptionalMemberExpression(b)) { - return ( - a.computed === b.computed - && a.optional === b.optional - && areEquivalentRepeatableExpressions(a.object as t.Expression, b.object as t.Expression) - && ( - a.computed - ? areEquivalentRepeatableExpressions(a.property as t.Expression, b.property as t.Expression) - : ( - t.isIdentifier(a.property) - && t.isIdentifier(b.property) - && a.property.name === b.property.name - ) - ) - ); - } - - return false; -} - -function isFragmentJsxName(name: t.JSXIdentifier | t.JSXMemberExpression | t.JSXNamespacedName): boolean { - return ( - (t.isJSXIdentifier(name) && name.name === 'Fragment') - || ( - t.isJSXMemberExpression(name) - && t.isJSXIdentifier(name.object) - && name.object.name === 'React' - && t.isJSXIdentifier(name.property) - && name.property.name === 'Fragment' - ) - ); -} - -function normalizeLintExpressionAst(node: T): T { - if (t.isConditionalExpression(node)) { - node.test = normalizeLintExpressionAst(node.test); - node.consequent = normalizeLintExpressionAst(node.consequent); - node.alternate = normalizeLintExpressionAst(node.alternate); - - if ( - isRepeatableTruthyExpression(node.test) - && areEquivalentRepeatableExpressions(node.test, node.consequent) - ) { - return t.logicalExpression('||', node.test, node.alternate) as T; - } - - return node; - } - - if (t.isJSXElement(node)) { - node.children = node.children.map(child => normalizeLintExpressionAst(child)); - if ( - isFragmentJsxName(node.openingElement.name) - && node.openingElement.attributes.length === 0 - && !node.openingElement.selfClosing - && node.closingElement - ) { - return t.jsxFragment( - t.jsxOpeningFragment(), - t.jsxClosingFragment(), - node.children, - ) as T; - } - return node; - } - - if (t.isJSXFragment(node)) { - node.children = node.children.map(child => normalizeLintExpressionAst(child)); - return node; - } - - if (t.isJSXExpressionContainer(node)) { - node.expression = normalizeLintExpressionAst(node.expression); - return node; - } - - if (Array.isArray((node as any).children)) { - (node as any).children = (node as any).children.map((child: any) => normalizeLintExpressionAst(child)); - } - - for (const [key, value] of Object.entries(node as any)) { - if (key === 'loc' || key === 'start' || key === 'end' || key === 'extra') continue; - if (Array.isArray(value)) { - (node as any)[key] = value.map(item => { - if (!item || typeof item !== 'object' || typeof (item as any).type !== 'string') return item; - return normalizeLintExpressionAst(item as t.Node); - }); - continue; - } - - if (value && typeof value === 'object' && typeof (value as any).type === 'string') { - (node as any)[key] = normalizeLintExpressionAst(value as t.Node); - } - } - - return node; -} - -function normalizeLintExpression(expr: string, filename: string): string { - try { - const ast = parseExpression(expr, { - plugins: getExpressionParserPlugins(filename), - errorRecovery: false, - }) as t.Expression; - const normalized = normalizeLintExpressionAst(ast); - return generate(normalized, { - comments: true, - jsescOption: { - minimal: true, - }, - }).code; - } catch { - return expr; - } -} - -function indentFormattedRegion( +function rebaseFormattedRegion( text: string, baseIndent: string, - closingIndentOffset = 0, - inlinePrefix = false, + wrapperLineIndentWidth: number, ): string { - if (baseIndent.length === 0 || text.length === 0) return text; - + if (text.length === 0) return text; const lines = text.split('\n'); if (lines.length === 1) return text; - const firstTrimmed = lines[0].trim(); - const lastTrimmed = lines[lines.length - 1].trim(); - const isStructuredMultilineExpression = ( - (firstTrimmed === '(' && lastTrimmed === ')') - || (firstTrimmed.startsWith('(() => {') && lastTrimmed.startsWith('})()')) - || (firstTrimmed.startsWith('<') && (lastTrimmed.startsWith('' || lastTrimmed === '>')) - ); - - const structuredBodyIndent = isStructuredMultilineExpression - ? Math.min(...lines - .slice(1, -1) - .filter(line => line.trim().length > 0) - .map(line => line.match(/^[ \t]*/)?.[0].length ?? 0)) - : 0; - return lines .map((line, index) => { - if (index === 0) return inlinePrefix ? line.trimStart() : line; + if (index === 0) return line.trimStart(); + if (line.trim().length === 0) return ''; - if (isStructuredMultilineExpression && index < lines.length - 1) { - return `${baseIndent} ${line.slice(structuredBodyIndent)}`; - } - - if (isStructuredMultilineExpression) { - return `${baseIndent}${' '.repeat(closingIndentOffset)}${line.trimStart()}`; - } - - return `${baseIndent}${line}`; + const indentWidth = line.match(/^[ \t]*/)?.[0].length ?? 0; + const relativeIndent = Math.max(0, indentWidth - wrapperLineIndentWidth); + return `${baseIndent}${' '.repeat(relativeIndent)}${line.trimStart()}`; }) .join('\n'); } -function normalizeTernaryBranchIndent(text: string): string { - const lines = text.split('\n'); - const stack: Array<{ - branchIndent: number; - iifeOpenIndex: number | null; - }> = []; - - const getIndent = (line: string) => line.match(/^[ \t]*/)?.[0].length ?? 0; - - for (let i = 0; i < lines.length; i += 1) { - const line = lines[i]; - const trimmed = line.trim(); - const indent = getIndent(line); - - if (/^[?:]\s*\($/.test(trimmed)) { - stack.push({ branchIndent: indent, iifeOpenIndex: null }); - continue; - } - - const current = stack[stack.length - 1]; - if (!current) continue; - - if (current.iifeOpenIndex == null && (trimmed === ')' || trimmed === ')}')) { - lines[i] = `${' '.repeat(current.branchIndent + 2)}${trimmed}`; - stack.pop(); - continue; - } - - if (trimmed.startsWith('(() => {') || trimmed.startsWith('{(() => {')) { - current.iifeOpenIndex = i; - lines[i] = `${' '.repeat(current.branchIndent + 4)}${trimmed}`; - continue; - } - - if (current.iifeOpenIndex != null && trimmed.startsWith('})()')) { - const bodyLines = lines - .slice(current.iifeOpenIndex + 1, i) - .filter(branchLine => branchLine.trim().length > 0); - const bodyBaseIndent = bodyLines.length > 0 - ? Math.min(...bodyLines.map(getIndent)) - : current.branchIndent + 2; - - for (let bodyIndex = current.iifeOpenIndex + 1; bodyIndex < i; bodyIndex += 1) { - const bodyLine = lines[bodyIndex]; - const bodyTrimmed = bodyLine.trim(); - if (bodyTrimmed.length === 0) continue; - - const relativeIndent = Math.max(0, getIndent(bodyLine) - bodyBaseIndent); - lines[bodyIndex] = `${' '.repeat(current.branchIndent + 6 + relativeIndent)}${bodyTrimmed}`; - } - - lines[i] = `${' '.repeat(current.branchIndent + 4)}${trimmed}`; - current.iifeOpenIndex = null; - continue; - } - - if (current.iifeOpenIndex == null && trimmed.length > 0) { - const expectedIndent = /^[<>{]/.test(trimmed) - ? current.branchIndent + 2 - : current.branchIndent + 4; - - if (indent < expectedIndent) { - lines[i] = `${' '.repeat(expectedIndent)}${trimmed}`; - } - } - } - - return lines.join('\n'); -} - function normalizeJsxClosingBracketIndent(text: string): string { const lines = text.split('\n'); const stack: number[] = []; @@ -638,179 +343,10 @@ function normalizeJsxClosingBracketIndent(text: string): string { return lines.join('\n'); } -function parseExpressionTokens(expr: string, filename: string) { - const wrapped = `${FORMAT_WRAPPER_PREFIX}${expr}\n`; - const ast = parse(wrapped, { - sourceType: 'module', - plugins: [ - 'jsx', - 'decorators-legacy', - ...(isTypeScriptLikeFilename(filename) ? ['typescript'] : []), - ] as any, - errorRecovery: false, - tokens: true, - }) as any; - - const prefixLength = FORMAT_WRAPPER_PREFIX.length; - const endLimit = wrapped.length - 1; - const tokens = (ast.tokens ?? []) - .filter((token: any) => { - if (token.start < prefixLength || token.end > endLimit) return false; - const rawText = wrapped.slice(token.start, token.end); - if (token.type?.label === 'jsxText' && rawText.trim().length === 0) return false; - return true; - }) - .map((token: any) => ({ - start: token.start - prefixLength, - end: token.end - prefixLength, - label: token.type?.label ?? token.type, - value: token.value, - raw: wrapped.slice(token.start, token.end), - })); - - return tokens; -} - -function tokenAlignmentKey(token: { - label: string; - value?: unknown; - raw: string; -}): string { - switch (token.label) { - case 'name': - case 'jsxName': - case 'privateName': - return `${token.label}:${token.raw}`; - case 'string': - return `${token.label}:${String(token.value ?? token.raw)}`; - case 'num': - case 'bigint': - case 'decimal': - case 'regexp': - return `${token.label}:${token.raw}`; - case 'jsxText': - return `${token.label}:${token.raw.trim()}`; - default: - return token.label; - } -} - -function alignExpressionTokens( - originalTokens: Array<{ - start: number; - end: number; - label: string; - value?: unknown; - raw: string; - }>, - formattedTokens: Array<{ - start: number; - end: number; - label: string; - value?: unknown; - raw: string; - }>, -): Array<[number, number]> { - const originalKeys = originalTokens.map(tokenAlignmentKey); - const formattedKeys = formattedTokens.map(tokenAlignmentKey); - const dp = Array.from({ length: originalKeys.length + 1 }, () => ( - new Array(formattedKeys.length + 1).fill(0) - )); - - for (let i = originalKeys.length - 1; i >= 0; i -= 1) { - for (let j = formattedKeys.length - 1; j >= 0; j -= 1) { - if (originalKeys[i] === formattedKeys[j]) { - dp[i][j] = dp[i + 1][j + 1] + 1; - } else { - dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]); - } - } - } - - const matches: Array<[number, number]> = []; - let i = 0; - let j = 0; - while (i < originalKeys.length && j < formattedKeys.length) { - if (originalKeys[i] === formattedKeys[j]) { - matches.push([i, j]); - i += 1; - j += 1; - continue; - } - if (dp[i + 1][j] >= dp[i][j + 1]) { - i += 1; - } else { - j += 1; - } - } - - return matches; -} - -function buildBoundaryMap( - originalExpr: string, - formattedExpr: string, - filename: string, -): number[] { - try { - const originalTokens = parseExpressionTokens(originalExpr, filename); - const formattedTokens = parseExpressionTokens(formattedExpr, filename); - const matchedTokens = alignExpressionTokens(originalTokens, formattedTokens); - if (matchedTokens.length === 0) throw new Error('token-alignment-empty'); - - const anchors = [{ formatted: 0, original: 0 }]; - for (const [originalIndex, formattedIndex] of matchedTokens) { - const original = originalTokens[originalIndex]; - const formatted = formattedTokens[formattedIndex]; - anchors.push({ formatted: formatted.start, original: original.start }); - anchors.push({ formatted: formatted.end, original: original.end }); - } - anchors.push({ formatted: formattedExpr.length, original: originalExpr.length }); - - anchors.sort((a, b) => a.formatted - b.formatted || a.original - b.original); - - const deduped: Array<{ formatted: number; original: number }> = []; - for (const anchor of anchors) { - const last = deduped[deduped.length - 1]; - if (!last || last.formatted !== anchor.formatted || last.original !== anchor.original) { - deduped.push(anchor); - } - } - - const boundaryMap = new Array(formattedExpr.length + 1); - for (let i = 0; i < deduped.length - 1; i += 1) { - const current = deduped[i]; - const next = deduped[i + 1]; - const formattedSpan = next.formatted - current.formatted; - const originalSpan = next.original - current.original; - - if (formattedSpan <= 0) continue; - - for (let offset = current.formatted; offset < next.formatted; offset += 1) { - const relative = offset - current.formatted; - boundaryMap[offset] = current.original + Math.round(relative * originalSpan / formattedSpan); - } - } - - boundaryMap[formattedExpr.length] = originalExpr.length; - for (let i = 0; i < boundaryMap.length; i += 1) { - if (boundaryMap[i] == null) { - boundaryMap[i] = i === 0 ? 0 : boundaryMap[i - 1]; - } - } - return boundaryMap; - } catch { - return Array.from({ length: formattedExpr.length + 1 }, (_, index) => ( - Math.min(originalExpr.length, Math.round(index * originalExpr.length / Math.max(1, formattedExpr.length))) - )); - } -} - function formatPugRegionForLint( expr: string, baseIndent: string, - closingIndentOffset: number, - inlinePrefix: boolean, + formattingContext: RegionFormattingContext, filename: string, ): { code: string; boundaryMap: number[] } { const lintConfig: any[] = [{ @@ -825,9 +361,8 @@ function formatPugRegionForLint( } : {}), }]; - const normalizedExpr = normalizeLintExpression(expr, filename); - const wrapped = `${FORMAT_WRAPPER_PREFIX}${normalizedExpr}\n`; - const prettyWrapped = prettier.format(wrapped, { + const wrapper = createFormattingWrapper(expr, formattingContext.containerKind); + const prettyWrapped = prettier.format(wrapper, { parser: isTypeScriptLikeFilename(filename) ? 'babel-ts' : 'babel', semi: false, singleQuote: true, @@ -837,39 +372,35 @@ function formatPugRegionForLint( }); const fixedWrapped = formatLinter.verifyAndFix(prettyWrapped, lintConfig, getFormatterLintFilename(filename)).output; - let body = fixedWrapped.slice(FORMAT_WRAPPER_PREFIX.length); - if (body.endsWith('\n')) body = body.slice(0, -1); - body = normalizeTernaryBranchIndent(body); - body = normalizeJsxClosingBracketIndent(body); - const normalizedWrapped = `${FORMAT_WRAPPER_PREFIX}${body}\n`; + const normalizedWrapped = normalizeJsxClosingBracketIndent(fixedWrapped); const refixedWrapped = formatLinter.verifyAndFix( normalizedWrapped, lintConfig, getFormatterLintFilename(filename), ).output; - body = refixedWrapped.slice(FORMAT_WRAPPER_PREFIX.length); - if (body.endsWith('\n')) body = body.slice(0, -1); - body = normalizeTernaryBranchIndent(body); - body = normalizeJsxClosingBracketIndent(body); const finalWrapped = formatLinter.verifyAndFix( - `${FORMAT_WRAPPER_PREFIX}${body}\n`, + normalizeJsxClosingBracketIndent(refixedWrapped), lintConfig, getFormatterLintFilename(filename), ).output; - body = finalWrapped.slice(FORMAT_WRAPPER_PREFIX.length); - if (body.endsWith('\n')) body = body.slice(0, -1); - body = indentFormattedRegion(body, baseIndent, closingIndentOffset, inlinePrefix); + + const extracted = extractFormattedExpressionFromWrapper(finalWrapped, formattingContext.containerKind, filename); + const body = rebaseFormattedRegion( + extracted?.code ?? expr, + baseIndent, + extracted?.wrapperLineIndentWidth ?? 0, + ); return { code: body, - boundaryMap: buildBoundaryMap(expr, body, filename), + boundaryMap: buildExpressionBoundaryMap(expr, body, filename), }; } -function formatLintCode(transformed: SourceTransformState, filename: string): FormattedLintCode | null { - const pugRegions = transformed.document.mappedRegions - .filter(region => region.kind === 'pug') - .sort((a, b) => a.shadowStart - b.shadowStart); +function formatLintCode(transformed: LintTransformState, filename: string): FormattedLintCode | null { + const pugRegions = transformed.regionSegments + .slice() + .sort((a, b) => a.rewrittenStart - b.rewrittenStart); if (pugRegions.length === 0) return null; @@ -879,45 +410,35 @@ function formatLintCode(transformed: SourceTransformState, filename: string): Fo const regionSegments: FormattedRegionSegment[] = []; for (const region of pugRegions) { - if (cursor < region.shadowStart) { + if (cursor < region.rewrittenStart) { const formattedStart = code.length; - const copied = transformed.code.slice(cursor, region.shadowStart); + const copied = transformed.code.slice(cursor, region.rewrittenStart); code += copied; copySegments.push({ formattedStart, formattedEnd: code.length, transformedStart: cursor, - transformedEnd: region.shadowStart, + transformedEnd: region.rewrittenStart, }); } const formattedStart = code.length; - const baseIndent = getLineIndent(transformed.code, region.shadowStart); - const lineStart = transformed.code.lastIndexOf('\n', Math.max(0, region.shadowStart - 1)) + 1; - const beforeRegionOnLine = transformed.code.slice(lineStart, region.shadowStart); - const inlinePrefix = beforeRegionOnLine.trim().length > 0; - const closingIndentOffset = ( - /[?:]\s*$/.test(beforeRegionOnLine) - || /^\s*[?:].*=>\s*$/.test(beforeRegionOnLine) - ) - ? 2 - : 0; + const baseIndent = getLineIndent(transformed.code, region.rewrittenStart); const formattedRegion = formatPugRegionForLint( - transformed.code.slice(region.shadowStart, region.shadowEnd), + transformed.code.slice(region.rewrittenStart, region.rewrittenEnd), baseIndent, - closingIndentOffset, - inlinePrefix, + region.formattingContext, filename, ); code += formattedRegion.code; regionSegments.push({ formattedStart, formattedEnd: code.length, - transformedStart: region.shadowStart, - transformedEnd: region.shadowEnd, + transformedStart: region.rewrittenStart, + transformedEnd: region.rewrittenEnd, boundaryMap: formattedRegion.boundaryMap, }); - cursor = region.shadowEnd; + cursor = region.rewrittenEnd; } if (cursor < transformed.code.length) { @@ -956,16 +477,15 @@ function mapFormattedOffsetToTransformed( } function intersectsTransformedPugRegion( - transformed: SourceTransformState | null, + transformed: LintTransformState | null, generatedStart: number, generatedEnd: number, ): boolean { if (!transformed) return false; const end = Math.max(generatedStart, generatedEnd); - return transformed.document.mappedRegions.some(region => ( - region.kind === 'pug' - && generatedStart < region.shadowEnd - && end > region.shadowStart + return transformed.regionSegments.some(region => ( + generatedStart < region.rewrittenEnd + && end > region.rewrittenStart )); } @@ -988,10 +508,14 @@ function mapLintFix( return undefined; } + const baseStart = cached.transformed.mapRewrittenOffsetToBase(generatedStart); + const baseEnd = cached.transformed.mapRewrittenOffsetToBase(generatedEnd); + if (baseStart == null || baseEnd == null) return undefined; + const mapped = mapGeneratedRangeToOriginal( - cached.transformed.document, - generatedStart, - Math.max(0, generatedEnd - generatedStart), + cached.transformed.baseTransform.document, + baseStart, + Math.max(0, baseEnd - baseStart), ); if (!mapped) return undefined; @@ -1022,32 +546,13 @@ function shouldSuppressOriginalRangeMessage( return false; } -function isSyntheticStyleCallRange( - cached: CachedLintState, - generatedStart: number, - generatedEnd: number, -): boolean { - if (!cached.transformed) return false; - return cached.transformed.document.insertions.some(insertion => ( - insertion.kind === 'style-call' - && generatedStart >= insertion.shadowStart - && generatedEnd <= insertion.shadowEnd - )); -} - function shouldSuppressGeneratedRangeMessage( cached: CachedLintState, message: EslintLintMessage, generatedStart: number, generatedEnd: number, ): boolean { - if ( - message.ruleId === 'react/jsx-boolean-value' - && overlapsRangeList(cached.providerBooleanValueRanges, generatedStart, generatedEnd) - ) { - return true; - } - if (isSyntheticStyleCallRange(cached, generatedStart, generatedEnd)) { + if (overlapsRangeList(cached.syntheticStyleCallRanges, generatedStart, generatedEnd)) { return true; } return false; @@ -1094,10 +599,14 @@ function mapLintMessage( return null; } + const baseStart = cached.transformed.mapRewrittenOffsetToBase(generatedStart); + const baseEnd = cached.transformed.mapRewrittenOffsetToBase(generatedEnd); + if (baseStart == null || baseEnd == null) return message; + const mapped = mapGeneratedRangeToOriginal( - cached.transformed.document, - generatedStart, - Math.max(1, generatedEnd - generatedStart), + cached.transformed.baseTransform.document, + baseStart, + Math.max(1, baseEnd - baseStart), ); if (!mapped) return message; @@ -1107,7 +616,7 @@ function mapLintMessage( const startLc = offsetToLineColumn(cached.originalText, mapped.start); const endLc = offsetToLineColumn(cached.originalText, mapped.end); - const hasTransformedPug = cached.transformed.regions.length > 0; + const hasTransformedPug = cached.transformed.baseTransform.regions.length > 0; const mappedFix = hasTransformedPug ? undefined : mapLintFix(message.fix, cached); const mappedSuggestions = hasTransformedPug ? undefined @@ -1156,7 +665,7 @@ function createReactPugProcessor( transformed: null, formatted: null, legacyStyleStatementRanges, - providerBooleanValueRanges: [], + syntheticStyleCallRanges: [], }); } else { cache.delete(filename); @@ -1168,16 +677,15 @@ function createReactPugProcessor( }]; } - const transformed = transformSourceFile(text, filename, { + const transformed = createLintTransform(text, filename, { tagFunction: configuredTagFunction, - compileMode: 'runtime', requirePugImport: options.requirePugImport ?? false, classAttribute: options.classShorthandProperty ?? 'auto', classMerge: options.classShorthandMerge ?? 'auto', startupjsCssxjs: options.startupjsCssxjs ?? 'auto', componentPathFromUppercaseClassShorthand: options.componentPathFromUppercaseClassShorthand ?? true, }); - const hasTransformedPug = transformed.regions.length > 0; + const hasTransformedPug = transformed.baseTransform.regions.length > 0; const jsLikeFilename = isJavaScriptLikeFilename(filename); const shouldAlwaysVirtualizeJs = ( options.jsxInJsFiles === 'always' @@ -1195,7 +703,7 @@ function createReactPugProcessor( transformed, formatted, legacyStyleStatementRanges, - providerBooleanValueRanges: collectProviderBooleanValueRanges(transformed.code, filename), + syntheticStyleCallRanges: collectSyntheticStyleCallRanges(transformed), }); if (!shouldUseVirtualJsxFilename) return [transformed.code]; return [{ diff --git a/packages/eslint-plugin-react-pug/test/integration/autofix.test.ts b/packages/eslint-plugin-react-pug/test/integration/autofix.test.ts index 454ad21..4a848d3 100644 --- a/packages/eslint-plugin-react-pug/test/integration/autofix.test.ts +++ b/packages/eslint-plugin-react-pug/test/integration/autofix.test.ts @@ -73,15 +73,38 @@ afterEach(() => { }) describe('eslint --fix integration for react-pug processor', () => { - it('does not corrupt files and produces lint-clean output for an unformatted example fixture', async () => { + it('does not corrupt files and preserves only the expected non-fixable diagnostics for an unformatted example fixture', async () => { const tempDir = createTempFixtureCopy() const firstPass = await createExampleEslint(tempDir, true).lintFiles(['src/**/*.{js,jsx,ts,tsx}']) await ESLint.outputFixes(firstPass) const secondPass = await createExampleEslint(tempDir, false).lintFiles(['src/**/*.{js,jsx,ts,tsx}']) - const allMessages = secondPass.flatMap(result => result.messages) - expect(allMessages).toEqual([]) + const allMessages = secondPass.flatMap(result => ( + result.messages.map(message => ({ + filePath: result.filePath.replace(/.*\/src\//, 'src/'), + ruleId: message.ruleId, + line: message.line, + column: message.column, + message: message.message, + })) + )) + expect(allMessages).toEqual([ + { + filePath: 'src/StartupjsUiMdxComponents.js', + ruleId: 'react/jsx-boolean-value', + line: 205, + column: 34, + message: 'Value must be omitted for boolean attribute `value`', + }, + { + filePath: 'src/StartupjsUiMdxComponents.js', + ruleId: 'react/jsx-boolean-value', + line: 257, + column: 27, + message: 'Value must be omitted for boolean attribute `value`', + }, + ]) const fixedFiles = [ 'src/App.tsx', @@ -93,6 +116,7 @@ describe('eslint --fix integration for react-pug processor', () => { 'src/StartupjsUiDraggableReadme.js', 'src/StartupjsLogin.js', 'src/StartupjsUiMdxComponents.js', + 'src/StartupjsUiPrompt.tsx', 'src/StartupjsUiTypeCell.js', 'src/StartupjsUiWrapInput.tsx', 'src/StartupjsTabThree.js', diff --git a/packages/eslint-plugin-react-pug/test/integration/fixture-diagnostics.test.ts b/packages/eslint-plugin-react-pug/test/integration/fixture-diagnostics.test.ts index d1129df..61bd369 100644 --- a/packages/eslint-plugin-react-pug/test/integration/fixture-diagnostics.test.ts +++ b/packages/eslint-plugin-react-pug/test/integration/fixture-diagnostics.test.ts @@ -83,5 +83,14 @@ describe('eslint diagnostics for example-unformatted fixture', () => { const startupjsUiTypeCell = results.find(result => result.filePath.endsWith('/src/StartupjsUiTypeCell.js')) expect(startupjsUiTypeCell?.messages.some(message => message.ruleId === '@stylistic/no-multi-spaces')).toBe(false) + + const startupjsUiPrompt = results.find(result => result.filePath.endsWith('/src/StartupjsUiPrompt.tsx')) + expect(startupjsUiPrompt?.messages.some(message => message.ruleId === '@stylistic/indent')).toBe(false) + + const startupjsUiMdxComponents = results.find(result => result.filePath.endsWith('/src/StartupjsUiMdxComponents.js')) + expect(startupjsUiMdxComponents?.messages.map(message => message.ruleId)).toEqual([ + 'react/jsx-boolean-value', + 'react/jsx-boolean-value', + ]) }, 30000) }) diff --git a/packages/eslint-plugin-react-pug/test/integration/startupjs-ui-regressions.test.ts b/packages/eslint-plugin-react-pug/test/integration/startupjs-ui-regressions.test.ts index b3be6d8..a4ee7a9 100644 --- a/packages/eslint-plugin-react-pug/test/integration/startupjs-ui-regressions.test.ts +++ b/packages/eslint-plugin-react-pug/test/integration/startupjs-ui-regressions.test.ts @@ -48,13 +48,14 @@ function createStartupjsUiStyleEslint(fix: boolean): ESLint { } describe('startupjs-ui regressions', () => { - it('does not report false indent or Provider(value=true) diagnostics for startupjs-ui repros', async () => { + it('does not report false processor diagnostics for startupjs-ui repros', async () => { const files = [ resolve(fixtureRoot, 'StartupjsUiDialogsReadme.js'), resolve(fixtureRoot, 'StartupjsUiDraggableReadme.js'), resolve(fixtureRoot, 'StartupjsUiTypeCell.js'), resolve(fixtureRoot, 'StartupjsUiWrapInput.tsx'), resolve(fixtureRoot, 'StartupjsUiMdxComponents.js'), + resolve(fixtureRoot, 'StartupjsUiPrompt.tsx'), ] const results = await createStartupjsUiStyleEslint(false).lintFiles(files) @@ -68,7 +69,22 @@ describe('startupjs-ui regressions', () => { })) )) - expect(messages).toEqual([]) + expect(messages).toEqual([ + { + column: 34, + filePath: resolve(fixtureRoot, 'StartupjsUiMdxComponents.js'), + line: 205, + message: 'Value must be omitted for boolean attribute `value`', + ruleId: 'react/jsx-boolean-value', + }, + { + column: 27, + filePath: resolve(fixtureRoot, 'StartupjsUiMdxComponents.js'), + line: 257, + message: 'Value must be omitted for boolean attribute `value`', + ruleId: 'react/jsx-boolean-value', + }, + ]) }) it('still reports jsx-boolean-value for intrinsic boolean attrs inside pug', async () => { @@ -97,6 +113,7 @@ describe('startupjs-ui regressions', () => { 'StartupjsUiDialogsReadme.js', 'StartupjsUiDraggableReadme.js', 'StartupjsUiMdxComponents.js', + 'StartupjsUiPrompt.tsx', 'StartupjsUiTypeCell.js', 'StartupjsUiWrapInput.tsx', ]) { 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 50a2493..2ce5fec 100644 --- a/packages/eslint-plugin-react-pug/test/unit/processor.test.ts +++ b/packages/eslint-plugin-react-pug/test/unit/processor.test.ts @@ -253,6 +253,60 @@ describe('eslint-plugin-react-pug processor', () => { `); }); + it('formats multiline pug used as an object-property value without stylistic indent drift', () => { + const processor = createReactPugProcessor(); + const input = [ + 'const config = {', + ' children: pug`', + " - const label = 'Child'", + ' Span= label', + ' `', + '}', + ].join('\n'); + + const [block] = processor.preprocess(input, 'file.jsx'); + const code = typeof block === 'string' ? block : block.text; + expect(code).toMatchInlineSnapshot(` + "const config = { + children: (() => { + const label = 'Child' + return {label} + })() + }" + `); + + const lintMessages = lintStylisticIndent(code, 'file.jsx'); + const mapped = processor.postprocess([lintMessages as any], 'file.jsx'); + expect(mapped.filter(message => String(message.ruleId).includes('indent'))).toEqual([]); + }); + + it('formats multiline pug used in a ternary branch without stylistic indent drift', () => { + const processor = createReactPugProcessor(); + const input = [ + 'const view = ready', + ' ? pug`', + " - const label = 'Yes'", + ' Span= label', + ' `', + ' : null', + ].join('\n'); + + const [block] = processor.preprocess(input, 'file.jsx'); + const code = typeof block === 'string' ? block : block.text; + expect(code).toMatchInlineSnapshot(` + "const view = ready + ? (() => { + const label = 'Yes' + return {label} + })() + : null" + `); + + const lintMessages = lintStylisticIndent(code, 'file.jsx'); + const mapped = processor.postprocess([lintMessages as any], 'file.jsx'); + expect(mapped.filter(message => String(message.ruleId).includes('indent'))).toEqual([]); + }); + it('suppresses legacy styl warnings without hiding normal linting', () => { const processor = createReactPugProcessor(); const input = [ diff --git a/packages/react-pug-core/package.json b/packages/react-pug-core/package.json index fb9655e..2f47c49 100644 --- a/packages/react-pug-core/package.json +++ b/packages/react-pug-core/package.json @@ -11,7 +11,9 @@ "prepublishOnly": "npm run build" }, "dependencies": { + "@babel/generator": "^7.29.0", "@babel/parser": "^7.0.0", + "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.13", "@react-pug/pug-lexer": "^0.1.7", "@volar/source-map": "^2.4.28", diff --git a/packages/react-pug-core/src/index.ts b/packages/react-pug-core/src/index.ts index 8156a38..d59e4e6 100644 --- a/packages/react-pug-core/src/index.ts +++ b/packages/react-pug-core/src/index.ts @@ -4,5 +4,6 @@ export * from './language/pugToTsx'; export * from './language/shadowDocument'; export * from './language/positionMapping'; export * from './language/sourceTransform'; +export * from './language/lintTransform'; export * from './language/diagnosticMapping'; export * from './language/tagFunctionPresence'; diff --git a/packages/react-pug-core/src/language/lintTransform.ts b/packages/react-pug-core/src/language/lintTransform.ts new file mode 100644 index 0000000..6721c3b --- /dev/null +++ b/packages/react-pug-core/src/language/lintTransform.ts @@ -0,0 +1,764 @@ +import generate from '@babel/generator'; +import { parse, parseExpression } from '@babel/parser'; +import * as t from '@babel/types'; +import type { ShadowMappedRegion } from './mapping'; +import type { SourceTransformOptions, SourceTransformResult } from './sourceTransform'; +import { transformSourceFile } from './sourceTransform'; + +export interface RewrittenCopySegment { + rewrittenStart: number; + rewrittenEnd: number; + baseStart: number; + baseEnd: number; +} + +export interface RewrittenRegionSegment { + rewrittenStart: number; + rewrittenEnd: number; + baseStart: number; + baseEnd: number; + boundaryMap: number[]; + region: ShadowMappedRegion; + formattingContext: RegionFormattingContext; +} + +export interface RewrittenPugRegionsResult { + code: string; + copySegments: RewrittenCopySegment[]; + regionSegments: RewrittenRegionSegment[]; + mapRewrittenOffsetToBase: (offset: number) => number | null; + mapBaseOffsetToRewritten: (offset: number) => number | null; +} + +export interface BoundaryMappedExpression { + code: string; + boundaryMap: number[]; +} + +export interface LintTransformResult extends RewrittenPugRegionsResult { + baseTransform: SourceTransformResult; + mapGeneratedOffsetToOriginal: (offset: number) => number | null; + mapBaseOffsetToOriginal: (offset: number) => number | null; +} + +export type RegionContainerKind = + | 'standalone' + | 'return-value' + | 'variable-init' + | 'assignment-value' + | 'object-property-value' + | 'call-argument' + | 'arrow-body' + | 'logical-operand' + | 'conditional-branch' + | 'other-expression'; + +export interface RegionFormattingContext { + containerKind: RegionContainerKind; +} + +export interface FormattingWrapperExtraction { + code: string; + wrapperLineIndentWidth: number; +} + +interface ExpressionToken { + start: number; + end: number; + label: string; + value?: unknown; + raw: string; +} + +const FORMAT_WRAPPER_PREFIX = 'const __pug = '; + +function isTypeScriptLikeFilename(filename: string): boolean { + return /\.(?:ts|tsx|mts|cts)$/i.test(filename); +} + +function getExpressionParserPlugins(filename: string): any[] { + return [ + 'jsx', + 'decorators-legacy', + ...(isTypeScriptLikeFilename(filename) ? ['typescript'] : []), + ] as any; +} + +function parseExpressionTokens(expr: string, filename: string): ExpressionToken[] { + const wrapped = `${FORMAT_WRAPPER_PREFIX}${expr}\n`; + const ast = parse(wrapped, { + sourceType: 'module', + plugins: getExpressionParserPlugins(filename), + errorRecovery: false, + tokens: true, + }) as any; + + const prefixLength = FORMAT_WRAPPER_PREFIX.length; + const endLimit = wrapped.length - 1; + return (ast.tokens ?? []) + .filter((token: any) => { + if (token.start < prefixLength || token.end > endLimit) return false; + const rawText = wrapped.slice(token.start, token.end); + if (token.type?.label === 'jsxText' && rawText.trim().length === 0) return false; + return true; + }) + .map((token: any) => ({ + start: token.start - prefixLength, + end: token.end - prefixLength, + label: token.type?.label ?? token.type, + value: token.value, + raw: wrapped.slice(token.start, token.end), + })); +} + +function tokenAlignmentKey(token: Pick): string { + switch (token.label) { + case 'name': + case 'jsxName': + case 'privateName': + return `${token.label}:${token.raw}`; + case 'string': + return `${token.label}:${String(token.value ?? token.raw)}`; + case 'num': + case 'bigint': + case 'decimal': + case 'regexp': + return `${token.label}:${token.raw}`; + case 'jsxText': + return `${token.label}:${token.raw.trim()}`; + default: + return token.label; + } +} + +function alignExpressionTokens(originalTokens: ExpressionToken[], rewrittenTokens: ExpressionToken[]): Array<[number, number]> { + const originalKeys = originalTokens.map(tokenAlignmentKey); + const rewrittenKeys = rewrittenTokens.map(tokenAlignmentKey); + const dp = Array.from({ length: originalKeys.length + 1 }, () => new Array(rewrittenKeys.length + 1).fill(0)); + + for (let i = originalKeys.length - 1; i >= 0; i -= 1) { + for (let j = rewrittenKeys.length - 1; j >= 0; j -= 1) { + if (originalKeys[i] === rewrittenKeys[j]) dp[i][j] = dp[i + 1][j + 1] + 1; + else dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + + const matches: Array<[number, number]> = []; + let i = 0; + let j = 0; + while (i < originalKeys.length && j < rewrittenKeys.length) { + if (originalKeys[i] === rewrittenKeys[j]) { + matches.push([i, j]); + i += 1; + j += 1; + continue; + } + if (dp[i + 1][j] >= dp[i][j + 1]) i += 1; + else j += 1; + } + + return matches; +} + +export function buildExpressionBoundaryMap(originalExpr: string, rewrittenExpr: string, filename: string): number[] { + try { + const originalTokens = parseExpressionTokens(originalExpr, filename); + const rewrittenTokens = parseExpressionTokens(rewrittenExpr, filename); + const matchedTokens = alignExpressionTokens(originalTokens, rewrittenTokens); + if (matchedTokens.length === 0) throw new Error('token-alignment-empty'); + + const anchors = [{ rewritten: 0, original: 0 }]; + for (const [originalIndex, rewrittenIndex] of matchedTokens) { + const original = originalTokens[originalIndex]; + const rewritten = rewrittenTokens[rewrittenIndex]; + anchors.push({ rewritten: rewritten.start, original: original.start }); + anchors.push({ rewritten: rewritten.end, original: original.end }); + } + anchors.push({ rewritten: rewrittenExpr.length, original: originalExpr.length }); + anchors.sort((a, b) => a.rewritten - b.rewritten || a.original - b.original); + + const deduped: Array<{ rewritten: number; original: number }> = []; + for (const anchor of anchors) { + const last = deduped[deduped.length - 1]; + if (!last || last.rewritten !== anchor.rewritten || last.original !== anchor.original) deduped.push(anchor); + } + + const boundaryMap = new Array(rewrittenExpr.length + 1); + for (let i = 0; i < deduped.length - 1; i += 1) { + const current = deduped[i]; + const next = deduped[i + 1]; + const rewrittenSpan = next.rewritten - current.rewritten; + const originalSpan = next.original - current.original; + if (rewrittenSpan <= 0) continue; + + for (let offset = current.rewritten; offset < next.rewritten; offset += 1) { + const relative = offset - current.rewritten; + boundaryMap[offset] = current.original + Math.round(relative * originalSpan / rewrittenSpan); + } + } + + boundaryMap[rewrittenExpr.length] = originalExpr.length; + for (let i = 0; i < boundaryMap.length; i += 1) { + if (boundaryMap[i] == null) boundaryMap[i] = i === 0 ? 0 : boundaryMap[i - 1]; + } + + return boundaryMap; + } catch { + return Array.from({ length: rewrittenExpr.length + 1 }, (_, index) => ( + Math.min(originalExpr.length, Math.round(index * originalExpr.length / Math.max(1, rewrittenExpr.length))) + )); + } +} + +function unwrapLintComparableExpression(node: t.Expression): t.Expression { + if (t.isParenthesizedExpression(node)) return unwrapLintComparableExpression(node.expression as t.Expression); + if (t.isTSAsExpression(node)) return unwrapLintComparableExpression(node.expression as t.Expression); + if (t.isTSTypeAssertion(node)) return unwrapLintComparableExpression(node.expression as t.Expression); + if (t.isTSNonNullExpression(node)) return unwrapLintComparableExpression(node.expression as t.Expression); + return node; +} + +function isRepeatableTruthyExpression(node: t.Expression): boolean { + const unwrapped = unwrapLintComparableExpression(node); + return ( + t.isIdentifier(unwrapped) + || t.isThisExpression(unwrapped) + || t.isSuper(unwrapped) + || t.isMemberExpression(unwrapped) + || t.isOptionalMemberExpression(unwrapped) + ); +} + +function areEquivalentRepeatableExpressions(left: t.Expression, right: t.Expression): boolean { + const a = unwrapLintComparableExpression(left); + const b = unwrapLintComparableExpression(right); + if (a.type !== b.type) return false; + if (t.isIdentifier(a) && t.isIdentifier(b)) return a.name === b.name; + if (t.isThisExpression(a) && t.isThisExpression(b)) return true; + if (t.isSuper(a) && t.isSuper(b)) return true; + + if (t.isMemberExpression(a) && t.isMemberExpression(b)) { + return ( + a.computed === b.computed + && areEquivalentRepeatableExpressions(a.object as t.Expression, b.object as t.Expression) + && ( + a.computed + ? ( + t.isExpression(a.property) + && t.isExpression(b.property) + && areEquivalentRepeatableExpressions(a.property, b.property) + ) + : ( + t.isIdentifier(a.property) + && t.isIdentifier(b.property) + && a.property.name === b.property.name + ) + ) + ); + } + + if (t.isOptionalMemberExpression(a) && t.isOptionalMemberExpression(b)) { + return ( + a.computed === b.computed + && a.optional === b.optional + && areEquivalentRepeatableExpressions(a.object as t.Expression, b.object as t.Expression) + && ( + a.computed + ? areEquivalentRepeatableExpressions(a.property as t.Expression, b.property as t.Expression) + : ( + t.isIdentifier(a.property) + && t.isIdentifier(b.property) + && a.property.name === b.property.name + ) + ) + ); + } + + return false; +} + +function isFragmentJsxName(name: t.JSXIdentifier | t.JSXMemberExpression | t.JSXNamespacedName): boolean { + return ( + (t.isJSXIdentifier(name) && name.name === 'Fragment') + || ( + t.isJSXMemberExpression(name) + && t.isJSXIdentifier(name.object) + && name.object.name === 'React' + && t.isJSXIdentifier(name.property) + && name.property.name === 'Fragment' + ) + ); +} + +function isNode(value: unknown): value is t.Node { + return !!value && typeof value === 'object' && typeof (value as any).type === 'string'; +} + +function isTransparentContainer(node: t.Node): boolean { + return ( + t.isParenthesizedExpression(node) + || t.isTSAsExpression(node) + || t.isTSTypeAssertion(node) + || t.isTSNonNullExpression(node) + ); +} + +interface AstPathEntry { + node: t.Node; + parent: t.Node | null; + key: string | number | null; +} + +function findInnermostAstPathContainingRange( + root: t.Node, + start: number, + end: number, +): AstPathEntry[] | null { + let bestPath: AstPathEntry[] | null = null; + + const visit = (node: t.Node, parent: t.Node | null, key: string | number | null, path: AstPathEntry[]) => { + if (typeof node.start !== 'number' || typeof node.end !== 'number') return; + if (start < node.start || end > node.end) return; + + const nextPath = [...path, { node, parent, key }]; + if ( + !bestPath + || (node.end - node.start) <= ((bestPath[bestPath.length - 1].node.end ?? 0) - (bestPath[bestPath.length - 1].node.start ?? 0)) + ) { + bestPath = nextPath; + } + + for (const [childKey, value] of Object.entries(node as any)) { + if (childKey === 'loc' || childKey === 'start' || childKey === 'end' || childKey === 'extra') continue; + if (Array.isArray(value)) { + value.forEach((item, index) => { + if (isNode(item)) visit(item, node, index, nextPath); + }); + continue; + } + if (isNode(value)) visit(value, node, childKey, nextPath); + } + }; + + visit(root, null, null, []); + return bestPath; +} + +function getInlinePrefix(baseCode: string, offset: number): boolean { + const lineStart = baseCode.lastIndexOf('\n', Math.max(0, offset - 1)) + 1; + return baseCode.slice(lineStart, offset).trim().length > 0; +} + +function classifyRegionFormattingContextFromAst( + baseCode: string, + astRoot: t.Node | null, + start: number, + end: number, +): RegionFormattingContext { + const inlinePrefix = getInlinePrefix(baseCode, start); + + try { + const path = astRoot ? findInnermostAstPathContainingRange(astRoot, start, end) : null; + if (!path || path.length === 0) { + return { + containerKind: inlinePrefix ? 'other-expression' : 'standalone', + }; + } + + for (let index = path.length - 1; index >= 0; index -= 1) { + const entry = path[index]; + const parent = entry.parent; + if (!parent) continue; + + if (t.isConditionalExpression(parent) && (entry.key === 'consequent' || entry.key === 'alternate')) { + return { + containerKind: 'conditional-branch', + }; + } + } + + for (let index = path.length - 1; index >= 0; index -= 1) { + const entry = path[index]; + const parent = entry.parent; + if (!parent) continue; + + if (t.isReturnStatement(parent) && entry.key === 'argument') { + return { containerKind: 'return-value' }; + } + if (t.isVariableDeclarator(parent) && entry.key === 'init') { + return { containerKind: 'variable-init' }; + } + if (t.isAssignmentExpression(parent) && entry.key === 'right') { + return { containerKind: 'assignment-value' }; + } + if ( + (t.isObjectProperty(parent) || t.isObjectMethod(parent)) + && entry.key === 'value' + ) { + return { containerKind: 'object-property-value' }; + } + if ( + (t.isCallExpression(parent) || t.isNewExpression(parent)) + && typeof entry.key === 'number' + ) { + return { containerKind: 'call-argument' }; + } + if (t.isArrowFunctionExpression(parent) && entry.key === 'body') { + return { containerKind: 'arrow-body' }; + } + if (t.isLogicalExpression(parent) && (entry.key === 'left' || entry.key === 'right')) { + return { containerKind: 'logical-operand' }; + } + } + + return { + containerKind: inlinePrefix ? 'other-expression' : 'standalone', + }; + } catch { + return { + containerKind: inlinePrefix ? 'other-expression' : 'standalone', + }; + } +} + +function createRegionFormattingContextResolver(baseCode: string, filename: string) { + let astRoot: t.Node | null = null; + try { + const ast = parse(baseCode, { + sourceType: 'module', + plugins: getExpressionParserPlugins(filename), + createParenthesizedExpressions: true, + errorRecovery: false, + }) as any; + astRoot = ast.program as t.Node; + } catch { + astRoot = null; + } + + return (start: number, end: number) => classifyRegionFormattingContextFromAst(baseCode, astRoot, start, end); +} + +function normalizeLintExpressionAst(node: T): T { + if (t.isConditionalExpression(node)) { + node.test = normalizeLintExpressionAst(node.test); + node.consequent = normalizeLintExpressionAst(node.consequent); + node.alternate = normalizeLintExpressionAst(node.alternate); + + if (isRepeatableTruthyExpression(node.test) && areEquivalentRepeatableExpressions(node.test, node.consequent)) { + return t.logicalExpression('||', node.test, node.alternate) as T; + } + + return node; + } + + if (t.isJSXElement(node)) { + node.children = node.children.map(child => normalizeLintExpressionAst(child)); + if ( + isFragmentJsxName(node.openingElement.name) + && node.openingElement.attributes.length === 0 + && !node.openingElement.selfClosing + && node.closingElement + ) { + return t.jsxFragment(t.jsxOpeningFragment(), t.jsxClosingFragment(), node.children) as T; + } + return node; + } + + if (t.isJSXFragment(node)) { + node.children = node.children.map(child => normalizeLintExpressionAst(child)); + return node; + } + + if (t.isJSXExpressionContainer(node)) { + node.expression = normalizeLintExpressionAst(node.expression); + return node; + } + + if (Array.isArray((node as any).children)) { + (node as any).children = (node as any).children.map((child: any) => normalizeLintExpressionAst(child)); + } + + for (const [key, value] of Object.entries(node as any)) { + if (key === 'loc' || key === 'start' || key === 'end' || key === 'extra') continue; + if (Array.isArray(value)) { + (node as any)[key] = value.map((item: any) => { + if (!item || typeof item !== 'object' || typeof item.type !== 'string') return item; + return normalizeLintExpressionAst(item as t.Node); + }); + continue; + } + if (value && typeof value === 'object' && typeof (value as any).type === 'string') { + (node as any)[key] = normalizeLintExpressionAst(value as t.Node); + } + } + + return node; +} + +export function normalizePugExpressionForLint(expr: string, filename: string): BoundaryMappedExpression { + try { + const ast = parseExpression(expr, { + plugins: getExpressionParserPlugins(filename), + errorRecovery: false, + }) as t.Expression; + const normalized = normalizeLintExpressionAst(ast); + const code = generate(normalized, { + comments: true, + jsescOption: { + minimal: true, + }, + }).code; + return { + code, + boundaryMap: buildExpressionBoundaryMap(expr, code, filename), + }; + } catch { + return { + code: expr, + boundaryMap: buildExpressionBoundaryMap(expr, expr, filename), + }; + } +} + +interface FormattingWrapperPlan { + code: string; + extract: (ast: any) => { start: number; end: number } | null; +} + +function getFormattingWrapperPlan(expr: string, containerKind: RegionContainerKind): FormattingWrapperPlan { + switch (containerKind) { + case 'conditional-branch': + return { + code: `const __ctx = __cond ? ${expr} : __alt\n`, + extract: (ast) => ast.program.body[0]?.declarations?.[0]?.init?.consequent ?? null, + }; + case 'object-property-value': + return { + code: `const __ctx = {\n value: ${expr}\n}\n`, + extract: (ast) => ast.program.body[0]?.declarations?.[0]?.init?.properties?.[0]?.value ?? null, + }; + case 'return-value': + return { + code: `function __ctx () {\n return ${expr}\n}\n`, + extract: (ast) => ast.program.body[0]?.body?.body?.[0]?.argument ?? null, + }; + case 'assignment-value': + return { + code: `__reactPugFmt = ${expr}\n`, + extract: (ast) => ast.program.body[0]?.expression?.right ?? null, + }; + case 'call-argument': + return { + code: `__reactPugFmt(${expr})\n`, + extract: (ast) => ast.program.body[0]?.expression?.arguments?.[0] ?? null, + }; + case 'arrow-body': + return { + code: `const __ctx = () => ${expr}\n`, + extract: (ast) => ast.program.body[0]?.declarations?.[0]?.init?.body ?? null, + }; + case 'logical-operand': + return { + code: `const __ctx = __cond && ${expr}\n`, + extract: (ast) => ast.program.body[0]?.declarations?.[0]?.init?.right ?? null, + }; + case 'standalone': + return { + code: `${expr}\n`, + extract: (ast) => ast.program.body[0]?.expression ?? null, + }; + case 'variable-init': + case 'other-expression': + default: + return { + code: `const __pug = ${expr}\n`, + extract: (ast) => ast.program.body[0]?.declarations?.[0]?.init ?? null, + }; + } +} + +export function createFormattingWrapper(expr: string, containerKind: RegionContainerKind): string { + return getFormattingWrapperPlan(expr, containerKind).code; +} + +function getLineIndentWidth(text: string, offset: number): number { + const lineStart = text.lastIndexOf('\n', Math.max(0, offset - 1)) + 1; + const lineEnd = text.indexOf('\n', lineStart) >= 0 ? text.indexOf('\n', lineStart) : text.length; + const line = text.slice(lineStart, lineEnd); + return line.match(/^[ \t]*/)?.[0].length ?? 0; +} + +export function extractFormattedExpressionFromWrapper( + formattedWrapper: string, + containerKind: RegionContainerKind, + filename: string, +): FormattingWrapperExtraction | null { + try { + const ast = parse(formattedWrapper, { + sourceType: 'module', + plugins: getExpressionParserPlugins(filename), + createParenthesizedExpressions: true, + errorRecovery: false, + }) as any; + const wrapper = getFormattingWrapperPlan('', containerKind); + const node = wrapper.extract(ast); + if (!node || typeof node.start !== 'number' || typeof node.end !== 'number') return null; + + return { + code: formattedWrapper.slice(node.start, node.end), + wrapperLineIndentWidth: getLineIndentWidth(formattedWrapper, node.start), + }; + } catch { + return null; + } +} + +export function rewriteMappedPugRegions( + baseTransform: SourceTransformResult, + filename: string, + rewriteRegion: (expr: string, region: ShadowMappedRegion, filename: string) => BoundaryMappedExpression, +): RewrittenPugRegionsResult { + const pugRegions = baseTransform.document.mappedRegions + .filter(region => region.kind === 'pug') + .sort((a, b) => a.shadowStart - b.shadowStart); + + if (pugRegions.length === 0) { + return { + code: baseTransform.code, + copySegments: [{ + rewrittenStart: 0, + rewrittenEnd: baseTransform.code.length, + baseStart: 0, + baseEnd: baseTransform.code.length, + }], + regionSegments: [], + mapRewrittenOffsetToBase: (offset: number) => ( + offset >= 0 && offset <= baseTransform.code.length ? offset : null + ), + mapBaseOffsetToRewritten: (offset: number) => ( + offset >= 0 && offset <= baseTransform.code.length ? offset : null + ), + }; + } + + let code = ''; + let cursor = 0; + const copySegments: RewrittenCopySegment[] = []; + const regionSegments: RewrittenRegionSegment[] = []; + const resolveFormattingContext = createRegionFormattingContextResolver(baseTransform.code, filename); + + for (const region of pugRegions) { + if (cursor < region.shadowStart) { + const rewrittenStart = code.length; + const copied = baseTransform.code.slice(cursor, region.shadowStart); + code += copied; + copySegments.push({ + rewrittenStart, + rewrittenEnd: code.length, + baseStart: cursor, + baseEnd: region.shadowStart, + }); + } + + const rewrittenStart = code.length; + const originalExpr = baseTransform.code.slice(region.shadowStart, region.shadowEnd); + const rewritten = rewriteRegion(originalExpr, region, filename); + code += rewritten.code; + regionSegments.push({ + rewrittenStart, + rewrittenEnd: code.length, + baseStart: region.shadowStart, + baseEnd: region.shadowEnd, + boundaryMap: rewritten.boundaryMap, + region, + formattingContext: resolveFormattingContext(region.shadowStart, region.shadowEnd), + }); + cursor = region.shadowEnd; + } + + if (cursor < baseTransform.code.length) { + const rewrittenStart = code.length; + const copied = baseTransform.code.slice(cursor); + code += copied; + copySegments.push({ + rewrittenStart, + rewrittenEnd: code.length, + baseStart: cursor, + baseEnd: baseTransform.code.length, + }); + } + + const mapRewrittenOffsetToBase = (offset: number): number | null => { + const clamped = Math.max(0, Math.min(offset, code.length)); + + for (const region of regionSegments) { + if (clamped < region.rewrittenStart || clamped > region.rewrittenEnd) continue; + const localOffset = clamped - region.rewrittenStart; + const mappedLocal = region.boundaryMap[Math.min(localOffset, region.boundaryMap.length - 1)] ?? 0; + return region.baseStart + mappedLocal; + } + + for (const segment of copySegments) { + if (clamped < segment.rewrittenStart || clamped > segment.rewrittenEnd) continue; + return segment.baseStart + (clamped - segment.rewrittenStart); + } + + return null; + }; + + const mapBaseOffsetToRewritten = (offset: number): number | null => { + const clamped = Math.max(0, Math.min(offset, baseTransform.code.length)); + + for (const region of regionSegments) { + if (clamped < region.baseStart || clamped > region.baseEnd) continue; + const localOffset = clamped - region.baseStart; + + for (let i = 0; i < region.boundaryMap.length; i += 1) { + if (region.boundaryMap[i] >= localOffset) { + return region.rewrittenStart + i; + } + } + + return region.rewrittenEnd; + } + + for (const segment of copySegments) { + if (clamped < segment.baseStart || clamped > segment.baseEnd) continue; + return segment.rewrittenStart + (clamped - segment.baseStart); + } + + return null; + }; + + return { + code, + copySegments, + regionSegments, + mapRewrittenOffsetToBase, + mapBaseOffsetToRewritten, + }; +} + +export function createLintTransform( + sourceText: string, + fileName: string, + options: SourceTransformOptions = {}, +): LintTransformResult { + const baseTransform = transformSourceFile(sourceText, fileName, { + ...options, + compileMode: 'runtime', + }); + const rewritten = rewriteMappedPugRegions(baseTransform, fileName, (expr, _region, currentFileName) => ( + normalizePugExpressionForLint(expr, currentFileName) + )); + + return { + ...rewritten, + baseTransform, + mapGeneratedOffsetToOriginal: (offset: number) => { + const baseOffset = rewritten.mapRewrittenOffsetToBase(offset); + return baseOffset == null ? null : baseTransform.mapGeneratedOffsetToOriginal(baseOffset); + }, + mapBaseOffsetToOriginal: (offset: number) => baseTransform.mapGeneratedOffsetToOriginal(offset), + }; +} diff --git a/packages/react-pug-core/test/unit/lintTransform.test.ts b/packages/react-pug-core/test/unit/lintTransform.test.ts new file mode 100644 index 0000000..9efdd7c --- /dev/null +++ b/packages/react-pug-core/test/unit/lintTransform.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'vitest' +import { + buildExpressionBoundaryMap, + createLintTransform, + createFormattingWrapper, + extractFormattedExpressionFromWrapper, + normalizePugExpressionForLint, +} from '../../src/language/lintTransform' + +function remapSnippet (source: string, generated: string, snippet: string, mapper: (offset: number) => number | null): string | null { + const generatedOffset = generated.indexOf(snippet) + expect(generatedOffset).toBeGreaterThanOrEqual(0) + const originalOffset = mapper(generatedOffset) + if (originalOffset == null) return null + return source.slice(originalOffset, originalOffset + snippet.length) +} + +describe('lintTransform', () => { + it('normalizes attrless Fragment elements to JSX fragment shorthand', () => { + const result = normalizePugExpressionForLint('Ok', 'file.jsx') + + expect(result.code).toBe('<>Ok') + expect(result.boundaryMap).toHaveLength(result.code.length + 1) + }) + + it('normalizes repeated ternaries into logical fallback expressions when safe', () => { + const result = normalizePugExpressionForLint('children ? children : Fallback', 'file.jsx') + + expect(result.code).toBe('children || Fallback') + expect(result.boundaryMap).toHaveLength(result.code.length + 1) + }) + + it('keeps non-repeatable ternaries unchanged', () => { + const result = normalizePugExpressionForLint('getValue() ? getValue() : fallback', 'file.jsx') + + expect(result.code).toBe('getValue() ? getValue() : fallback') + }) + + it('rewrites only pug regions and leaves plain JSX untouched', () => { + const source = [ + "import { Fragment, pug } from 'startupjs'", + 'const plain = Plain', + 'const view = pug`', + ' Fragment', + ' span Pug', + '`', + ].join('\n') + + const result = createLintTransform(source, 'file.jsx') + + expect(result.code).toContain('const plain = Plain') + expect(result.code).toContain('const view = <>Pug') + }) + + it('maps rewritten fragment output back to original pug source', () => { + const source = [ + "import { Fragment, pug } from 'startupjs'", + 'const view = pug`', + ' Fragment', + ' Button Save', + '`', + ].join('\n') + + const result = createLintTransform(source, 'file.jsx') + + expect(remapSnippet(source, result.code, 'Button', result.mapGeneratedOffsetToOriginal)).toBe('Button') + expect(remapSnippet(source, result.code, 'Save', result.mapGeneratedOffsetToOriginal)).toBe('Save') + }) + + it('maps rewritten logical fallback output back to original pug source', () => { + const source = [ + "import { pug } from 'startupjs'", + 'function Demo ({ children }) {', + ' return pug`', + ' if children', + ' = children', + ' else', + ' span Fallback', + ' `', + '}', + ].join('\n') + + const result = createLintTransform(source, 'file.jsx') + + expect(result.code).toContain('children || Fallback') + expect(remapSnippet(source, result.code, 'children', result.mapGeneratedOffsetToOriginal)).toBe('children') + expect(remapSnippet(source, result.code, 'Fallback', result.mapGeneratedOffsetToOriginal)).toBe('Fallback') + }) + + it('classifies ternary branch formatting context structurally', () => { + const source = [ + "import { pug } from 'startupjs'", + 'const view = condition', + ' ? pug`', + ' span Yes', + ' `', + " : No", + ].join('\n') + + const result = createLintTransform(source, 'file.jsx') + expect(result.regionSegments).toHaveLength(1) + expect(result.regionSegments[0].formattingContext).toEqual({ containerKind: 'conditional-branch' }) + }) + + it('classifies object-property formatting context structurally', () => { + const source = [ + "import { pug } from 'startupjs'", + 'const config = {', + ' children: pug`', + ' span Child', + ' `', + '}', + ].join('\n') + + const result = createLintTransform(source, 'file.jsx') + expect(result.regionSegments).toHaveLength(1) + expect(result.regionSegments[0].formattingContext).toEqual({ containerKind: 'object-property-value' }) + }) + + it('classifies return-value formatting context structurally', () => { + const source = [ + "import { pug } from 'startupjs'", + 'function Demo () {', + ' return pug`', + ' span Demo', + ' `', + '}', + ].join('\n') + + const result = createLintTransform(source, 'file.jsx') + expect(result.regionSegments).toHaveLength(1) + expect(result.regionSegments[0].formattingContext).toEqual({ containerKind: 'return-value' }) + }) + + it('classifies call-argument formatting context structurally', () => { + const source = [ + "import { pug } from 'startupjs'", + 'render(', + ' pug`', + ' span Demo', + ' `', + ')', + ].join('\n') + + const result = createLintTransform(source, 'file.jsx') + expect(result.regionSegments).toHaveLength(1) + expect(result.regionSegments[0].formattingContext).toEqual({ containerKind: 'call-argument' }) + }) + + it('builds a stable boundary map for equivalent expressions', () => { + const boundaryMap = buildExpressionBoundaryMap('children ? children : Fallback', 'children || Fallback', 'file.jsx') + expect(boundaryMap).toHaveLength('children || Fallback'.length + 1) + expect(boundaryMap[0]).toBe(0) + expect(boundaryMap[boundaryMap.length - 1]).toBe('children ? children : Fallback'.length) + }) + + it('extracts a multiline conditional branch expression from a formatting wrapper', () => { + const wrapper = createFormattingWrapper('No photo', 'conditional-branch') + expect(wrapper).toContain('__cond ?') + + const extracted = extractFormattedExpressionFromWrapper( + [ + 'const __ctx = __cond ? (', + ' No photo', + ') : __alt', + '', + ].join('\n'), + 'conditional-branch', + 'file.jsx', + ) + + expect(extracted).toEqual({ + code: "(\n No photo\n)", + wrapperLineIndentWidth: 0, + }) + }) + + it('extracts an object-property expression from a formatting wrapper', () => { + const extracted = extractFormattedExpressionFromWrapper( + [ + 'const __ctx = {', + ' value: (', + ' ', + ' )', + '}', + '', + ].join('\n'), + 'object-property-value', + 'file.jsx', + ) + + expect(extracted).toEqual({ + code: "(\n \n )", + wrapperLineIndentWidth: 2, + }) + }) +}) diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..25eb040 --- /dev/null +++ b/plan.md @@ -0,0 +1,396 @@ +# ESLint / Core Architecture Revamp Plan + +## Context +We found recurring false positives in real consumer repos (`../startupjs`, `../startupjs-ui`) even when the runtime transform was semantically correct: + +- `react/jsx-fragments` on Pug `Fragment` +- `no-unneeded-ternary` on Pug `if / else` +- `@stylistic/no-multi-spaces` mapped to `return pug\`` due to inline region formatting +- previous cases around `jsx-indent`, `Provider(value=true)`, legacy `styl\`...\`` warnings, and comment-triggered VS Code highlighting leakage + +These are not isolated fixture bugs. They expose a structural issue: + +- one transform pipeline is currently serving multiple consumers with different needs +- the ESLint plugin has grown extra logic to compensate for code shape problems after the core transform is already done +- too much of the correction currently happens in the ESLint plugin string-formatting layer instead of in a stable semantic layer + +## Problems Identified + +### 1. Runtime-correct JSX is not automatically lint-correct JSX +Examples: +- attrless `Fragment` lowers to `...`, which is runtime-correct but conflicts with `react/jsx-fragments` +- `if children ... else ...` lowers to a ternary, which is runtime-correct but can conflict with `no-unneeded-ternary` + +### 2. Semantic lowering and lint normalization are mixed into the ESLint plugin +Current plugin responsibilities include: +- source transform orchestration +- lint-only AST normalization +- prettier pass +- internal ESLint stylistic pass +- custom indentation normalization +- range remapping + +This makes the plugin too stateful and too responsible for semantics that should live closer to the core transform. + +### 3. Region rewriting is ad hoc +We currently need to rewrite transformed Pug regions for linting, but the pipeline for doing so is local to the ESLint plugin. +That means: +- harder reuse +- harder testing +- harder reasoning about mappings + +### 4. String-format corrections are too late in the pipeline +Some bugs were not semantic bugs at all; they came from splicing formatted regions back into surrounding code, especially when a Pug region starts inline (for example after `return `). + +## Architecture Direction + +### Goal +Split responsibilities more cleanly: + +- `react-pug-core` owns semantic/lint-specific normalization of Pug-generated JSX +- the ESLint plugin owns only: + - invoking the lint-oriented core transform path + - final stylistic shaping / formatter passes + - diagnostic/fix remapping + +### Explicit Boundary +This branch should enforce one clear rule: + +- if logic changes the semantic/code-shape of generated Pug JSX in a lint-aware but semantics-preserving way, it belongs in `react-pug-core` +- if logic only formats already-normalized JSX for stylistic convergence under ESLint, it stays in the ESLint plugin + +That means the plugin must stop owning: +- JSX fragment normalization +- conditional-expression simplification +- generic region-rewrite infrastructure + +And the core should not grow: +- prettier passes +- internal stylistic ESLint formatting +- rule-specific diagnostic suppression + +### New Core Concepts + +#### A. Lint normalization of transformed Pug regions +Add a core lint-normalization layer which works on already generated JSX expressions from Pug regions and performs **semantics-preserving, lint-oriented** normalization. + +Examples: +- attrless `Fragment` / `React.Fragment` -> fragment shorthand +- safe repeated ternaries like `x ? x : y` -> `x || y` + +Important constraint: +- only perform transformations proven safe and repeatable +- do not broaden rewrites just to silence rules + +#### B. Generic mapped-region rewriting helper +Add a core helper that can rewrite only Pug-mapped regions while preserving mapping back to the base transformed output. + +This helper should: +- iterate only mapped Pug regions +- allow rewriting region text +- generate boundary maps from rewritten region back to original transformed region +- expose mapping from rewritten offsets back to the base transform + +This is generic and reusable, not ESLint-specific. + +#### C. Lint-oriented transform result +Expose a core helper that produces a lint-oriented transform result from runtime output. + +This result should provide: +- `code` for lint consumers +- mapping from lint-oriented output back to original source +- access to the underlying base transform/document for downstream mapping + +This becomes the semantic input to the ESLint plugin. + +## Target State + +### Core +Add something conceptually like: +- `normalizePugExpressionForLint(...)` +- `rewriteMappedPugRegions(...)` +- `createLintTransform(...)` + +### ESLint plugin +The plugin should: +1. call core runtime transform +2. call core lint transform / lint normalization helper +3. run final formatting pass on the lint-oriented code +4. remap diagnostics from formatted -> lint transform -> original source + +The plugin should no longer own lint-specific AST normalization logic itself. + +## Constraints / Non-Goals + +- no broad suppressions for rule classes +- do not weaken diagnostics for real JS/TS code inside Pug +- do not relax existing correctness checks just to get green tests +- keep the old narrow, justified exceptions only where they are fundamentally synthetic (`styl\`...\`` wrapper statements, etc.) +- do not change Babel / TS / runtime semantics for non-lint consumers unless required and proven safe + +## Architectural Decision + +### We are not moving to a full-file Babel IR pipeline +We explicitly do **not** want to replace the current shadow/document pipeline with: + +- parse the whole file into Babel AST +- generate the whole transformed file from AST +- use Babel node locations as the primary metadata model + +That would not simplify the hardest part of this codebase. Our main problem is not syntax generation; it is: + +- precise original-to-generated mapping +- preserving untouched source outside Pug regions +- keeping stable copied/synthetic spans for TS, ESLint, and editor tooling +- carrying feature-level mapping metadata, not just AST node locations + +### Chosen model +The intended architecture is a **hybrid**: + +- keep the file/document pipeline text-and-mapping based +- keep explicit copied slices and synthetic insertions +- use Babel structurally where it gives us leverage: + - region-level lint normalization + - structural container/context classification + - future region-level formatting metadata when justified + +### Why this is the right tradeoff +This gives us: +- stable mapping fidelity +- minimal rewriting outside transformed Pug regions +- fewer whole-file formatting side effects +- structural reasoning where it actually reduces edge cases + +So the goal of this branch is **not** “move to Babel for everything”. +The goal is: +- keep the current mapping architecture +- keep moving structural decisions into explicit core contracts +- avoid whole-file AST reprint pipelines unless there is a compelling reason later + +## Implementation Tasks + +### Phase 1. Capture the architecture in code +1. Add a new core module for lint normalization of JSX expressions. +2. Add a new core module/helper for rewriting mapped Pug regions with preserved mapping to the base transform. +3. Export these helpers from `@react-pug/react-pug-core`. +4. Add the Babel generator/types dependencies to `react-pug-core`, since semantic lint normalization now lives there instead of in the ESLint plugin. + +### Phase 2. Move semantic lint fixes into core +5. Move the current `Fragment` normalization logic from the ESLint plugin into core. +6. Move the safe repeated-ternary normalization logic from the ESLint plugin into core. +7. Add focused unit tests in core for these transformations. +8. Add direct mapping assertions in core tests so we verify rewritten offsets still map to the correct original Pug spans. + +### Phase 3. Introduce a core lint transform path +9. Build a core helper which produces lint-oriented code plus mapping back to original source. +10. Add core tests which verify: + - rewritten code is as expected + - mapping survives region rewriting + - only Pug regions are rewritten + - non-Pug code on the same line or in the same file is not touched + +### Phase 4. Thin the ESLint plugin +11. Remove moved AST normalization logic from the ESLint plugin. +12. Update the plugin to consume the new core lint transform path. +13. Keep only formatter-specific logic in the plugin: + - prettier + - stylistic pass + - indentation/closing-bracket normalization + - remapping from formatted code back through the core lint transform +14. Remove now-duplicated parser/token/boundary-map utilities from the plugin where they are replaced by core utilities. + +### Phase 5. Strengthen tests +15. Expand existing ESLint integration tests with the new startupjs-ui repro fixtures. +16. Keep autofix tests for those files. +17. Keep diagnostics snapshot tests for those files. +18. Add core-level tests for lint normalization behavior so we do not rely only on plugin integration tests. +19. Tighten any tests that were previously only checking “no diagnostics” by also checking the specific false-positive rule classes where appropriate. +20. Keep real-project compiler snapshots updated if the lint-preprocess output changes for good reasons. + +### Phase 6. Real-project validation +21. Validate `npm test` in this repo. +22. Validate full ESLint in `../startupjs-ui` using the local file-based override. +23. Validate relevant ESLint behavior in `../startupjs` using the local file-based override. +24. Check that the VS Code grammar/highlighting fix still behaves correctly and does not regress during refactor. + +## Current Branch Intent + +The specific implementation target for this branch is: + +1. Land `packages/react-pug-core/src/language/lintTransform.ts` as the single source of truth for lint-oriented semantic rewrites. +2. Remove duplicated lint-normalization code from `packages/eslint-plugin-react-pug/src/index.ts`. +3. Keep the plugin-side formatting pipeline for now, but make it consume the core lint transform output instead of re-owning semantics. +4. Validate the result not only with repo tests but also with `../startupjs` and `../startupjs-ui`, because those repos are the most realistic signal for Pug-heavy consumer behavior. + +## Current Implementation Status + +### Landed in code +- `packages/react-pug-core/src/language/lintTransform.ts` exists and owns: + - expression boundary mapping + - lint-oriented semantic normalization + - mapped-region rewriting + - lint transform creation on top of the runtime transform +- `@react-pug/react-pug-core` exports the new lint-transform helpers. +- `@react-pug/react-pug-core` now owns the Babel generator/types dependencies required for lint normalization. +- `packages/eslint-plugin-react-pug/src/index.ts` now consumes the core lint transform instead of owning: + - fragment normalization + - repeated-ternary normalization + - local expression token alignment helpers +- core lint transform now also exposes structural `formattingContext` metadata per rewritten Pug region, and the ESLint plugin consumes that instead of inferring ternary/property placement from raw line-prefix regexes +- core now also owns the structural formatting wrapper contract: + - wrapper creation per region container kind + - formatted-expression extraction back out of a wrapper + - unit-tested indentation baseline semantics for wrapper extraction +- `RegionFormattingContext` was simplified to only the structural field that still matters: + - `containerKind` + - the earlier `inlinePrefix` / `closingIndentOffset` metadata was removed because it no longer drove real formatting decisions +- startupjs / startupjs-ui real-world repros were copied into the fixture suite and now guard: + - fragment lowering + - repeated ternary lowering + - inline `return pug\`` formatting + - object-property `children: pug\`` formatting + - startupjs-ui prompt / mdx / wrapInput regressions + +### Still intentionally left in the ESLint plugin +- prettier pass +- internal stylistic formatting pass +- rebasing formatted region indentation back into surrounding source +- JSX closing-bracket normalization +- message/fix remapping +- narrow justified filtering for fundamentally synthetic legacy constructs only + +### Removed during this refactor +- the earlier `Provider(value=true)` suppression was removed +- current startupjs-ui behavior now intentionally reports the two real `react/jsx-boolean-value` diagnostics in `mdxComponents` +- this is the correct outcome: we no longer suppress real rule results just because they were inconvenient in one consumer fixture + +### Remaining validation tasks +- re-run full `npm test` after the latest wrapper-contract move into core +- re-run targeted `../startupjs-ui` validation with local `file:` overrides for: + - `eslint-plugin-cssxjs` + - `@react-pug/react-pug-core` +- re-run targeted `../startupjs` validation with the same local `file:` overrides +- current remaining work is architectural cleanup, not “make the suite green” + +### Known non-goal for this branch +- removing the internal dependency on deprecated `@stylistic/jsx-indent` + - this remains a follow-up + - correctness and stable diagnostics matter more than removing that warning in this branch + +## Current Architectural Assessment + +### Direction check +The refactor direction is correct: + +- semantic, lint-aware code-shape rewrites now live in core +- the ESLint plugin is thinner and no longer owns semantic AST rewrites +- real consumer repos were used as validation targets instead of relying only on synthetic tests + +This is a genuine simplification. The branch is not moving in the wrong direction. + +### Main remaining weak point +The main remaining fragility is no longer semantic normalization. It is the final formatter-layer shaping in the ESLint plugin. + +The biggest earlier problem, formatting-context inference from line prefixes, has now been reduced materially: +- container-kind classification lives in core +- wrapper creation/extraction per container kind lives in core +- the plugin no longer guesses branch/property/return wrappers from raw text + +The remaining heuristic layer is now smaller and genuinely formatter-specific: +- region rebasing against surrounding source indentation +- `normalizeJsxClosingBracketIndent(...)` +- continued reliance on deprecated internal `@stylistic/jsx-indent` + +These are still string-level concerns, but they are now downstream of an explicit structural contract instead of mixed into semantic rewriting. + +### What this means +The branch already improved the architecture materially, but it is **not yet the fully generic end state**. + +The next real simplification target is now smaller: +- keep pushing more formatter decisions toward explicit structural metadata when that reduces real consumer edge cases +- avoid reintroducing line-prefix inference into the plugin + +## Next Generic Cleanup Target + +### Problem +Right now the plugin asks questions like: +- “is this region after `return `?” +- “is this line prefix actually a ternary branch or just an object property like `children:`?” +- “should the closing `)` be aligned with the branch or the property key?” + +Those are structural questions, but the plugin is answering them from line-prefix regex checks. + +That is exactly the class of logic that tends to create edge cases. + +### Better architecture +The better long-term design is: + +- core lint transform returns rewritten regions, offset maps, and structural formatting metadata +- core also owns the wrapper/envelope contract used to format those regions structurally +- the plugin formats that wrapper, extracts the region back out, and remaps diagnostics + +That means the plugin should make as few structural decisions as possible on its own. + +### Why this matters +If we do that: +- the prompt-style `children: pug\`` case stops being a regex special case +- ternary-branch indentation stops being a hand-maintained line normalizer +- future consumer repros are more likely to be covered by the model automatically + +This is the next place where genericity can improve meaningfully. + +## Additional Implementation Tasks + +### Phase 7. Reduce formatter heuristics +25. Audit the remaining plugin formatter helpers and classify them into: + - genuinely style-tool-specific repairs + - still-avoidable string-splice repairs +26. Keep expanding the core structural formatting contract only when it removes real consumer edge cases cleanly. +27. Add or keep fixture coverage for each formatting context shape we support: + - `return pug\`...\`` + - object property `children: pug\`...\`,` + - ternary branch + - arrow body + - call argument + - logical branch if we encounter one in real code +28. Only keep plugin-side formatter post-processing that is genuinely style-tool-specific, not context inference. +29. Reassess whether `normalizeJsxClosingBracketIndent(...)` and the region rebasing helper can be reduced further once enough structural context is exposed. + +### Phase 8. Keep the hybrid model explicit +30. Keep core/plugin boundaries aligned with the hybrid text+mapping model, not a full-file Babel IR model. +31. When introducing new structural metadata, prefer: + - core contracts + - per-region analysis + over: + - whole-file regeneration + - AST-location-only mapping schemes +32. Only revisit a broader Babel-backed generation layer if a concrete unsolved problem shows that the current hybrid model cannot support it cleanly. + +### Phase 9. Re-evaluate branch completeness +33. After reducing formatter heuristics, re-run: + - repo `npm test` + - `../startupjs` targeted lint checks + - `../startupjs-ui` targeted lint checks +34. Reassess whether any remaining suppressions are still principled and synthetic-only. +35. Only then consider the architecture revamp “complete”. + +## Success Criteria + +We are done when all of the following are true: +- `npm test` passes in this repo +- no broad suppressions were added for the new false-positive classes +- startupjs-ui files previously failing now lint correctly: + - `packages/input/wrapInput.tsx` + - `packages/mdx/client/mdxComponents/index.js` + - fragment / ternary / inline-return repro files +- startupjs targeted regressions remain fixed +- lint-only semantic normalization logic lives in core, not in the ESLint plugin +- the ESLint plugin becomes simpler, not more ad hoc +- the remaining formatter behavior is driven mostly by explicit context, not line-prefix heuristics +- the architecture remains intentionally hybrid: text/mapping backbone plus region-level structural analysis + +## Open Follow-Ups (not mandatory for this branch) + +- consider whether the formatter layer can later stop depending on deprecated `@stylistic/jsx-indent` +- consider whether some formatting-specific utilities should also move into a reusable core utility layer +- consider richer origin metadata for synthetic wrappers so future mapping/filtering decisions can be principled without rule-specific handling diff --git a/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiMdxComponents.js.txt b/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiMdxComponents.js.txt index 69e487c..c99b293 100644 --- a/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiMdxComponents.js.txt +++ b/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiMdxComponents.js.txt @@ -1 +1,2 @@ -No diagnostics. +205:34 error react/jsx-boolean-value Value must be omitted for boolean attribute `value` +257:27 error react/jsx-boolean-value Value must be omitted for boolean attribute `value` diff --git a/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiPrompt.tsx.txt b/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiPrompt.tsx.txt new file mode 100644 index 0000000..69e487c --- /dev/null +++ b/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiPrompt.tsx.txt @@ -0,0 +1 @@ +No diagnostics. diff --git a/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiPrompt.tsx b/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiPrompt.tsx new file mode 100644 index 0000000..746343c --- /dev/null +++ b/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiPrompt.tsx @@ -0,0 +1,73 @@ +import { type ReactNode } from 'react' +import { pug, $, observer } from 'startupjs' +import TextInput from '@startupjs-ui/text-input' +import Br from '@startupjs-ui/br' +import Span from '@startupjs-ui/span' +import { openDialog } from './helpers' + +export const _PropsJsonSchema = {/* PromptOptions */} + +export interface PromptOptions { + /** An optional dialog title displayed above the message */ + title?: string + /** The message displayed above the input field */ + message: string + /** An optional initial value for the input field */ + defaultValue?: string +} + +export default async function prompt (message: string, defaultValue?: string): Promise +export default async function prompt (options: PromptOptions, defaultValue?: string): Promise +export default async function prompt (options: string | PromptOptions, defaultValue?: string): Promise { + let title: unknown + let message: unknown + + if (typeof options === 'string') { + message = options + } else if (options && typeof options === 'object') { + title = (options as any).title + message = (options as any).message + defaultValue = defaultValue ?? (options as any).defaultValue + } + + if (title != null && typeof title !== 'string') { + throw new Error('[@startupjs-ui/dialogs] prompt: title should be a string') + } + + if (typeof message !== 'string') { + throw new Error('[@startupjs-ui/dialogs] prompt: message should be a string') + } + + const normalizedTitle = typeof title === 'string' ? title : undefined + + const result = await new Promise(resolve => { + const $prompt = $(defaultValue) + + openDialog({ + title: normalizedTitle, + children: pug` + Span= message + Br(half) + TextInputWrapper($prompt=$prompt) + `, + showCross: false, + enableBackdropPress: false, + cancelLabel: 'Cancel', + confirmLabel: 'OK', + onCancel: () => { resolve(null) }, + onConfirm: () => { resolve(($prompt.get() as string | undefined) ?? '') } + }) + }) + + return result +} + +// We need to make an observable wrapper so that in auto-rerenders when signal changes +const TextInputWrapper = observer(({ $prompt }: { $prompt: any }): ReactNode => { + return pug` + TextInput( + value=$prompt.get() + onChangeText=text => $prompt.set(text) + ) + ` +}) diff --git a/test/fixtures/example-unformatted/src/StartupjsUiPrompt.tsx b/test/fixtures/example-unformatted/src/StartupjsUiPrompt.tsx new file mode 100644 index 0000000..746343c --- /dev/null +++ b/test/fixtures/example-unformatted/src/StartupjsUiPrompt.tsx @@ -0,0 +1,73 @@ +import { type ReactNode } from 'react' +import { pug, $, observer } from 'startupjs' +import TextInput from '@startupjs-ui/text-input' +import Br from '@startupjs-ui/br' +import Span from '@startupjs-ui/span' +import { openDialog } from './helpers' + +export const _PropsJsonSchema = {/* PromptOptions */} + +export interface PromptOptions { + /** An optional dialog title displayed above the message */ + title?: string + /** The message displayed above the input field */ + message: string + /** An optional initial value for the input field */ + defaultValue?: string +} + +export default async function prompt (message: string, defaultValue?: string): Promise +export default async function prompt (options: PromptOptions, defaultValue?: string): Promise +export default async function prompt (options: string | PromptOptions, defaultValue?: string): Promise { + let title: unknown + let message: unknown + + if (typeof options === 'string') { + message = options + } else if (options && typeof options === 'object') { + title = (options as any).title + message = (options as any).message + defaultValue = defaultValue ?? (options as any).defaultValue + } + + if (title != null && typeof title !== 'string') { + throw new Error('[@startupjs-ui/dialogs] prompt: title should be a string') + } + + if (typeof message !== 'string') { + throw new Error('[@startupjs-ui/dialogs] prompt: message should be a string') + } + + const normalizedTitle = typeof title === 'string' ? title : undefined + + const result = await new Promise(resolve => { + const $prompt = $(defaultValue) + + openDialog({ + title: normalizedTitle, + children: pug` + Span= message + Br(half) + TextInputWrapper($prompt=$prompt) + `, + showCross: false, + enableBackdropPress: false, + cancelLabel: 'Cancel', + confirmLabel: 'OK', + onCancel: () => { resolve(null) }, + onConfirm: () => { resolve(($prompt.get() as string | undefined) ?? '') } + }) + }) + + return result +} + +// We need to make an observable wrapper so that in auto-rerenders when signal changes +const TextInputWrapper = observer(({ $prompt }: { $prompt: any }): ReactNode => { + return pug` + TextInput( + value=$prompt.get() + onChangeText=text => $prompt.set(text) + ) + ` +}) diff --git a/test/fixtures/real-project/snapshots/eslint/cat-profile-link.js.output.jsx b/test/fixtures/real-project/snapshots/eslint/cat-profile-link.js.output.jsx index 11cbe12..c6dcc7f 100644 --- a/test/fixtures/real-project/snapshots/eslint/cat-profile-link.js.output.jsx +++ b/test/fixtures/real-project/snapshots/eslint/cat-profile-link.js.output.jsx @@ -60,7 +60,11 @@ const Profile = observer(({ $cat, $event }) => {
) : ( - )} @@ -82,8 +86,8 @@ function renderExpired () { Cat profile link is incorrect or already expired. Your cat meetup - profile link is only valid for a limited period of time. If you believe - this is an error, please contact the cat meetup organizer. + profile link is only valid for a limited period of time. If you + believe this is an error, please contact the cat meetup organizer. diff --git a/test/fixtures/real-project/snapshots/eslint/cat-profile-link.tsx.output.jsx b/test/fixtures/real-project/snapshots/eslint/cat-profile-link.tsx.output.jsx index 11cbe12..c6dcc7f 100644 --- a/test/fixtures/real-project/snapshots/eslint/cat-profile-link.tsx.output.jsx +++ b/test/fixtures/real-project/snapshots/eslint/cat-profile-link.tsx.output.jsx @@ -60,7 +60,11 @@ const Profile = observer(({ $cat, $event }) => {
) : ( - )} @@ -82,8 +86,8 @@ function renderExpired () { Cat profile link is incorrect or already expired. Your cat meetup - profile link is only valid for a limited period of time. If you believe - this is an error, please contact the cat meetup organizer. + profile link is only valid for a limited period of time. If you + believe this is an error, please contact the cat meetup organizer. diff --git a/test/fixtures/real-project/snapshots/eslint/event-tabs-breed.js.output.jsx b/test/fixtures/real-project/snapshots/eslint/event-tabs-breed.js.output.jsx index 4f38f5b..ad880e5 100644 --- a/test/fixtures/real-project/snapshots/eslint/event-tabs-breed.js.output.jsx +++ b/test/fixtures/real-project/snapshots/eslint/event-tabs-breed.js.output.jsx @@ -134,7 +134,11 @@ const CatsList = observer(({ onEdit, breed, eventId }) => {
{!hasContact($cat) ? No contact : null} - {!$cat.photoFileId.get() ? No photo : null} + {!$cat.photoFileId.get() + ? ( + No photo + ) + : null}