mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
chore: enforce titles on CSS-truncated elements (#41438)
This commit is contained in:
25 files changed
+1242
-17
No files matched your search
@@ -7,3 +7,9 @@ Dify is an open-source platform for building LLM applications, agentic workflows
|
||||
- Run backend commands through `uv run --project api <command>`.
|
||||
- Backend integration tests are CI-only and are not expected to run locally.
|
||||
- Keep `docker/.env.example` limited to variables required for a default Docker Compose deployment to start. Put optional and provider-specific settings in the matching `docker/envs/*.env.example` file; `docker/.env` overrides those service-specific env files.
|
||||
|
||||
## Frontend Workflow
|
||||
|
||||
- For truncated text disclosure and native `title` decisions, follow [Truncated Text Disclosure].
|
||||
|
||||
[Truncated Text Disclosure]: web/docs/truncated-text-disclosure.md
|
||||
@@ -1117,6 +1117,19 @@ export const lintConfig = {
|
||||
'eslint-react/use-memo': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['web/**/*.{jsx,tsx}'],
|
||||
excludeFiles: [
|
||||
'web/**/__tests__/**',
|
||||
'web/**/*.spec.{jsx,tsx}',
|
||||
'web/**/*.test.{jsx,tsx}',
|
||||
'web/**/*.stories.{jsx,tsx}',
|
||||
'web/**/*.story.{jsx,tsx}',
|
||||
],
|
||||
rules: {
|
||||
'dify/require-title-for-truncated-text': 'warn',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'web/**/__tests__/**/*.{js,cjs,mjs,jsx,ts,cts,mts,tsx}',
|
||||
|
||||
@@ -21,6 +21,11 @@ describe('AccessPointCard', () => {
|
||||
'data-highlighted',
|
||||
'true',
|
||||
)
|
||||
expect(screen.getByRole('heading', { name: 'Web App' })).toHaveAttribute('title', 'Web App')
|
||||
expect(screen.getByText('Web application access')).toHaveAttribute(
|
||||
'title',
|
||||
'Web application access',
|
||||
)
|
||||
})
|
||||
|
||||
it.each<[AccessPointStatus, string, boolean]>([
|
||||
|
||||
@@ -72,10 +72,12 @@ export function AccessPointCard({
|
||||
icon
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<h2 id={titleId} className="truncate system-md-semibold text-text-primary">
|
||||
<h2 id={titleId} className="truncate system-md-semibold text-text-primary" title={title}>
|
||||
{title}
|
||||
</h2>
|
||||
<span className="block truncate system-xs-regular text-text-tertiary">{description}</span>
|
||||
<span className="block truncate system-xs-regular text-text-tertiary" title={description}>
|
||||
{description}
|
||||
</span>
|
||||
</span>
|
||||
{showStatus && (
|
||||
<>
|
||||
|
||||
@@ -26,7 +26,10 @@ const ClearAllAnnotationsConfirmModal: FC<Props> = ({ isShow, onHide, onConfirm
|
||||
<AlertDialog open={isShow} onOpenChange={(open) => !open && onHide()}>
|
||||
<AlertDialogContent>
|
||||
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
|
||||
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
|
||||
<AlertDialogTitle
|
||||
className="w-full truncate title-2xl-semi-bold text-text-primary"
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
</AlertDialogTitle>
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,10 @@ const RemoveAnnotationConfirmModal: FC<Props> = ({ isShow, onHide, onRemove }) =
|
||||
<AlertDialog open={isShow} onOpenChange={(open) => !open && onHide()}>
|
||||
<AlertDialogContent>
|
||||
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
|
||||
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
|
||||
<AlertDialogTitle
|
||||
className="w-full truncate title-2xl-semi-bold text-text-primary"
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
</AlertDialogTitle>
|
||||
</div>
|
||||
|
||||
@@ -74,7 +74,10 @@ const WorkflowToolAction = ({
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-h-7 items-center gap-2 pt-1 pr-1 pb-1">
|
||||
<span className="min-w-0 flex-1 truncate system-sm-medium text-text-secondary">
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate system-sm-medium text-text-secondary"
|
||||
title={workflowToolLabel}
|
||||
>
|
||||
{workflowToolLabel}
|
||||
</span>
|
||||
<WorkflowToolStateLabel label={stateLabel} outdated={outdated} />
|
||||
|
||||
@@ -68,7 +68,9 @@ export function DeploymentStatus({ status }: { status?: DeploymentStatusValue })
|
||||
) : (
|
||||
<StatusDot size="small" status={STATUS_DOT[resolvedStatus]} />
|
||||
)}
|
||||
<span className="truncate">{label}</span>
|
||||
<span className="truncate" title={label}>
|
||||
{label}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -40,7 +40,7 @@ describe('app-chart', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Cost title'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('Cost title')).toHaveAttribute('title', 'Cost title')
|
||||
expect(screen.getByText('300'))!.toBeInTheDocument()
|
||||
expect(screen.queryByText('Last 7 days'))!.not.toBeInTheDocument()
|
||||
expect(screen.getByText(/\$3\.7500/))!.toBeInTheDocument()
|
||||
|
||||
@@ -96,7 +96,10 @@ const Chart: React.FC<IChartProps> = ({
|
||||
>
|
||||
<div className="flex h-11 shrink-0 items-center px-6 pt-6 pb-1">
|
||||
<div className="flex min-w-0 items-center">
|
||||
<div className="min-w-0 truncate system-sm-semibold-uppercase text-text-secondary">
|
||||
<div
|
||||
className="min-w-0 truncate system-sm-semibold-uppercase text-text-secondary"
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
{explanation && (
|
||||
|
||||
@@ -246,7 +246,10 @@ const AddExternalAPIModal: FC<AddExternalAPIModalProps> = ({
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
|
||||
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
|
||||
<AlertDialogTitle
|
||||
className="w-full truncate title-2xl-semi-bold text-text-primary"
|
||||
title="Warning"
|
||||
>
|
||||
Warning
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
|
||||
|
||||
@@ -119,7 +119,10 @@ const Conversion = () => {
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
|
||||
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
|
||||
<AlertDialogTitle
|
||||
className="w-full truncate title-2xl-semi-bold text-text-primary"
|
||||
title={confirmTitle}
|
||||
>
|
||||
{confirmTitle}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
|
||||
|
||||
@@ -28,7 +28,10 @@ const RemoveVarConfirm: FC<Props> = ({ isShow, onConfirm, onCancel }) => {
|
||||
<AlertDialog open={isShow} onOpenChange={(open) => !open && onCancel()}>
|
||||
<AlertDialogContent>
|
||||
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
|
||||
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
|
||||
<AlertDialogTitle
|
||||
className="w-full truncate title-2xl-semi-bold text-text-primary"
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
|
||||
|
||||
@@ -7,7 +7,10 @@ describe('LargeDataAlert', () => {
|
||||
<LargeDataAlert downloadUrl="https://example.com/export.json" className="extra-alert" />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.debug.variableInspect.largeData')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.debug.variableInspect.largeData')).toHaveAttribute(
|
||||
'title',
|
||||
'workflow.debug.variableInspect.largeData',
|
||||
)
|
||||
expect(screen.getByText('workflow.debug.variableInspect.export')).toBeInTheDocument()
|
||||
expect(container.firstChild).toHaveClass('extra-alert')
|
||||
})
|
||||
@@ -15,7 +18,10 @@ describe('LargeDataAlert', () => {
|
||||
it('should render the no-export message and omit the export action when the URL is missing', () => {
|
||||
render(<LargeDataAlert textHasNoExport />)
|
||||
|
||||
expect(screen.getByText('workflow.debug.variableInspect.largeDataNoExport')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.debug.variableInspect.largeDataNoExport')).toHaveAttribute(
|
||||
'title',
|
||||
'workflow.debug.variableInspect.largeDataNoExport',
|
||||
)
|
||||
expect(screen.queryByText('workflow.debug.variableInspect.export')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -25,7 +25,9 @@ const LargeDataAlert: FC<Props> = ({ textHasNoExport, downloadUrl, className })
|
||||
>
|
||||
<div className="flex h-full w-0 grow items-center space-x-1">
|
||||
<RiInformation2Fill className="size-4 shrink-0 text-text-accent" />
|
||||
<div className="w-0 grow truncate system-xs-regular text-text-primary">{text}</div>
|
||||
<div className="w-0 grow truncate system-xs-regular text-text-primary" title={text}>
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
{downloadUrl && (
|
||||
<div className="ml-1 shrink-0 cursor-pointer system-xs-medium-uppercase text-text-accent">
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Truncated Text Disclosure
|
||||
|
||||
Treat native `title` as an opt-in, supplemental product behavior. Do not treat it as a mechanical companion to `truncate`, `text-overflow`, or `line-clamp-*`.
|
||||
|
||||
Missing `title` is not by itself an accessibility defect. Native title tooltips must not:
|
||||
|
||||
- be the only way to access essential information;
|
||||
- replace a visible label, accessible name, or accessible description;
|
||||
- compete with an existing hover, focus, pointer, expand, copy, or detail interaction.
|
||||
|
||||
Before making a change:
|
||||
|
||||
1. Trace the value to the final rendered DOM element.
|
||||
2. Identify the existing owner of full-content disclosure.
|
||||
3. Classify the candidate as `AUTO`, `COVERED`, `SKIP`, or `REVIEW`.
|
||||
4. Modify only `AUTO` candidates. Report the others without changing code.
|
||||
|
||||
## AUTO
|
||||
|
||||
Automatically add `title` only when every condition below is satisfied:
|
||||
|
||||
- The target is a native, non-editable, pointer-reachable text container whose own hit area receives the pointer, or a documented component that forwards `title` unchanged to that final DOM element.
|
||||
- The final element intentionally implements single-line truncation. A truncation-related class alone is not sufficient evidence.
|
||||
- The full content is a bounded, non-sensitive, single-line plain string.
|
||||
- “Bounded” means fixed text, an enum, or a value with an explicit owner-level maximum length.
|
||||
- The exact already-evaluated display value can be reused without repeating a function call, getter, conversion, mutation, async operation, or other potentially effectful expression.
|
||||
- The final rendered element does not already receive an equivalent title through its props, wrapper, child component, or covering interaction target.
|
||||
- No Tooltip, PreviewCard, Popover, expandable content, “show more” action, detail view, copy/reveal action, or other full-content owner exists.
|
||||
- The native tooltip will not compete with another hover, focus, pointer, or keyboard interaction.
|
||||
|
||||
## COVERED
|
||||
|
||||
Classify as `COVERED` and make no change when another component or interaction already owns full-content disclosure, including:
|
||||
|
||||
- Tooltip, PreviewCard, or Popover;
|
||||
- expandable or “show more” content;
|
||||
- a detail view opened from the current surface;
|
||||
- a copy or reveal action;
|
||||
- a parent or covering interaction target that already provides the equivalent title.
|
||||
|
||||
## SKIP
|
||||
|
||||
Classify as `SKIP` and make no change when any condition below applies:
|
||||
|
||||
- The value contains or may contain a secret, token, API key, password, credential, or other sensitive data.
|
||||
- The value is unbounded user content, multiline content, a comment, prompt, generated output, log body, or another potentially large string.
|
||||
- The visible content is JSX, `ReactNode`, structured content, or would require a stringification helper.
|
||||
- Producing the title would repeat or relocate evaluation of a function call, getter, conversion, mutation, async operation, or other potentially effectful expression.
|
||||
- The element is an input, textarea, editable surface, `pointer-events-none`, covered by another element, or not the actual pointer target.
|
||||
- An existing `title`, including `title=""`, would need to be overwritten or removed.
|
||||
- The content uses `line-clamp-*` and already has an expand or detail interaction.
|
||||
|
||||
## REVIEW
|
||||
|
||||
Classify as `REVIEW`, make no code change, and report the reason when:
|
||||
|
||||
- the correct disclosure owner is ambiguous or product-specific;
|
||||
- the value bound or sensitivity cannot be proven;
|
||||
- the final DOM element or pointer owner cannot be traced;
|
||||
- the component is interactive;
|
||||
- the content uses `line-clamp-*` without an existing full-content path;
|
||||
- actual truncation or the intended product behavior cannot be established from the owner contract.
|
||||
|
||||
Before adding `title` to a custom component, inspect its implementation and trace the prop to the final DOM element. A `value`, `label`, or similar prop may already provide the native title; do not add a duplicate at the call site.
|
||||
|
||||
Do not introduce:
|
||||
|
||||
- a repository-wide missing-title lint error;
|
||||
- a mass autofix or migration;
|
||||
- a `ReactNode`-to-string helper;
|
||||
- suppressions for candidates outside the allowlist.
|
||||
|
||||
Test the feature-owned disclosure behavior through its public interface. Assert native `title` only when it is an explicitly accepted product contract. Do not use `getByTitle` or `toHaveAttribute('title', ...)` merely to prove a migration, and do not use title-based selectors to test unrelated interactions.
|
||||
+1
@@ -42,6 +42,7 @@ describe('AccessSurfaceCard', () => {
|
||||
)
|
||||
|
||||
expect(screen.getByRole('article', { name: 'Web app' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: 'Web app' })).toHaveAttribute('title', 'Web app')
|
||||
})
|
||||
|
||||
it('should copy the endpoint and render copied state from the clipboard hook', async () => {
|
||||
|
||||
@@ -90,7 +90,11 @@ export function AccessSurfaceCard({
|
||||
>
|
||||
<span aria-hidden className={cn(icon, 'size-4')} />
|
||||
</span>
|
||||
<h3 id={titleId} className="truncate system-md-semibold text-text-secondary">
|
||||
<h3
|
||||
id={titleId}
|
||||
className="truncate system-md-semibold text-text-secondary"
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
{badge}
|
||||
|
||||
+3
-1
@@ -166,7 +166,9 @@ function AgentContentModerationSettingsContent() {
|
||||
<div className="mb-0.5 truncate system-2xs-medium-uppercase text-text-tertiary">
|
||||
{t(($) => $['feature.moderation.contentEnableLabel'], { ns: 'appDebug' })}
|
||||
</div>
|
||||
<div className="truncate system-xs-regular text-text-secondary">{enabledContent}</div>
|
||||
<div className="truncate system-xs-regular text-text-secondary" title={enabledContent}>
|
||||
{enabledContent}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -129,7 +129,10 @@ describe('RosterPage', () => {
|
||||
it('uses the localized roster title for the page heading', () => {
|
||||
render(<RosterPage />)
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'agentV2.roster.title' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: 'agentV2.roster.title' })).toHaveAttribute(
|
||||
'title',
|
||||
'agentV2.roster.title',
|
||||
)
|
||||
expect(screen.getByRole('region', { name: 'agentV2.roster.title' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ export default function RosterPage() {
|
||||
<h1
|
||||
id={pageTitleId}
|
||||
className="min-w-0 flex-1 truncate text-[18px]/[21.6px] font-semibold text-text-primary"
|
||||
title={pageTitle}
|
||||
>
|
||||
{pageTitle}
|
||||
</h1>
|
||||
|
||||
@@ -2,6 +2,7 @@ import consistentPlaceholders from './rules/consistent-placeholders.js'
|
||||
import i18nFlatKey from './rules/i18n-flat-key.js'
|
||||
import noExtraKeys from './rules/no-extra-keys.js'
|
||||
import preferTailwindIcons from './rules/prefer-tailwind-icons.js'
|
||||
import requireTitleForTruncatedText from './rules/require-title-for-truncated-text.js'
|
||||
|
||||
/** @type {import('eslint').ESLint.Plugin} */
|
||||
const plugin = {
|
||||
@@ -14,6 +15,7 @@ const plugin = {
|
||||
'i18n-flat-key': i18nFlatKey,
|
||||
'no-extra-keys': noExtraKeys,
|
||||
'prefer-tailwind-icons': preferTailwindIcons,
|
||||
'require-title-for-truncated-text': requireTitleForTruncatedText,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
.singleLine {
|
||||
@apply truncate;
|
||||
}
|
||||
|
||||
.multipleLines {
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.noClamp {
|
||||
line-clamp: none;
|
||||
}
|
||||
|
||||
.zeroClamp {
|
||||
-webkit-line-clamp: 0;
|
||||
}
|
||||
|
||||
.unsetClamp {
|
||||
line-clamp: unset !important;
|
||||
}
|
||||
|
||||
.regular {
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,743 @@
|
||||
import { readFileSync, statSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
const DEFAULT_TRUNCATION_CLASSES = new Set(['text-ellipsis', 'truncate'])
|
||||
const cssModuleClassCache = new Map()
|
||||
const UNSAFE_TITLE_EXPRESSION = Symbol('unsafe-title-expression')
|
||||
const STYLE_WRAPPER_TYPES = new Set([
|
||||
'ChainExpression',
|
||||
'TSAsExpression',
|
||||
'TSInstantiationExpression',
|
||||
'TSNonNullExpression',
|
||||
'TSSatisfiesExpression',
|
||||
'TSTypeAssertion',
|
||||
])
|
||||
|
||||
function getJsxName(node) {
|
||||
if (!node) return null
|
||||
if (node.type === 'JSXIdentifier') return node.name
|
||||
if (node.type === 'JSXMemberExpression') {
|
||||
const objectName = getJsxName(node.object)
|
||||
const propertyName = getJsxName(node.property)
|
||||
return objectName && propertyName ? `${objectName}.${propertyName}` : null
|
||||
}
|
||||
if (node.type === 'JSXNamespacedName') {
|
||||
const namespaceName = getJsxName(node.namespace)
|
||||
const name = getJsxName(node.name)
|
||||
return namespaceName && name ? `${namespaceName}:${name}` : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getAttribute(openingElement, attributeName) {
|
||||
return openingElement.attributes.find(
|
||||
(attribute) =>
|
||||
attribute.type === 'JSXAttribute' &&
|
||||
attribute.name.type === 'JSXIdentifier' &&
|
||||
attribute.name.name === attributeName,
|
||||
)
|
||||
}
|
||||
|
||||
function unwrapExpression(node) {
|
||||
let current = node
|
||||
while (current && STYLE_WRAPPER_TYPES.has(current.type)) current = current.expression
|
||||
return current
|
||||
}
|
||||
|
||||
function getStaticString(node) {
|
||||
const current = unwrapExpression(node)
|
||||
if (!current) return null
|
||||
if (current.type === 'Literal' && typeof current.value === 'string') return current.value
|
||||
if (current.type === 'TemplateLiteral' && current.expressions.length === 0)
|
||||
return current.quasis[0]?.value.cooked ?? current.quasis[0]?.value.raw ?? ''
|
||||
return null
|
||||
}
|
||||
|
||||
function getUnprefixedClassName(token) {
|
||||
let bracketDepth = 0
|
||||
let lastVariantSeparator = -1
|
||||
|
||||
for (let index = 0; index < token.length; index++) {
|
||||
const character = token[index]
|
||||
if (character === '[' || character === '(') bracketDepth++
|
||||
else if (character === ']' || character === ')') bracketDepth = Math.max(0, bracketDepth - 1)
|
||||
else if (character === ':' && bracketDepth === 0) lastVariantSeparator = index
|
||||
}
|
||||
|
||||
return token
|
||||
.slice(lastVariantSeparator + 1)
|
||||
.replace(/^!/u, '')
|
||||
.replace(/!$/u, '')
|
||||
}
|
||||
|
||||
function isTruncationClassToken(token) {
|
||||
const className = getUnprefixedClassName(token)
|
||||
if (DEFAULT_TRUNCATION_CLASSES.has(className)) return true
|
||||
if (className.startsWith('line-clamp-') && className !== 'line-clamp-none') return true
|
||||
|
||||
const normalizedArbitraryClass = className.replaceAll(' ', '').toLowerCase()
|
||||
return (
|
||||
normalizedArbitraryClass === '[text-overflow:ellipsis]' ||
|
||||
/^\[(?:-webkit-)?line-clamp:(?!none(?:\]|$))[^\]]+\]$/u.test(normalizedArbitraryClass)
|
||||
)
|
||||
}
|
||||
|
||||
function stringContainsTruncationClass(value) {
|
||||
return value.split(/\s+/u).some((token) => token && isTruncationClassToken(token))
|
||||
}
|
||||
|
||||
function hasActiveLineClampDeclaration(cssText) {
|
||||
const lineClampPattern = /(?:-webkit-)?line-clamp\s*:\s*([^;}]+)/giu
|
||||
return [...cssText.matchAll(lineClampPattern)].some((match) => {
|
||||
const value = (match[1] ?? '').trim()
|
||||
return value !== '' && !/^(?:0|none|unset)\b/iu.test(value)
|
||||
})
|
||||
}
|
||||
|
||||
function getTruncatingCssModuleClassNames(cssText) {
|
||||
const classNames = new Set()
|
||||
const classBlockPattern = /\.([A-Z_a-z][\w-]*)\s*\{([^{}]*)\}/gu
|
||||
|
||||
for (const match of cssText.matchAll(classBlockPattern)) {
|
||||
const [, className, body = ''] = match
|
||||
const hasTextEllipsis = /text-overflow\s*:\s*ellipsis\b/iu.test(body)
|
||||
const hasLineClamp = hasActiveLineClampDeclaration(body)
|
||||
const hasTruncationApply = [...body.matchAll(/@apply\s+([^;}]+)/gu)].some((applyMatch) =>
|
||||
stringContainsTruncationClass(applyMatch[1] ?? ''),
|
||||
)
|
||||
|
||||
if (hasTextEllipsis || hasLineClamp || hasTruncationApply) classNames.add(className)
|
||||
}
|
||||
|
||||
return classNames
|
||||
}
|
||||
|
||||
function getCssModuleClassNames(cssModulePath) {
|
||||
try {
|
||||
const modifiedTime = statSync(cssModulePath).mtimeMs
|
||||
const cached = cssModuleClassCache.get(cssModulePath)
|
||||
if (cached?.modifiedTime === modifiedTime) return cached.classNames
|
||||
|
||||
const classNames = getTruncatingCssModuleClassNames(readFileSync(cssModulePath, 'utf8'))
|
||||
cssModuleClassCache.set(cssModulePath, { classNames, modifiedTime })
|
||||
return classNames
|
||||
} catch {
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
|
||||
function getPropertyName(property) {
|
||||
if (!property || property.type !== 'Property') return null
|
||||
if (!property.computed && property.key.type === 'Identifier') return property.key.name
|
||||
if (property.key.type === 'Literal' && typeof property.key.value === 'string')
|
||||
return property.key.value
|
||||
return null
|
||||
}
|
||||
|
||||
function getVariableByName(scope, name) {
|
||||
let currentScope = scope
|
||||
while (currentScope) {
|
||||
const variable = currentScope.set.get(name)
|
||||
if (variable) return variable
|
||||
currentScope = currentScope.upper
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getVariableInitializer(identifier, sourceCode) {
|
||||
const variable = getVariableByName(sourceCode.getScope(identifier), identifier.name)
|
||||
if (!variable || variable.defs.length !== 1) return null
|
||||
|
||||
const [definition] = variable.defs
|
||||
if (
|
||||
definition.type !== 'Variable' ||
|
||||
definition.node.type !== 'VariableDeclarator' ||
|
||||
definition.node.id.type !== 'Identifier' ||
|
||||
!definition.node.init
|
||||
)
|
||||
return null
|
||||
|
||||
return { initializer: definition.node.init, variable }
|
||||
}
|
||||
|
||||
function expressionContainsTruncationClass(
|
||||
node,
|
||||
sourceCode,
|
||||
cssModuleBindings,
|
||||
seenVariables = new Set(),
|
||||
) {
|
||||
const current = unwrapExpression(node)
|
||||
if (!current) return false
|
||||
|
||||
const staticString = getStaticString(current)
|
||||
if (staticString !== null) return stringContainsTruncationClass(staticString)
|
||||
|
||||
switch (current.type) {
|
||||
case 'Identifier': {
|
||||
const resolvedVariable = getVariableInitializer(current, sourceCode)
|
||||
if (!resolvedVariable || seenVariables.has(resolvedVariable.variable)) return false
|
||||
const nextSeenVariables = new Set(seenVariables)
|
||||
nextSeenVariables.add(resolvedVariable.variable)
|
||||
return expressionContainsTruncationClass(
|
||||
resolvedVariable.initializer,
|
||||
sourceCode,
|
||||
cssModuleBindings,
|
||||
nextSeenVariables,
|
||||
)
|
||||
}
|
||||
case 'MemberExpression': {
|
||||
const object = unwrapExpression(current.object)
|
||||
if (object?.type !== 'Identifier') return false
|
||||
const propertyName = current.computed
|
||||
? getStaticString(current.property)
|
||||
: current.property.type === 'Identifier'
|
||||
? current.property.name
|
||||
: null
|
||||
const variable = getVariableByName(sourceCode.getScope(object), object.name)
|
||||
return propertyName !== null && cssModuleBindings.get(variable)?.has(propertyName)
|
||||
}
|
||||
case 'TemplateLiteral':
|
||||
return (
|
||||
current.quasis.some((quasi) =>
|
||||
stringContainsTruncationClass(quasi.value.cooked ?? quasi.value.raw),
|
||||
) ||
|
||||
current.expressions.some((expression) =>
|
||||
expressionContainsTruncationClass(
|
||||
expression,
|
||||
sourceCode,
|
||||
cssModuleBindings,
|
||||
seenVariables,
|
||||
),
|
||||
)
|
||||
)
|
||||
case 'TaggedTemplateExpression':
|
||||
return expressionContainsTruncationClass(
|
||||
current.quasi,
|
||||
sourceCode,
|
||||
cssModuleBindings,
|
||||
seenVariables,
|
||||
)
|
||||
case 'CallExpression':
|
||||
case 'NewExpression':
|
||||
return (
|
||||
expressionContainsTruncationClass(
|
||||
current.callee,
|
||||
sourceCode,
|
||||
cssModuleBindings,
|
||||
seenVariables,
|
||||
) ||
|
||||
current.arguments.some((argument) =>
|
||||
argument.type === 'SpreadElement'
|
||||
? expressionContainsTruncationClass(
|
||||
argument.argument,
|
||||
sourceCode,
|
||||
cssModuleBindings,
|
||||
seenVariables,
|
||||
)
|
||||
: expressionContainsTruncationClass(
|
||||
argument,
|
||||
sourceCode,
|
||||
cssModuleBindings,
|
||||
seenVariables,
|
||||
),
|
||||
)
|
||||
)
|
||||
case 'ConditionalExpression':
|
||||
return (
|
||||
expressionContainsTruncationClass(
|
||||
current.consequent,
|
||||
sourceCode,
|
||||
cssModuleBindings,
|
||||
seenVariables,
|
||||
) ||
|
||||
expressionContainsTruncationClass(
|
||||
current.alternate,
|
||||
sourceCode,
|
||||
cssModuleBindings,
|
||||
seenVariables,
|
||||
)
|
||||
)
|
||||
case 'LogicalExpression':
|
||||
case 'BinaryExpression':
|
||||
return (
|
||||
expressionContainsTruncationClass(
|
||||
current.left,
|
||||
sourceCode,
|
||||
cssModuleBindings,
|
||||
seenVariables,
|
||||
) ||
|
||||
expressionContainsTruncationClass(
|
||||
current.right,
|
||||
sourceCode,
|
||||
cssModuleBindings,
|
||||
seenVariables,
|
||||
)
|
||||
)
|
||||
case 'ArrayExpression':
|
||||
return current.elements.some(
|
||||
(element) =>
|
||||
element &&
|
||||
(element.type === 'SpreadElement'
|
||||
? expressionContainsTruncationClass(
|
||||
element.argument,
|
||||
sourceCode,
|
||||
cssModuleBindings,
|
||||
seenVariables,
|
||||
)
|
||||
: expressionContainsTruncationClass(
|
||||
element,
|
||||
sourceCode,
|
||||
cssModuleBindings,
|
||||
seenVariables,
|
||||
)),
|
||||
)
|
||||
case 'ObjectExpression':
|
||||
return current.properties.some((property) => {
|
||||
if (property.type === 'SpreadElement')
|
||||
return expressionContainsTruncationClass(
|
||||
property.argument,
|
||||
sourceCode,
|
||||
cssModuleBindings,
|
||||
seenVariables,
|
||||
)
|
||||
|
||||
const propertyName = getPropertyName(property)
|
||||
return (
|
||||
(propertyName !== null && stringContainsTruncationClass(propertyName)) ||
|
||||
expressionContainsTruncationClass(
|
||||
property.value,
|
||||
sourceCode,
|
||||
cssModuleBindings,
|
||||
seenVariables,
|
||||
)
|
||||
)
|
||||
})
|
||||
case 'SequenceExpression':
|
||||
return current.expressions.some((expression) =>
|
||||
expressionContainsTruncationClass(expression, sourceCode, cssModuleBindings, seenVariables),
|
||||
)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isActiveLineClampValue(node) {
|
||||
const current = unwrapExpression(node)
|
||||
if (!current) return false
|
||||
if (current.type === 'Literal') {
|
||||
if (current.value === null || current.value === 0) return false
|
||||
if (typeof current.value === 'string')
|
||||
return !['', '0', 'none', 'unset'].includes(current.value.trim().toLowerCase())
|
||||
return true
|
||||
}
|
||||
return current.type !== 'Identifier' || current.name !== 'undefined'
|
||||
}
|
||||
|
||||
function expressionContainsTruncationStyle(node, sourceCode, seenVariables = new Set()) {
|
||||
const current = unwrapExpression(node)
|
||||
if (!current) return false
|
||||
|
||||
if (current.type === 'Identifier') {
|
||||
const resolvedVariable = getVariableInitializer(current, sourceCode)
|
||||
if (!resolvedVariable || seenVariables.has(resolvedVariable.variable)) return false
|
||||
const nextSeenVariables = new Set(seenVariables)
|
||||
nextSeenVariables.add(resolvedVariable.variable)
|
||||
return expressionContainsTruncationStyle(
|
||||
resolvedVariable.initializer,
|
||||
sourceCode,
|
||||
nextSeenVariables,
|
||||
)
|
||||
}
|
||||
|
||||
if (current.type === 'ConditionalExpression') {
|
||||
return (
|
||||
expressionContainsTruncationStyle(current.consequent, sourceCode, seenVariables) ||
|
||||
expressionContainsTruncationStyle(current.alternate, sourceCode, seenVariables)
|
||||
)
|
||||
}
|
||||
|
||||
if (current.type === 'LogicalExpression') {
|
||||
return (
|
||||
expressionContainsTruncationStyle(current.left, sourceCode, seenVariables) ||
|
||||
expressionContainsTruncationStyle(current.right, sourceCode, seenVariables)
|
||||
)
|
||||
}
|
||||
|
||||
if (current.type === 'ArrayExpression') {
|
||||
return current.elements.some(
|
||||
(element) =>
|
||||
element &&
|
||||
expressionContainsTruncationStyle(
|
||||
element.type === 'SpreadElement' ? element.argument : element,
|
||||
sourceCode,
|
||||
seenVariables,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (current.type !== 'ObjectExpression') return false
|
||||
|
||||
return current.properties.some((property) => {
|
||||
if (property.type === 'SpreadElement')
|
||||
return expressionContainsTruncationStyle(property.argument, sourceCode, seenVariables)
|
||||
|
||||
const propertyName = getPropertyName(property)
|
||||
if (propertyName === 'textOverflow' || propertyName === 'text-overflow')
|
||||
return getStaticString(property.value)?.trim().toLowerCase() === 'ellipsis'
|
||||
if (
|
||||
['WebkitLineClamp', 'webkitLineClamp', 'lineClamp', '-webkit-line-clamp'].includes(
|
||||
propertyName,
|
||||
)
|
||||
)
|
||||
return isActiveLineClampValue(property.value)
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
function hasTruncation(openingElement, sourceCode, cssModuleBindings) {
|
||||
const classAttribute =
|
||||
getAttribute(openingElement, 'className') ?? getAttribute(openingElement, 'class')
|
||||
if (classAttribute?.value) {
|
||||
if (
|
||||
classAttribute.value.type === 'Literal' &&
|
||||
typeof classAttribute.value.value === 'string' &&
|
||||
stringContainsTruncationClass(classAttribute.value.value)
|
||||
)
|
||||
return true
|
||||
if (
|
||||
classAttribute.value.type === 'JSXExpressionContainer' &&
|
||||
expressionContainsTruncationClass(
|
||||
classAttribute.value.expression,
|
||||
sourceCode,
|
||||
cssModuleBindings,
|
||||
)
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
const styleAttribute = getAttribute(openingElement, 'style')
|
||||
return (
|
||||
styleAttribute?.value?.type === 'JSXExpressionContainer' &&
|
||||
expressionContainsTruncationStyle(styleAttribute.value.expression, sourceCode)
|
||||
)
|
||||
}
|
||||
|
||||
function isEmptyTitleAttribute(attribute) {
|
||||
if (!attribute || !attribute.value) return true
|
||||
if (attribute.value.type === 'Literal')
|
||||
return typeof attribute.value.value !== 'string' || attribute.value.value.trim() === ''
|
||||
if (attribute.value.type !== 'JSXExpressionContainer') return false
|
||||
|
||||
const expression = unwrapExpression(attribute.value.expression)
|
||||
if (!expression || expression.type === 'JSXEmptyExpression') return true
|
||||
if (expression.type === 'Literal')
|
||||
return expression.value === null || String(expression.value).trim() === ''
|
||||
if (expression.type === 'TemplateLiteral' && expression.expressions.length === 0)
|
||||
return (expression.quasis[0]?.value.cooked ?? '').trim() === ''
|
||||
return expression.type === 'Identifier' && expression.name === 'undefined'
|
||||
}
|
||||
|
||||
function isRenderableTitleExpression(node) {
|
||||
const current = unwrapExpression(node)
|
||||
if (!current) return false
|
||||
return ![
|
||||
'ArrowFunctionExpression',
|
||||
'FunctionExpression',
|
||||
'JSXElement',
|
||||
'JSXEmptyExpression',
|
||||
'JSXFragment',
|
||||
'ObjectExpression',
|
||||
'SequenceExpression',
|
||||
].includes(current.type)
|
||||
}
|
||||
|
||||
function isSafeToDuplicateTitleExpression(node) {
|
||||
const current = unwrapExpression(node)
|
||||
if (!current) return false
|
||||
|
||||
if (current.type === 'Identifier') return current.name !== 'undefined'
|
||||
if (current.type === 'Literal')
|
||||
return (
|
||||
current.value !== null && !['boolean', 'object', 'undefined'].includes(typeof current.value)
|
||||
)
|
||||
if (current.type === 'TemplateLiteral' && current.expressions.length === 0)
|
||||
return (current.quasis[0]?.value.cooked ?? '').trim() !== ''
|
||||
return false
|
||||
}
|
||||
|
||||
function getMeaningfulChildren(element) {
|
||||
return element.children.filter((child) => {
|
||||
if (child.type === 'JSXText') return child.value.trim() !== ''
|
||||
if (child.type === 'JSXExpressionContainer')
|
||||
return child.expression.type !== 'JSXEmptyExpression'
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function getSingleTextChild(element) {
|
||||
const meaningfulChildren = getMeaningfulChildren(element)
|
||||
if (meaningfulChildren.length !== 1) return null
|
||||
|
||||
const child = meaningfulChildren[0]
|
||||
if (child.type === 'JSXElement') return getSingleTextChild(child)
|
||||
return child
|
||||
}
|
||||
|
||||
function getExpressionTextChildCount(node) {
|
||||
const current = unwrapExpression(node)
|
||||
if (!current) return 0
|
||||
|
||||
if (current.type === 'JSXElement' || current.type === 'JSXFragment')
|
||||
return getTextChildCount(current)
|
||||
if (current.type === 'ConditionalExpression')
|
||||
return Math.max(
|
||||
getExpressionTextChildCount(current.consequent),
|
||||
getExpressionTextChildCount(current.alternate),
|
||||
)
|
||||
if (current.type === 'LogicalExpression') {
|
||||
if (current.operator === '&&') return getExpressionTextChildCount(current.right)
|
||||
return Math.max(
|
||||
getExpressionTextChildCount(current.left),
|
||||
getExpressionTextChildCount(current.right),
|
||||
)
|
||||
}
|
||||
if (current.type === 'ArrayExpression') {
|
||||
let count = 0
|
||||
for (const element of current.elements) {
|
||||
if (!element) continue
|
||||
count += getExpressionTextChildCount(
|
||||
element.type === 'SpreadElement' ? element.argument : element,
|
||||
)
|
||||
if (count > 1) return count
|
||||
}
|
||||
return count
|
||||
}
|
||||
if (current.type === 'Literal') {
|
||||
if (current.value === null || typeof current.value === 'boolean') return 0
|
||||
if (typeof current.value === 'string') return current.value.trim() === '' ? 0 : 1
|
||||
}
|
||||
if (current.type === 'TemplateLiteral' && current.expressions.length === 0)
|
||||
return (current.quasis[0]?.value.cooked ?? '').trim() === '' ? 0 : 1
|
||||
if (current.type === 'Identifier' && current.name === 'undefined') return 0
|
||||
return isRenderableTitleExpression(current) ? 1 : 0
|
||||
}
|
||||
|
||||
function getTextChildCount(element) {
|
||||
let count = 0
|
||||
|
||||
for (const child of getMeaningfulChildren(element)) {
|
||||
if (child.type === 'JSXText') count++
|
||||
else if (child.type === 'JSXElement' || child.type === 'JSXFragment')
|
||||
count += getTextChildCount(child)
|
||||
else if (child.type === 'JSXExpressionContainer')
|
||||
count += getExpressionTextChildCount(child.expression)
|
||||
|
||||
if (count > 1) return count
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
function hasMultipleTextChildren(openingElement) {
|
||||
const element = openingElement.parent
|
||||
return element?.type === 'JSXElement' && getTextChildCount(element) > 1
|
||||
}
|
||||
|
||||
function getTitleFixTextFromAttribute(attribute, sourceCode) {
|
||||
if (!attribute?.value) return null
|
||||
if (attribute.value.type === 'Literal') {
|
||||
if (typeof attribute.value.value !== 'string' || attribute.value.value.trim() === '')
|
||||
return null
|
||||
return `title=${JSON.stringify(attribute.value.value)}`
|
||||
}
|
||||
if (attribute.value.type !== 'JSXExpressionContainer') return null
|
||||
if (!isSafeToDuplicateTitleExpression(attribute.value.expression)) return UNSAFE_TITLE_EXPRESSION
|
||||
return `title={${sourceCode.getText(attribute.value.expression)}}`
|
||||
}
|
||||
|
||||
function getTitleFixText(openingElement, sourceCode) {
|
||||
const element = openingElement.parent
|
||||
if (!element || element.type !== 'JSXElement') return null
|
||||
|
||||
const child = getSingleTextChild(element)
|
||||
if (child?.type === 'JSXText') {
|
||||
const value = child.value.trim().replace(/\s+/gu, ' ')
|
||||
return value ? `title=${JSON.stringify(value)}` : null
|
||||
}
|
||||
let hasUnsafeTitleExpression = false
|
||||
if (child?.type === 'JSXExpressionContainer') {
|
||||
if (isSafeToDuplicateTitleExpression(child.expression))
|
||||
return `title={${sourceCode.getText(child.expression)}}`
|
||||
hasUnsafeTitleExpression = true
|
||||
}
|
||||
|
||||
for (const attributeName of ['value', 'placeholder', 'content', 'label', 'name', 'aria-label']) {
|
||||
const attributeFixText = getTitleFixTextFromAttribute(
|
||||
getAttribute(openingElement, attributeName),
|
||||
sourceCode,
|
||||
)
|
||||
if (attributeFixText === UNSAFE_TITLE_EXPRESSION) {
|
||||
hasUnsafeTitleExpression = true
|
||||
continue
|
||||
}
|
||||
if (attributeFixText) return attributeFixText
|
||||
}
|
||||
|
||||
return hasUnsafeTitleExpression ? UNSAFE_TITLE_EXPRESSION : null
|
||||
}
|
||||
|
||||
function hasTitledSingleChild(openingElement) {
|
||||
const element = openingElement.parent
|
||||
if (!element || element.type !== 'JSXElement') return false
|
||||
const meaningfulChildren = getMeaningfulChildren(element)
|
||||
if (meaningfulChildren.length !== 1 || meaningfulChildren[0].type !== 'JSXElement') return null
|
||||
const titleAttribute = getAttribute(meaningfulChildren[0].openingElement, 'title')
|
||||
return !!titleAttribute && !isEmptyTitleAttribute(titleAttribute)
|
||||
}
|
||||
|
||||
function isTooltipTriggerOpening(openingElement, tooltipTriggerNames) {
|
||||
const name = getJsxName(openingElement.name)
|
||||
return name !== null && tooltipTriggerNames.has(name)
|
||||
}
|
||||
|
||||
function isInsideTooltipTrigger(openingElement, tooltipTriggerNames) {
|
||||
let current = openingElement.parent
|
||||
while (current) {
|
||||
if (
|
||||
current.type === 'JSXElement' &&
|
||||
isTooltipTriggerOpening(current.openingElement, tooltipTriggerNames)
|
||||
)
|
||||
return true
|
||||
if (
|
||||
current.type === 'JSXAttribute' &&
|
||||
current.name.type === 'JSXIdentifier' &&
|
||||
current.name.name === 'render' &&
|
||||
current.parent?.type === 'JSXOpeningElement' &&
|
||||
isTooltipTriggerOpening(current.parent, tooltipTriggerNames)
|
||||
)
|
||||
return true
|
||||
current = current.parent
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function getOwningVariableName(openingElement) {
|
||||
let current = openingElement.parent
|
||||
while (current) {
|
||||
if (current.type === 'VariableDeclarator')
|
||||
return current.id.type === 'Identifier' ? current.id.name : null
|
||||
if (
|
||||
current.type === 'FunctionDeclaration' ||
|
||||
current.type === 'FunctionExpression' ||
|
||||
current.type === 'ArrowFunctionExpression'
|
||||
)
|
||||
return null
|
||||
current = current.parent
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function collectReferencedIdentifiers(node, names) {
|
||||
const current = unwrapExpression(node)
|
||||
if (!current) return
|
||||
if (current.type === 'Identifier') {
|
||||
names.add(current.name)
|
||||
return
|
||||
}
|
||||
if (current.type === 'ConditionalExpression') {
|
||||
collectReferencedIdentifiers(current.consequent, names)
|
||||
collectReferencedIdentifiers(current.alternate, names)
|
||||
} else if (current.type === 'LogicalExpression') {
|
||||
collectReferencedIdentifiers(current.left, names)
|
||||
collectReferencedIdentifiers(current.right, names)
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
export default {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
docs: {
|
||||
description: 'Flag truncated JSX text for manual full-content disclosure review',
|
||||
},
|
||||
messages: {
|
||||
emptyTitle:
|
||||
'Review this truncated text against the disclosure policy; do not replace its existing title automatically.',
|
||||
missingTitle:
|
||||
'Review this truncated text against the AUTO, COVERED, SKIP, and REVIEW disclosure policy.',
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
create(context) {
|
||||
const candidates = []
|
||||
const renderIdentifiers = new Set()
|
||||
const tooltipTriggerNames = new Set()
|
||||
const cssModuleBindings = new Map()
|
||||
|
||||
function recordCssModuleImport(node) {
|
||||
if (typeof node.source.value !== 'string' || !node.source.value.endsWith('.module.css'))
|
||||
return
|
||||
const defaultSpecifier = node.specifiers.find(
|
||||
(specifier) => specifier.type === 'ImportDefaultSpecifier',
|
||||
)
|
||||
if (!defaultSpecifier) return
|
||||
|
||||
const filename = context.filename || context.getFilename()
|
||||
if (!filename || filename.startsWith('<')) return
|
||||
const cssModulePath = resolve(dirname(filename), node.source.value)
|
||||
const classNames = getCssModuleClassNames(cssModulePath)
|
||||
const variable = context.sourceCode.getDeclaredVariables(defaultSpecifier)[0]
|
||||
if (classNames.size > 0 && variable) cssModuleBindings.set(variable, classNames)
|
||||
}
|
||||
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
recordCssModuleImport(node)
|
||||
for (const specifier of node.specifiers) {
|
||||
if (
|
||||
specifier.type === 'ImportSpecifier' &&
|
||||
(specifier.imported.name ?? specifier.imported.value) === 'TooltipTrigger'
|
||||
)
|
||||
tooltipTriggerNames.add(specifier.local.name)
|
||||
}
|
||||
},
|
||||
JSXOpeningElement(node) {
|
||||
if (isTooltipTriggerOpening(node, tooltipTriggerNames)) {
|
||||
const renderAttribute = getAttribute(node, 'render')
|
||||
if (renderAttribute?.value?.type === 'JSXExpressionContainer')
|
||||
collectReferencedIdentifiers(renderAttribute.value.expression, renderIdentifiers)
|
||||
}
|
||||
|
||||
if (
|
||||
getAttribute(node, 'className') ||
|
||||
getAttribute(node, 'class') ||
|
||||
getAttribute(node, 'style')
|
||||
)
|
||||
candidates.push(node)
|
||||
},
|
||||
'Program:exit': function () {
|
||||
for (const openingElement of candidates) {
|
||||
if (!hasTruncation(openingElement, context.sourceCode, cssModuleBindings)) continue
|
||||
if (isInsideTooltipTrigger(openingElement, tooltipTriggerNames)) continue
|
||||
|
||||
const owningVariableName = getOwningVariableName(openingElement)
|
||||
if (owningVariableName && renderIdentifiers.has(owningVariableName)) continue
|
||||
|
||||
if (hasMultipleTextChildren(openingElement)) continue
|
||||
if (hasTitledSingleChild(openingElement)) continue
|
||||
|
||||
const titleAttribute = getAttribute(openingElement, 'title')
|
||||
if (titleAttribute && !isEmptyTitleAttribute(titleAttribute)) continue
|
||||
|
||||
const fixText = getTitleFixText(openingElement, context.sourceCode)
|
||||
if (fixText === UNSAFE_TITLE_EXPRESSION) continue
|
||||
|
||||
context.report({
|
||||
node: titleAttribute ?? openingElement.name,
|
||||
messageId: titleAttribute ? 'emptyTitle' : 'missingTitle',
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { resolve } from 'node:path'
|
||||
import tsParser from '@typescript-eslint/parser'
|
||||
import { Linter } from 'eslint'
|
||||
import { it } from 'vitest'
|
||||
import rule from './require-title-for-truncated-text.js'
|
||||
import './fixtures/truncation.module.css'
|
||||
|
||||
const plugin = {
|
||||
rules: {
|
||||
'require-title-for-truncated-text': rule,
|
||||
},
|
||||
}
|
||||
|
||||
function verifyAndFix(code, filename = 'test.tsx') {
|
||||
const linter = new Linter({ cwd: resolve('..') })
|
||||
return linter.verifyAndFix(
|
||||
code,
|
||||
[
|
||||
{
|
||||
files: ['**/*.tsx'],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
ecmaFeatures: { jsx: true },
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
plugins: { dify: plugin },
|
||||
rules: { 'dify/require-title-for-truncated-text': 'warn' },
|
||||
},
|
||||
],
|
||||
{ filename },
|
||||
)
|
||||
}
|
||||
|
||||
it('accepts an explicit title and line-clamp-none', () => {
|
||||
const result = verifyAndFix(`
|
||||
const name = 'Dify'
|
||||
export const Example = () => <>
|
||||
<span className="truncate" title={name}>{name}</span>
|
||||
<span className="line-clamp-none">{name}</span>
|
||||
</>
|
||||
`)
|
||||
|
||||
assert.equal(result.messages.length, 0)
|
||||
assert.equal(result.fixed, false)
|
||||
})
|
||||
|
||||
it('accepts Dify UI TooltipTrigger children and render elements', () => {
|
||||
const result = verifyAndFix(`
|
||||
import { TooltipTrigger as Trigger } from '@langgenius/dify-ui/tooltip'
|
||||
const name = 'Dify'
|
||||
export const Example = () => <>
|
||||
<Trigger><span className="truncate">{name}</span></Trigger>
|
||||
<Trigger render={<span className="line-clamp-2">{name}</span>} />
|
||||
</>
|
||||
`)
|
||||
|
||||
assert.equal(result.messages.length, 0)
|
||||
assert.equal(result.fixed, false)
|
||||
})
|
||||
|
||||
it('reports static classes, class helpers, constants, and inline styles without fixing', () => {
|
||||
const code = `
|
||||
const truncatedClassName = cn('min-w-0', condition && 'md:truncate')
|
||||
export const Example = ({ itemName, label }) => <>
|
||||
<span className={truncatedClassName}>{itemName}</span>
|
||||
<p className={cn('line-clamp-2', condition && 'text-secondary')}>{label}</p>
|
||||
<div style={{ textOverflow: 'ellipsis' }}>Description</div>
|
||||
</>
|
||||
`
|
||||
const result = verifyAndFix(code)
|
||||
|
||||
assert.equal(result.fixed, false)
|
||||
assert.equal(result.messages.length, 3)
|
||||
assert.ok(result.messages.every((message) => message.severity === 1))
|
||||
assert.equal(result.output, code)
|
||||
})
|
||||
|
||||
it('resolves truncation variables from the active lexical scope', () => {
|
||||
const shadowedParameterCode = `
|
||||
const className = 'truncate'
|
||||
const style = { textOverflow: 'ellipsis' }
|
||||
export const Example = ({ className, style, label }) => <>
|
||||
<span className={className}>{label}</span>
|
||||
<span style={style}>{label}</span>
|
||||
</>
|
||||
`
|
||||
const shadowedParameterResult = verifyAndFix(shadowedParameterCode)
|
||||
|
||||
assert.equal(shadowedParameterResult.fixed, false)
|
||||
assert.equal(shadowedParameterResult.messages.length, 0)
|
||||
assert.equal(shadowedParameterResult.output, shadowedParameterCode)
|
||||
|
||||
const nestedBindingResult = verifyAndFix(`
|
||||
const className = 'regular'
|
||||
const style = { color: 'red' }
|
||||
export const Example = ({ label }) => {
|
||||
const className = 'truncate'
|
||||
const style = { textOverflow: 'ellipsis' }
|
||||
return <>
|
||||
<span className={className}>{label}</span>
|
||||
<span style={style}>{label}</span>
|
||||
</>
|
||||
}
|
||||
`)
|
||||
|
||||
assert.equal(nestedBindingResult.fixed, false)
|
||||
assert.equal(nestedBindingResult.messages.length, 2)
|
||||
assert.ok(nestedBindingResult.messages.every((message) => message.severity === 1))
|
||||
})
|
||||
|
||||
it('reports an empty title without replacing it', () => {
|
||||
const code = `
|
||||
export const Example = ({ name }) => <span className="truncate" title="">{name}</span>
|
||||
`
|
||||
const result = verifyAndFix(code)
|
||||
|
||||
assert.equal(result.fixed, false)
|
||||
assert.equal(result.messages.length, 1)
|
||||
assert.equal(result.messages[0].severity, 1)
|
||||
assert.equal(result.messages[0].messageId, 'emptyTitle')
|
||||
assert.equal(result.output, code)
|
||||
})
|
||||
|
||||
it('reports custom components without fixing them', () => {
|
||||
const code = `
|
||||
export const Example = ({ name }) => (
|
||||
<CustomText className="truncate">{name}</CustomText>
|
||||
)
|
||||
`
|
||||
const result = verifyAndFix(code)
|
||||
|
||||
assert.equal(result.fixed, false)
|
||||
assert.equal(result.messages.length, 1)
|
||||
assert.equal(result.messages[0].severity, 1)
|
||||
assert.equal(result.output, code)
|
||||
})
|
||||
|
||||
it('ignores expressions that may execute user code', () => {
|
||||
const code = `
|
||||
export const Example = ({ item }) => {
|
||||
let index = 0
|
||||
return <>
|
||||
<span className="truncate">{item.name}</span>
|
||||
<span className="truncate" title="">{getLabel()}</span>
|
||||
<span className="truncate">{index++}</span>
|
||||
<Markdown className="line-clamp-2" content={getContent()} />
|
||||
</>
|
||||
}
|
||||
`
|
||||
const result = verifyAndFix(code)
|
||||
|
||||
assert.equal(result.fixed, false)
|
||||
assert.equal(result.messages.length, 0)
|
||||
assert.equal(result.output, code)
|
||||
})
|
||||
|
||||
it('reports a single nested text child or a text-bearing prop without fixing', () => {
|
||||
const result = verifyAndFix(`
|
||||
export const Example = ({ name, content }) => <>
|
||||
<div className="truncate"><span>{name}</span></div>
|
||||
<Markdown className="line-clamp-2" content={content} />
|
||||
</>
|
||||
`)
|
||||
|
||||
assert.equal(result.fixed, false)
|
||||
assert.equal(result.messages.length, 2)
|
||||
assert.ok(result.messages.every((message) => message.severity === 1))
|
||||
})
|
||||
|
||||
it('checks Dify UI single text but ignores multiple text children', () => {
|
||||
const filename = resolve('../packages/dify-ui/src/example.tsx')
|
||||
const result = verifyAndFix(
|
||||
`
|
||||
export const Example = ({ primary, secondary }) => <>
|
||||
<span className="truncate">{primary}</span>
|
||||
<span className="truncate">{primary}{secondary}</span>
|
||||
<div className="line-clamp-2">
|
||||
<span>{primary}</span>
|
||||
<span>{secondary}</span>
|
||||
</div>
|
||||
</>
|
||||
`,
|
||||
filename,
|
||||
)
|
||||
|
||||
assert.equal(result.fixed, false)
|
||||
assert.equal(result.messages.length, 1)
|
||||
assert.equal(result.messages[0].severity, 1)
|
||||
})
|
||||
|
||||
it('does not count non-text conditional children as additional text', () => {
|
||||
const result = verifyAndFix(`
|
||||
export const Example = ({ primary, showOverlay }) => (
|
||||
<span className="truncate">
|
||||
{primary}
|
||||
{showOverlay && <span aria-hidden />}
|
||||
</span>
|
||||
)
|
||||
`)
|
||||
|
||||
assert.equal(result.fixed, false)
|
||||
assert.equal(result.messages.length, 1)
|
||||
assert.equal(result.messages[0].messageId, 'missingTitle')
|
||||
})
|
||||
|
||||
it('resolves truncation classes imported from CSS modules', () => {
|
||||
const filename = resolve('plugins/eslint/rules/fixtures/example.tsx')
|
||||
const result = verifyAndFix(
|
||||
`
|
||||
import styles from './truncation.module.css'
|
||||
export const Example = ({ name }) => <>
|
||||
<span className={styles.singleLine}>{name}</span>
|
||||
<span className={styles.multipleLines}>{name}</span>
|
||||
<span className={styles.noClamp}>{name}</span>
|
||||
<span className={styles.zeroClamp}>{name}</span>
|
||||
<span className={styles.unsetClamp}>{name}</span>
|
||||
<span className={styles.regular}>{name}</span>
|
||||
</>
|
||||
export const Shadowed = ({ styles, name }) => (
|
||||
<span className={styles.singleLine}>{name}</span>
|
||||
)
|
||||
`,
|
||||
filename,
|
||||
)
|
||||
|
||||
assert.equal(result.fixed, false)
|
||||
assert.equal(result.messages.length, 2)
|
||||
assert.ok(result.messages.every((message) => message.severity === 1))
|
||||
})
|
||||
|
||||
it('reports dynamic truncation class and style expressions without fixing', () => {
|
||||
const code = `
|
||||
const regularClassName = 'regular'
|
||||
const dynamicClassNames = ['regular']
|
||||
const classMap = { regular: true }
|
||||
const regularStyle = { color: 'red' }
|
||||
const styles = [regularStyle]
|
||||
const tag = strings => strings[0]
|
||||
export const Example = ({ condition, name }) => <>
|
||||
<span className={\`regular \${condition ? 'truncate' : regularClassName}\`}>{name}</span>
|
||||
<span className={tag\`md:line-clamp-2\`}>{name}</span>
|
||||
<span className={cn(...dynamicClassNames, new String('truncate'))}>{name}</span>
|
||||
<span className={condition ? regularClassName : 'truncate'}>{name}</span>
|
||||
<span className={condition && (regularClassName + ' truncate')}>{name}</span>
|
||||
<span className={[...dynamicClassNames, 'truncate']}>{name}</span>
|
||||
<span className={{ ...classMap, ['truncate']: condition }}>{name}</span>
|
||||
<span className={(regularClassName, 'truncate')}>{name}</span>
|
||||
<span style={condition ? regularStyle : { ['text-overflow']: 'ellipsis' }}>{name}</span>
|
||||
<span style={condition && { WebkitLineClamp: 2 }}>{name}</span>
|
||||
<span style={[...styles, { lineClamp: '3' }]}>{name}</span>
|
||||
<span style={{ lineClamp: undefined }}>{name}</span>
|
||||
<span style={{ WebkitLineClamp: 0 }}>{name}</span>
|
||||
<span style={{ webkitLineClamp: 'none' }}>{name}</span>
|
||||
</>
|
||||
`
|
||||
const result = verifyAndFix(code)
|
||||
|
||||
assert.equal(result.fixed, false)
|
||||
assert.equal(result.messages.length, 11)
|
||||
assert.ok(result.messages.every((message) => message.messageId === 'missingTitle'))
|
||||
assert.equal(result.output, code)
|
||||
})
|
||||
|
||||
it('reports statically empty title forms without replacing them', () => {
|
||||
const code = `
|
||||
export const Example = ({ name }) => <>
|
||||
<span className="truncate" title>{name}</span>
|
||||
<span className="truncate" title={null}>{name}</span>
|
||||
<span className="truncate" title={undefined}>{name}</span>
|
||||
<span className="truncate" title={\`\`}>{name}</span>
|
||||
<span className="truncate" title={' '}>{name}</span>
|
||||
</>
|
||||
`
|
||||
const result = verifyAndFix(code)
|
||||
|
||||
assert.equal(result.fixed, false)
|
||||
assert.equal(result.messages.length, 5)
|
||||
assert.ok(result.messages.every((message) => message.messageId === 'emptyTitle'))
|
||||
assert.equal(result.output, code)
|
||||
})
|
||||
|
||||
it('reports safe literal content but accepts a titled single child', () => {
|
||||
const result = verifyAndFix(`
|
||||
export const Example = ({ name }) => <>
|
||||
<span className="truncate">{42}</span>
|
||||
<span className="truncate">{\`Dify\`}</span>
|
||||
<span className="truncate" title={/* intentionally empty */}>{name}</span>
|
||||
<div className="truncate"><span title={name}>{name}</span></div>
|
||||
</>
|
||||
`)
|
||||
|
||||
assert.equal(result.fixed, false)
|
||||
assert.equal(result.messages.length, 3)
|
||||
assert.deepEqual(
|
||||
result.messages.map((message) => message.messageId),
|
||||
['missingTitle', 'missingTitle', 'emptyTitle'],
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts referenced TooltipTrigger render candidates', () => {
|
||||
const result = verifyAndFix(`
|
||||
import { TooltipTrigger as Trigger } from '@langgenius/dify-ui/tooltip'
|
||||
export const Example = ({ condition, name }) => {
|
||||
const primaryTrigger = <span className="truncate">{name}</span>
|
||||
const fallbackTrigger = <span className="line-clamp-2">{name}</span>
|
||||
return <Trigger render={condition && (condition ? primaryTrigger : fallbackTrigger)} />
|
||||
}
|
||||
`)
|
||||
|
||||
assert.equal(result.fixed, false)
|
||||
assert.equal(result.messages.length, 0)
|
||||
})
|
||||
Reference in new issue
Block a user