313 lines
8.9 KiB
JavaScript
313 lines
8.9 KiB
JavaScript
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
|
|
import ts from 'typescript-api'
|
|
|
|
const STYLED_SCROLLBAR_CLASSES = new Set(
|
|
'scrollbar-sleek scrollbar-editor worktree-sidebar-scrollbar'.split(' ')
|
|
)
|
|
// Why: vertical scroll is where native scrollbar drift keeps recurring. The
|
|
// guard intentionally ignores horizontal-only overflow.
|
|
const VERTICAL_SCROLL_CLASSES = new Set(
|
|
'overflow-auto overflow-scroll overflow-y-auto overflow-y-scroll'.split(' ')
|
|
)
|
|
const VERTICAL_SCROLL_STYLE_VALUES = new Set(['auto', 'scroll'])
|
|
|
|
export function plainClassName(token) {
|
|
const normalizedToken = token.startsWith('!') ? token.slice(1) : token
|
|
const parts = []
|
|
let bracketDepth = 0
|
|
let currentPart = ''
|
|
|
|
for (const char of normalizedToken) {
|
|
if (char === '[') {
|
|
bracketDepth += 1
|
|
} else if (char === ']') {
|
|
bracketDepth = Math.max(0, bracketDepth - 1)
|
|
}
|
|
|
|
if (char === ':' && bracketDepth === 0) {
|
|
parts.push(currentPart)
|
|
currentPart = ''
|
|
continue
|
|
}
|
|
currentPart += char
|
|
}
|
|
|
|
parts.push(currentPart)
|
|
const className = parts.at(-1) ?? ''
|
|
return className.startsWith('!') ? className.slice(1) : className
|
|
}
|
|
|
|
function classTokenParts(token) {
|
|
const variants = []
|
|
let bracketDepth = 0
|
|
let currentPart = ''
|
|
|
|
for (const char of token.startsWith('!') ? token.slice(1) : token) {
|
|
if (char === '[') {
|
|
bracketDepth += 1
|
|
} else if (char === ']') {
|
|
bracketDepth = Math.max(0, bracketDepth - 1)
|
|
}
|
|
if (char === ':' && bracketDepth === 0) {
|
|
variants.push(currentPart)
|
|
currentPart = ''
|
|
continue
|
|
}
|
|
currentPart += char
|
|
}
|
|
|
|
return { className: plainClassName(token), variants: variants.filter(Boolean) }
|
|
}
|
|
|
|
function classTokens(text) {
|
|
return text.split(/\s+/).filter(Boolean).map(classTokenParts)
|
|
}
|
|
|
|
function sameVariants(left, right) {
|
|
return left.length === right.length && left.every((variant, index) => variant === right[index])
|
|
}
|
|
|
|
function literalHasScrollbarForVertical(text, verticalToken) {
|
|
return classTokens(text).some((candidate) => {
|
|
if (!STYLED_SCROLLBAR_CLASSES.has(candidate.className)) {
|
|
return false
|
|
}
|
|
return (
|
|
candidate.variants.length === 0 || sameVariants(candidate.variants, verticalToken.variants)
|
|
)
|
|
})
|
|
}
|
|
|
|
function uncoveredVerticalClass(text) {
|
|
return classTokens(text).find((token) => {
|
|
return (
|
|
VERTICAL_SCROLL_CLASSES.has(token.className) && !literalHasScrollbarForVertical(text, token)
|
|
)
|
|
})
|
|
}
|
|
|
|
function reportAt(node, filePath, sourceFile, text) {
|
|
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
|
|
return {
|
|
filePath,
|
|
line: position.line + 1,
|
|
column: position.character + 1,
|
|
text
|
|
}
|
|
}
|
|
|
|
function stringLiteralTexts(node) {
|
|
if (ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
return [node.text]
|
|
}
|
|
if (!ts.isTemplateExpression(node)) {
|
|
return []
|
|
}
|
|
return [node.head.text, ...node.templateSpans.map((span) => span.literal.text)]
|
|
}
|
|
|
|
function collectClassLiteralReports(node, filePath, sourceFile) {
|
|
const reports = []
|
|
|
|
function visit(current) {
|
|
for (const text of stringLiteralTexts(current)) {
|
|
const uncovered = uncoveredVerticalClass(text)
|
|
if (uncovered) {
|
|
reports.push(reportAt(current, filePath, sourceFile, uncovered.className))
|
|
}
|
|
}
|
|
|
|
ts.forEachChild(current, visit)
|
|
}
|
|
|
|
visit(node)
|
|
return reports
|
|
}
|
|
|
|
function expressionHasStyledScrollbarLiteral(node) {
|
|
let hasStyledScrollbar = false
|
|
|
|
function visit(current) {
|
|
if (hasStyledScrollbar) {
|
|
return
|
|
}
|
|
if (
|
|
stringLiteralTexts(current).some((text) =>
|
|
classTokens(text).some((token) => STYLED_SCROLLBAR_CLASSES.has(token.className))
|
|
)
|
|
) {
|
|
hasStyledScrollbar = true
|
|
return
|
|
}
|
|
// Why: a scrollbar literal that only renders on some branches must not be
|
|
// treated as covering an unconditional inline overflow. Skip conditional
|
|
// and short-circuit expressions when proving unconditional coverage.
|
|
if (ts.isConditionalExpression(current)) {
|
|
return
|
|
}
|
|
if (
|
|
ts.isBinaryExpression(current) &&
|
|
(current.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken ||
|
|
current.operatorToken.kind === ts.SyntaxKind.BarBarToken ||
|
|
current.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken)
|
|
) {
|
|
return
|
|
}
|
|
ts.forEachChild(current, visit)
|
|
}
|
|
|
|
visit(node)
|
|
return hasStyledScrollbar
|
|
}
|
|
|
|
function propertyNameText(name) {
|
|
if (ts.isIdentifier(name) || ts.isStringLiteralLike(name)) {
|
|
return name.text
|
|
}
|
|
if (ts.isComputedPropertyName(name) && ts.isStringLiteralLike(name.expression)) {
|
|
return name.expression.text
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
function styleValueIsVerticalScroll(propertyName, value) {
|
|
const parts = value.trim().toLowerCase().split(/\s+/).filter(Boolean)
|
|
if (parts.length === 0) {
|
|
return false
|
|
}
|
|
if (propertyName === 'overflowY' || propertyName === 'overflow-y') {
|
|
return VERTICAL_SCROLL_STYLE_VALUES.has(parts[0])
|
|
}
|
|
if (propertyName !== 'overflow') {
|
|
return false
|
|
}
|
|
const verticalValue = parts.length > 1 ? parts[1] : parts[0]
|
|
return VERTICAL_SCROLL_STYLE_VALUES.has(verticalValue)
|
|
}
|
|
|
|
function collectStyleReports(node, filePath, sourceFile) {
|
|
const reports = []
|
|
|
|
function visit(current) {
|
|
if (ts.isPropertyAssignment(current)) {
|
|
const propertyName = propertyNameText(current.name)
|
|
for (const value of propertyName ? stringLiteralTexts(current.initializer) : []) {
|
|
if (styleValueIsVerticalScroll(propertyName, value)) {
|
|
reports.push(reportAt(current, filePath, sourceFile, 'inline vertical scroll'))
|
|
}
|
|
}
|
|
ts.forEachChild(current.initializer, visit)
|
|
return
|
|
}
|
|
ts.forEachChild(current, visit)
|
|
}
|
|
|
|
visit(node)
|
|
return reports
|
|
}
|
|
|
|
function jsxAttributeName(attribute) {
|
|
return ts.isIdentifier(attribute.name) ? attribute.name.text : undefined
|
|
}
|
|
|
|
function jsxAttributeExpression(attribute) {
|
|
if (ts.isStringLiteral(attribute.initializer)) {
|
|
return attribute.initializer
|
|
}
|
|
if (attribute.initializer && ts.isJsxExpression(attribute.initializer)) {
|
|
return attribute.initializer.expression
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
function spreadPropExpressions(node, propName) {
|
|
if (
|
|
ts.isParenthesizedExpression(node) ||
|
|
ts.isAsExpression(node) ||
|
|
ts.isSatisfiesExpression(node)
|
|
) {
|
|
return spreadPropExpressions(node.expression, propName)
|
|
}
|
|
if (ts.isConditionalExpression(node)) {
|
|
return [
|
|
...spreadPropExpressions(node.whenTrue, propName),
|
|
...spreadPropExpressions(node.whenFalse, propName)
|
|
]
|
|
}
|
|
if (ts.isBinaryExpression(node)) {
|
|
return [
|
|
...spreadPropExpressions(node.left, propName),
|
|
...spreadPropExpressions(node.right, propName)
|
|
]
|
|
}
|
|
if (!ts.isObjectLiteralExpression(node)) {
|
|
return []
|
|
}
|
|
return node.properties.flatMap((property) => {
|
|
if (ts.isSpreadAssignment(property)) {
|
|
return spreadPropExpressions(property.expression, propName)
|
|
}
|
|
if (ts.isPropertyAssignment(property) && propertyNameText(property.name) === propName) {
|
|
return [property.initializer]
|
|
}
|
|
return []
|
|
})
|
|
}
|
|
|
|
function jsxElementReports(node, filePath, sourceFile) {
|
|
const reports = []
|
|
let classExpression
|
|
const styleExpressions = []
|
|
|
|
for (const attribute of node.attributes.properties) {
|
|
if (ts.isJsxSpreadAttribute(attribute)) {
|
|
// Why: at runtime React applies attributes in source order, so a later
|
|
// spread that supplies className overrides an earlier explicit className.
|
|
const spreadClassExpression = spreadPropExpressions(attribute.expression, 'className').at(-1)
|
|
if (spreadClassExpression) {
|
|
classExpression = spreadClassExpression
|
|
}
|
|
styleExpressions.push(...spreadPropExpressions(attribute.expression, 'style'))
|
|
} else if (jsxAttributeName(attribute) === 'className') {
|
|
classExpression = jsxAttributeExpression(attribute)
|
|
} else if (jsxAttributeName(attribute) === 'style') {
|
|
const expression = jsxAttributeExpression(attribute)
|
|
if (expression) {
|
|
styleExpressions.push(expression)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (classExpression) {
|
|
reports.push(...collectClassLiteralReports(classExpression, filePath, sourceFile))
|
|
}
|
|
if (classExpression && expressionHasStyledScrollbarLiteral(classExpression)) {
|
|
return reports
|
|
}
|
|
for (const expression of styleExpressions) {
|
|
reports.push(...collectStyleReports(expression, filePath, sourceFile))
|
|
}
|
|
return reports
|
|
}
|
|
|
|
export function reportUnstyledScrollbars(filePath, sourceText) {
|
|
const sourceFile = ts.createSourceFile(
|
|
filePath,
|
|
sourceText,
|
|
ts.ScriptTarget.Latest,
|
|
true,
|
|
ts.ScriptKind.TSX
|
|
)
|
|
const reports = []
|
|
|
|
function visit(node) {
|
|
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
|
|
reports.push(...jsxElementReports(node, filePath, sourceFile))
|
|
}
|
|
ts.forEachChild(node, visit)
|
|
}
|
|
|
|
visit(sourceFile)
|
|
return reports
|
|
}
|