chore: enforce titles on CSS-truncated elements (#41438)

This commit is contained in:
Joel authored and GitHub committed 2026-09-01 06:18:53 +00:00
1 parent 6411d6235c
commit fdca3e2a1a
25 files changed
+1242 -17

No files matched your search

+6
View File
@@ -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
+13
View File
@@ -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">
+73
View File
@@ -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.
@@ -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}
@@ -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()
})
+1
View File
@@ -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
View File
@@ -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)
})