Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
49 changes: 49 additions & 0 deletions docs/rules/no-ambiguous-platform-shortcut.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# No Ambiguous Platform Shortcut (`@raycast/no-ambiguous-platform-shortcut`)

<!-- end auto-generated rule header -->

Warns when a single-form shortcut literal (one modifiers/key object) uses only `cmd` or only `ctrl` in an extension that targets multiple platforms. In multi-platform extensions, you should either provide platform-specific shortcuts (with `macOS`/`Windows` sections) or include both modifiers to ensure parity.

## Rule Details

This rule checks JSX attributes named `shortcut`. It only inspects literal objects of the simple form:

```tsx
<Action shortcut={{ modifiers: ["cmd"], key: "s" }} />
```

Platform-specific shortcut objects (those with `macOS`/`Windows`) and dynamic expressions are ignored.

The rule first locates the closest `package.json` to the file being linted. If the package declares a `platforms` array with more than one value, the rule activates and warns about ambiguous shortcuts.

### Examples of incorrect code

```tsx
<Action shortcut={{ modifiers: ["cmd"], key: "s" }} />
```

```tsx
<Action shortcut={{ modifiers: ["ctrl"], key: "s" }} />
```

### Examples of correct code

```tsx
<Action shortcut={{ modifiers: ["cmd", "ctrl"], key: "s" }} />
```

```tsx
<Action
shortcut={{
macOS: { modifiers: ["cmd"], key: "s" },
Windows: { modifiers: ["ctrl"], key: "s" },
}}
/>
```

```tsx
// Single-platform extension ⇒ rule does not apply
<Action shortcut={{ modifiers: ["cmd"], key: "s" }} />
```

> ℹ️ The rule only warns; it does not provide an autofix. Choose platform-specific shortcuts or include both modifiers manually.
60 changes: 60 additions & 0 deletions docs/rules/no-reserved-shortcut.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# No Reserved Shortcut (`@raycast/no-reserved-shortcut`)

<!-- end auto-generated rule header -->

Warns when you define a literal shortcut that conflicts with one of Raycast's reserved shortcuts. Reserved shortcuts are used internally by the Raycast UI and reusing them in extensions may create confusing experiences.

## Rule Details

This rule inspects JSX `shortcut` attributes with a direct object literal of the simple form:

```tsx
<Action shortcut={{ modifiers: ["cmd"], key: "w" }} />
```

Platform form objects (with `macOS` / `Windows`) and dynamically built shortcuts are ignored.

### Examples of incorrect code

```tsx
<Action shortcut={{ modifiers: ["cmd"], key: "w" }} />
```

```tsx
<Action shortcut={{ modifiers: [], key: "delete" }} />
```

### Examples of correct code

```tsx
<Action shortcut={{ modifiers: ["cmd"], key: "x" }} />
```

```tsx
<Action
shortcut={{
macOS: { modifiers: ["cmd"], key: "w" },
Windows: { modifiers: ["ctrl"], key: "w" },
}}
/>
```

## Reserved Shortcuts Checked

- CloseWindow: cmd + w
- Delete: delete
- DeleteForward: deleteForward
- DeleteLineBackward: cmd + delete
- DeleteWordBackward: opt + delete
- GoBack: escape
- OpenActionPanel: cmd + k
- OpenPreferences: cmd + ,
- OpenSearchBarDropdown: cmd + p
- OpenSearchBarLink: shift + cmd + /
- PrimaryAction: enter
- Quit: cmd + q
- ReturnToRoot: cmd + escape
- SecondaryAction: cmd + enter
- SelectAll: cmd + a

Note: The rule does not attempt an autofix; choose an alternate shortcut instead.
52 changes: 52 additions & 0 deletions docs/rules/prefer-common-shortcut.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Prefer Common Shortcuts (`@raycast/prefer-common-shortcut`)

🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).

<!-- end auto-generated rule header -->

When a React component uses a `shortcut` prop with a literal object that matches a
well-known shortcut, prefer using `Keyboard.Shortcut.Common.*` from `@raycast/api`.

This keeps shortcuts consistent across platforms and makes intent explicit.

## Rule Details

This rule inspects JSX attributes named `shortcut` and looks for object literals in either of these forms:

- Single form: `{ modifiers: ["cmd"], key: "s" }`
- Platform form: `{ macOS: { modifiers: ["cmd"], key: "s" }, Windows: { modifiers: ["ctrl"], key: "s" } }`

If the value equals a known common shortcut, the rule suggests replacing it with the corresponding
`Keyboard.Shortcut.Common.Name` and will also add `Keyboard` to your `@raycast/api` import or create
one if missing.

### Examples

Incorrect code:

```tsx
<Action shortcut={{ modifiers: ["cmd"], key: "n" }} />
```

```tsx
<Action
shortcut={{
macOS: { modifiers: ["cmd", "shift"], key: "arrowUp" },
Windows: { modifiers: ["ctrl", "shift"], key: "arrowUp" },
}}
/>
```

Correct code:

```tsx
import { Keyboard } from "@raycast/api";

<Action shortcut={Keyboard.Shortcut.Common.New} />;
```

```tsx
<Action shortcut={Keyboard.Shortcut.Common.MoveUp} />
```

Note that the rule only flags "single" (non-platform) object literals when the common shortcut is exactly the same on macOS and Windows. For platform-specific differences, use the `platform` object form in your code or the corresponding `Keyboard.Shortcut.Common.*` reference.
9 changes: 9 additions & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import path from "path";
import preferEllipis from "./rules/prefer-ellipsis";
import preferPlaceholders from "./rules/prefer-placeholders";
import preferTitleCase from "./rules/prefer-title-case";
import preferCommonShortcut from "./rules/prefer-common-shortcut";
import noReservedShortcut from "./rules/no-reserved-shortcut";
import noAmbiguousPlatformShortcut from "./rules/no-ambiguous-platform-shortcut";

