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', }) } }, } }, }