eslint-plugin-obsidianmd
Version:
Validates guidelines for Obsidian plugins
366 lines (365 loc) • 14.2 kB
JavaScript
import { ESLintUtils } from "@typescript-eslint/utils";
import { createSentenceCaseReporter, resolveSentenceCaseConfig, unwrapExpression, } from "./sentenceCaseUtil.js";
const ruleCreator = ESLintUtils.RuleCreator((name) => `https://github.com/obsidianmd/eslint-plugin/blob/master/docs/rules/ui/${name}.md`);
// Default configuration with no custom options specified
const defaultOptions = [{}];
// Extracts string value from AST nodes, unwrapping TypeScript type assertions
function getStringFromNode(node) {
if (node.type === "TSAsExpression" ||
node.type === "TSSatisfiesExpression" ||
node.type === "TSNonNullExpression") {
return getStringFromNode(node.expression);
}
if (node.type === "Literal" && typeof node.value === "string")
return node.value;
if (node.type === "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, keyName) {
if (prop.key.type === "Identifier")
return prop.key.name === keyName;
if (prop.key.type === "Literal")
return prop.key.value === keyName;
return false;
}
// Retrieves the key name from an object property node
function getPropertyKeyName(prop) {
if (prop.key.type === "Identifier")
return prop.key.name;
if (prop.key.type === "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) {
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, quote) {
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 = {
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, report) {
const unwrapped = unwrapExpression(node);
if (!unwrapped || unwrapped.type !== "ObjectExpression")
return;
for (const prop of unwrapped.properties) {
if (prop.type !== "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 === "ObjectExpression") {
for (const attrProp of valueNode.properties) {
if (attrProp.type !== "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 === "ObjectExpression") {
inspectCreateElOptions(valueNode, report);
}
}
}
// Traverses statement trees to find return statements with string values
function visitStatementTree(statement, handleReturn) {
if (!statement)
return;
if (statement.type === "BlockStatement") {
for (const inner of statement.body)
visitStatementTree(inner, handleReturn);
return;
}
if (statement.type === "ReturnStatement") {
if (!statement.argument)
return;
const str = getStringFromNode(statement.argument);
if (str != null)
handleReturn(statement.argument, str);
return;
}
if (statement.type === "IfStatement") {
visitStatementTree(statement.consequent, handleReturn);
visitStatementTree(statement.alternate, handleReturn);
return;
}
if (statement.type === "SwitchStatement") {
for (const switchCase of statement.cases) {
for (const consequent of switchCase.consequent) {
visitStatementTree(consequent, handleReturn);
}
}
return;
}
if (statement.type === "ForStatement" ||
statement.type === "ForInStatement" ||
statement.type === "ForOfStatement" ||
statement.type === "WhileStatement" ||
statement.type === "DoWhileStatement" ||
statement.type === "LabeledStatement" ||
statement.type === "WithStatement") {
visitStatementTree(statement.body, handleReturn);
return;
}
if (statement.type === "TryStatement") {
visitStatementTree(statement.block, handleReturn);
if (statement.handler)
visitStatementTree(statement.handler.body, handleReturn);
if (statement.finalizer)
visitStatementTree(statement.finalizer, handleReturn);
}
}
export default ruleCreator({
name: "sentence-case",
meta: {
type: "suggestion",
docs: {
description: "Enforce sentence case for UI strings",
url: "https://github.com/obsidianmd/eslint-plugin/blob/master/docs/rules/ui/sentence-case.md",
},
fixable: "code", // Allows ESLint to automatically fix violations
hasSuggestions: false,
schema: [
{
type: "object",
properties: {
mode: { type: "string", enum: ["loose", "strict"] },
brands: { type: "array", items: { type: "string" } },
acronyms: { type: "array", items: { type: "string" } },
ignoreWords: { type: "array", items: { type: "string" } },
ignoreRegex: { type: "array", items: { type: "string" } },
allowAutoFix: { type: "boolean" },
enforceCamelCaseLower: { type: "boolean" },
},
additionalProperties: false,
},
],
messages: {
useSentenceCase: "Use sentence case for UI text.",
},
},
defaultOptions,
create(context) {
const { evaluatorOptions, allowAutoFix } = resolveSentenceCaseConfig(context.options);
const reportIfNeeded = createSentenceCaseReporter({
context,
evaluatorOptions,
allowAutoFix,
messageId: "useSentenceCase",
debugLabel: "ui/sentence-case",
formatReplacement: (node, suggestion) => {
if (node.type === "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) {
// createEl(tag, { text: "...", title: "..." })
if (node.arguments.length < 2)
return;
const arg = node.arguments[1];
if (arg.type === "SpreadElement")
return;
inspectCreateElOptions(arg, reportIfNeeded);
}
// Checks setAttribute calls for text-containing attributes
function checkSetAttribute(node) {
// 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) {
// this.addCommand({ name: "..." })
if (node.arguments.length < 1)
return;
const arg = node.arguments[0];
if (arg.type !== "ObjectExpression")
return;
for (const prop of arg.properties) {
if (prop.type !== "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) {
const callee = node.callee;
// Handle bare calls like createEl(...)
if (callee.type === "Identifier") {
if (callee.name === "createEl")
checkCreateEl(node);
if (callee.name === "addCommand")
checkAddCommand(node);
return;
}
if (callee.type !== "MemberExpression")
return;
if (callee.property.type !== "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 === "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) {
if (node.key.type !== "Identifier" || node.key.name !== "getDisplayText")
return;
const fn = node.value;
if (!fn || fn.type !== "FunctionExpression" || !fn.body)
return;
visitStatementTree(fn.body, (target, text) => reportIfNeeded(target, text));
}
return {
// new Notice("...")
NewExpression(node) {
if (node.callee.type === "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) {
if (node.left.type !== "MemberExpression" || node.left.property.type !== "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,
};
},
});