const pkg = JSON.parse(
fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8")
Expand All @@ -19,6 +22,9 @@ const plugin = {
"prefer-ellipsis": preferEllipis,
"prefer-title-case": preferTitleCase,
"prefer-placeholders": preferPlaceholders,
"prefer-common-shortcut": preferCommonShortcut,
"no-reserved-shortcut": noReservedShortcut,
"no-ambiguous-platform-shortcut": noAmbiguousPlatformShortcut,
},
};

Expand All @@ -31,6 +37,9 @@ Object.assign(plugin.configs, {
rules: {
"@raycast/prefer-ellipsis": "warn",
"@raycast/prefer-title-case": "warn",
"@raycast/prefer-common-shortcut": "warn",
"@raycast/no-reserved-shortcut": "warn",
"@raycast/no-ambiguous-platform-shortcut": "warn",
},
},
],
Expand Down
142 changes: 142 additions & 0 deletions lib/rules/no-ambiguous-platform-shortcut.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import fs from "fs";
import path from "path";

import { AST_NODE_TYPES, TSESLint, TSESTree } from "@typescript-eslint/utils";

import { createRule } from "../utils";

type SimpleShortcut = { modifiers: string[]; key: string };

const packagePlatformsCache = new Map<string, boolean>();
Copy link

Copilot AI Nov 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The global packagePlatformsCache Map (line 10) persists across multiple linting runs. In a long-running ESLint process (like in an IDE), if a package.json file is modified, the cache will return stale data. Consider either clearing the cache between runs or using ESLint's built-in caching mechanisms, or documenting this limitation.

Copilot uses AI. Check for mistakes.

function hasMultiPlatformConfig(filename: string | undefined): boolean {
if (!filename || filename.startsWith("<")) {
return false;
}

let dir = path.dirname(filename);
while (true) {
const pkgPath = path.join(dir, "package.json");
if (packagePlatformsCache.has(pkgPath)) {
return packagePlatformsCache.get(pkgPath)!;
}

if (fs.existsSync(pkgPath)) {
try {
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
const platforms = pkg?.platforms;
const multi = Array.isArray(platforms) && platforms.length > 1;
packagePlatformsCache.set(pkgPath, multi);
return multi;
} catch {
packagePlatformsCache.set(pkgPath, false);
return false;
}
}

const parent = path.dirname(dir);
if (parent === dir) {
break;
}
dir = parent;
}

return false;
}

function parseSimpleShortcut(
node: TSESTree.ObjectExpression
): SimpleShortcut | null {
const props = new Map<string, TSESTree.Property>();
for (const prop of node.properties) {
if (
prop.type !== AST_NODE_TYPES.Property ||
prop.key.type !== AST_NODE_TYPES.Identifier
) {
return null;
}
if (prop.key.name !== "modifiers" && prop.key.name !== "key") {
return null;
}
props.set(prop.key.name, prop);
}

if (props.size !== 2) {
return null;
}

const modifiersProp = props.get("modifiers");
const keyProp = props.get("key");
if (!modifiersProp || !keyProp) return null;
if (
modifiersProp.value.type !== AST_NODE_TYPES.ArrayExpression ||
keyProp.value.type !== AST_NODE_TYPES.Literal ||
typeof keyProp.value.value !== "string"
) {
return null;
}

const modifiers: string[] = [];
for (const el of modifiersProp.value.elements) {
if (!el) continue;
if (el.type !== AST_NODE_TYPES.Literal || typeof el.value !== "string") {
return null;
}
modifiers.push(el.value);
}

return { modifiers, key: keyProp.value.value };
}

function isAmbiguous(modifiers: string[]) {
const hasCmd = modifiers.includes("cmd");
const hasCtrl = modifiers.includes("ctrl");
return (hasCmd || hasCtrl) && !(hasCmd && hasCtrl);
}

export default createRule({
name: "no-ambiguous-platform-shortcut",
meta: {
type: "problem",
docs: {
description:
"Warn when a shortcut is ambiguous in cross-platform extensions.",
},
schema: [],
messages: {
ambiguous:
"This shortcut is ambiguous across platforms. Provide platform-specific shortcuts.",
},
},
defaultOptions: [],
create(context) {
const hasMultiPlatform = hasMultiPlatformConfig(
(context as TSESLint.RuleContext<any, any>).filename
Copy link

Copilot AI Nov 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type assertion as TSESLint.RuleContext<any, any> bypasses type safety. The context parameter should already have the correct type from the rule definition. Using any here could mask type errors and should be avoided.

Suggested change
(context as TSESLint.RuleContext<any, any>).filename
context.filename

Copilot uses AI. Check for mistakes.
);

if (!hasMultiPlatform) {
return {};
}

return {
JSXAttribute(node) {
if (
node.name.type === AST_NODE_TYPES.JSXIdentifier &&
node.name.name === "shortcut" &&
node.value &&
node.value.type === AST_NODE_TYPES.JSXExpressionContainer &&
node.value.expression.type === AST_NODE_TYPES.ObjectExpression
) {
const simple = parseSimpleShortcut(node.value.expression);
if (!simple) return;
if (!isAmbiguous(simple.modifiers)) return;

context.report({
node: node.value,
messageId: "ambiguous",
});
}
},
};
},
});
Loading
Loading