Skip to content

Commit 90c3f9e

Browse files
committed
Add a command to generate macOS shortcuts
1 parent 526bd26 commit 90c3f9e

5 files changed

Lines changed: 242 additions & 2 deletions

File tree

‎README.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,21 @@ To format the code on the macOS clipboard, run:
2828
```sh
2929
pbpaste | format-code json | pbcopy
3030
```
31+
32+
## macOS shortcuts
33+
34+
To generate a signed shortcut for each language, run:
35+
36+
```sh
37+
format-code shortcuts --sql-dialect postgresql
38+
```
39+
40+
Each shortcut formats the code on the clipboard and copies the result back to the
41+
clipboard. The command writes the files to the current directory. To import a
42+
shortcut, open its file:
43+
44+
```sh
45+
open "Format JSON.shortcut"
46+
```
47+
48+
To see all the options, run `format-code shortcuts --help`.

‎src/cli.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
parsePrintWidth,
1212
prettierParsers,
1313
} from "./options.js";
14+
import { findBin, generateShortcuts } from "./shortcuts.js";
1415

1516
function formatSQL(input: string, sqlDialect: SqlLanguage): string {
1617
return sqlFormat(input, {
@@ -107,6 +108,7 @@ function checkLanguage(value: string): Language {
107108

108109
const usage = `Usage:
109110
format-code <language> [options] < input
111+
format-code shortcuts [options] [language...]
110112
111113
Format the code from stdin and write the result to stdout.
112114
@@ -118,12 +120,21 @@ Options:
118120
--sql-dialect NAME SQL dialect (default: sql)
119121
-h, --help Show this help
120122
123+
The shortcuts command generates signed macOS shortcuts that format the code on
124+
the clipboard. If you give no language, it generates a shortcut for each language.
125+
126+
Shortcuts options:
127+
--bin PATH Path of the format-code binary (default: the one on the PATH)
128+
--out DIR Output directory (default: the current directory)
129+
121130
SQL dialects:
122131
${supportedDialects.join(", ")}`;
123132

124133
type CommandValues = {
125134
"print-width"?: string;
126135
"sql-dialect"?: string;
136+
bin?: string;
137+
out?: string;
127138
};
128139

129140
async function runFormat(language: Language, values: CommandValues) {
@@ -134,12 +145,29 @@ async function runFormat(language: Language, values: CommandValues) {
134145
process.stdout.write(format({ language, input, printWidth, sqlDialect }));
135146
}
136147

148+
async function runShortcuts(names: string[], values: CommandValues) {
149+
const selectedLanguages = names.length > 0 ? names.map(checkLanguage) : languages;
150+
const printWidth = values["print-width"] === undefined ? undefined : checkPrintWidth(values["print-width"]);
151+
const sqlDialect = values["sql-dialect"] === undefined ? undefined : checkSqlDialect(values["sql-dialect"]);
152+
const bin = values.bin ?? findBin() ?? fail("Cannot find format-code on the PATH. Run 'npm link' or give --bin PATH.");
153+
const outDir = values.out ?? ".";
154+
const count = selectedLanguages.length;
155+
156+
console.error(`Apple signs ${count} ${count === 1 ? "shortcut" : "shortcuts"}. Each shortcut takes a few seconds.`);
157+
158+
for await (const path of generateShortcuts({ languages: selectedLanguages, outDir, bin, printWidth, sqlDialect })) {
159+
console.log(path);
160+
}
161+
}
162+
137163
async function main() {
138164
const { values, positionals } = parseArgs({
139165
allowPositionals: true,
140166
options: {
141167
"print-width": { type: "string" },
142168
"sql-dialect": { type: "string" },
169+
bin: { type: "string" },
170+
out: { type: "string" },
143171
help: { type: "boolean", short: "h" },
144172
},
145173
});
@@ -154,6 +182,11 @@ async function main() {
154182
fail(usage);
155183
}
156184

185+
if (command === "shortcuts") {
186+
await runShortcuts(rest, values);
187+
return;
188+
}
189+
157190
if (rest.length > 0) {
158191
fail(`Unexpected argument: ${rest[0]}`);
159192
}

‎src/options.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,27 @@ export const prettierParsers: Record<PrettierLanguage, BuiltInParserName> = {
4040
yaml: "yaml",
4141
};
4242

43+
export const displayNames: Record<Language, string> = {
44+
angular: "Angular",
45+
css: "CSS",
46+
glimmer: "Glimmer",
47+
graphql: "GraphQL",
48+
html: "HTML",
49+
javascript: "JavaScript",
50+
json: "JSON",
51+
json5: "JSON5",
52+
less: "Less",
53+
lwc: "LWC",
54+
markdown: "Markdown",
55+
mdx: "MDX",
56+
scss: "SCSS",
57+
sql: "SQL",
58+
"sql-output": "SQL output",
59+
typescript: "TypeScript",
60+
vue: "Vue",
61+
yaml: "YAML",
62+
};
63+
4364
export const languages: Language[] = [...(Object.keys(prettierParsers) as PrettierLanguage[]), "sql", "sql-output"];
4465

4566
export function isLanguage(value: string): value is Language {

‎src/shortcuts.ts‎

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { execFile, execFileSync } from "node:child_process";
2+
import { randomUUID } from "node:crypto";
3+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { setTimeout } from "node:timers/promises";
7+
import { promisify } from "node:util";
8+
import { displayNames, Language } from "./options.js";
9+
10+
const execFileAsync = promisify(execFile);
11+
12+
type CommandOptions = {
13+
bin: string;
14+
printWidth?: number;
15+
sqlDialect?: string;
16+
};
17+
18+
export type ShortcutsOptions = CommandOptions & {
19+
languages: Language[];
20+
outDir: string;
21+
};
22+
23+
function escapeXml(value: string): string {
24+
return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
25+
}
26+
27+
function shellCommand(language: Language, { bin, printWidth, sqlDialect }: CommandOptions): string {
28+
const parts = ["pbpaste", "|", bin, language];
29+
30+
if (printWidth !== undefined) parts.push(`--print-width=${printWidth}`);
31+
if (sqlDialect && language.startsWith("sql")) parts.push(`--sql-dialect=${sqlDialect}`);
32+
33+
return parts.join(" ");
34+
}
35+
36+
function shortcutPlist(name: string, command: string): string {
37+
const scriptUUID = randomUUID().toUpperCase();
38+
const clipboardUUID = randomUUID().toUpperCase();
39+
40+
return `<?xml version="1.0" encoding="UTF-8"?>
41+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
42+
<plist version="1.0">
43+
<dict>
44+
<key>WFWorkflowName</key>
45+
<string>${escapeXml(name)}</string>
46+
<key>WFWorkflowClientVersion</key>
47+
<string>2607.0.2</string>
48+
<key>WFWorkflowMinimumClientVersion</key>
49+
<integer>900</integer>
50+
<key>WFWorkflowMinimumClientVersionString</key>
51+
<string>900</string>
52+
<key>WFWorkflowIcon</key>
53+
<dict>
54+
<key>WFWorkflowIconStartColor</key>
55+
<integer>463140863</integer>
56+
<key>WFWorkflowIconGlyphNumber</key>
57+
<integer>59446</integer>
58+
</dict>
59+
<key>WFWorkflowTypes</key>
60+
<array>
61+
<string>WFWorkflowTypeShowInSearch</string>
62+
</array>
63+
<key>WFWorkflowInputContentItemClasses</key>
64+
<array/>
65+
<key>WFWorkflowImportQuestions</key>
66+
<array/>
67+
<key>WFWorkflowActions</key>
68+
<array>
69+
<dict>
70+
<key>WFWorkflowActionIdentifier</key>
71+
<string>is.workflow.actions.runshellscript</string>
72+
<key>WFWorkflowActionParameters</key>
73+
<dict>
74+
<key>Script</key>
75+
<string>${escapeXml(command)}</string>
76+
<key>Shell</key>
77+
<string>/bin/bash</string>
78+
<key>UUID</key>
79+
<string>${scriptUUID}</string>
80+
</dict>
81+
</dict>
82+
<dict>
83+
<key>WFWorkflowActionIdentifier</key>
84+
<string>is.workflow.actions.setclipboard</string>
85+
<key>WFWorkflowActionParameters</key>
86+
<dict>
87+
<key>UUID</key>
88+
<string>${clipboardUUID}</string>
89+
<key>WFInput</key>
90+
<dict>
91+
<key>Value</key>
92+
<dict>
93+
<key>OutputName</key>
94+
<string>Shell Script Result</string>
95+
<key>OutputUUID</key>
96+
<string>${scriptUUID}</string>
97+
<key>Type</key>
98+
<string>ActionOutput</string>
99+
</dict>
100+
<key>WFSerializationType</key>
101+
<string>WFTextTokenAttachment</string>
102+
</dict>
103+
</dict>
104+
</dict>
105+
</array>
106+
</dict>
107+
</plist>
108+
`;
109+
}
110+
111+
// Shortcuts runs the script with a small PATH, so the shortcut needs the full path.
112+
export function findBin(): string | undefined {
113+
try {
114+
return execFileSync("which", ["format-code"], { encoding: "utf8" }).trim();
115+
} catch {
116+
return undefined;
117+
}
118+
}
119+
120+
const signAttempts = 3;
121+
const retryDelayMs = 2000;
122+
123+
// The "shortcuts sign" command sends the shortcut to Apple. Apple sometimes returns a server error.
124+
async function signShortcut(unsignedPath: string, signedPath: string): Promise<void> {
125+
const signArgs = ["sign", "--mode", "anyone", "--input", unsignedPath, "--output", signedPath];
126+
127+
for (let attempt = 1; ; attempt++) {
128+
try {
129+
await execFileAsync("shortcuts", signArgs);
130+
return;
131+
} catch (e) {
132+
const stderr = (e as { stderr?: string }).stderr?.trim();
133+
134+
if (attempt === signAttempts) {
135+
throw new Error(`Cannot sign ${signedPath}: ${stderr || e}`, { cause: e });
136+
}
137+
138+
console.error(`Cannot sign ${signedPath}: ${stderr || e}. Try again in ${retryDelayMs / 1000} s.`);
139+
await setTimeout(retryDelayMs);
140+
}
141+
}
142+
}
143+
144+
// Signing takes a few seconds for each shortcut, so give each path as soon as it is signed.
145+
export async function* generateShortcuts({
146+
languages,
147+
outDir,
148+
...commandOptions
149+
}: ShortcutsOptions): AsyncGenerator<string> {
150+
const workDir = mkdtempSync(join(tmpdir(), "format-code-shortcuts-"));
151+
152+
mkdirSync(outDir, { recursive: true });
153+
154+
try {
155+
for (const language of languages) {
156+
const name = `Format ${displayNames[language]}`;
157+
// The "shortcuts sign" command accepts only an input file with the .shortcut extension.
158+
const unsignedPath = join(workDir, `${language}.shortcut`);
159+
const signedPath = join(outDir, `${name}.shortcut`);
160+
161+
writeFileSync(unsignedPath, shortcutPlist(name, shellCommand(language, commandOptions)));
162+
await signShortcut(unsignedPath, signedPath);
163+
yield signedPath;
164+
}
165+
} finally {
166+
rmSync(workDir, { recursive: true, force: true });
167+
}
168+
}

‎tsconfig.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
"$schema": "https://json.schemastore.org/tsconfig",
33
"include": ["src"],
44
"compilerOptions": {
5-
"lib": ["es2021"],
5+
"lib": ["es2022"],
66
"module": "NodeNext",
77
"moduleResolution": "NodeNext",
8-
"target": "es2021",
8+
"target": "es2022",
99
"rootDir": "src",
1010
"outDir": "dist",
1111
"strict": true,

0 commit comments

Comments
 (0)