/
githubmirror
/
eslint-plugin
Обзор
Документация
Войти
/
githubmirror
/
eslint-plugin
Код
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
master
lib/rules/ui/sentenceCase.ts
381 строка
16 KB
saberzero1
refactor: replace all hardcoded URLs with docsUrl()
10 июн 2026, 16:14
10 июн 2026, 16:14
396eae1
Код
Авторство
О чём код?
import { TSESTree, TSESLint } from "@typescript-eslint/utils"; import { docsUrl, ruleCreator } from "../../ruleCreator.js"; import { createSentenceCaseReporter, resolveSentenceCaseConfig, SentenceCaseRuleOptions, unwrapExpression, } from "./sentenceCaseUtil.js"; type MessageId = "useSentenceCase"; // Default configuration with no custom options specified const defaultOptions: SentenceCaseRuleOptions = [{}] as const; // Extracts string value from AST nodes, unwrapping TypeScript type assertions function getStringFromNode(node: TSESTree.Node): string | null { if ( node.type === TSESTree.AST_NODE_TYPES.TSAsExpression || node.type === TSESTree.AST_NODE_TYPES.TSSatisfiesExpression || node.type === TSESTree.AST_NODE_TYPES.TSNonNullExpression ) { return getStringFromNode(node.expression); } if (node.type === TSESTree.AST_NODE_TYPES.Literal && typeof node.value === "string") return node.value; if (node.type === TSESTree.AST_NODE_TYPES.TemplateLiteral && node.expressions.length === 0) { return node.quasis[0]?.value.raw ?? null; } return null; } // Checks if an object property has a specific key name function isPropertyWithKey(prop: TSESTree.Property, keyName: string): boolean { if (prop.key.type === TSESTree.AST_NODE_TYPES.Identifier) return prop.key.name === keyName; if (prop.key.type === TSESTree.AST_NODE_TYPES.Literal) return prop.key.value === keyName; return false; } // Retrieves the key name from an object property node function getPropertyKeyName(prop: TSESTree.Property): string | null { if (prop.key.type === TSESTree.AST_NODE_TYPES.Identifier) return prop.key.name; if (prop.key.type === TSESTree.AST_NODE_TYPES.Literal && typeof prop.key.value === "string") return prop.key.value; return null; } // Converts attribute names to kebab-case (e.g., "ariaLabel" -> "aria-label") function normalizeAttributeName(name: string): string { return name.replace(/_/g, "-").replace(/[A-Z]/g, (ch) => `-${ch.toLowerCase()}`).toLowerCase(); } // Encodes a JavaScript string literal with proper escape sequences and quote handling function encodeStringLiteral(value: string, quote: "'" | "\""): string { let result = quote; for (let i = 0; i < value.length; i++) { const ch = value[i]; switch (ch) { case quote: result += `\\${quote}`; break; case "\\": result += "\\\\"; break; case "\n": result += "\\n"; break; case "\r": result += "\\r"; break; case "\t": result += "\\t"; break; case "\b": result += "\\b"; break; case "\f": result += "\\f"; break; case "\v": result += "\\v"; break; case "\0": { const next = value.charCodeAt(i + 1); if (Number.isFinite(next) && next >= 48 && next <= 57) { result += "\\x00"; } else { result += "\\0"; } break; } default: { const code = ch.charCodeAt(0); if (code === 0x2028 || code === 0x2029) { result += `\\u${code.toString(16).padStart(4, "0")}`; } else if (code < 0x20) { result += `\\x${code.toString(16).padStart(2, "0")}`; } else { result += ch; } } } } result += quote; return result; } // Maps method names to the position of their text argument const METHOD_STRING_ARG_POS: Record<string, number> = { setName: 0, setDesc: 0, setButtonText: 0, setTooltip: 0, setPlaceholder: 0, setText: 0, setTitle: 0, addRibbonIcon: 1, // tooltip is second argument addOption: 1, // DropdownComponent.addOption(value, label) - label is second }; // HTML attributes that contain user-facing text const ATTR_TEXT_KEYS = new Set(["aria-label", "aria-description", "title", "placeholder"]); // Recursively inspects createEl options object for text properties function inspectCreateElOptions( node: TSESTree.Expression, report: (target: TSESTree.Node, text: string) => void, ) { const unwrapped = unwrapExpression(node); if (!unwrapped || unwrapped.type !== TSESTree.AST_NODE_TYPES.ObjectExpression) return; for (const prop of unwrapped.properties) { if (prop.type !== TSESTree.AST_NODE_TYPES.Property) continue; const keyName = getPropertyKeyName(prop); if (!keyName) continue; if (keyName === "text" || keyName === "title") { const str = getStringFromNode(prop.value); if (str != null) report(prop.value, str); continue; } const valueNode = unwrapExpression(prop.value); if (!valueNode) continue; if (keyName === "attr" && valueNode.type === TSESTree.AST_NODE_TYPES.ObjectExpression) { for (const attrProp of valueNode.properties) { if (attrProp.type !== TSESTree.AST_NODE_TYPES.Property) continue; const attrName = getPropertyKeyName(attrProp); if (!attrName) continue; const normalized = normalizeAttributeName(attrName); if (!ATTR_TEXT_KEYS.has(normalized)) continue; const attrValue = getStringFromNode(attrProp.value); if (attrValue != null) report(attrProp.value, attrValue); } continue; } if (valueNode.type === TSESTree.AST_NODE_TYPES.ObjectExpression) { inspectCreateElOptions(valueNode, report); } } } // Traverses statement trees to find return statements with string values function visitStatementTree( statement: TSESTree.Statement | TSESTree.BlockStatement | null | undefined, handleReturn: (node: TSESTree.Expression, value: string) => void, ) { if (!statement) return; if (statement.type === TSESTree.AST_NODE_TYPES.BlockStatement) { for (const inner of statement.body) visitStatementTree(inner, handleReturn); return; } if (statement.type === TSESTree.AST_NODE_TYPES.ReturnStatement) { if (!statement.argument) return; const str = getStringFromNode(statement.argument); if (str != null) handleReturn(statement.argument, str); return; } if (statement.type === TSESTree.AST_NODE_TYPES.IfStatement) { visitStatementTree(statement.consequent, handleReturn); visitStatementTree(statement.alternate, handleReturn); return; } if (statement.type === TSESTree.AST_NODE_TYPES.SwitchStatement) { for (const switchCase of statement.cases) { for (const consequent of switchCase.consequent) { visitStatementTree(consequent, handleReturn); } } return; } if ( statement.type === TSESTree.AST_NODE_TYPES.ForStatement || statement.type === TSESTree.AST_NODE_TYPES.ForInStatement || statement.type === TSESTree.AST_NODE_TYPES.ForOfStatement || statement.type === TSESTree.AST_NODE_TYPES.WhileStatement || statement.type === TSESTree.AST_NODE_TYPES.DoWhileStatement || statement.type === TSESTree.AST_NODE_TYPES.LabeledStatement || statement.type === TSESTree.AST_NODE_TYPES.WithStatement ) { visitStatementTree(statement.body, handleReturn); return; } if (statement.type === TSESTree.AST_NODE_TYPES.TryStatement) { visitStatementTree(statement.block, handleReturn); if (statement.handler) visitStatementTree(statement.handler.body, handleReturn); if (statement.finalizer) visitStatementTree(statement.finalizer, handleReturn); } } export default ruleCreator({ meta: { type: "suggestion" as const, docs: { description: "Enforce sentence case for UI strings", url: docsUrl("sentence-case", "ui"), }, fixable: "code", // Allows ESLint to automatically fix violations hasSuggestions: false, schema: [ { type: "object", properties: { mode: { type: "string", enum: ["loose", "strict"], description: "Controls sentence case strictness; loose preserves CamelCase tokens while strict lowercases them.", }, brands: { type: "array", items: { type: "string" }, description: "Brand names to preserve with their canonical casing.", }, acronyms: { type: "array", items: { type: "string" }, description: "Acronyms that should remain uppercase.", }, ignoreWords: { type: "array", items: { type: "string" }, description: "Words to skip when applying sentence case.", }, ignoreRegex: { type: "array", items: { type: "string" }, description: "Regex patterns for strings to ignore entirely.", }, allowAutoFix: { type: "boolean", description: "Whether to enable auto-fixes for sentence case violations.", }, enforceCamelCaseLower: { type: "boolean", description: "Whether to force CamelCase/PascalCase tokens to lowercase.", }, }, additionalProperties: false, }, ], messages: { useSentenceCase: "Use sentence case for UI text. Expected: '{{suggestion}}'", }, }, defaultOptions, create(context: TSESLint.RuleContext<MessageId, SentenceCaseRuleOptions>) { const { evaluatorOptions, allowAutoFix } = resolveSentenceCaseConfig(context.options); const reportIfNeeded = createSentenceCaseReporter({ context, evaluatorOptions, allowAutoFix, messageId: "useSentenceCase", formatReplacement: (node, suggestion) => { if (node.type === TSESTree.AST_NODE_TYPES.Literal && typeof node.value === "string" && typeof node.raw === "string") { const quoteChar = node.raw[0]; if (quoteChar === "'" || quoteChar === '"') { return encodeStringLiteral(suggestion, quoteChar); } } return null; }, }); // Checks createEl calls for text properties in options object function checkCreateEl(node: TSESTree.CallExpression) { // createEl(tag, { text: "...", title: "..." }) if (node.arguments.length < 2) return; const arg = node.arguments[1]; if (arg.type === TSESTree.AST_NODE_TYPES.SpreadElement) return; inspectCreateElOptions(arg, reportIfNeeded); } // Checks setAttribute calls for text-containing attributes function checkSetAttribute(node: TSESTree.CallExpression) { // setAttribute("aria-label", "...") if (node.arguments.length < 2) return; const nameArg = node.arguments[0]; const valArg = node.arguments[1]; const name = getStringFromNode(nameArg); const value = getStringFromNode(valArg); if (!name || !value) return; const normalized = normalizeAttributeName(name); if (ATTR_TEXT_KEYS.has(normalized)) { reportIfNeeded(valArg, value); } } // Checks addCommand calls for command name property function checkAddCommand(node: TSESTree.CallExpression) { // this.addCommand({ name: "..." }) if (node.arguments.length < 1) return; const arg = node.arguments[0]; if (arg.type !== TSESTree.AST_NODE_TYPES.ObjectExpression) return; for (const prop of arg.properties) { if (prop.type !== TSESTree.AST_NODE_TYPES.Property) continue; if (!isPropertyWithKey(prop, "name")) continue; const str = getStringFromNode(prop.value); if (str != null) reportIfNeeded(prop.value, str); } } // Checks various method calls for UI text arguments function checkMethodCall(node: TSESTree.CallExpression) { const callee = node.callee; // Handle bare calls like createEl(...) if (callee.type === TSESTree.AST_NODE_TYPES.Identifier) { if (callee.name === "createEl") checkCreateEl(node); if (callee.name === "addCommand") checkAddCommand(node); return; } if (callee.type !== TSESTree.AST_NODE_TYPES.MemberExpression) return; if (callee.property.type !== TSESTree.AST_NODE_TYPES.Identifier) return; const name = callee.property.name; if (name in METHOD_STRING_ARG_POS) { const idx = METHOD_STRING_ARG_POS[name]; const arg = node.arguments[idx]; if (!arg || arg.type === TSESTree.AST_NODE_TYPES.SpreadElement) return; const str = getStringFromNode(arg); if (str != null) reportIfNeeded(arg, str); } if (name === "setAttribute") checkSetAttribute(node); if (name === "createEl") checkCreateEl(node); if (name === "addCommand") checkAddCommand(node); } // Checks getDisplayText method implementations for returned strings function checkGetDisplayTextReturns(node: TSESTree.MethodDefinition) { if (node.key.type !== TSESTree.AST_NODE_TYPES.Identifier || node.key.name !== "getDisplayText") return; const fn = node.value; if (!fn || fn.type !== TSESTree.AST_NODE_TYPES.FunctionExpression || !fn.body) return; visitStatementTree(fn.body, (target, text) => reportIfNeeded(target, text)); } return { // new Notice("...") NewExpression(node: TSESTree.NewExpression) { if (node.callee.type === TSESTree.AST_NODE_TYPES.Identifier && node.callee.name === "Notice") { const first = node.arguments?.[0]; if (!first) return; const str = getStringFromNode(first); if (str != null) reportIfNeeded(first, str); } }, // Method calls like setName(), setText(), etc. CallExpression: checkMethodCall, // Direct property assignments: textContent, innerText, title AssignmentExpression(node: TSESTree.AssignmentExpression) { if (node.left.type !== TSESTree.AST_NODE_TYPES.MemberExpression || node.left.property.type !== TSESTree.AST_NODE_TYPES.Identifier) return; const prop = node.left.property.name; const normalized = normalizeAttributeName(prop); if ( prop !== "textContent" && prop !== "innerText" && !ATTR_TEXT_KEYS.has(normalized) ) { return; } const str = getStringFromNode(node.right); if (str != null) reportIfNeeded(node.right, str); }, MethodDefinition: checkGetDisplayTextReturns, }; }, });