@remcostoeten/use-shortcut 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,469 +0,0 @@
1
- #!/usr/bin/env node
2
- import{existsSync as h,mkdirSync as f,readFileSync as A,writeFileSync as x}from"fs";import{join as l,dirname as B}from"path";import{fileURLToPath as I}from"url";function m(t){return{"scopes.ts":`/** App-defined scope catalog used by the scaffolded provider. */
3
- export const shortcutScopes = ["global", "navigation", "editor", "modal"] as const
4
-
5
- export type ShortcutScope = (typeof shortcutScopes)[number]
6
-
7
- /** Default active scopes at provider boot. */
8
- export const defaultActiveScopes: ShortcutScope[] = ["global", "navigation"]
9
-
10
- /**
11
- * Normalizes a scope input into an array form.
12
- *
13
- * @param scopes - One or many scope names
14
- * @returns Array of scope names
15
- */
16
- export function normalizeScopes(scopes: ShortcutScope | ShortcutScope[]): ShortcutScope[] {
17
- return Array.isArray(scopes) ? scopes : [scopes]
18
- }
19
- `,"registry.ts":`import type { HandlerOptions } from "@remcostoeten/use-shortcut"
20
- import type { ShortcutScope } from "./scopes"
21
-
22
- export type ShortcutDefinition = {
23
- description: string
24
- defaultKeys: string | string[]
25
- scopes: ShortcutScope[]
26
- options?: Omit<HandlerOptions, "scopes">
27
- }
28
-
29
- export const shortcutRegistry = {
30
- openCommandPalette: {
31
- description: "Open global command palette",
32
- defaultKeys: "mod+k",
33
- scopes: ["global", "navigation"],
34
- },
35
- saveDocument: {
36
- description: "Save the active editor document",
37
- defaultKeys: "mod+s",
38
- scopes: ["editor"],
39
- },
40
- goDashboard: {
41
- description: "Navigate to dashboard (vim style sequence)",
42
- defaultKeys: ["g", "d"],
43
- scopes: ["navigation"],
44
- },
45
- closeOverlay: {
46
- description: "Close active overlay",
47
- defaultKeys: "escape",
48
- scopes: ["global", "modal"],
49
- },
50
- } as const satisfies Record<string, ShortcutDefinition>
51
-
52
- export type ShortcutActionId = keyof typeof shortcutRegistry
53
- export type ShortcutBindings = Record<ShortcutActionId, string | string[]>
54
- `,"types.ts":`import type { ShortcutActionId, ShortcutBindings } from "./registry"
55
- import type { ShortcutScope } from "./scopes"
56
-
57
- export type ShortcutHandlers = Record<ShortcutActionId, (event: KeyboardEvent) => void>
58
-
59
- export type ShortcutState = {
60
- activeScopes: ShortcutScope[]
61
- bindings: ShortcutBindings
62
- enabled: boolean
63
- }
64
-
65
- export type ShortcutActions = {
66
- setScopes: (scopes: ShortcutScope | ShortcutScope[]) => void
67
- enableScope: (scope: ShortcutScope) => void
68
- disableScope: (scope: ShortcutScope) => void
69
- setBinding: (actionId: ShortcutActionId, keys: string | string[]) => void
70
- resetBinding: (actionId: ShortcutActionId) => void
71
- resetBindings: () => void
72
- setEnabled: (enabled: boolean) => void
73
- }
74
-
75
- export type ShortcutMeta = {
76
- hasBindingOverrides: boolean
77
- availableActions: ShortcutActionId[]
78
- }
79
-
80
- export type ShortcutContextValue = {
81
- state: ShortcutState
82
- actions: ShortcutActions
83
- meta: ShortcutMeta
84
- }
85
- `,"runtime.ts":`import type { HandlerOptions, ShortcutMap } from "@remcostoeten/use-shortcut"
86
- import { shortcutRegistry, type ShortcutActionId, type ShortcutBindings } from "./registry"
87
- import type { ShortcutHandlers } from "./types"
88
-
89
- /**
90
- * Returns a fresh binding object seeded from \`shortcutRegistry\` defaults.
91
- *
92
- * @returns Default shortcut bindings for every registered action
93
- */
94
- export function createDefaultShortcutBindings(): ShortcutBindings {
95
- const bindings = {} as ShortcutBindings
96
-
97
- for (const actionId of Object.keys(shortcutRegistry) as ShortcutActionId[]) {
98
- bindings[actionId] = shortcutRegistry[actionId].defaultKeys
99
- }
100
-
101
- return bindings
102
- }
103
-
104
- /**
105
- * Builds a \`ShortcutMap\` by combining current bindings with action handlers.
106
- * Actions without handlers are skipped.
107
- *
108
- * @param bindings - Current binding state (defaults + user overrides)
109
- * @param handlers - Runtime action handlers from the consuming app
110
- * @returns Runtime shortcut map consumable by \`registerShortcutMap\`
111
- */
112
- export function buildShortcutMap(bindings: ShortcutBindings, handlers: ShortcutHandlers): ShortcutMap {
113
- const map: ShortcutMap = {}
114
-
115
- for (const actionId of Object.keys(shortcutRegistry) as ShortcutActionId[]) {
116
- const definition = shortcutRegistry[actionId]
117
- const handler = handlers[actionId]
118
-
119
- if (!handler) {
120
- continue
121
- }
122
-
123
- const definitionOptions: Partial<HandlerOptions> = ("options" in definition && definition.options)
124
- ? definition.options
125
- : {}
126
-
127
- map[actionId] = {
128
- keys: bindings[actionId],
129
- handler,
130
- options: {
131
- ...definitionOptions,
132
- scopes: definition.scopes,
133
- description: definition.description,
134
- },
135
- }
136
- }
137
-
138
- return map
139
- }
140
- `,"storage.ts":`import type { ShortcutActionId, ShortcutBindings } from "./registry"
141
-
142
- /** Default localStorage key used by the scaffolded shortcut provider. */
143
- export const DEFAULT_SHORTCUT_STORAGE_KEY = "app-shortcut-bindings"
144
-
145
- function _isBindingValue(value: unknown): value is string | string[] {
146
- if (typeof value === "string") return true
147
- return Array.isArray(value) && value.every((entry) => typeof entry === "string")
148
- }
149
-
150
- /**
151
- * Loads persisted shortcut binding overrides from localStorage.
152
- *
153
- * @param storageKey - Storage key namespace
154
- * @returns Partial binding overrides keyed by action id
155
- */
156
- export function loadShortcutBindings(storageKey: string): Partial<ShortcutBindings> {
157
- if (typeof window === "undefined") return {}
158
-
159
- try {
160
- const raw = window.localStorage.getItem(storageKey)
161
- if (!raw) return {}
162
-
163
- const parsed = JSON.parse(raw) as unknown
164
- if (!parsed || typeof parsed !== "object") return {}
165
-
166
- const result: Partial<ShortcutBindings> = {}
167
-
168
- for (const [actionId, value] of Object.entries(parsed as Record<string, unknown>)) {
169
- if (!_isBindingValue(value)) continue
170
- result[actionId as ShortcutActionId] = value
171
- }
172
-
173
- return result
174
- } catch {
175
- return {}
176
- }
177
- }
178
-
179
- /**
180
- * Persists all shortcut bindings to localStorage.
181
- *
182
- * @param storageKey - Storage key namespace
183
- * @param bindings - Full current binding set
184
- */
185
- export function saveShortcutBindings(storageKey: string, bindings: ShortcutBindings): void {
186
- if (typeof window === "undefined") return
187
-
188
- try {
189
- window.localStorage.setItem(storageKey, JSON.stringify(bindings))
190
- } catch {
191
- // Ignore quota/security errors.
192
- }
193
- }
194
-
195
- /**
196
- * Removes persisted shortcut bindings from localStorage.
197
- *
198
- * @param storageKey - Storage key namespace
199
- */
200
- export function clearShortcutBindings(storageKey: string): void {
201
- if (typeof window === "undefined") return
202
-
203
- try {
204
- window.localStorage.removeItem(storageKey)
205
- } catch {
206
- // Ignore quota/security errors.
207
- }
208
- }
209
- `,"provider.tsx":`"use client"
210
-
211
- import {
212
- createContext,
213
- useCallback,
214
- useContext,
215
- useEffect,
216
- useMemo,
217
- useState,
218
- type ReactNode,
219
- } from "react"
220
- import { registerShortcutMap, useShortcut, type UseShortcutOptions } from "@remcostoeten/use-shortcut"
221
- import { shortcutRegistry, type ShortcutActionId, type ShortcutBindings } from "./registry"
222
- import { buildShortcutMap, createDefaultShortcutBindings } from "./runtime"
223
- import { defaultActiveScopes, normalizeScopes, type ShortcutScope } from "./scopes"
224
- import {
225
- DEFAULT_SHORTCUT_STORAGE_KEY,
226
- clearShortcutBindings,
227
- loadShortcutBindings,
228
- saveShortcutBindings,
229
- } from "./storage"
230
- import type { ShortcutContextValue, ShortcutHandlers } from "./types"
231
-
232
- const _ShortcutContext = createContext<ShortcutContextValue | null>(null)
233
-
234
- function _mergeBindings(defaultBindings: ShortcutBindings, persisted: Partial<ShortcutBindings>): ShortcutBindings {
235
- const merged = { ...defaultBindings }
236
-
237
- for (const actionId of Object.keys(defaultBindings) as ShortcutActionId[]) {
238
- const value = persisted[actionId]
239
- if (typeof value === "string" || Array.isArray(value)) {
240
- merged[actionId] = value
241
- }
242
- }
243
-
244
- return merged
245
- }
246
-
247
- function _isSameBinding(a: string | string[], b: string | string[]): boolean {
248
- const left = Array.isArray(a) ? a.join("|") : a
249
- const right = Array.isArray(b) ? b.join("|") : b
250
- return left === right
251
- }
252
-
253
- function _hasBindingOverrides(bindings: ShortcutBindings): boolean {
254
- const defaults = createDefaultShortcutBindings()
255
-
256
- for (const actionId of Object.keys(defaults) as ShortcutActionId[]) {
257
- if (!_isSameBinding(bindings[actionId], defaults[actionId])) {
258
- return true
259
- }
260
- }
261
-
262
- return false
263
- }
264
-
265
- /** Props for the scaffolded \`ShortcutProvider\`. */
266
- export type ShortcutProviderProps = {
267
- children: ReactNode
268
- handlers: ShortcutHandlers
269
- initialScopes?: ShortcutScope[]
270
- initialEnabled?: boolean
271
- persistBindings?: boolean
272
- storageKey?: string
273
- shortcutOptions?: Omit<UseShortcutOptions, "activeScopes" | "disabled">
274
- }
275
-
276
- /**
277
- * App-level provider that binds action handlers, scope state, and optional binding persistence.
278
- */
279
- export function ShortcutProvider({
280
- children,
281
- handlers,
282
- initialScopes = defaultActiveScopes,
283
- initialEnabled = true,
284
- persistBindings = true,
285
- storageKey = DEFAULT_SHORTCUT_STORAGE_KEY,
286
- shortcutOptions,
287
- }: ShortcutProviderProps) {
288
- const [activeScopes, setActiveScopes] = useState<ShortcutScope[]>(initialScopes)
289
- const [enabled, setEnabled] = useState(initialEnabled)
290
- const [bindings, setBindings] = useState<ShortcutBindings>(() => createDefaultShortcutBindings())
291
-
292
- useEffect(() => {
293
- setEnabled(initialEnabled)
294
- }, [initialEnabled])
295
-
296
- useEffect(() => {
297
- if (!persistBindings) return
298
-
299
- const persisted = loadShortcutBindings(storageKey)
300
- setBindings((current) => _mergeBindings(current, persisted))
301
- }, [persistBindings, storageKey])
302
-
303
- useEffect(() => {
304
- if (!persistBindings) return
305
- saveShortcutBindings(storageKey, bindings)
306
- }, [bindings, persistBindings, storageKey])
307
-
308
- const shortcutMap = useMemo(() => buildShortcutMap(bindings, handlers), [bindings, handlers])
309
-
310
- const $ = useShortcut({
311
- ...shortcutOptions,
312
- activeScopes,
313
- disabled: !enabled,
314
- })
315
-
316
- useEffect(() => {
317
- const registrations = registerShortcutMap($, shortcutMap)
318
-
319
- return () => {
320
- for (const result of Object.values(registrations)) {
321
- result.unbind()
322
- }
323
- }
324
- }, [$, shortcutMap])
325
-
326
- const setScopes = useCallback((scopes: ShortcutScope | ShortcutScope[]) => {
327
- setActiveScopes(normalizeScopes(scopes))
328
- }, [])
329
-
330
- const enableScope = useCallback((scope: ShortcutScope) => {
331
- setActiveScopes((current) => {
332
- if (current.includes(scope)) return current
333
- return [...current, scope]
334
- })
335
- }, [])
336
-
337
- const disableScope = useCallback((scope: ShortcutScope) => {
338
- setActiveScopes((current) => current.filter((item) => item !== scope))
339
- }, [])
340
-
341
- const setBinding = useCallback((actionId: ShortcutActionId, keys: string | string[]) => {
342
- setBindings((current) => ({
343
- ...current,
344
- [actionId]: keys,
345
- }))
346
- }, [])
347
-
348
- const resetBinding = useCallback((actionId: ShortcutActionId) => {
349
- setBindings((current) => ({
350
- ...current,
351
- [actionId]: shortcutRegistry[actionId].defaultKeys,
352
- }))
353
- }, [])
354
-
355
- const resetBindings = useCallback(() => {
356
- setBindings(createDefaultShortcutBindings())
357
- if (persistBindings) clearShortcutBindings(storageKey)
358
- }, [persistBindings, storageKey])
359
-
360
- const contextValue = useMemo<ShortcutContextValue>(
361
- () => ({
362
- state: {
363
- activeScopes,
364
- bindings,
365
- enabled,
366
- },
367
- actions: {
368
- setScopes,
369
- enableScope,
370
- disableScope,
371
- setBinding,
372
- resetBinding,
373
- resetBindings,
374
- setEnabled,
375
- },
376
- meta: {
377
- hasBindingOverrides: _hasBindingOverrides(bindings),
378
- availableActions: Object.keys(shortcutRegistry) as ShortcutActionId[],
379
- },
380
- }),
381
- [
382
- activeScopes,
383
- bindings,
384
- enabled,
385
- setScopes,
386
- enableScope,
387
- disableScope,
388
- setBinding,
389
- resetBinding,
390
- resetBindings,
391
- ],
392
- )
393
-
394
- return <_ShortcutContext.Provider value={contextValue}>{children}</_ShortcutContext.Provider>
395
- }
396
-
397
- /**
398
- * Reads the shortcut manager context exposed by \`ShortcutProvider\`.
399
- */
400
- export function useShortcutManager(): ShortcutContextValue {
401
- const context = useContext(_ShortcutContext)
402
-
403
- if (!context) {
404
- throw new Error("useShortcutManager must be used within <ShortcutProvider>")
405
- }
406
-
407
- return context
408
- }
409
- `,"index.ts":`export { ShortcutProvider, useShortcutManager, type ShortcutProviderProps } from "./provider"
410
- export { shortcutRegistry, type ShortcutActionId, type ShortcutBindings } from "./registry"
411
- export { defaultActiveScopes, shortcutScopes, type ShortcutScope } from "./scopes"
412
- export type {
413
- ShortcutContextValue,
414
- ShortcutActions,
415
- ShortcutHandlers,
416
- ShortcutMeta,
417
- ShortcutState,
418
- } from "./types"
419
- `,"README.md":`# Shortcut Architecture Scaffold
420
-
421
- This folder was generated by \`use-shortcut scaffold\`.
422
-
423
- It follows a scalable architecture with a strict split between:
424
-
425
- - **Registry (data)**: \`registry.ts\` is the single source of truth for action ids, default keys, scope ownership, and metadata.
426
- - **Runtime assembly**: \`runtime.ts\` converts registry + current bindings + handlers into a runtime map.
427
- - **State and actions provider**: \`provider.tsx\` owns scope state, binding overrides, and persistence.
428
- - **Storage adapter**: \`storage.ts\` isolates persistence so you can swap \`localStorage\` for API/DB.
429
- - **Typed contract**: \`types.ts\` exposes \`state/actions/meta\` for UI and feature modules.
430
-
431
- ## How To Extend
432
-
433
- 1. Add a new action in \`registry.ts\`.
434
- 2. Implement its handler in your app and pass it via \`handlers\` to \`<ShortcutProvider>\`.
435
- 3. Optionally expose a user-configurable key in your settings UI through \`useShortcutManager().actions.setBinding\`.
436
- 4. Activate scopes from feature boundaries (for example editor route enters \`editor\` scope).
437
-
438
- ## Wiring A React App
439
-
440
- Scaffold the shortcut files into a React app shell:
441
-
442
- \`\`\`bash
443
- npx @remcostoeten/use-shortcut scaffold --framework react --target apps/shortcut-playground/src
444
- \`\`\`
445
-
446
- Then wire those generated files into your app root by wrapping your app in \`<ShortcutProvider handlers={...} />\`.
447
-
448
- ${t==="next"?"## Next.js Integration\n\n1. Create a client provider wrapper at `app/shortcut-provider.tsx` and render `<ShortcutProvider />` there.\n2. Render that provider inside `app/layout.tsx` around your app shell.\n3. Keep page/server components pure; shortcut handlers stay in client components.\n":"## React Integration\n\n1. Wrap your app root (for example in `main.tsx`) with `<ShortcutProvider />`.\n2. Keep handlers in a top-level client component and pass them to the provider via `handlers`.\n3. Use `useShortcutManager()` inside feature components to toggle scopes and bindings.\n"}
449
- ## Rules For Scale
450
-
451
- - Keep handlers side-effect focused and feature-owned; keep the registry declarative.
452
- - Use scopes instead of conditionals in handlers.
453
- - Persist only key bindings, not executable handlers.
454
- - Treat \`registry.ts\` as your architectural contract.
455
- `}}var k=I(import.meta.url),O=B(k),e={reset:"\x1B[0m",green:"\x1B[32m",cyan:"\x1B[36m",yellow:"\x1B[33m",dim:"\x1B[2m",red:"\x1B[31m"};function r(t,o=e.reset){console.log(`${o}${t}${e.reset}`)}function _(){return l(O,"..","src")}function R(t){return l(process.cwd(),t,"use-shortcut")}function C(t,o){return l(process.cwd(),t,o)}function p(t,o,a){let s=t.indexOf(o);if(s===-1)return a;let n=t[s+1];return!n||n.startsWith("--")?a:n}function g(t,o){return t.includes(o)}var P=["index.ts","hook.ts","builder.ts","types.ts","parser.ts","constants.ts","formatter.ts","runtime/types.ts","runtime/binding.ts","runtime/conflicts.ts","runtime/debug.ts","runtime/guards.ts","runtime/keys.ts","runtime/listener.ts","runtime/recording.ts"];function E(t="hooks",o=!1){let a=_(),s=R(t);if(r(`
456
- use-shortcut CLI
457
- `,e.cyan),h(s)&&!o){r(`Directory already exists: ${s}`,e.yellow),r(`Use --force to overwrite existing files
458
- `,e.dim);return}f(s,{recursive:!0});let n=0;for(let i of P){let c=l(a,i),d=l(s,i);if(f(B(d),{recursive:!0}),!h(c)){r(`Source file not found: ${i}`,e.yellow);continue}let u=A(c,"utf-8");x(d,u),n+=1,r(` wrote ${i}`,e.green)}r(`
459
- Copied ${n} files to:`,e.green),r(` ${s}
460
- `,e.dim),r("Usage:",e.cyan),r(` import { useShortcut } from "@/${t}/use-shortcut"`,e.dim),r(" const $ = useShortcut()",e.dim),r(` $.mod.key("k").on(() => console.log("Search"))
461
- `,e.dim)}function y(t,o="src",a="shortcuts",s=!1){let n=C(o,a),i=m(t);r(`
462
- use-shortcut CLI
463
- `,e.cyan),r(`Scaffolding ${t} architecture in ${n}
464
- `,e.dim),f(n,{recursive:!0});let c=0,d=0;for(let[u,w]of Object.entries(i)){let S=l(n,u);if(h(S)&&!s){d+=1,r(` skipped ${u} (already exists)`,e.yellow);continue}x(S,w),c+=1,r(` wrote ${u}`,e.green)}r("",e.reset),r(`Architecture scaffold complete: ${c} written, ${d} skipped.`,e.green),r(`Location: ${n}
465
- `,e.dim),r("Next steps:",e.cyan),r(` 1. Open ${l(o,a,"registry.ts")} and define your action catalog`,e.dim),r(" 2. Wire app handlers into <ShortcutProvider handlers={...} />",e.dim),r(" 3. Toggle scopes from feature boundaries via useShortcutManager()",e.dim),r(` 4. Optionally expose setBinding/resetBinding in your settings UI
466
- `,e.dim)}function b(){r(`
467
- use-shortcut CLI
468
- `,e.cyan),r("Commands:",e.yellow),r(" init [--target hooks] [--force]",e.dim),r(" Copy source files into your project (shadcn-style).",e.dim),r("",e.dim),r(" scaffold [--framework next|react] [--target src] [--dir shortcuts] [--force]",e.dim),r(" Generate a scalable app shortcut architecture.",e.dim),r("",e.dim),r(" init --architecture",e.dim),r(` Alias for scaffold with defaults.
469
- `,e.dim)}function v(t){if(t==="next"||t==="react")return t;r(`Invalid framework: ${t}. Expected "next" or "react".`,e.red),process.exit(1)}function K(){let t=process.argv.slice(2),o=t[0];if(!o||o==="--help"||o==="-h"||o==="help"){b();return}if(o==="init"){if(g(t,"--architecture")||g(t,"--app")||g(t,"--scaffold")){let i=v(p(t,"--framework","next")),c=p(t,"--target","src"),d=p(t,"--dir","shortcuts"),u=g(t,"--force");y(i,c,d,u);return}let s=p(t,"--target","hooks"),n=g(t,"--force");E(s,n);return}if(o==="scaffold"||o==="architecture"){let s=v(p(t,"--framework","next")),n=p(t,"--target","src"),i=p(t,"--dir","shortcuts"),c=g(t,"--force");y(s,n,i,c);return}b(),process.exit(1)}K();