Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ node_modules
# build folder
dist
# misc
.vscode
.DS_Store
*.tgz
62 changes: 35 additions & 27 deletions lib/rules/prefer-title-case.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// <Action title="Submit form" />
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}"`),
});
}
}
Expand All @@ -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}"`),
});
}
}
Expand All @@ -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}"`),
});
}
}
Expand All @@ -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}"`),
});
}
}
Expand All @@ -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;
Expand Down Expand Up @@ -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: [{}],
});
19 changes: 11 additions & 8 deletions lib/title-case.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand All @@ -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, "…");

Expand All @@ -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())
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand Down
73 changes: 69 additions & 4 deletions tests/prefer-title-case.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,37 @@ ruleTester.run("prefer-title-case", rule, {
<Action.OpenInBrowser url="https://www.cs.utah.edu/~mflatt/" title="https://www.cs.utah.edu/~mflatt/" />
`,
},
{
code: `
<Action title="MyApp DevTool" />
`,
options: [{ extraFixedCaseWords: ["MyApp", "DevTool"] }],
},
{
code: `
<Action title={"MyApp DevTool"} />
`,
options: [{ extraFixedCaseWords: ["MyApp", "DevTool"] }],
},
{
code: `
<Action title={isEnabled ? "MyApp DevTool" : "MyApp Tool"} />
`,
options: [{ extraFixedCaseWords: ["MyApp", "DevTool"] }],
},
{
code: `
<Action title={\`MyApp DevTool\`} />
`,
options: [{ extraFixedCaseWords: ["MyApp", "DevTool"] }],
},
// Built-in words have higher priority than user-provided words
{
code: `
<Action title="GitHub DevTool" />
`,
options: [{ extraFixedCaseWords: ["github", "DevTool"] }],
},
],
invalid: [
{
Expand Down Expand Up @@ -176,10 +207,7 @@ ruleTester.run("prefer-title-case", rule, {
code: `
<Action title={isAssignedToMe ? "Assign to me" : "Unassign from me"} />
`,
errors: [
{ messageId: "isNotTitleCased" },
{ messageId: "isNotTitleCased" },
],
errors: [{ messageId: "isNotTitleCased" }, { messageId: "isNotTitleCased" }],
output: `
<Action title={isAssignedToMe ? "Assign to Me" : "Unassign from Me"} />
`,
Expand All @@ -196,5 +224,42 @@ ruleTester.run("prefer-title-case", rule, {
code: "<Action title={`Submit form to ${service} and also to ${service2}`} />",
errors: [{ messageId: "isNotTitleCased" }],
},
{
code: `
<Action title="MyApp DevTool" />
`,
options: [{ extraFixedCaseWords: ["MyApp"] }],
errors: [{ messageId: "isNotTitleCased" }],
output: `
<Action title="MyApp Devtool" />
`,
},
{
code: `
<Action title={"myapp devtool"} />
`,
options: [{ extraFixedCaseWords: ["MyApp", "DevTool"] }],
errors: [{ messageId: "isNotTitleCased" }],
output: `
<Action title={"MyApp DevTool"} />
`,
},
{
code: `
<Action title={isEnabled ? "myapp devtool" : "myapp tool"} />
`,
options: [{ extraFixedCaseWords: ["MyApp", "DevTool"] }],
errors: [{ messageId: "isNotTitleCased" }, { messageId: "isNotTitleCased" }],
output: `
<Action title={isEnabled ? "MyApp DevTool" : "MyApp Tool"} />
`,
},
{
code: `
<Action title={\`myapp devtool\`} />
`,
options: [{ extraFixedCaseWords: ["MyApp", "DevTool"] }],
errors: [{ messageId: "isNotTitleCased" }],
},
],
});
Loading