const UPPERCASE_PATTERN = /\b[A-Z]/ const IGNORED_JSX_PARENTS = new Set(["code", "Code"]) const CHECKED_JSX_ATTRIBUTES = new Set(["placeholder", "alt", "title"]) const NOTIFY_NAMES = new Set(["notify"]) function isInsideCodeElement(node) { let current = node.parent while (current) { if ( current.type === "JSXElement" && current.openingElement?.name?.name && IGNORED_JSX_PARENTS.has(current.openingElement.name.name) ) { return true } current = current.parent } return false } function isClassName(node) { return ( node.parent?.type === "JSXAttribute" && node.parent.name?.name === "className" ) } function isHrefOrSrc(node) { return ( node.parent?.type === "JSXAttribute" && (node.parent.name?.name === "href" || node.parent.name?.name === "src") ) } function isFetchMethod(node) { if (node.parent?.type !== "Property") return false const key = node.parent.key return key?.type === "Identifier" && key.name === "method" } function isObjectKey(node) { return ( node.parent?.type === "Property" && node.parent.key === node ) } function isImportSource(node) { return ( node.parent?.type === "ImportDeclaration" && node.parent.source === node ) } function isTypeContext(node) { return ( node.parent?.type === "TSTypeReference" || node.parent?.type === "TSLiteralType" || node.parent?.type === "TSPropertySignature" ) } function isQueryKey(node) { return ( node.parent?.type === "ArrayExpression" && node.parent.parent?.type === "Property" && node.parent.parent.key?.name === "queryKey" ) } function isContentTypeHeader(node) { if (node.parent?.type !== "Property") return false const key = node.parent.key return key?.type === "Literal" && key.value === "Content-Type" } function isHeaderValue(node) { if (node.parent?.type !== "Property") return false const key = node.parent.key if (key?.type === "Literal") { const keyValue = String(key.value).toLowerCase() return keyValue === "content-type" || keyValue === "authorization" } return false } function isComparisonTarget(node) { return ( node.parent?.type === "BinaryExpression" && (node.parent.operator === "===" || node.parent.operator === "!==") && node.parent.right === node ) } function looksLikeCode(value) { if (value.startsWith("Bearer ")) return true if (value.startsWith("Authorization:")) return true if (/^https?:\/\//.test(value)) return true if (value.includes("::")) return true return false } const rule = { meta: { type: "suggestion", docs: { description: "enforce lowercase user-facing strings", }, messages: { uppercaseString: "user-facing string contains uppercase: \"{{value}}\". all user-facing text must be lowercase.", uppercaseJsxText: "jsx text contains uppercase: \"{{value}}\". all user-facing text must be lowercase.", }, schema: [], }, create(context) { function checkStringLiteral(node, value, messageId) { if (!UPPERCASE_PATTERN.test(value)) return if (looksLikeCode(value)) return context.report({ node, messageId, data: { value: value.length > 60 ? value.slice(0, 57) + "..." : value, }, }) } return { JSXText(node) { const trimmed = node.value.trim() if (!trimmed) return if (!UPPERCASE_PATTERN.test(trimmed)) return if (isInsideCodeElement(node)) return context.report({ node, messageId: "uppercaseJsxText", data: { value: trimmed.length > 60 ? trimmed.slice(0, 57) + "..." : trimmed, }, }) }, Literal(node) { if (typeof node.value !== "string") return if (!UPPERCASE_PATTERN.test(node.value)) return if (isInsideCodeElement(node)) return if (isClassName(node)) return if (isHrefOrSrc(node)) return if (isFetchMethod(node)) return if (isObjectKey(node)) return if (isImportSource(node)) return if (isTypeContext(node)) return if (isQueryKey(node)) return if (isContentTypeHeader(node)) return if (isHeaderValue(node)) return if (isComparisonTarget(node)) return if ( node.parent?.type === "CallExpression" && node.parent.callee?.type === "Identifier" && NOTIFY_NAMES.has(node.parent.callee.name) ) { checkStringLiteral(node, node.value, "uppercaseString") return } if ( node.parent?.type === "NewExpression" && node.parent.callee?.type === "Identifier" && node.parent.callee.name === "Error" ) { checkStringLiteral(node, node.value, "uppercaseString") return } if ( node.parent?.type === "JSXAttribute" && CHECKED_JSX_ATTRIBUTES.has(node.parent.name?.name) ) { checkStringLiteral(node, node.value, "uppercaseString") return } if (node.parent?.type === "JSXExpressionContainer") { if (isInsideCodeElement(node.parent)) return checkStringLiteral(node, node.value, "uppercaseString") return } if ( node.parent?.type === "Property" && node.parent.value === node && node.parent.key?.type === "Identifier" ) { const keyName = node.parent.key.name if ( keyName === "label" || keyName === "description" || keyName === "title" || keyName === "error" ) { checkStringLiteral(node, node.value, "uppercaseString") return } } }, TemplateLiteral(node) { const isNotifyArg = node.parent?.type === "CallExpression" && node.parent.callee?.type === "Identifier" && NOTIFY_NAMES.has(node.parent.callee.name) const isErrorArg = node.parent?.type === "NewExpression" && node.parent.callee?.type === "Identifier" && node.parent.callee.name === "Error" const isJsxExpression = node.parent?.type === "JSXExpressionContainer" if (!isNotifyArg && !isErrorArg && !isJsxExpression) return if (isInsideCodeElement(node)) return for (const quasi of node.quasis) { const raw = quasi.value.raw if (UPPERCASE_PATTERN.test(raw) && !looksLikeCode(raw)) { context.report({ node: quasi, messageId: "uppercaseString", data: { value: raw.length > 60 ? raw.slice(0, 57) + "..." : raw, }, }) } } }, } }, } const plugin = { meta: { name: "asa-lowercase", version: "1.0.0", }, rules: { "lowercase-strings": rule, }, } export default plugin