UNPKG

eslint-plugin-obsidianmd

Version:

Validates guidelines for Obsidian plugins

252 lines (251 loc) 11.8 kB
import { ESLintUtils } from "@typescript-eslint/utils"; import path from "node:path"; const ruleCreator = ESLintUtils.RuleCreator((name) => `https://github.com/obsidianmd/eslint-plugin/blob/master/docs/rules/${name}.md`); const BASE_SCHEMA = { author: "string", minAppVersion: "string", name: "string", version: "string", id: "string", description: "string", isDesktopOnly: "boolean", }; const OPTIONAL_SCHEMA = { authorUrl: "string", fundingUrl: "string|object", }; const FORBIDDEN_WORDS = ["obsidian", "plugin"]; function hasForbiddenWords(str) { let forbiddenWordsFound = new Set(); for (const word of FORBIDDEN_WORDS) { if (str.match(new RegExp(`\\b${word}\\b`, "i"))) { forbiddenWordsFound.add(word); } } if (forbiddenWordsFound.size > 0) { return [true, Array.from(forbiddenWordsFound).sort().join("' or '")]; } return [false, ""]; } function getAstNodeType(node) { if (node.type === "Literal") { if (node.value === null) return "null"; return typeof node.value; } if (node.type === "ObjectExpression") return "object"; if (node.type === "ArrayExpression") return "array"; return "unknown"; } export default ruleCreator({ name: "validate-manifest", meta: { type: "problem", docs: { description: "Validate the structure of manifest.json for Obsidian plugins.", url: "https://docs.obsidian.md/Reference/Manifest", }, schema: [], messages: { missingKey: "The manifest is missing the required '{{key}}' property.", invalidType: "The '{{key}}' property must be of type '{{expectedType}}', but was '{{actualType}}'.", disallowedKey: "The '{{key}}' property is not allowed in the manifest.", duplicateKey: "The '{{key}}' property is defined multiple times in the manifest.", invalidFundingUrl: "The 'fundingUrl' object must only contain string values.", emptyFundingUrlObject: "The 'fundingUrl' cannot be empty.", mustBeRootObject: "The manifest must be a single JSON object.", noForbiddenWords: "The '{{key}}' property cannot contain '{{word}}'.", descriptionFormat: "The 'description' property should be concise and follow the submission requirements.", }, }, defaultOptions: [], create(context) { const filename = context.physicalFilename; if (!path.basename(filename).endsWith("manifest.json")) { return {}; } const requiredKeys = BASE_SCHEMA; const allAllowedKeys = { ...requiredKeys, ...OPTIONAL_SCHEMA }; return { Program(programNode) { const body = programNode.body[0]; if (programNode.body.length !== 1 || body.type !== "ExpressionStatement" || body.expression.type !== "ObjectExpression") { context.report({ node: programNode, messageId: "mustBeRootObject", }); return; } const node = body.expression; const properties = node.properties; const presentKeys = new Map(properties.map((prop) => [ prop.key.value, prop, ])); // 1. Check for duplicate keys if (properties.length !== presentKeys.size) { const seenKeys = new Set(); for (const prop of properties) { const key = prop.key .value; if (seenKeys.has(key)) { context.report({ node: prop.key, messageId: "duplicateKey", data: { key }, }); } else { seenKeys.add(key); } } } // 2. Check for missing required keys for (const key of Object.keys(requiredKeys)) { if (!presentKeys.has(key)) { context.report({ node, messageId: "missingKey", data: { key }, }); } } // 3. Check types and disallowed keys for (const [key, propNode] of presentKeys.entries()) { if (key && !(key in allAllowedKeys)) { context.report({ node: propNode.key, messageId: "disallowedKey", data: { key: key }, }); continue; } const expectedType = allAllowedKeys[key]; if (!expectedType) continue; const valueNode = propNode.value; const actualType = getAstNodeType(valueNode); if (expectedType.includes(actualType)) { if (key === "fundingUrl") { if (actualType === "object" && valueNode.type === "ObjectExpression") { if (valueNode.properties.length > 0) { // Check for duplicate keys in fundingUrl const fundingKeys = new Set(); for (const prop of valueNode.properties) { const propKey = prop.key.value; if (fundingKeys.has(propKey)) { context.report({ node: prop.key, messageId: "duplicateKey", data: { key: propKey }, }); } fundingKeys.add(propKey); // Check if each property in fundingUrl is a string if (getAstNodeType(prop.value) !== "string") { context.report({ node: prop.value, messageId: "invalidFundingUrl", data: { key: key, expectedType: "string", actualType: getAstNodeType(prop.value), }, }); } // Check for empty string values if (prop.value.type === "Literal" && typeof prop.value.value === "string" && prop.value.value.length === 0) { context.report({ node: prop.value, messageId: "emptyFundingUrlObject", data: { key: key }, }); } } } else { // Check for empty fundingUrl object context.report({ node: valueNode, messageId: "emptyFundingUrlObject", data: { key: key }, }); } } else if (actualType === "string" && valueNode.type === "Literal" && typeof valueNode.value === "string" && valueNode.value.length === 0) { context.report({ node: valueNode, messageId: "emptyFundingUrlObject", }); } } else if ( // check for forbidden words in specific string fields actualType === "string" && valueNode.type === "Literal" && typeof valueNode.value === "string" && hasForbiddenWords(valueNode.value)[0] && (key === "name" || key === "description" || key === "id")) { context.report({ node: valueNode, messageId: "noForbiddenWords", data: { word: hasForbiddenWords(valueNode.value)[1], key: key, }, }); } else if (actualType === "string" && valueNode.type === "Literal" && typeof valueNode.value === "string" && key === "description") { // Check description format const description = valueNode.value; if ( // 10 characters min description.length < 10 || // 250 characters max description.length > 250 || // Should start with a capital letter !description.match(/^[A-Z]/) || // Should end with a period !description.endsWith(".") || // Should not contain emoji or special characters !description.match(/^[A-Za-z0-9\s.,!?'"-]+$/)) { context.report({ node: valueNode, messageId: "descriptionFormat", }); } } } else { context.report({ node: valueNode, messageId: "invalidType", data: { key: key, expectedType: expectedType.replace("|", " or "), actualType, }, }); } } }, }; }, });