diff --git a/.gitignore b/.gitignore index 8d79ba4..6e62ea5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,6 @@ node_modules # build folder dist # misc +.vscode .DS_Store *.tgz \ No newline at end of file diff --git a/lib/rules/prefer-title-case.ts b/lib/rules/prefer-title-case.ts index 83e9cd0..0697bec 100644 --- a/lib/rules/prefer-title-case.ts +++ b/lib/rules/prefer-title-case.ts @@ -3,39 +3,36 @@ import { AST_NODE_TYPES } from "@typescript-eslint/utils"; import { isActionComponent, createRule } from "../utils"; import { titleCase } from "../title-case"; -export default createRule({ - create: (context) => { +interface PreferTitleCaseOptions { + extraFixedCaseWords?: string[]; +} + +export default createRule<[PreferTitleCaseOptions], "isNotTitleCased">({ + create: (context, [options = {}]) => { + const extraFixedCaseWords = options.extraFixedCaseWords ?? []; return { JSXOpeningElement: (node) => { if (isActionComponent(node.name)) { const titleAttribute = node.attributes.find((attribute) => { - return ( - attribute.type === "JSXAttribute" && - attribute.name.name === "title" - ); + return attribute.type === "JSXAttribute" && attribute.name.name === "title"; }); if (titleAttribute) { - const value = - titleAttribute.type === "JSXAttribute" && titleAttribute.value; + const value = titleAttribute.type === "JSXAttribute" && titleAttribute.value; // In the case of a simple string // if (value) { - if ( - value.type === AST_NODE_TYPES.Literal && - typeof value.value === "string" - ) { + if (value.type === AST_NODE_TYPES.Literal && typeof value.value === "string") { const originalTitle = value.value; - const formattedTitle = titleCase(originalTitle); + const formattedTitle = titleCase(originalTitle, extraFixedCaseWords); if (originalTitle !== formattedTitle) { context.report({ node: value, messageId: "isNotTitleCased", - fix: (fixer) => - fixer.replaceText(value, `"${formattedTitle}"`), + fix: (fixer) => fixer.replaceText(value, `"${formattedTitle}"`), }); } } @@ -51,14 +48,13 @@ export default createRule({ expression.value && typeof expression.value === "string" ) { - const formattedTitle = titleCase(expression.value); + const formattedTitle = titleCase(expression.value, extraFixedCaseWords); if (expression.value !== formattedTitle) { context.report({ node: expression, messageId: "isNotTitleCased", - fix: (fixer) => - fixer.replaceText(expression, `"${formattedTitle}"`), + fix: (fixer) => fixer.replaceText(expression, `"${formattedTitle}"`), }); } } @@ -74,14 +70,13 @@ export default createRule({ consequent.value && typeof consequent.value === "string" ) { - const formattedTitle = titleCase(consequent.value); + const formattedTitle = titleCase(consequent.value, extraFixedCaseWords); if (consequent.value !== formattedTitle) { context.report({ node: consequent, messageId: "isNotTitleCased", - fix: (fixer) => - fixer.replaceText(consequent, `"${formattedTitle}"`), + fix: (fixer) => fixer.replaceText(consequent, `"${formattedTitle}"`), }); } } @@ -91,14 +86,13 @@ export default createRule({ alternate.value && typeof alternate.value === "string" ) { - const formattedTitle = titleCase(alternate.value); + const formattedTitle = titleCase(alternate.value, extraFixedCaseWords); if (alternate.value !== formattedTitle) { context.report({ node: alternate, messageId: "isNotTitleCased", - fix: (fixer) => - fixer.replaceText(alternate, `"${formattedTitle}"`), + fix: (fixer) => fixer.replaceText(alternate, `"${formattedTitle}"`), }); } } @@ -119,7 +113,7 @@ export default createRule({ quasi.value && typeof quasi.value.raw === "string" ) { - const formattedTitle = titleCase(quasi.value.raw); + const formattedTitle = titleCase(quasi.value.raw, extraFixedCaseWords); if (quasi.value.raw !== formattedTitle) { hasQuasiWithoutTitleCase = true; @@ -152,7 +146,21 @@ export default createRule({ docs: { description: "Prefer Title Case", }, - schema: [], + schema: [ + { + type: "object", + properties: { + extraFixedCaseWords: { + type: "array", + items: { + type: "string", + }, + uniqueItems: true, + }, + }, + additionalProperties: false, + }, + ], }, - defaultOptions: [], + defaultOptions: [{}], }); diff --git a/lib/title-case.ts b/lib/title-case.ts index 2962c75..0875954 100644 --- a/lib/title-case.ts +++ b/lib/title-case.ts @@ -15,7 +15,7 @@ // - The second word in a hyphenated compound (except for `Built-in` and `Plug-in`) // - High-Level Events // - 32-Bit Addressing -export function titleCase(s: string): string { +export function titleCase(s: string, extraFixedCaseWords?: string[]): string { // Define the words that should not be capitalized unless they are the first or last word in the title or follow a colon. const noCaps: { [key: string]: boolean } = { and: true, @@ -73,7 +73,7 @@ export function titleCase(s: string): string { "plug-in": "Plug-in", "pub.dev": "pub.dev", "ray.so": "ray.so", - "servicenow": "ServiceNow", + servicenow: "ServiceNow", svg: "SVG", totp: "TOTP", url: "URL", @@ -83,6 +83,13 @@ export function titleCase(s: string): string { xkcd: "xkcd", }; + // Merge user-provided extra fixed case words and built-in words + // Built-in words have higher priority + const allFixedCaseWords = { + ...Object.fromEntries((extraFixedCaseWords || []).map((word) => [word.toLowerCase(), word])), + ...fixedCaseWords, + }; + // Replace all instances of '...' with '…' s = s.replace(/\.\.\./g, "…"); @@ -107,17 +114,13 @@ export function titleCase(s: string): string { const lowerWord = word.toLowerCase(); const ok = noCaps[lowerWord]; const isArticle = articles[lowerWord]; - const fixedCase = fixedCaseWords[lowerWord]; + const fixedCase = allFixedCaseWords[lowerWord]; if (fixedCase) { words[i] = fixedCase; } else if (word.startsWith("http://") || word.startsWith("https://")) { words[i] = word; - } else if ( - (!ok && !isArticle) || - i === 0 || - (isArticle && words[i - 1].endsWith(":")) - ) { + } else if ((!ok && !isArticle) || i === 0 || (isArticle && words[i - 1].endsWith(":"))) { words[i] = word .split("-") .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) diff --git a/package-lock.json b/package-lock.json index 318276f..c8d2e21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@raycast/eslint-plugin", - "version": "2.0.6", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@raycast/eslint-plugin", - "version": "2.0.6", + "version": "2.1.0", "license": "MIT", "dependencies": { "@typescript-eslint/utils": "^8.26.1" diff --git a/package.json b/package.json index 936b849..85977dc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@raycast/eslint-plugin", - "version": "2.0.7", + "version": "2.1.0", "description": "ESLint plugin designed to help Raycast's extensions authors follow best practices", "author": "Raycast Technologies Ltd.", "keywords": [ diff --git a/tests/prefer-title-case.test.ts b/tests/prefer-title-case.test.ts index 68cdd59..875b1ff 100644 --- a/tests/prefer-title-case.test.ts +++ b/tests/prefer-title-case.test.ts @@ -62,6 +62,37 @@ ruleTester.run("prefer-title-case", rule, { `, }, + { + code: ` + + `, + options: [{ extraFixedCaseWords: ["MyApp", "DevTool"] }], + }, + { + code: ` + + `, + options: [{ extraFixedCaseWords: ["MyApp", "DevTool"] }], + }, + { + code: ` + + `, + options: [{ extraFixedCaseWords: ["MyApp", "DevTool"] }], + }, + { + code: ` + + `, + options: [{ extraFixedCaseWords: ["MyApp", "DevTool"] }], + }, + // Built-in words have higher priority than user-provided words + { + code: ` + + `, + options: [{ extraFixedCaseWords: ["github", "DevTool"] }], + }, ], invalid: [ { @@ -176,10 +207,7 @@ ruleTester.run("prefer-title-case", rule, { code: ` `, - errors: [ - { messageId: "isNotTitleCased" }, - { messageId: "isNotTitleCased" }, - ], + errors: [{ messageId: "isNotTitleCased" }, { messageId: "isNotTitleCased" }], output: ` `, @@ -196,5 +224,42 @@ ruleTester.run("prefer-title-case", rule, { code: "", errors: [{ messageId: "isNotTitleCased" }], }, + { + code: ` + + `, + options: [{ extraFixedCaseWords: ["MyApp"] }], + errors: [{ messageId: "isNotTitleCased" }], + output: ` + + `, + }, + { + code: ` + + `, + options: [{ extraFixedCaseWords: ["MyApp", "DevTool"] }], + errors: [{ messageId: "isNotTitleCased" }], + output: ` + + `, + }, + { + code: ` + + `, + options: [{ extraFixedCaseWords: ["MyApp", "DevTool"] }], + errors: [{ messageId: "isNotTitleCased" }, { messageId: "isNotTitleCased" }], + output: ` + + `, + }, + { + code: ` + + `, + options: [{ extraFixedCaseWords: ["MyApp", "DevTool"] }], + errors: [{ messageId: "isNotTitleCased" }], + }, ], });