mirror of
https://github.com/langgenius/dify.git
synced 2026-09-05 08:48:10 +08:00
744 lines
25 KiB
JavaScript
744 lines
25 KiB
JavaScript
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',
|
|
})
|
|
}
|
|
},
|
|
}
|
|
},
|
|
}
|