From 8649f2767887eb7e8a40d86800927a0c3c5a0bca Mon Sep 17 00:00:00 2001 From: Priyanshu Garg Date: Mon, 12 Oct 2026 21:25:56 +0530 Subject: [PATCH 1/4] docs: move the full reference from README.md to GUIDE.md The README keeps the pitch, install, Quick Start and links. Every other section moves word for word, so GUIDE.md headings keep the old anchors. --- GUIDE.md | 1310 ++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 1318 +---------------------------------------------------- 2 files changed, 1328 insertions(+), 1300 deletions(-) create mode 100644 GUIDE.md diff --git a/GUIDE.md b/GUIDE.md new file mode 100644 index 0000000..3576a71 --- /dev/null +++ b/GUIDE.md @@ -0,0 +1,1310 @@ +# cmdk-engine guide + +The full reference on one page. The [README](README.md) has the overview, installation and Quick Start. The [docs site](https://priyans-hu.github.io/cmdk-engine/docs/) has the same material split into pages. + +**Contents:** [Requirements](#requirements), [Beyond the Quick Start](#beyond-the-quick-start), [Styling](#styling), [Examples](#examples), [CommandPalette Props](#commandpalette-props), [Base UI Adapter](#base-ui-adapter), [React Router Integration](#react-router-integration), [Next.js](#nextjs), [RBAC / Access Control](#rbac--access-control), [Search](#search), [Synonyms](#synonyms), [Frecency Ranking](#frecency-ranking), [Context / Scope Boosting](#context--scope-boosting), [Nested Commands](#nested-commands), [Groups](#groups), [Internationalization (i18n)](#internationalization-i18n), [Search History](#search-history), [Palette Events](#palette-events), [Async Command Sources](#async-command-sources), [CLI Tool](#cli-tool), [Architecture](#architecture), [API Reference](#api-reference), [Type Safety](#type-safety), [Testing](#testing), [cmdk Issues We Solve](#cmdk-issues-we-solve). + +--- + +## Requirements + +> **Peer dependencies (all optional — install only what you use):** `react`, +> `react-dom`, `cmdk` (for the cmdk adapter), `@base-ui/react` (for the Base +> UI adapter), `match-sorter` (for the match-sorter search backend), and +> `react-router` v6, v7 or v8 / +> `react-router-dom` v6 or v7 (for the React Router adapter; v8 ships only +> `react-router`). The core engine (`cmdk-engine`) has zero runtime +> dependencies. + +- **React** 18 or 19. +- **A UI adapter:** `cmdk` ^1 for the cmdk adapter, or `@base-ui/react` ^1.1 for the + Base UI adapter. Or build your own UI with the hooks. +- **Optional:** `react-router` 6, 7 or 8 for the route scanner, and `match-sorter` 7 or 8 + for the match-sorter search backend. +- **Node.js** 20 or later, needed by the CLI only. The library runs in the browser and + during SSR. + +**Support:** the latest minor release gets fixes. While the version is 0.x, a minor +release can change behavior; each such change is listed as a "Behavior change" in the +[changelog](./CHANGELOG.md). To report a vulnerability, see [SECURITY.md](./SECURITY.md). + +--- + +## Beyond the Quick Start + +The [Quick Start](README.md#quick-start) puts everything in one file. This section covers what comes next. + +### Splitting it into files + +- **Provider:** wrap your app once, near the root. Define `config` outside + components, or `useMemo` it: a new object on every render rebuilds the + engine. +- **Commands:** call `useCommandRegister` in any component under the provider. + Its commands go away when that component unmounts, so register app-wide + navigation in a layout that stays mounted and page commands in the page. +- **Palette:** keep `CommandPalette` and `useCommandPaletteShortcut()` together + in one component, anywhere under the provider. + +### Navigating with your router + +`onNavigate` gets the `href` of each selected command that has no `action`. +`window.location.assign` reloads the page, so pass your router instead. With +a React Router data router, use the router object: + +```tsx +const router = createBrowserRouter(routes) +const config = { onNavigate: (href: string) => router.navigate(href) } +``` + +To use `useNavigate()` instead, render the provider inside the router (in a +root layout route, for example) and `useMemo` the config there. A command's +`action` runs instead of `onNavigate`, and an `onSelect` on the provider +config or on `CommandPalette` replaces both. + +### `onSelect` replaces the default handling + +`config.onSelect` runs for every selected command instead of its `action` and +instead of `onNavigate`. The `onSelect` prop of `CommandPalette` does the same, +and wins over the config. To track selections, call the default yourself, or +leave `onSelect` unset and track inside `onNavigate` and your actions: + + +```tsx +import type { CommandItem } from 'cmdk-engine' + +const config = { + onSelect: (item: CommandItem) => { + console.log('command selected', item.id) // your analytics call + if (item.action) item.action(item) + else if (item.href) window.location.assign(item.href) + }, +} +``` + +### Errors from commands + +*New in 0.6.* + +A command's `action`, your `onSelect` or your `onNavigate` can throw or return +a rejected promise. Set `onSelectError` to handle that; the palette still +closes right away. Without it, errors propagate as before. + + +```tsx +import type { CommandItem } from 'cmdk-engine' + +const config = { + onSelectError: (error: unknown, item: CommandItem) => { + console.error(`"${item.label}" failed`, error) // or show a toast + }, +} +``` + +### Or build your own UI with hooks + + +```tsx +import { useCommandPalette } from 'cmdk-engine/react' + +function CustomCommandMenu() { + const { search, setSearch, groupedResults, isOpen, toggle, select } = + useCommandPalette() + + return ( +
+ setSearch(e.target.value)} /> + {groupedResults.map(({ group, items }) => ( +
+

{group.label}

+ {items.map(({ item }) => ( + + ))} +
+ ))} +
+ ) +} +``` + +> `select()` records frecency + search history, runs `onSelect` → `action` → +> `onNavigate`/`href`, and closes the palette — all in one call. + +--- + +## Styling + +`CommandPalette` ships no styles. Style cmdk's `[cmdk-*]` parts and the +adapter's `data-cmdk-engine-*` attributes from any global stylesheet. This CSS +gives the look in the screenshot at the top: + +```css +[cmdk-overlay] { position: fixed; inset: 0; z-index: 50; background: rgb(0 0 0 / 0.4); } +[cmdk-dialog] { + position: fixed; top: 15vh; left: 50%; z-index: 50; transform: translateX(-50%); + width: min(560px, calc(100vw - 32px)); +} +[cmdk-root] { + overflow: hidden; border: 1px solid #e5e7eb; border-radius: 12px; + background: #fff; color: #111827; font: 14px/1.4 system-ui, sans-serif; + box-shadow: 0 16px 48px rgb(0 0 0 / 0.2); +} +[cmdk-input] { + box-sizing: border-box; width: 100%; padding: 14px 16px; border: 0; + border-bottom: 1px solid #e5e7eb; font: inherit; font-size: 16px; outline: none; +} +[cmdk-input]:focus-visible { border-bottom-color: #6366f1; box-shadow: inset 0 -1px 0 #6366f1; } +[cmdk-list] { max-height: 320px; overflow-y: auto; padding: 8px; } +[cmdk-group-heading] { padding: 8px 8px 4px; font-size: 12px; color: #6b7280; } +[cmdk-item] { padding: 8px; border-radius: 8px; cursor: pointer; } +[cmdk-item][data-selected='true'] { background: #f3f4f6; } +[cmdk-item][data-disabled='true'] { opacity: 0.5; cursor: default; } +[data-cmdk-engine-item] { display: flex; align-items: center; gap: 8px; } +[data-cmdk-engine-item-content] { display: flex; flex: 1; flex-direction: column; } +[data-cmdk-engine-item-description] { font-size: 12px; color: #6b7280; } +[data-cmdk-engine-item-shortcut] kbd { + margin-left: 4px; padding: 0 6px; border: 1px solid #e5e7eb; border-radius: 4px; + font: inherit; font-size: 12px; +} +[data-cmdk-engine-empty], +[data-cmdk-engine-loading] { padding: 16px; text-align: center; color: #6b7280; } +``` + +The accent underline on `[cmdk-input]:focus-visible` replaces the outline the +input drops, so keyboard users can see where focus is. Keep a focus style if +you restyle the input. + +Items also carry `data-cmdk-engine-icon` and `data-cmdk-engine-item-label`, +plus `data-cmdk-engine-item-chevron` when they have children. Nested commands +add `data-cmdk-engine-breadcrumbs`, with `data-cmdk-engine-breadcrumb-back`, +`data-cmdk-engine-breadcrumb` and `data-cmdk-engine-breadcrumb-separator` +inside. cmdk documents its parts in +[Parts and styling](https://github.com/dip/cmdk#parts-and-styling) and has +[drop-in stylesheets](https://github.com/dip/cmdk/tree/main/website/styles/cmdk). + +With Tailwind (v3 or v4), `@apply` the same utilities to the same selectors in +your main CSS file: + +```css +[cmdk-overlay] { @apply fixed inset-0 z-50 bg-black/40; } +[cmdk-dialog] { @apply fixed left-1/2 top-[15vh] z-50 w-[min(560px,calc(100vw-32px))] -translate-x-1/2; } +[cmdk-root] { @apply overflow-hidden rounded-xl border border-gray-200 bg-white text-sm text-gray-900 shadow-2xl; } +[cmdk-input] { @apply w-full border-0 border-b border-gray-200 px-4 py-3.5 text-base outline-none focus-visible:border-indigo-500 focus-visible:shadow-[inset_0_-1px_0_#6366f1]; } +[cmdk-list] { @apply max-h-80 overflow-y-auto p-2; } +[cmdk-group-heading] { @apply px-2 pb-1 pt-2 text-xs text-gray-500; } +[cmdk-item] { @apply cursor-pointer rounded-lg p-2 data-[selected=true]:bg-gray-100 data-[disabled=true]:opacity-50; } +[data-cmdk-engine-item] { @apply flex items-center gap-2; } +[data-cmdk-engine-item-content] { @apply flex flex-1 flex-col; } +[data-cmdk-engine-item-description] { @apply text-xs text-gray-500; } +[data-cmdk-engine-item-shortcut] kbd { @apply ml-1 rounded border border-gray-200 px-1.5 font-sans text-xs; } +[data-cmdk-engine-empty], [data-cmdk-engine-loading] { @apply p-4 text-center text-gray-500; } +``` + +To style per instance instead, `CommandPalette` passes `className`, +`overlayClassName`, `contentClassName`, `inputClassName`, `listClassName`, +`groupClassName`, `itemClassName` and `emptyClassName` to those parts. + +### shadcn/ui + +If your app uses [shadcn/ui](https://ui.shadcn.com), install the palette from +this project's shadcn registry instead: + +```bash +npx shadcn@latest add https://priyans-hu.github.io/cmdk-engine/r/command-palette.json +``` + +It writes `components/command-palette.tsx`, the cmdk adapter's palette styled +with your theme's tokens, so it follows light and dark mode and is yours to +edit. Render `` once inside `CommandEngineProvider`. For Base +UI instead of cmdk, add `.../r/command-palette-base-ui.json`. Both need +cmdk-engine 0.6.0 or later. See the +[shadcn/ui page](https://priyans-hu.github.io/cmdk-engine/docs/shadcn/). + +--- + +## Examples + +Runnable apps in [`examples/`](examples). CI builds each one against this +repo, and each installs `cmdk-engine` from npm, so you can copy one out. + +| Example | What it shows | Try it | +|---|---|---| +| [Vite + React Router](examples/vite-react-router) | Commands from the route tree with `scanRoutes`, the provider inside the router, this Styling CSS, and the same palette on Base UI with `?adapter=base-ui` | [StackBlitz](https://stackblitz.com/github/Priyans-hu/cmdk-engine/tree/main/examples/vite-react-router?file=src/layout.tsx) | +| [Next.js App Router](examples/nextjs-app-router) | A `'use client'` provider file under a server layout, `router.push` as `onNavigate`, and `[locale]` pages found by `cmdk-engine scan --include-dynamic locale` and filled in with `sitemapToCommands` | [StackBlitz](https://stackblitz.com/github/Priyans-hu/cmdk-engine/tree/main/examples/nextjs-app-router?file=components/command-menu.tsx) | +| [shadcn/ui](examples/shadcn) | Both shadcn registry items in an app set up with `shadcn init`, in light and dark mode | [StackBlitz](https://stackblitz.com/github/Priyans-hu/cmdk-engine/tree/main/examples/shadcn?file=src/App.tsx) | + +The docs site is a live demo too: Cmd+K there searches its own pages. Its +[Examples page](https://priyans-hu.github.io/cmdk-engine/docs/examples/) has more +recipes: RBAC, a custom UI and a pre-commit hook. + +--- + +## CommandPalette Props + +Both adapters export `CommandPalette` with the same props, except `vimBindings`, which +only the cmdk adapter has. Every prop is optional. + +| Prop | Default | Description | +|------|---------|-------------| +| `dialog` | `false` | Render in a modal dialog with an overlay. When `false`, the palette is inline and always visible. Open a dialog with `useCommandPaletteShortcut()` or `toggle()` | +| `placeholder` | `palette.placeholder` | Input placeholder | +| `label` | `palette.label` | Accessible name of the palette and of the dialog | +| `loop` | `true` | Wrap from the last item to the first, and back | +| `onSelect` | none | `(item) => void`. Runs instead of the default handling (`action`, then `onNavigate` or `href`), and wins over `config.onSelect` | +| `renderItem` | built-in row | `(item, score) => ReactNode`. The row content; the adapter still renders the selectable wrapper | +| `renderGroupHeading` | the group label | `(group) => ReactNode` | +| `renderEmpty` | `palette.empty` text | `() => ReactNode`, shown when nothing matches | +| `renderLoading` | `palette.loading` text | `() => ReactNode`, shown while async sources load | +| `renderBreadcrumbs` | built-in trail | `(crumbs, onBack) => ReactNode`, shown inside a sub-menu ([Nested Commands](#nested-commands)) | +| `footer` | none | Node rendered below the list | +| `container` | the page body | Portal target in dialog mode | +| `disablePointerSelection` | `false` | The pointer no longer moves the highlight. Clicking an item still runs it | +| `vimBindings` | `true` | cmdk adapter only. Ctrl+N, P, J and K move the highlight | +| `className`, `inputClassName`, `listClassName`, `itemClassName`, `groupClassName`, `emptyClassName` | none | Class names for those parts ([Styling](#styling)) | +| `overlayClassName`, `contentClassName` | none | Class names for the dialog overlay and content | + +--- + +## Base UI Adapter + +*New in 0.6.* + +Prefer [Base UI](https://base-ui.com)? `cmdk-engine/adapters/base-ui` renders +the palette with Base UI's +[Autocomplete](https://base-ui.com/react/components/autocomplete) (plus its +[Dialog](https://base-ui.com/react/components/dialog) in dialog mode) instead of +cmdk. The engine still does all filtering, ranking and grouping. + +```bash +npm install cmdk-engine @base-ui/react +``` + +`@base-ui/react` (`^1.1.0`) is an optional peer, like `cmdk`. The adapter +exports `CommandPalette` and `useCommandPaletteShortcut` with the cmdk adapter's +props, so switching adapters is an import-path change: + + +```tsx +import { CommandPalette, useCommandPaletteShortcut } from 'cmdk-engine/adapters/base-ui' + +// Inline: always rendered +function SearchPanel() { + return +} + +// Dialog: opened with Cmd+K / Ctrl+K +function CommandMenu() { + useCommandPaletteShortcut() + return +} +``` + +The default renderers produce the same `data-cmdk-engine-*` markup as the cmdk +adapter, so CSS written against those attributes carries over. cmdk's own +`[cmdk-*]` attributes don't exist here: style the parts with the `*ClassName` +props. Other differences: + +| | cmdk adapter | Base UI adapter | +|---|---|---| +| Vim keys | `vimBindings` (Ctrl+N/P/J/K) | None; there is no `vimBindings` prop | +| Home / End | First / last item | Move the caret in the input | +| Highlighted item | `[cmdk-item][data-selected="true"]` | `[role="option"][data-highlighted]` | +| Loading row | `role="progressbar"`, after the list | `role="status"` live region, after the list | +| Results change while open (async sources) | Keeps the highlighted item | Keeps the highlighted position | +| IME input | The query updates while composing | The query updates when composition ends | + +Follow Base UI's [quick start](https://base-ui.com/react/overview/quick-start): +give your app's root element `isolation: isolate` so the dialog stays on top, +and for iOS 26+ Safari give the backdrop (`overlayClassName`) +`position: absolute` and add `body { position: relative }`. Like cmdk's, the +dialog is unstyled. Its visually hidden close button is labelled by the +`palette.close` translation key. With `@base-ui/react` 1.1, Firefox logs a +`mozInputSource` deprecation warning the first time the input is clicked, and +axe reports that the combobox input lacks `aria-expanded`. Both come from Base +UI and are gone in later versions (1.9 is clean). In WebKit, axe also flags +Base UI's focus guards as elements without a name; that one is upstream too. + +Base UI costs more than cmdk: about 44 kB min + brotli for Autocomplete and +48 kB with Dialog, versus about 14 kB for cmdk with its Radix dialog. In Node, +load the adapter with either `import` or `require`, not both, or two copies of +`@base-ui/react` run side by side; bundlers load one. + +--- + +## React Router Integration + +Auto-discover routes from your React Router config. Register them inside the provider, +and navigate with the router object, since the provider sits above `RouterProvider`: + + +```tsx +import { createBrowserRouter, RouterProvider } from 'react-router-dom' +import { CommandEngineProvider, useCommandRegister } from 'cmdk-engine/react' +import { scanRoutes } from 'cmdk-engine/adapters/react-router' + +const routes = [ + { path: '/', element:

Home

}, + { path: '/billing', element:

Billing

}, +] +const router = createBrowserRouter(routes) +const commands = scanRoutes(routes) +const config = { onNavigate: (href: string) => router.navigate(href) } + +function RegisterRoutes() { + useCommandRegister(commands) + return null +} + +export function App() { + return ( + + + {/* the palette from the Quick Start goes here */} + + + ) +} +``` + +On React Router 8 there is no `react-router-dom`: import `createBrowserRouter` from +`react-router` and `RouterProvider` from `react-router/dom`. + +### Smart defaults + +The scanner automatically: +- **Excludes auth routes** — `/login`, `/signup`, `/forgot-password`, `/oauth/callback`, etc. +- **Excludes error pages** — `/404`, `/500`, `/error`, `/not-found` +- **Skips dynamic routes** — `/users/:id`, `/billing/:uuid` (can't navigate without a real ID) +- **Derives labels** from the path — `/billing/overview` → "Overview" +- **Derives groups** from the first segment — `/billing/overview` → group "Billing" + +### Scanner options + +```tsx +const commands = scanRoutes(routeConfig, { + exclude: ['/admin/*', /^\/debug\//, '/internal'], // string, glob, or regex + noDefaultExclude: false, // set true to skip default auth/error exclusion + includeDynamic: false, // set true to include :id routes +}) +``` + +### Route metadata + +Enrich routes with metadata using the `handle` convention: + +```tsx +{ + path: '/billing/overview', + handle: { + command: { + label: 'Billing Dashboard', + keywords: ['money', 'payment'], + group: 'Billing', + icon: , + priority: 10, + } + }, + element: , +} +``` + +Routes with `handle.command` are always included, even if they have dynamic segments. The scanner also falls back to `route.title` and `route.icon` if `handle.command` doesn't define them. + +A `handle` returned from `lazy()` is not read, because the scanner never calls `lazy()`. Put `handle.command` on the route object itself. + +### Index routes + +*New in 0.6.* + +An index route (`index: true` without a `path`) resolves to its parent's URL, so the index route of a pathless root becomes `/` (label "Home", id `home`). It never adds a second command for a URL another route already has: its `handle.command` is merged over that command instead, and the index route's fields win. Only `handle.command` is merged; an index route's `route.title` and `route.icon` fallbacks apply only when it gets its own command. Index routes follow their parent's exclusion and the dynamic-route rule. `index: true` with a `path` is a normal path route. + +The CLI scanner (`npx cmdk-engine scan`) does not resolve index routes. + +--- + +## Next.js + +The palette needs a Client Component. Put the provider, the palette and the +shortcut in one file that starts with `'use client'`, pass `router.push` as +`onNavigate`, and render it from your root layout. `npx cmdk-engine scan` reads +App Router and Pages Router files; for `[locale]` pages see +[Dynamic routes and `[locale]`](#dynamic-routes-and-locale). The +[Next.js guide](https://priyans-hu.github.io/cmdk-engine/docs/nextjs/) has the +full files, and [`examples/nextjs-app-router`](examples/nextjs-app-router) is a +runnable app. + +--- + +## RBAC / Access Control + +Filter commands based on user permissions: + + +```tsx +import { createSimpleAccessProvider } from 'cmdk-engine' + +// Build the provider once, outside the component, or `useMemo` it per user. +const config = { + accessControl: createSimpleAccessProvider(['admin.view', 'billing.read']), + accessCheckMode: 'any' as const, // user needs ANY listed permission +} + +// +``` + +Commands with `permissions: ['admin.view']` will only show for users who have that permission. + +### Per-command access mode + +`accessCheckMode` is the engine-wide default, but any command can override it — +useful when most commands need *any* of their permissions but a few sensitive +ones need *all*: + +```tsx +useCommandRegister([ + // Uses the engine default ('any') + { id: 'reports', label: 'Reports', permissions: ['reports.view', 'admin'] }, + // Overrides to require ALL permissions for this one command + { id: 'delete-org', label: 'Delete Org', permissions: ['org.admin', 'billing.owner'], accessMode: 'all' }, +]) +``` + +### Dynamic visibility (`when`) + +Static `permissions` cover role-based access. For everything else — feature +flags, plan tiers, org type, A/B gates — use `when`. A command whose `when` +resolves to `false` is removed entirely (not searchable, not browsable): + +```tsx +useCommandRegister([ + { id: 'beta-tool', label: 'Beta Tool', when: () => flags.betaEnabled }, + { id: 'enterprise', label: 'SSO Settings', when: () => org.plan === 'enterprise' }, +], [flags.betaEnabled, org.plan]) +``` + +> `permissions` (+ `accessMode`) and `when` compose: a command must pass both. +> `hidden: true` is different again — it keeps a command out of the empty-query +> browse list but still lets a matching query find it. + +> **Note:** access filtering is a UI concern, not a security boundary. Always +> enforce permissions server-side. + +--- + +## Search + +*New in 0.6: words in any order, and folding of accents and spaces.* + +The built-in search matches labels, descriptions and keywords, and tolerates +typos, partial words and initials. + +- **Words in any order:** "overview billing" finds "Billing Overview", and each + word can match a different field. These matches come after the ones that + match the whole query, and never score above the weakest of them. +- **Accents, Unicode forms and spaces:** the query and the commands are + compared after Unicode compatibility decomposition (NFKD), with the combining + accents U+0300 to U+036F removed, in lowercase, with repeated whitespace + collapsed. "resume" finds "Résumé" and `billing over` (two spaces) finds + "Billing Overview". Other marks (Indic vowel signs, kana voicing marks) are + kept, ß and dotless ı are not folded, and the built-in search compares + Korean by its letters (jamo), so a partial syllable already matches. +- **match-sorter:** `createMatchSorterSearch()` from + `cmdk-engine/search/match-sorter` needs `match-sorter` (7 or 8) installed; + it is part of the bundle that imports this entry. It folds the query's + accents, compatibility forms and spaces the same way but keeps its case + (an exact-case match ranks first), and also matches synonym keywords + (ranked at most CONTAINS, below direct matches) and words in any order. + A keystroke with several words takes about 1.5 to 2 times as long as + match-sorter alone on Node 22 and 2 to 3 times on Node 20, the most for + three or more words. + +Pass the engine in the provider config, and create it once, outside the +component: + + +```tsx +import { createMatchSorterSearch } from 'cmdk-engine/search/match-sorter' + +const config = { searchEngine: createMatchSorterSearch() } +``` + +Any object with a `search(query, items)` method works as `searchEngine` too. +The [Search page](https://priyans-hu.github.io/cmdk-engine/docs/search/) has the +scoring tiers, the `threshold` and `keys` options and a custom engine. + +--- + +## Synonyms + +Synonyms work both ways. With this config, typing "money" or "payment" finds +the "Billing Overview" command from the Quick Start: + +```tsx +const config = { + synonyms: { + billing: ['money', 'payment', 'credits'], + settings: ['preferences', 'config', 'options'], + }, +} +``` + +- **Query:** when the whole query (ignoring case, accents and extra spaces) + equals a key or a value, the other terms are searched too: a key brings its + values, a value its key. Commands found only this way are listed after the + direct matches and never score above the weakest one. Frecency and context + boosts apply afterwards, so a command you use often can still move up. +- **Commands:** a command whose keyword or whole label equals a key or a value + also matches the other terms, at a lower weight (with match-sorter, ranked at + most CONTAINS). +- **Not expanded:** the query, while it is a partial word ("mon" is searched + as typed until "money" is complete) or a longer phrase that contains a + synonym ("money transfer"). + +When the query expands, a custom `searchEngine` is called once more for each +extra term. + +--- + +## Frecency Ranking + +Commands you use frequently and recently appear higher in results. No configuration needed: it uses `localStorage` by default. When you use `select()`, frecency is recorded automatically. + +The algorithm uses exponential decay with a configurable half-life: + +``` +score = count * 2^(-timeSinceLastUse / halfLife) +``` + +### Recent commands + +Show a "Recent" group at the top of the palette when the search is empty: + +```tsx + +``` + +> Frecency (and search history, below) persist to `localStorage` by default and +> fall back to memory where it is unavailable: during SSR, in sandboxed iframes +> and when the browser blocks cookies, or `window.localStorage` is `null` or +> rejects writes. To test storage, the provider writes and removes a +> `cmdk-engine-probe` key once when it mounts. Malformed data under their keys +> is ignored and replaced on the next write. Override the backend via +> `config.frecency.storage`. + +> `frecency.storageKey` and `searchHistory.storageKey` are full `localStorage` +> keys, not prefixes, so everyone using a browser shares the defaults. If +> several users can sign in on one browser, namespace both keys per user, e.g. +> ``storageKey: `cmdk-frecency:${user.id}` ``. + +### Turning frecency off + +*New in 0.6.* + +Set `frecency: { enabled: false }` to turn frecency off. Nothing is stored in +or read from `localStorage`, results are not ranked by past use, and no +"Recent" group shows, even with `showRecent`. + +--- + +## Context / Scope Boosting + +Commands with a `scope` are boosted when they match the current app context, so on +`/billing`, billing commands rank higher while you search. The empty-query browse +list is not boosted. + +```tsx + + +// A command relevant to the billing area: +{ id: 'add-card', label: 'Add Card', scope: ['/billing', '/billing/*'] } +``` + +A `scope` entry matches when it equals the current `context.path` or is a parent of it +(`/billing` matches `/billing/overview`), when a glob such as `/billing/*` covers it (the +glob matches below `/billing`, not `/billing` itself), or when it equals one of the +`context.tags`. + +--- + +## Nested Commands + +Give a command `children` to make a sub-menu. Selecting it opens its children instead +of running it: + + +```tsx +import { useCommandRegister } from 'cmdk-engine/react' + +const setTheme = (theme: string) => document.documentElement.setAttribute('data-theme', theme) + +function ThemeCommands() { + useCommandRegister([ + { + id: 'theme', + label: 'Change theme', + children: [ + { id: 'theme-light', label: 'Light', action: () => setTheme('light') }, + { id: 'theme-dark', label: 'Dark', action: () => setTheme('dark') }, + ], + }, + ]) + return null +} +``` + +- A command with children never runs its own `action` or `href`. An empty `children` + array counts as a leaf. +- The palette lists only the children, and search covers only that level. `when`, + `permissions` and `hidden` apply to them as at the root. +- Backspace in an empty input, or the back button in the breadcrumbs, goes up one level. + Closing the palette returns to the root. +- Opening a sub-menu is not recorded in frecency or search history; running a child is. +- Async sources load at the root level only. +- Changes to the registered command's `children` show up while you are inside the + sub-menu. A parent that is no longer registered keeps showing the children it had. +- In a custom UI, `useCommandPalette()` returns `breadcrumbs`, `depth`, + `drillDown(item)`, `drillUp()` and `resetPath()`. Both adapters take + `renderBreadcrumbs(crumbs, onBack)` and mark the chevron and the trail with + `data-cmdk-engine-item-chevron` and `data-cmdk-engine-breadcrumbs` (see + [Styling](#styling)). + +--- + +## Groups + +A command's `group` string puts it under a heading. Without any config, each distinct +`group` becomes a heading with the same text, and commands without a `group` go under +"Other", always last. Set `groups` in the provider config to choose labels and order: + + +```tsx +import { CommandEngineProvider, useCommandRegister } from 'cmdk-engine/react' + +const config = { + groups: [ + { id: 'navigation', label: 'Go to', priority: 10 }, + { id: 'actions', label: 'Actions', priority: 5 }, + ], +} + +function Commands() { + useCommandRegister([ + { id: 'home', label: 'Home', href: '/', group: 'navigation' }, + { id: 'invite', label: 'Invite teammate', action: () => {}, group: 'actions' }, + ]) + return null +} + +export function Root({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + ) +} +``` + +- A command's `group` matches a group's `id`. A `group` you did not define still shows, + labelled with the `group` string. +- With an empty query, defined groups are listed by `priority` (higher first), then the + other groups in the order their first command appears. While searching, groups are + ordered by their best match instead. +- A group's `icon` is not rendered by the built-in components; `renderGroupHeading(group)` + receives it. +- `maxResults` (default 50) caps the total number of results across groups. +- The Recent group (`frecency.showRecent`) comes first, above your configured groups. + The palette highlights the first item when it opens, so with groups configured that + is the most recent command. To keep your groups on top, leave `showRecent` off, or + render your own list from `groupedResults`. + +--- + +## Internationalization (i18n) + +Built-in UI strings go through a translation function. Pass your own to +localize the placeholder, empty state, "Recent" heading, accessible labels, etc: + + +```tsx +import { getTranslationKeys } from 'cmdk-engine' + +const myDictionary: Record = { + 'palette.placeholder': 'Buscar comandos...', + 'palette.empty': 'Sin resultados.', +} + +// Define `t` once, outside the component: a new function on every render rebuilds the engine. +const config = { t: (key: string) => myDictionary[key] ?? key } + +// + +// getTranslationKeys() lists every key that has a default English string. +console.log(getTranslationKeys()) +``` + +> `getTranslationKeys()` also lists `group.other` and `search.history`, which +> nothing reads yet. The heading of the ungrouped "Other" group is fixed +> English text for now. + +`palette.close` (default "Close") names the visually hidden close button in the +Base UI adapter's dialog. `palette.list` (default "Suggestions") names the +results listbox in both adapters. A `t` that returns the key unchanged, like the +one above, or an empty string keeps "Suggestions". + +--- + +## Search History + +Opt-in tracking of past queries (persisted to `localStorage`). A query is recorded when +the user selects a command, not on every keystroke. Where `localStorage` is unavailable, the +history stays in memory, with the options it was created with until the provider remounts: + +```tsx +const config = { searchHistory: { enabled: true, maxEntries: 20, minQueryLength: 2 } } +``` + +Read it with `useSearchHistory()`, and set the search box with `setSearch` from +`useCommandPalette()`: + + +```tsx +import { useCommandPalette, useSearchHistory } from 'cmdk-engine/react' + +function RecentSearches() { + const { setSearch } = useCommandPalette() + const { getRecent } = useSearchHistory() // also: remove(query), clear() + return ( + <> + {getRecent(5).map((entry) => ( + + ))} + + ) +} +``` + +--- + +## Palette Events + +*New in 0.6.* + +`useCommandPaletteEvents` reports what happens in the palette, for analytics. +Call it once, in any component inside the provider: + + +```tsx +import { useCommandPaletteEvents } from 'cmdk-engine/react' + +function PaletteAnalytics() { + useCommandPaletteEvents((event) => { + if (event.type === 'search' && event.resultCount === 0) { + console.log('no results for', event.query) // your analytics call + } + }) + return null +} +``` + +| Event | When | Fields | +|---|---|---| +| `open`, `close` | The palette opens or closes | | +| `search` | The results for a query settle, async sources included | `query` (trimmed), `resultCount` (`0`: nothing found) | +| `select` | A command is selected (drilling into children is not) | `item`, `query`, `sourceId` (loaded items) | +| `asyncError` | An async source fails or drops items | `sourceId`, `error` | + +`search` fires for every settled query while the user types, so debounce it +before sending it anywhere. An error thrown by your handler never breaks the +palette. Without the hook, nothing is reported. + +--- + +## Async Command Sources + +*New in 0.6.* + +Mix registered commands with results loaded for each query, such as a +server-side search. The provider runs every source once per query for all +consumers: it debounces, aborts stale requests and ignores late responses. + + +```tsx +import type { AsyncSource } from 'cmdk-engine' + +const issueSearch: AsyncSource = { + id: 'issues', + load: async (query, { signal }) => { + const res = await fetch(`/api/issues?q=${encodeURIComponent(query)}`, { signal }) + const issues: { id: string; title: string }[] = await res.json() + return issues.map((issue) => ({ + id: `issue-${issue.id}`, + label: issue.title, + href: `/issues/${issue.id}`, + })) + }, + shouldFilter: false, // the server already matched the query + group: 'Issues', +} + +const config = { asyncSources: [issueSearch] } + +// +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `id` | required | Keys `asyncErrors`; changing the set of ids restarts loading | +| `load(query, { signal })` | required | Returns the items; pass `signal` to `fetch` | +| `trigger(query)` | non-empty trimmed query | Whether to load for this query | +| `debounceMs` | `200` | Delay after the last query change | +| `shouldFilter` | `true` | `true`: items are searched and ranked with your commands and count toward `maxResults`. `false`: the server matched them, so they are shown as returned, after the local results | +| `maxResults` | `10` | Cap per source when `shouldFilter` is `false` | +| `group` | each item's `group` | Group for every item from this source | + +- `isLoading` is true from the moment a trigger passes (debounce included) + until every source settles. `CommandPalette` shows a `palette.loading` row + meanwhile; override it with `renderLoading`. +- `asyncErrors` maps a source id to its last error, cleared on that source's + next success. A failing source never breaks the palette, and nothing is + logged. +- Loaded items need a non-empty string `id` and `label`. Items without them + (children included) are dropped, the rest still show, and `asyncErrors[id]` + says so, for example "2 items dropped: missing label", until a load drops + nothing. Non-string `keywords` entries are removed. +- Sources load at the root level only. Loads are aborted and their items + cleared when the query changes, the palette closes, the user drills into a + command, or the provider unmounts. A palette that reopens loads again; an + inline palette that never opens keeps loading. +- `when`, permissions and `hidden` apply to loaded items too. Unfiltered items + skip search, frecency, the context boost and `maxResults`, and their groups + come after the local groups, in server order. +- Registered commands win on duplicate ids, then earlier sources. Loaded items + are never recorded in frecency. +- `load`, `trigger` and `debounceMs` are read when needed, so an inline + `config` does not restart loads. Change a source's `id` to force a reload. +- `trigger` runs during render (twice under StrictMode in development), so + keep it pure and cheap. A trigger that passes on an empty query loads as + soon as the provider mounts, even for a palette that has never been opened. + +> **Security:** loaded items are untrusted. Only relative, `http(s):`, +> `mailto:` and `tel:` hrefs are kept; any other `href` is removed when the +> items arrive (children included), so it never reaches `window.location` or a +> custom `renderItem` anchor. For deep links (`myapp://...`), return an +> `action`, or an allowed `href` that your `onNavigate` maps. + +--- + +## CLI Tool + +Auto-discover routes and generate sitemaps for your command palette. + +### Setup + +```bash +# Initialize config +npx cmdk-engine init + +# Scan routes +npx cmdk-engine scan + +# Scan without default auth/error exclusions +npx cmdk-engine scan --no-default-exclude + +# Keep routes under a [locale] folder (or another :param) +npx cmdk-engine scan --include-dynamic locale + +# Validate config +npx cmdk-engine validate +``` + +### Use the output + +*New in 0.6.* + +`scan` writes `src/generated/command-routes.json`. `sitemapToCommands` turns it +into commands; register them once, at the app level: + +```tsx +import { useCommandRegister } from 'cmdk-engine/react' +import { sitemapToCommands } from 'cmdk-engine/adapters/sitemap' +import sitemap from './generated/command-routes.json' + +const routeCommands = sitemapToCommands(sitemap) + +function RouteCommands() { + useCommandRegister(routeCommands) + return null +} +``` + +Each route becomes `{ id, label, keywords, group, href }`, and selecting one +calls your `onNavigate`. Commit the JSON, or run the scan in a `prebuild` script. + +### Standalone binary + +The CLI also ships as a standalone binary for macOS on Apple silicon and for +Linux x64, so it runs without a Node project: + +```bash +curl -fsSL https://raw.githubusercontent.com/Priyans-hu/cmdk-engine/main/install.sh | bash +``` + +There is no binary for Intel Macs or Linux on ARM yet; use `npx cmdk-engine` +there. + +### Smart defaults + +The CLI scanner shares the same default exclusion list as the runtime +React Router adapter, so the generated sitemap automatically skips: +- **Auth routes** — `/login`, `/logout`, `/signup`, `/signin`, `/register`, `/forgot-password`, `/reset-password`, `/verify-email` +- **OAuth callbacks** — `/oauth/callback`, `/auth/callback`, `/callback` +- **Error pages** — `/404`, `/500`, `/error`, `/not-found` + +Pass `--no-default-exclude` to opt out (you'll have full control via +the `exclude` config field instead). + +### Dynamic routes and `[locale]` + +*New in 0.6.* + +A command needs a real URL, so the scan skips routes with a `:param` +(`/users/:id`, `app/[id]/page.tsx`). To keep routes under a segment you can +fill at runtime, such as a Next.js `[locale]` folder, name it in +`includeDynamic` (or pass `--include-dynamic locale`). `app/[locale]/billing/page.tsx` +then becomes `/:locale/billing`, which `params` fills: + +```tsx +const commands = useMemo(() => sitemapToCommands(sitemap, { params: { locale } }), [locale]) +useCommandRegister(commands) +``` + +- `params` values are inserted as given, and `''` drops the segment (a default + locale served without a prefix). +- Ids keep the placeholder (`locale--billing`), so frecency is shared across + locales. +- `includeDynamic: true` keeps every `:param` route. A route whose param you do + not fill is skipped, and catch-alls (`[...slug]`, `/docs/*`) are never kept. +- A React Router route with its own `handle.command` is kept, as with the + runtime `scanRoutes`. + +### Config file + + +```ts +// cmdk-engine.config.ts +import { defineConfig } from 'cmdk-engine' + +export default defineConfig({ + framework: 'react-router', // or 'nextjs-app', 'nextjs-pages' + routesDir: './src/routes', + output: './src/generated/command-routes.json', + overrides: { + '/billing': { keywords: ['money', 'payment'], group: 'Billing' }, + }, + exclude: ['/_*', '/admin/*', /^\/debug\//], // strings, globs, or RegExp + includeDynamic: ['locale'], // keep /:locale/... routes + synonyms: { + billing: ['money', 'payment', 'credits'], + }, +}) +``` + +The CLI reads `cmdk-engine.config.ts` without running it, so its values must be +static: strings, numbers, booleans, arrays, objects, RegExp literals and +top-level `const`s, with `as const` and `satisfies` allowed. For computed values +such as `process.env`, use `cmdk-engine.config.mjs`. A config the CLI cannot +read fails `scan` and `validate`, naming the line. + +`exclude` takes exact paths, RegExp and globs. In a glob, `*` matches within one +path segment and `**` across segments. Excluding a path also excludes the paths +below it, and a trailing `/*` also matches the base: `/admin/*` excludes +`/admin` and everything under it. + +### What the scan reads + +The scan reads your files without running them. + +- **React Router:** + - Reads route objects with a string `path` (as passed to `createBrowserRouter`) + and `` elements, with the `label`, `keywords` and `group` + of their own `handle.command`. + - Joins relative child paths to their parent route in the same file + (`children` arrays, nested ``s). + - Does not read paths built at runtime, routes imported from another file, + index routes, or a `handle` returned by `lazy()`. +- **Next.js:** + - Reads `app/**/page.*` (`nextjs-app`) and `pages/**` (`nextjs-pages`). + - Route groups and `@slot` folders add no segment. + - Private `_folders`, intercepting `(.)` routes and `api/` are skipped. + - `[[...slug]]` gives its parent's URL; other dynamic routes need `includeDynamic`. + +Ids keep letters, digits and `-` (`/billing/overview` gives `billing--overview`), +so `/a_b` and `/ab` would share `ab`. The scan then keeps it for the last of them +in path order and gives the others `ab-2`, `ab-3`, ... + +### Pre-commit hook + +With [husky](https://typicode.github.io/husky) 9, run `npx husky init`, then put the +command in `.husky/pre-commit`: + +```sh +npx cmdk-engine scan && git add src/generated/command-routes.json +``` + +### GitHub Actions + +```yaml +- run: npx cmdk-engine scan +- run: npx cmdk-engine validate +``` + +--- + +## Architecture + +``` +Route Config ─→ Route Adapter ─→ Command Registry ─→ Keyword Engine + │ + ├─→ Access Control Filter + │ + ├─→ Search Engine (fuzzy / match-sorter) + │ + └─→ Frecency Ranking + │ + ▼ + Headless API / Hooks + │ + ▼ + UI Adapter (cmdk / Base UI) +``` + +### Package Entry Points + +| Import | Size (own code; siblings and peers excluded) | Purpose | +|--------|------|---------| +| `cmdk-engine` | 3.7 kB | Core engine (types, registry, search, keywords, access control, frecency) | +| `cmdk-engine/react` | 4.4 kB | React hooks (provider, useCommandPalette, useCommandRegister) | +| `cmdk-engine/adapters/cmdk` | 1.8 kB | Pre-wired cmdk components | +| `cmdk-engine/adapters/react-router` | 1.1 kB | React Router v6/v7/v8 route scanner | +| `cmdk-engine/search/match-sorter` | 0.55 kB | Optional match-sorter search backend | +| `cmdk-engine/adapters/base-ui` | 1.8 kB | Pre-wired Base UI components | +| `cmdk-engine/adapters/sitemap` | 0.23 kB | `sitemapToCommands()`: turns the CLI route sitemap into commands | + +Sizes are measured with size-limit, minified + brotli. Entries import the siblings they use (the cmdk +adapter imports `cmdk-engine/react`, which imports `cmdk-engine`) instead of +bundling them, so each one's code ships once. The Quick Start stack +(`CommandEngineProvider`, `useCommandRegister`, `CommandPalette`, +`useCommandPaletteShortcut`) is **8.3 kB** in total, without the `react`, +`react-dom` and `cmdk` peers. CI enforces a size budget for each entry, set +slightly above these figures. + +All entry points are tree-shakeable. The core has **zero runtime dependencies**. + +--- + +## API Reference + +A quick index. The [docs site](https://priyans-hu.github.io/cmdk-engine/docs/api/) has +every option, field and hook. + +### Core + + +```ts +import { + createRegistry, // Command store (pub/sub, useSyncExternalStore compatible) + createFuzzySearch, // Built-in lightweight fuzzy search + createKeywordEngine, // Synonym expansion + user aliases + createAccessFilter, // RBAC filter (any/all modes) + createSimpleAccessProvider, // Permission provider from array/Set + createFrecencyEngine, // Frecency ranking with exponential decay + createGroupManager, // Command group management + createContextEngine, // Scope boosting + createSearchHistory, // Search history (localStorage); createInMemorySearchHistory for tests and SSR + createInMemoryStorage, // In-memory frecency storage; createLocalStorageFrecencyStorage for localStorage + isCommandVisible, // Resolve a command's `when` gate; filterVisible filters a list with it + getTranslationKeys, // Every UI string key; createDefaultTranslation is the English `t` + pathToId, pathToLabel, pathToGroup, pathSegmentToLabel, // Route path helpers + defineConfig, // Typed config helper for the CLI config file +} from 'cmdk-engine' +``` + +### React + + +```ts +import { + CommandEngineProvider, // Context provider + useCommandPalette, // Main hook: search + filter + rank + useCommandRegister, // Register commands from components + useFrecency, // Direct frecency access + useSearchHistory, // Read and edit search history + useCommandContext, // Read the context config (read-only) + useEngineContext, // The engine singletons, for custom UIs; throws outside the provider + usePaletteState, // The shared open, search and path state; throws outside the provider + useCommandPaletteEvents, // Report palette events, for analytics +} from 'cmdk-engine/react' +``` + +### Adapters + + +```ts +import { CommandPalette, useCommandPaletteShortcut } from 'cmdk-engine/adapters/cmdk' +import { scanRoutes } from 'cmdk-engine/adapters/react-router' +import { sitemapToCommands } from 'cmdk-engine/adapters/sitemap' +// The same CommandPalette and useCommandPaletteShortcut, built on Base UI: +// import { CommandPalette, useCommandPaletteShortcut } from 'cmdk-engine/adapters/base-ui' +``` + +### Keyboard shortcut + +*New in 0.6: the function form.* + +`useCommandPaletteShortcut(shortcut?)` toggles the palette and returns +`{ isOpen, toggle }`. A string is the key pressed with Cmd or Ctrl (default +`'k'`). It also matches with Caps Lock on and, on non-Latin layouts such as +Russian or Greek, by the physical key. Holding the keys toggles once. For any +other shortcut, pass a function that decides the whole match, modifiers +included. Define it outside the component, or every render re-binds it: + + +```tsx +import { CommandPalette, useCommandPaletteShortcut } from 'cmdk-engine/adapters/cmdk' + +// Cmd/Ctrl+Shift+P, as in VS Code +const isPaletteKey = (e: KeyboardEvent) => + (e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'p' + +function CommandMenu() { + useCommandPaletteShortcut(isPaletteKey) + return +} +``` + +### Key hook return values + + +```ts +import { useCommandPalette } from 'cmdk-engine/react' + +const { + search, // Current query + setSearch, // Update query + results, // ScoredItem[] (flat) + flatResults, // The same array as results (an alias; use results) + groupedResults, // GroupedResult[] — results grouped by group + groups, // CommandGroup[] — active groups + isOpen, // Palette visibility + isLoading, // True while an async source is loading + asyncErrors, // Record — last error per async source + breadcrumbs, // CommandItem[], the sub-menu path (nested commands) + depth, // 0 at the root + drillDown, drillUp, resetPath, // Move through nested commands + open, close, toggle, + select, // Select a command (records frecency + runs handler + closes) + recordUsage, // Record frecency manually +} = useCommandPalette() +``` + +--- + +## Type Safety + +All types are exported and fully documented: + + +```ts +import type { + CommandItem, + CommandRegistry, + SearchEngine, + ScoredItem, + GroupedResult, + GroupedResults, + AccessControlProvider, + AsyncSource, + FrecencyOptions, + RecentCommandsConfig, + CommandGroup, + SynonymMap, + RouteCommandMeta, + CmdkEngineConfig, // the CLI config file (cmdk-engine.config.ts) + CommandEngineConfig, // the provider's `config` prop + CommandPaletteState, + CommandPaletteEvent, + AccessCheckMode, + FrecencyEntry, + FrecencyStorage, + CommandContext, + TranslationFn, + SearchHistoryConfig, + SearchHistoryEntry, + Sitemap, + SitemapRoute, +} from 'cmdk-engine' +``` + +--- + +## Testing + +jsdom lacks two browser APIs that cmdk uses, so tests that render the cmdk adapter need +stubs. Without them the first render throws `ResizeObserver is not defined`: + + +```ts +// your test setup file +globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} +} +Element.prototype.scrollIntoView = () => {} +``` + +- Render the palette inside `CommandEngineProvider`. To open a `dialog` palette, dispatch + `new KeyboardEvent('keydown', { key: 'k', metaKey: true })` on `document`, or call + `toggle()` from `useCommandPalette()`. +- Frecency and search history persist to `localStorage` (`cmdk-frecency` and + `cmdk-search-history`). Clear them between tests, or pass `frecency.storage`, so one + test's selections do not rank the next test's results. +- The cmdk adapter renders the empty state and the loading row right after the list, + not inside its `role="listbox"`. Search the palette for them, not the list. +- The Base UI adapter needs no stubs. Its dialog stays in the DOM for a moment after + Escape, so use `waitFor` before asserting that it is gone. + +--- + +## cmdk Issues We Solve + +| Issue | Description | How We Fix It | +|-------|-------------|---------------| +| [#264](https://github.com/dip/cmdk/issues/264) | Sort not restored after clearing search | We own filtering; restore original order when query is empty | +| [#280](https://github.com/dip/cmdk/issues/280) | First item not selected with dynamic content | Auto-select first item after each render cycle | +| [#375](https://github.com/dip/cmdk/issues/375) | Non-deterministic sorting | Deterministic: frecency → priority → registration order | +| [#267](https://github.com/dip/cmdk/issues/267) | Items not updating on async changes | Reactive pub/sub registry; items update immediately | + +> With `@radix-ui/react-dialog` 1.1.x, which cmdk 1.1 can install, Radix logs +> "`DialogContent` requires a `DialogTitle`" and a missing `Description` warning +> each time the dialog opens, and points `aria-labelledby`/`aria-describedby` at +> ids that do not exist. The dialog is still named by its `aria-label`. cmdk +> exposes no dialog title, so the adapter cannot add one: update +> `@radix-ui/react-dialog` to 1.2 or later (`npm update @radix-ui/react-dialog`). diff --git a/README.md b/README.md index e2c65ba..aa3417b 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@ Permission-aware command palette engine for React. Works with [cmdk](https://git ![The cmdk adapter's palette opened with Cmd+K, styled with the CSS from the Styling section](https://raw.githubusercontent.com/Priyans-hu/cmdk-engine/main/.github/assets/palette.png) -**Live demo:** press Cmd+K (Ctrl+K) on the [docs site](https://priyans-hu.github.io/cmdk-engine/). Runnable apps are in [Examples](#examples). +**Live demo:** press Cmd+K (Ctrl+K) on the [docs site](https://priyans-hu.github.io/cmdk-engine/). Runnable apps are in [Examples](https://priyans-hu.github.io/cmdk-engine/docs/examples/). -**Contents:** [Installation](#installation), [Quick Start](#quick-start), [Styling](#styling), [Examples](#examples), [Search](#search), [Nested Commands](#nested-commands), [Async Command Sources](#async-command-sources), [CLI Tool](#cli-tool), [API Reference](#api-reference), [Testing](#testing). +**Docs:** [Getting started](https://priyans-hu.github.io/cmdk-engine/docs/getting-started/), [the full guide](https://github.com/Priyans-hu/cmdk-engine/blob/main/GUIDE.md) (everything on one page), [API reference](https://priyans-hu.github.io/cmdk-engine/docs/api/). --- @@ -40,40 +40,10 @@ Permission-aware command palette engine for React. Works with [cmdk](https://git ## Installation ```bash -# npm npm install cmdk-engine cmdk - -# bun -bun add cmdk-engine cmdk - -# pnpm -pnpm add cmdk-engine cmdk - -# yarn -yarn add cmdk-engine cmdk ``` -> **Peer dependencies (all optional — install only what you use):** `react`, -> `react-dom`, `cmdk` (for the cmdk adapter), `@base-ui/react` (for the Base -> UI adapter), `match-sorter` (for the match-sorter search backend), and -> `react-router` v6, v7 or v8 / -> `react-router-dom` v6 or v7 (for the React Router adapter; v8 ships only -> `react-router`). The core engine (`cmdk-engine`) has zero runtime -> dependencies. - -### Requirements - -- **React** 18 or 19. -- **A UI adapter:** `cmdk` ^1 for the cmdk adapter, or `@base-ui/react` ^1.1 for the - Base UI adapter. Or build your own UI with the hooks. -- **Optional:** `react-router` 6, 7 or 8 for the route scanner, and `match-sorter` 7 or 8 - for the match-sorter search backend. -- **Node.js** 20 or later, needed by the CLI only. The library runs in the browser and - during SSR. - -**Support:** the latest minor release gets fixes. While the version is 0.x, a minor -release can change behavior; each such change is listed as a "Behavior change" in the -[changelog](./CHANGELOG.md). To report a vulnerability, see [SECURITY.md](./SECURITY.md). +Or use `bun add`, `pnpm add` or `yarn add`. For the [Base UI](https://base-ui.com) adapter, install `@base-ui/react` instead of `cmdk`. It needs React 18 or 19, and the core engine has no runtime dependencies. Every optional peer and supported version is under [Requirements](https://github.com/Priyans-hu/cmdk-engine/blob/main/GUIDE.md#requirements). --- @@ -117,1287 +87,35 @@ export default function App() { The palette starts closed. Cmd+K opens it, typing "invoices" narrows the list to Billing Overview, and Enter goes to `/billing` and closes the palette. It -has no styles until you add some (see [Styling](#styling)). In a Next.js App +has no styles until you add some (see [Styling](https://github.com/Priyans-hu/cmdk-engine/blob/main/GUIDE.md#styling)). In a Next.js App Router project, put `'use client'` at the top of this file. `useCommandPaletteShortcut()` binds Cmd+K / Ctrl+K. Call it in a component **inside** `CommandEngineProvider`, like `Palette` above: without it nothing opens the dialog, and in the component that renders the provider it throws. -### Splitting it into files - -- **Provider:** wrap your app once, near the root. Define `config` outside - components, or `useMemo` it: a new object on every render rebuilds the - engine. -- **Commands:** call `useCommandRegister` in any component under the provider. - Its commands go away when that component unmounts, so register app-wide - navigation in a layout that stays mounted and page commands in the page. -- **Palette:** keep `CommandPalette` and `useCommandPaletteShortcut()` together - in one component, anywhere under the provider. - -### Navigating with your router - -`onNavigate` gets the `href` of each selected command that has no `action`. -`window.location.assign` reloads the page, so pass your router instead. With -a React Router data router, use the router object: - -```tsx -const router = createBrowserRouter(routes) -const config = { onNavigate: (href: string) => router.navigate(href) } -``` - -To use `useNavigate()` instead, render the provider inside the router (in a -root layout route, for example) and `useMemo` the config there. A command's -`action` runs instead of `onNavigate`, and an `onSelect` on the provider -config or on `CommandPalette` replaces both. - -### `onSelect` replaces the default handling - -`config.onSelect` runs for every selected command instead of its `action` and -instead of `onNavigate`. The `onSelect` prop of `CommandPalette` does the same, -and wins over the config. To track selections, call the default yourself, or -leave `onSelect` unset and track inside `onNavigate` and your actions: - - -```tsx -import type { CommandItem } from 'cmdk-engine' - -const config = { - onSelect: (item: CommandItem) => { - console.log('command selected', item.id) // your analytics call - if (item.action) item.action(item) - else if (item.href) window.location.assign(item.href) - }, -} -``` - -### Errors from commands - -*New in 0.6.* - -A command's `action`, your `onSelect` or your `onNavigate` can throw or return -a rejected promise. Set `onSelectError` to handle that; the palette still -closes right away. Without it, errors propagate as before. - - -```tsx -import type { CommandItem } from 'cmdk-engine' - -const config = { - onSelectError: (error: unknown, item: CommandItem) => { - console.error(`"${item.label}" failed`, error) // or show a toast - }, -} -``` - -### Or build your own UI with hooks - - -```tsx -import { useCommandPalette } from 'cmdk-engine/react' - -function CustomCommandMenu() { - const { search, setSearch, groupedResults, isOpen, toggle, select } = - useCommandPalette() - - return ( -
- setSearch(e.target.value)} /> - {groupedResults.map(({ group, items }) => ( -
-

{group.label}

- {items.map(({ item }) => ( - - ))} -
- ))} -
- ) -} -``` - -> `select()` records frecency + search history, runs `onSelect` → `action` → -> `onNavigate`/`href`, and closes the palette — all in one call. - ---- - -## Styling - -`CommandPalette` ships no styles. Style cmdk's `[cmdk-*]` parts and the -adapter's `data-cmdk-engine-*` attributes from any global stylesheet. This CSS -gives the look in the screenshot at the top: - -```css -[cmdk-overlay] { position: fixed; inset: 0; z-index: 50; background: rgb(0 0 0 / 0.4); } -[cmdk-dialog] { - position: fixed; top: 15vh; left: 50%; z-index: 50; transform: translateX(-50%); - width: min(560px, calc(100vw - 32px)); -} -[cmdk-root] { - overflow: hidden; border: 1px solid #e5e7eb; border-radius: 12px; - background: #fff; color: #111827; font: 14px/1.4 system-ui, sans-serif; - box-shadow: 0 16px 48px rgb(0 0 0 / 0.2); -} -[cmdk-input] { - box-sizing: border-box; width: 100%; padding: 14px 16px; border: 0; - border-bottom: 1px solid #e5e7eb; font: inherit; font-size: 16px; outline: none; -} -[cmdk-input]:focus-visible { border-bottom-color: #6366f1; box-shadow: inset 0 -1px 0 #6366f1; } -[cmdk-list] { max-height: 320px; overflow-y: auto; padding: 8px; } -[cmdk-group-heading] { padding: 8px 8px 4px; font-size: 12px; color: #6b7280; } -[cmdk-item] { padding: 8px; border-radius: 8px; cursor: pointer; } -[cmdk-item][data-selected='true'] { background: #f3f4f6; } -[cmdk-item][data-disabled='true'] { opacity: 0.5; cursor: default; } -[data-cmdk-engine-item] { display: flex; align-items: center; gap: 8px; } -[data-cmdk-engine-item-content] { display: flex; flex: 1; flex-direction: column; } -[data-cmdk-engine-item-description] { font-size: 12px; color: #6b7280; } -[data-cmdk-engine-item-shortcut] kbd { - margin-left: 4px; padding: 0 6px; border: 1px solid #e5e7eb; border-radius: 4px; - font: inherit; font-size: 12px; -} -[data-cmdk-engine-empty], -[data-cmdk-engine-loading] { padding: 16px; text-align: center; color: #6b7280; } -``` - -The accent underline on `[cmdk-input]:focus-visible` replaces the outline the -input drops, so keyboard users can see where focus is. Keep a focus style if -you restyle the input. - -Items also carry `data-cmdk-engine-icon` and `data-cmdk-engine-item-label`, -plus `data-cmdk-engine-item-chevron` when they have children. Nested commands -add `data-cmdk-engine-breadcrumbs`, with `data-cmdk-engine-breadcrumb-back`, -`data-cmdk-engine-breadcrumb` and `data-cmdk-engine-breadcrumb-separator` -inside. cmdk documents its parts in -[Parts and styling](https://github.com/dip/cmdk#parts-and-styling) and has -[drop-in stylesheets](https://github.com/dip/cmdk/tree/main/website/styles/cmdk). - -With Tailwind (v3 or v4), `@apply` the same utilities to the same selectors in -your main CSS file: - -```css -[cmdk-overlay] { @apply fixed inset-0 z-50 bg-black/40; } -[cmdk-dialog] { @apply fixed left-1/2 top-[15vh] z-50 w-[min(560px,calc(100vw-32px))] -translate-x-1/2; } -[cmdk-root] { @apply overflow-hidden rounded-xl border border-gray-200 bg-white text-sm text-gray-900 shadow-2xl; } -[cmdk-input] { @apply w-full border-0 border-b border-gray-200 px-4 py-3.5 text-base outline-none focus-visible:border-indigo-500 focus-visible:shadow-[inset_0_-1px_0_#6366f1]; } -[cmdk-list] { @apply max-h-80 overflow-y-auto p-2; } -[cmdk-group-heading] { @apply px-2 pb-1 pt-2 text-xs text-gray-500; } -[cmdk-item] { @apply cursor-pointer rounded-lg p-2 data-[selected=true]:bg-gray-100 data-[disabled=true]:opacity-50; } -[data-cmdk-engine-item] { @apply flex items-center gap-2; } -[data-cmdk-engine-item-content] { @apply flex flex-1 flex-col; } -[data-cmdk-engine-item-description] { @apply text-xs text-gray-500; } -[data-cmdk-engine-item-shortcut] kbd { @apply ml-1 rounded border border-gray-200 px-1.5 font-sans text-xs; } -[data-cmdk-engine-empty], [data-cmdk-engine-loading] { @apply p-4 text-center text-gray-500; } -``` - -To style per instance instead, `CommandPalette` passes `className`, -`overlayClassName`, `contentClassName`, `inputClassName`, `listClassName`, -`groupClassName`, `itemClassName` and `emptyClassName` to those parts. - -### shadcn/ui - -If your app uses [shadcn/ui](https://ui.shadcn.com), install the palette from -this project's shadcn registry instead: - -```bash -npx shadcn@latest add https://priyans-hu.github.io/cmdk-engine/r/command-palette.json -``` - -It writes `components/command-palette.tsx`, the cmdk adapter's palette styled -with your theme's tokens, so it follows light and dark mode and is yours to -edit. Render `` once inside `CommandEngineProvider`. For Base -UI instead of cmdk, add `.../r/command-palette-base-ui.json`. Both need -cmdk-engine 0.6.0 or later. See the -[shadcn/ui page](https://priyans-hu.github.io/cmdk-engine/docs/shadcn/). - ---- - -## Examples - -Runnable apps in [`examples/`](examples). CI builds each one against this -repo, and each installs `cmdk-engine` from npm, so you can copy one out. - -| Example | What it shows | Try it | -|---|---|---| -| [Vite + React Router](examples/vite-react-router) | Commands from the route tree with `scanRoutes`, the provider inside the router, this Styling CSS, and the same palette on Base UI with `?adapter=base-ui` | [StackBlitz](https://stackblitz.com/github/Priyans-hu/cmdk-engine/tree/main/examples/vite-react-router?file=src/layout.tsx) | -| [Next.js App Router](examples/nextjs-app-router) | A `'use client'` provider file under a server layout, `router.push` as `onNavigate`, and `[locale]` pages found by `cmdk-engine scan --include-dynamic locale` and filled in with `sitemapToCommands` | [StackBlitz](https://stackblitz.com/github/Priyans-hu/cmdk-engine/tree/main/examples/nextjs-app-router?file=components/command-menu.tsx) | -| [shadcn/ui](examples/shadcn) | Both shadcn registry items in an app set up with `shadcn init`, in light and dark mode | [StackBlitz](https://stackblitz.com/github/Priyans-hu/cmdk-engine/tree/main/examples/shadcn?file=src/App.tsx) | - -The docs site is a live demo too: Cmd+K there searches its own pages. Its -[Examples page](https://priyans-hu.github.io/cmdk-engine/docs/examples/) has more -recipes: RBAC, a custom UI and a pre-commit hook. - ---- - -## CommandPalette Props - -Both adapters export `CommandPalette` with the same props, except `vimBindings`, which -only the cmdk adapter has. Every prop is optional. - -| Prop | Default | Description | -|------|---------|-------------| -| `dialog` | `false` | Render in a modal dialog with an overlay. When `false`, the palette is inline and always visible. Open a dialog with `useCommandPaletteShortcut()` or `toggle()` | -| `placeholder` | `palette.placeholder` | Input placeholder | -| `label` | `palette.label` | Accessible name of the palette and of the dialog | -| `loop` | `true` | Wrap from the last item to the first, and back | -| `onSelect` | none | `(item) => void`. Runs instead of the default handling (`action`, then `onNavigate` or `href`), and wins over `config.onSelect` | -| `renderItem` | built-in row | `(item, score) => ReactNode`. The row content; the adapter still renders the selectable wrapper | -| `renderGroupHeading` | the group label | `(group) => ReactNode` | -| `renderEmpty` | `palette.empty` text | `() => ReactNode`, shown when nothing matches | -| `renderLoading` | `palette.loading` text | `() => ReactNode`, shown while async sources load | -| `renderBreadcrumbs` | built-in trail | `(crumbs, onBack) => ReactNode`, shown inside a sub-menu ([Nested Commands](#nested-commands)) | -| `footer` | none | Node rendered below the list | -| `container` | the page body | Portal target in dialog mode | -| `disablePointerSelection` | `false` | The pointer no longer moves the highlight. Clicking an item still runs it | -| `vimBindings` | `true` | cmdk adapter only. Ctrl+N, P, J and K move the highlight | -| `className`, `inputClassName`, `listClassName`, `itemClassName`, `groupClassName`, `emptyClassName` | none | Class names for those parts ([Styling](#styling)) | -| `overlayClassName`, `contentClassName` | none | Class names for the dialog overlay and content | - ---- - -## Base UI Adapter - -*New in 0.6.* - -Prefer [Base UI](https://base-ui.com)? `cmdk-engine/adapters/base-ui` renders -the palette with Base UI's -[Autocomplete](https://base-ui.com/react/components/autocomplete) (plus its -[Dialog](https://base-ui.com/react/components/dialog) in dialog mode) instead of -cmdk. The engine still does all filtering, ranking and grouping. - -```bash -npm install cmdk-engine @base-ui/react -``` - -`@base-ui/react` (`^1.1.0`) is an optional peer, like `cmdk`. The adapter -exports `CommandPalette` and `useCommandPaletteShortcut` with the cmdk adapter's -props, so switching adapters is an import-path change: - - -```tsx -import { CommandPalette, useCommandPaletteShortcut } from 'cmdk-engine/adapters/base-ui' - -// Inline: always rendered -function SearchPanel() { - return -} - -// Dialog: opened with Cmd+K / Ctrl+K -function CommandMenu() { - useCommandPaletteShortcut() - return -} -``` - -The default renderers produce the same `data-cmdk-engine-*` markup as the cmdk -adapter, so CSS written against those attributes carries over. cmdk's own -`[cmdk-*]` attributes don't exist here: style the parts with the `*ClassName` -props. Other differences: - -| | cmdk adapter | Base UI adapter | -|---|---|---| -| Vim keys | `vimBindings` (Ctrl+N/P/J/K) | None; there is no `vimBindings` prop | -| Home / End | First / last item | Move the caret in the input | -| Highlighted item | `[cmdk-item][data-selected="true"]` | `[role="option"][data-highlighted]` | -| Loading row | `role="progressbar"`, after the list | `role="status"` live region, after the list | -| Results change while open (async sources) | Keeps the highlighted item | Keeps the highlighted position | -| IME input | The query updates while composing | The query updates when composition ends | - -Follow Base UI's [quick start](https://base-ui.com/react/overview/quick-start): -give your app's root element `isolation: isolate` so the dialog stays on top, -and for iOS 26+ Safari give the backdrop (`overlayClassName`) -`position: absolute` and add `body { position: relative }`. Like cmdk's, the -dialog is unstyled. Its visually hidden close button is labelled by the -`palette.close` translation key. With `@base-ui/react` 1.1, Firefox logs a -`mozInputSource` deprecation warning the first time the input is clicked, and -axe reports that the combobox input lacks `aria-expanded`. Both come from Base -UI and are gone in later versions (1.9 is clean). In WebKit, axe also flags -Base UI's focus guards as elements without a name; that one is upstream too. - -Base UI costs more than cmdk: about 44 kB min + brotli for Autocomplete and -48 kB with Dialog, versus about 14 kB for cmdk with its Radix dialog. In Node, -load the adapter with either `import` or `require`, not both, or two copies of -`@base-ui/react` run side by side; bundlers load one. - ---- - -## React Router Integration - -Auto-discover routes from your React Router config. Register them inside the provider, -and navigate with the router object, since the provider sits above `RouterProvider`: - - -```tsx -import { createBrowserRouter, RouterProvider } from 'react-router-dom' -import { CommandEngineProvider, useCommandRegister } from 'cmdk-engine/react' -import { scanRoutes } from 'cmdk-engine/adapters/react-router' - -const routes = [ - { path: '/', element:

Home

}, - { path: '/billing', element:

Billing

}, -] -const router = createBrowserRouter(routes) -const commands = scanRoutes(routes) -const config = { onNavigate: (href: string) => router.navigate(href) } - -function RegisterRoutes() { - useCommandRegister(commands) - return null -} - -export function App() { - return ( - - - {/* the palette from the Quick Start goes here */} - - - ) -} -``` - -On React Router 8 there is no `react-router-dom`: import `createBrowserRouter` from -`react-router` and `RouterProvider` from `react-router/dom`. - -### Smart defaults - -The scanner automatically: -- **Excludes auth routes** — `/login`, `/signup`, `/forgot-password`, `/oauth/callback`, etc. -- **Excludes error pages** — `/404`, `/500`, `/error`, `/not-found` -- **Skips dynamic routes** — `/users/:id`, `/billing/:uuid` (can't navigate without a real ID) -- **Derives labels** from the path — `/billing/overview` → "Overview" -- **Derives groups** from the first segment — `/billing/overview` → group "Billing" - -### Scanner options - -```tsx -const commands = scanRoutes(routeConfig, { - exclude: ['/admin/*', /^\/debug\//, '/internal'], // string, glob, or regex - noDefaultExclude: false, // set true to skip default auth/error exclusion - includeDynamic: false, // set true to include :id routes -}) -``` - -### Route metadata - -Enrich routes with metadata using the `handle` convention: - -```tsx -{ - path: '/billing/overview', - handle: { - command: { - label: 'Billing Dashboard', - keywords: ['money', 'payment'], - group: 'Billing', - icon: , - priority: 10, - } - }, - element: , -} -``` - -Routes with `handle.command` are always included, even if they have dynamic segments. The scanner also falls back to `route.title` and `route.icon` if `handle.command` doesn't define them. - -A `handle` returned from `lazy()` is not read, because the scanner never calls `lazy()`. Put `handle.command` on the route object itself. - -### Index routes - -*New in 0.6.* - -An index route (`index: true` without a `path`) resolves to its parent's URL, so the index route of a pathless root becomes `/` (label "Home", id `home`). It never adds a second command for a URL another route already has: its `handle.command` is merged over that command instead, and the index route's fields win. Only `handle.command` is merged; an index route's `route.title` and `route.icon` fallbacks apply only when it gets its own command. Index routes follow their parent's exclusion and the dynamic-route rule. `index: true` with a `path` is a normal path route. - -The CLI scanner (`npx cmdk-engine scan`) does not resolve index routes. - ---- - -## Next.js - -The palette needs a Client Component. Put the provider, the palette and the -shortcut in one file that starts with `'use client'`, pass `router.push` as -`onNavigate`, and render it from your root layout. `npx cmdk-engine scan` reads -App Router and Pages Router files; for `[locale]` pages see -[Dynamic routes and `[locale]`](#dynamic-routes-and-locale). The -[Next.js guide](https://priyans-hu.github.io/cmdk-engine/docs/nextjs/) has the -full files, and [`examples/nextjs-app-router`](examples/nextjs-app-router) is a -runnable app. - ---- - -## RBAC / Access Control - -Filter commands based on user permissions: - - -```tsx -import { createSimpleAccessProvider } from 'cmdk-engine' - -// Build the provider once, outside the component, or `useMemo` it per user. -const config = { - accessControl: createSimpleAccessProvider(['admin.view', 'billing.read']), - accessCheckMode: 'any' as const, // user needs ANY listed permission -} - -// -``` - -Commands with `permissions: ['admin.view']` will only show for users who have that permission. - -### Per-command access mode - -`accessCheckMode` is the engine-wide default, but any command can override it — -useful when most commands need *any* of their permissions but a few sensitive -ones need *all*: - -```tsx -useCommandRegister([ - // Uses the engine default ('any') - { id: 'reports', label: 'Reports', permissions: ['reports.view', 'admin'] }, - // Overrides to require ALL permissions for this one command - { id: 'delete-org', label: 'Delete Org', permissions: ['org.admin', 'billing.owner'], accessMode: 'all' }, -]) -``` - -### Dynamic visibility (`when`) - -Static `permissions` cover role-based access. For everything else — feature -flags, plan tiers, org type, A/B gates — use `when`. A command whose `when` -resolves to `false` is removed entirely (not searchable, not browsable): - -```tsx -useCommandRegister([ - { id: 'beta-tool', label: 'Beta Tool', when: () => flags.betaEnabled }, - { id: 'enterprise', label: 'SSO Settings', when: () => org.plan === 'enterprise' }, -], [flags.betaEnabled, org.plan]) -``` - -> `permissions` (+ `accessMode`) and `when` compose: a command must pass both. -> `hidden: true` is different again — it keeps a command out of the empty-query -> browse list but still lets a matching query find it. - -> **Note:** access filtering is a UI concern, not a security boundary. Always -> enforce permissions server-side. - ---- - -## Search - -*New in 0.6: words in any order, and folding of accents and spaces.* - -The built-in search matches labels, descriptions and keywords, and tolerates -typos, partial words and initials. - -- **Words in any order:** "overview billing" finds "Billing Overview", and each - word can match a different field. These matches come after the ones that - match the whole query, and never score above the weakest of them. -- **Accents, Unicode forms and spaces:** the query and the commands are - compared after Unicode compatibility decomposition (NFKD), with the combining - accents U+0300 to U+036F removed, in lowercase, with repeated whitespace - collapsed. "resume" finds "Résumé" and `billing over` (two spaces) finds - "Billing Overview". Other marks (Indic vowel signs, kana voicing marks) are - kept, ß and dotless ı are not folded, and the built-in search compares - Korean by its letters (jamo), so a partial syllable already matches. -- **match-sorter:** `createMatchSorterSearch()` from - `cmdk-engine/search/match-sorter` needs `match-sorter` (7 or 8) installed; - it is part of the bundle that imports this entry. It folds the query's - accents, compatibility forms and spaces the same way but keeps its case - (an exact-case match ranks first), and also matches synonym keywords - (ranked at most CONTAINS, below direct matches) and words in any order. - A keystroke with several words takes about 1.5 to 2 times as long as - match-sorter alone on Node 22 and 2 to 3 times on Node 20, the most for - three or more words. - -Pass the engine in the provider config, and create it once, outside the -component: - - -```tsx -import { createMatchSorterSearch } from 'cmdk-engine/search/match-sorter' - -const config = { searchEngine: createMatchSorterSearch() } -``` - -Any object with a `search(query, items)` method works as `searchEngine` too. -The [Search page](https://priyans-hu.github.io/cmdk-engine/docs/search/) has the -scoring tiers, the `threshold` and `keys` options and a custom engine. - ---- - -## Synonyms - -Synonyms work both ways. With this config, typing "money" or "payment" finds -the "Billing Overview" command from the Quick Start: - -```tsx -const config = { - synonyms: { - billing: ['money', 'payment', 'credits'], - settings: ['preferences', 'config', 'options'], - }, -} -``` - -- **Query:** when the whole query (ignoring case, accents and extra spaces) - equals a key or a value, the other terms are searched too: a key brings its - values, a value its key. Commands found only this way are listed after the - direct matches and never score above the weakest one. Frecency and context - boosts apply afterwards, so a command you use often can still move up. -- **Commands:** a command whose keyword or whole label equals a key or a value - also matches the other terms, at a lower weight (with match-sorter, ranked at - most CONTAINS). -- **Not expanded:** the query, while it is a partial word ("mon" is searched - as typed until "money" is complete) or a longer phrase that contains a - synonym ("money transfer"). - -When the query expands, a custom `searchEngine` is called once more for each -extra term. - ---- - -## Frecency Ranking - -Commands you use frequently and recently appear higher in results. No configuration needed: it uses `localStorage` by default. When you use `select()`, frecency is recorded automatically. - -The algorithm uses exponential decay with a configurable half-life: - -``` -score = count * 2^(-timeSinceLastUse / halfLife) -``` - -### Recent commands - -Show a "Recent" group at the top of the palette when the search is empty: - -```tsx - -``` - -> Frecency (and search history, below) persist to `localStorage` by default and -> fall back to memory where it is unavailable: during SSR, in sandboxed iframes -> and when the browser blocks cookies, or `window.localStorage` is `null` or -> rejects writes. To test storage, the provider writes and removes a -> `cmdk-engine-probe` key once when it mounts. Malformed data under their keys -> is ignored and replaced on the next write. Override the backend via -> `config.frecency.storage`. - -> `frecency.storageKey` and `searchHistory.storageKey` are full `localStorage` -> keys, not prefixes, so everyone using a browser shares the defaults. If -> several users can sign in on one browser, namespace both keys per user, e.g. -> ``storageKey: `cmdk-frecency:${user.id}` ``. - -### Turning frecency off - -*New in 0.6.* - -Set `frecency: { enabled: false }` to turn frecency off. Nothing is stored in -or read from `localStorage`, results are not ranked by past use, and no -"Recent" group shows, even with `showRecent`. - ---- - -## Context / Scope Boosting - -Commands with a `scope` are boosted when they match the current app context, so on -`/billing`, billing commands rank higher while you search. The empty-query browse -list is not boosted. - -```tsx - - -// A command relevant to the billing area: -{ id: 'add-card', label: 'Add Card', scope: ['/billing', '/billing/*'] } -``` - -A `scope` entry matches when it equals the current `context.path` or is a parent of it -(`/billing` matches `/billing/overview`), when a glob such as `/billing/*` covers it (the -glob matches below `/billing`, not `/billing` itself), or when it equals one of the -`context.tags`. - ---- - -## Nested Commands - -Give a command `children` to make a sub-menu. Selecting it opens its children instead -of running it: - - -```tsx -import { useCommandRegister } from 'cmdk-engine/react' - -const setTheme = (theme: string) => document.documentElement.setAttribute('data-theme', theme) - -function ThemeCommands() { - useCommandRegister([ - { - id: 'theme', - label: 'Change theme', - children: [ - { id: 'theme-light', label: 'Light', action: () => setTheme('light') }, - { id: 'theme-dark', label: 'Dark', action: () => setTheme('dark') }, - ], - }, - ]) - return null -} -``` - -- A command with children never runs its own `action` or `href`. An empty `children` - array counts as a leaf. -- The palette lists only the children, and search covers only that level. `when`, - `permissions` and `hidden` apply to them as at the root. -- Backspace in an empty input, or the back button in the breadcrumbs, goes up one level. - Closing the palette returns to the root. -- Opening a sub-menu is not recorded in frecency or search history; running a child is. -- Async sources load at the root level only. -- Changes to the registered command's `children` show up while you are inside the - sub-menu. A parent that is no longer registered keeps showing the children it had. -- In a custom UI, `useCommandPalette()` returns `breadcrumbs`, `depth`, - `drillDown(item)`, `drillUp()` and `resetPath()`. Both adapters take - `renderBreadcrumbs(crumbs, onBack)` and mark the chevron and the trail with - `data-cmdk-engine-item-chevron` and `data-cmdk-engine-breadcrumbs` (see - [Styling](#styling)). - ---- - -## Groups - -A command's `group` string puts it under a heading. Without any config, each distinct -`group` becomes a heading with the same text, and commands without a `group` go under -"Other", always last. Set `groups` in the provider config to choose labels and order: - - -```tsx -import { CommandEngineProvider, useCommandRegister } from 'cmdk-engine/react' - -const config = { - groups: [ - { id: 'navigation', label: 'Go to', priority: 10 }, - { id: 'actions', label: 'Actions', priority: 5 }, - ], -} - -function Commands() { - useCommandRegister([ - { id: 'home', label: 'Home', href: '/', group: 'navigation' }, - { id: 'invite', label: 'Invite teammate', action: () => {}, group: 'actions' }, - ]) - return null -} - -export function Root({ children }: { children: React.ReactNode }) { - return ( - - - {children} - - ) -} -``` - -- A command's `group` matches a group's `id`. A `group` you did not define still shows, - labelled with the `group` string. -- With an empty query, defined groups are listed by `priority` (higher first), then the - other groups in the order their first command appears. While searching, groups are - ordered by their best match instead. -- A group's `icon` is not rendered by the built-in components; `renderGroupHeading(group)` - receives it. -- `maxResults` (default 50) caps the total number of results across groups. -- The Recent group (`frecency.showRecent`) comes first, above your configured groups. - The palette highlights the first item when it opens, so with groups configured that - is the most recent command. To keep your groups on top, leave `showRecent` off, or - render your own list from `groupedResults`. - ---- - -## Internationalization (i18n) - -Built-in UI strings go through a translation function. Pass your own to -localize the placeholder, empty state, "Recent" heading, accessible labels, etc: - - -```tsx -import { getTranslationKeys } from 'cmdk-engine' - -const myDictionary: Record = { - 'palette.placeholder': 'Buscar comandos...', - 'palette.empty': 'Sin resultados.', -} - -// Define `t` once, outside the component: a new function on every render rebuilds the engine. -const config = { t: (key: string) => myDictionary[key] ?? key } - -// - -// getTranslationKeys() lists every key that has a default English string. -console.log(getTranslationKeys()) -``` - -> `getTranslationKeys()` also lists `group.other` and `search.history`, which -> nothing reads yet. The heading of the ungrouped "Other" group is fixed -> English text for now. - -`palette.close` (default "Close") names the visually hidden close button in the -Base UI adapter's dialog. `palette.list` (default "Suggestions") names the -results listbox in both adapters. A `t` that returns the key unchanged, like the -one above, or an empty string keeps "Suggestions". - ---- - -## Search History - -Opt-in tracking of past queries (persisted to `localStorage`). A query is recorded when -the user selects a command, not on every keystroke. Where `localStorage` is unavailable, the -history stays in memory, with the options it was created with until the provider remounts: - -```tsx -const config = { searchHistory: { enabled: true, maxEntries: 20, minQueryLength: 2 } } -``` - -Read it with `useSearchHistory()`, and set the search box with `setSearch` from -`useCommandPalette()`: - - -```tsx -import { useCommandPalette, useSearchHistory } from 'cmdk-engine/react' - -function RecentSearches() { - const { setSearch } = useCommandPalette() - const { getRecent } = useSearchHistory() // also: remove(query), clear() - return ( - <> - {getRecent(5).map((entry) => ( - - ))} - - ) -} -``` - ---- - -## Palette Events - -*New in 0.6.* - -`useCommandPaletteEvents` reports what happens in the palette, for analytics. -Call it once, in any component inside the provider: - - -```tsx -import { useCommandPaletteEvents } from 'cmdk-engine/react' - -function PaletteAnalytics() { - useCommandPaletteEvents((event) => { - if (event.type === 'search' && event.resultCount === 0) { - console.log('no results for', event.query) // your analytics call - } - }) - return null -} -``` - -| Event | When | Fields | -|---|---|---| -| `open`, `close` | The palette opens or closes | | -| `search` | The results for a query settle, async sources included | `query` (trimmed), `resultCount` (`0`: nothing found) | -| `select` | A command is selected (drilling into children is not) | `item`, `query`, `sourceId` (loaded items) | -| `asyncError` | An async source fails or drops items | `sourceId`, `error` | - -`search` fires for every settled query while the user types, so debounce it -before sending it anywhere. An error thrown by your handler never breaks the -palette. Without the hook, nothing is reported. - ---- - -## Async Command Sources - -*New in 0.6.* - -Mix registered commands with results loaded for each query, such as a -server-side search. The provider runs every source once per query for all -consumers: it debounces, aborts stale requests and ignores late responses. - - -```tsx -import type { AsyncSource } from 'cmdk-engine' - -const issueSearch: AsyncSource = { - id: 'issues', - load: async (query, { signal }) => { - const res = await fetch(`/api/issues?q=${encodeURIComponent(query)}`, { signal }) - const issues: { id: string; title: string }[] = await res.json() - return issues.map((issue) => ({ - id: `issue-${issue.id}`, - label: issue.title, - href: `/issues/${issue.id}`, - })) - }, - shouldFilter: false, // the server already matched the query - group: 'Issues', -} - -const config = { asyncSources: [issueSearch] } - -// -``` - -| Option | Default | Description | -|--------|---------|-------------| -| `id` | required | Keys `asyncErrors`; changing the set of ids restarts loading | -| `load(query, { signal })` | required | Returns the items; pass `signal` to `fetch` | -| `trigger(query)` | non-empty trimmed query | Whether to load for this query | -| `debounceMs` | `200` | Delay after the last query change | -| `shouldFilter` | `true` | `true`: items are searched and ranked with your commands and count toward `maxResults`. `false`: the server matched them, so they are shown as returned, after the local results | -| `maxResults` | `10` | Cap per source when `shouldFilter` is `false` | -| `group` | each item's `group` | Group for every item from this source | - -- `isLoading` is true from the moment a trigger passes (debounce included) - until every source settles. `CommandPalette` shows a `palette.loading` row - meanwhile; override it with `renderLoading`. -- `asyncErrors` maps a source id to its last error, cleared on that source's - next success. A failing source never breaks the palette, and nothing is - logged. -- Loaded items need a non-empty string `id` and `label`. Items without them - (children included) are dropped, the rest still show, and `asyncErrors[id]` - says so, for example "2 items dropped: missing label", until a load drops - nothing. Non-string `keywords` entries are removed. -- Sources load at the root level only. Loads are aborted and their items - cleared when the query changes, the palette closes, the user drills into a - command, or the provider unmounts. A palette that reopens loads again; an - inline palette that never opens keeps loading. -- `when`, permissions and `hidden` apply to loaded items too. Unfiltered items - skip search, frecency, the context boost and `maxResults`, and their groups - come after the local groups, in server order. -- Registered commands win on duplicate ids, then earlier sources. Loaded items - are never recorded in frecency. -- `load`, `trigger` and `debounceMs` are read when needed, so an inline - `config` does not restart loads. Change a source's `id` to force a reload. -- `trigger` runs during render (twice under StrictMode in development), so - keep it pure and cheap. A trigger that passes on an empty query loads as - soon as the provider mounts, even for a palette that has never been opened. - -> **Security:** loaded items are untrusted. Only relative, `http(s):`, -> `mailto:` and `tel:` hrefs are kept; any other `href` is removed when the -> items arrive (children included), so it never reaches `window.location` or a -> custom `renderItem` anchor. For deep links (`myapp://...`), return an -> `action`, or an allowed `href` that your `onNavigate` maps. - ---- - -## CLI Tool - -Auto-discover routes and generate sitemaps for your command palette. - -### Setup - -```bash -# Initialize config -npx cmdk-engine init - -# Scan routes -npx cmdk-engine scan - -# Scan without default auth/error exclusions -npx cmdk-engine scan --no-default-exclude - -# Keep routes under a [locale] folder (or another :param) -npx cmdk-engine scan --include-dynamic locale - -# Validate config -npx cmdk-engine validate -``` - -### Use the output - -*New in 0.6.* - -`scan` writes `src/generated/command-routes.json`. `sitemapToCommands` turns it -into commands; register them once, at the app level: - -```tsx -import { useCommandRegister } from 'cmdk-engine/react' -import { sitemapToCommands } from 'cmdk-engine/adapters/sitemap' -import sitemap from './generated/command-routes.json' - -const routeCommands = sitemapToCommands(sitemap) - -function RouteCommands() { - useCommandRegister(routeCommands) - return null -} -``` - -Each route becomes `{ id, label, keywords, group, href }`, and selecting one -calls your `onNavigate`. Commit the JSON, or run the scan in a `prebuild` script. - -### Standalone binary - -The CLI also ships as a standalone binary for macOS on Apple silicon and for -Linux x64, so it runs without a Node project: - -```bash -curl -fsSL https://raw.githubusercontent.com/Priyans-hu/cmdk-engine/main/install.sh | bash -``` - -There is no binary for Intel Macs or Linux on ARM yet; use `npx cmdk-engine` -there. - -### Smart defaults - -The CLI scanner shares the same default exclusion list as the runtime -React Router adapter, so the generated sitemap automatically skips: -- **Auth routes** — `/login`, `/logout`, `/signup`, `/signin`, `/register`, `/forgot-password`, `/reset-password`, `/verify-email` -- **OAuth callbacks** — `/oauth/callback`, `/auth/callback`, `/callback` -- **Error pages** — `/404`, `/500`, `/error`, `/not-found` - -Pass `--no-default-exclude` to opt out (you'll have full control via -the `exclude` config field instead). - -### Dynamic routes and `[locale]` - -*New in 0.6.* - -A command needs a real URL, so the scan skips routes with a `:param` -(`/users/:id`, `app/[id]/page.tsx`). To keep routes under a segment you can -fill at runtime, such as a Next.js `[locale]` folder, name it in -`includeDynamic` (or pass `--include-dynamic locale`). `app/[locale]/billing/page.tsx` -then becomes `/:locale/billing`, which `params` fills: - -```tsx -const commands = useMemo(() => sitemapToCommands(sitemap, { params: { locale } }), [locale]) -useCommandRegister(commands) -``` - -- `params` values are inserted as given, and `''` drops the segment (a default - locale served without a prefix). -- Ids keep the placeholder (`locale--billing`), so frecency is shared across - locales. -- `includeDynamic: true` keeps every `:param` route. A route whose param you do - not fill is skipped, and catch-alls (`[...slug]`, `/docs/*`) are never kept. -- A React Router route with its own `handle.command` is kept, as with the - runtime `scanRoutes`. - -### Config file - - -```ts -// cmdk-engine.config.ts -import { defineConfig } from 'cmdk-engine' - -export default defineConfig({ - framework: 'react-router', // or 'nextjs-app', 'nextjs-pages' - routesDir: './src/routes', - output: './src/generated/command-routes.json', - overrides: { - '/billing': { keywords: ['money', 'payment'], group: 'Billing' }, - }, - exclude: ['/_*', '/admin/*', /^\/debug\//], // strings, globs, or RegExp - includeDynamic: ['locale'], // keep /:locale/... routes - synonyms: { - billing: ['money', 'payment', 'credits'], - }, -}) -``` - -The CLI reads `cmdk-engine.config.ts` without running it, so its values must be -static: strings, numbers, booleans, arrays, objects, RegExp literals and -top-level `const`s, with `as const` and `satisfies` allowed. For computed values -such as `process.env`, use `cmdk-engine.config.mjs`. A config the CLI cannot -read fails `scan` and `validate`, naming the line. - -`exclude` takes exact paths, RegExp and globs. In a glob, `*` matches within one -path segment and `**` across segments. Excluding a path also excludes the paths -below it, and a trailing `/*` also matches the base: `/admin/*` excludes -`/admin` and everything under it. - -### What the scan reads - -The scan reads your files without running them. - -- **React Router:** - - Reads route objects with a string `path` (as passed to `createBrowserRouter`) - and `` elements, with the `label`, `keywords` and `group` - of their own `handle.command`. - - Joins relative child paths to their parent route in the same file - (`children` arrays, nested ``s). - - Does not read paths built at runtime, routes imported from another file, - index routes, or a `handle` returned by `lazy()`. -- **Next.js:** - - Reads `app/**/page.*` (`nextjs-app`) and `pages/**` (`nextjs-pages`). - - Route groups and `@slot` folders add no segment. - - Private `_folders`, intercepting `(.)` routes and `api/` are skipped. - - `[[...slug]]` gives its parent's URL; other dynamic routes need `includeDynamic`. - -Ids keep letters, digits and `-` (`/billing/overview` gives `billing--overview`), -so `/a_b` and `/ab` would share `ab`. The scan then keeps it for the last of them -in path order and gives the others `ab-2`, `ab-3`, ... - -### Pre-commit hook - -With [husky](https://typicode.github.io/husky) 9, run `npx husky init`, then put the -command in `.husky/pre-commit`: - -```sh -npx cmdk-engine scan && git add src/generated/command-routes.json -``` - -### GitHub Actions - -```yaml -- run: npx cmdk-engine scan -- run: npx cmdk-engine validate -``` - ---- - -## Architecture - -``` -Route Config ─→ Route Adapter ─→ Command Registry ─→ Keyword Engine - │ - ├─→ Access Control Filter - │ - ├─→ Search Engine (fuzzy / match-sorter) - │ - └─→ Frecency Ranking - │ - ▼ - Headless API / Hooks - │ - ▼ - UI Adapter (cmdk / Base UI) -``` - -### Package Entry Points - -| Import | Size (own code; siblings and peers excluded) | Purpose | -|--------|------|---------| -| `cmdk-engine` | 3.7 kB | Core engine (types, registry, search, keywords, access control, frecency) | -| `cmdk-engine/react` | 4.4 kB | React hooks (provider, useCommandPalette, useCommandRegister) | -| `cmdk-engine/adapters/cmdk` | 1.8 kB | Pre-wired cmdk components | -| `cmdk-engine/adapters/react-router` | 1.1 kB | React Router v6/v7/v8 route scanner | -| `cmdk-engine/search/match-sorter` | 0.55 kB | Optional match-sorter search backend | -| `cmdk-engine/adapters/base-ui` | 1.8 kB | Pre-wired Base UI components | -| `cmdk-engine/adapters/sitemap` | 0.23 kB | `sitemapToCommands()`: turns the CLI route sitemap into commands | - -Sizes are measured with size-limit, minified + brotli. Entries import the siblings they use (the cmdk -adapter imports `cmdk-engine/react`, which imports `cmdk-engine`) instead of -bundling them, so each one's code ships once. The Quick Start stack -(`CommandEngineProvider`, `useCommandRegister`, `CommandPalette`, -`useCommandPaletteShortcut`) is **8.3 kB** in total, without the `react`, -`react-dom` and `cmdk` peers. CI enforces a size budget for each entry, set -slightly above these figures. - -All entry points are tree-shakeable. The core has **zero runtime dependencies**. - ---- - -## API Reference - -A quick index. The [docs site](https://priyans-hu.github.io/cmdk-engine/docs/api/) has -every option, field and hook. - -### Core - - -```ts -import { - createRegistry, // Command store (pub/sub, useSyncExternalStore compatible) - createFuzzySearch, // Built-in lightweight fuzzy search - createKeywordEngine, // Synonym expansion + user aliases - createAccessFilter, // RBAC filter (any/all modes) - createSimpleAccessProvider, // Permission provider from array/Set - createFrecencyEngine, // Frecency ranking with exponential decay - createGroupManager, // Command group management - createContextEngine, // Scope boosting - createSearchHistory, // Search history (localStorage); createInMemorySearchHistory for tests and SSR - createInMemoryStorage, // In-memory frecency storage; createLocalStorageFrecencyStorage for localStorage - isCommandVisible, // Resolve a command's `when` gate; filterVisible filters a list with it - getTranslationKeys, // Every UI string key; createDefaultTranslation is the English `t` - pathToId, pathToLabel, pathToGroup, pathSegmentToLabel, // Route path helpers - defineConfig, // Typed config helper for the CLI config file -} from 'cmdk-engine' -``` - -### React - - -```ts -import { - CommandEngineProvider, // Context provider - useCommandPalette, // Main hook: search + filter + rank - useCommandRegister, // Register commands from components - useFrecency, // Direct frecency access - useSearchHistory, // Read and edit search history - useCommandContext, // Read the context config (read-only) - useEngineContext, // The engine singletons, for custom UIs; throws outside the provider - usePaletteState, // The shared open, search and path state; throws outside the provider - useCommandPaletteEvents, // Report palette events, for analytics -} from 'cmdk-engine/react' -``` - -### Adapters - - -```ts -import { CommandPalette, useCommandPaletteShortcut } from 'cmdk-engine/adapters/cmdk' -import { scanRoutes } from 'cmdk-engine/adapters/react-router' -import { sitemapToCommands } from 'cmdk-engine/adapters/sitemap' -// The same CommandPalette and useCommandPaletteShortcut, built on Base UI: -// import { CommandPalette, useCommandPaletteShortcut } from 'cmdk-engine/adapters/base-ui' -``` - -### Keyboard shortcut - -*New in 0.6: the function form.* - -`useCommandPaletteShortcut(shortcut?)` toggles the palette and returns -`{ isOpen, toggle }`. A string is the key pressed with Cmd or Ctrl (default -`'k'`). It also matches with Caps Lock on and, on non-Latin layouts such as -Russian or Greek, by the physical key. Holding the keys toggles once. For any -other shortcut, pass a function that decides the whole match, modifiers -included. Define it outside the component, or every render re-binds it: - - -```tsx -import { CommandPalette, useCommandPaletteShortcut } from 'cmdk-engine/adapters/cmdk' - -// Cmd/Ctrl+Shift+P, as in VS Code -const isPaletteKey = (e: KeyboardEvent) => - (e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'p' - -function CommandMenu() { - useCommandPaletteShortcut(isPaletteKey) - return -} -``` - -### Key hook return values - - -```ts -import { useCommandPalette } from 'cmdk-engine/react' - -const { - search, // Current query - setSearch, // Update query - results, // ScoredItem[] (flat) - flatResults, // The same array as results (an alias; use results) - groupedResults, // GroupedResult[] — results grouped by group - groups, // CommandGroup[] — active groups - isOpen, // Palette visibility - isLoading, // True while an async source is loading - asyncErrors, // Record — last error per async source - breadcrumbs, // CommandItem[], the sub-menu path (nested commands) - depth, // 0 at the root - drillDown, drillUp, resetPath, // Move through nested commands - open, close, toggle, - select, // Select a command (records frecency + runs handler + closes) - recordUsage, // Record frecency manually -} = useCommandPalette() -``` - ---- - -## Type Safety - -All types are exported and fully documented: - - -```ts -import type { - CommandItem, - CommandRegistry, - SearchEngine, - ScoredItem, - GroupedResult, - GroupedResults, - AccessControlProvider, - AsyncSource, - FrecencyOptions, - RecentCommandsConfig, - CommandGroup, - SynonymMap, - RouteCommandMeta, - CmdkEngineConfig, // the CLI config file (cmdk-engine.config.ts) - CommandEngineConfig, // the provider's `config` prop - CommandPaletteState, - CommandPaletteEvent, - AccessCheckMode, - FrecencyEntry, - FrecencyStorage, - CommandContext, - TranslationFn, - SearchHistoryConfig, - SearchHistoryEntry, - Sitemap, - SitemapRoute, -} from 'cmdk-engine' -``` +Next steps: [split it into files, navigate with your router, handle errors, or build your own UI with the hooks](https://github.com/Priyans-hu/cmdk-engine/blob/main/GUIDE.md#beyond-the-quick-start). --- -## Testing - -jsdom lacks two browser APIs that cmdk uses, so tests that render the cmdk adapter need -stubs. Without them the first render throws `ResizeObserver is not defined`: - - -```ts -// your test setup file -globalThis.ResizeObserver = class { - observe() {} - unobserve() {} - disconnect() {} -} -Element.prototype.scrollIntoView = () => {} -``` +## Features -- Render the palette inside `CommandEngineProvider`. To open a `dialog` palette, dispatch - `new KeyboardEvent('keydown', { key: 'k', metaKey: true })` on `document`, or call - `toggle()` from `useCommandPalette()`. -- Frecency and search history persist to `localStorage` (`cmdk-frecency` and - `cmdk-search-history`). Clear them between tests, or pass `frecency.storage`, so one - test's selections do not rank the next test's results. -- The cmdk adapter renders the empty state and the loading row right after the list, - not inside its `role="listbox"`. Search the palette for them, not the list. -- The Base UI adapter needs no stubs. Its dialog stays in the DOM for a moment after - Escape, so use `waitFor` before asserting that it is gone. +- **Route discovery:** turn your React Router config into commands at runtime, or scan a React Router or Next.js app with the CLI. [React Router](https://github.com/Priyans-hu/cmdk-engine/blob/main/GUIDE.md#react-router-integration), [Next.js](https://priyans-hu.github.io/cmdk-engine/docs/nextjs/), [CLI](https://priyans-hu.github.io/cmdk-engine/docs/cli/) +- **Access control:** show each user only the commands they can use, with permissions in any/all modes and `when` checks. [RBAC](https://github.com/Priyans-hu/cmdk-engine/blob/main/GUIDE.md#rbac--access-control) +- **Search:** fuzzy matching that tolerates typos, takes words in any order and ignores accents, plus synonyms. Or plug in match-sorter or your own engine. [Search](https://priyans-hu.github.io/cmdk-engine/docs/search/), [Synonyms](https://github.com/Priyans-hu/cmdk-engine/blob/main/GUIDE.md#synonyms) +- **Ranking:** frecency puts frequent and recent commands first, with a Recent group. Commands for the current page get a boost, and ties keep a stable order. [Frecency](https://github.com/Priyans-hu/cmdk-engine/blob/main/GUIDE.md#frecency-ranking), [Context boosting](https://github.com/Priyans-hu/cmdk-engine/blob/main/GUIDE.md#context--scope-boosting) +- **Nested commands and groups:** sub-menus with breadcrumbs, and group headings in the order you choose. [Nested commands](https://priyans-hu.github.io/cmdk-engine/docs/nested-commands/), [Groups](https://github.com/Priyans-hu/cmdk-engine/blob/main/GUIDE.md#groups) +- **Async sources:** mix registered commands with server results, debounced, abortable and loaded once per query. [Async sources](https://github.com/Priyans-hu/cmdk-engine/blob/main/GUIDE.md#async-command-sources) +- **Your UI:** the cmdk or Base UI adapter, a shadcn/ui registry item, or headless hooks for a fully custom palette. [Adapters](https://priyans-hu.github.io/cmdk-engine/docs/adapters/), [shadcn/ui](https://priyans-hu.github.io/cmdk-engine/docs/shadcn/), [Hooks](https://github.com/Priyans-hu/cmdk-engine/blob/main/GUIDE.md#or-build-your-own-ui-with-hooks) +- **Also:** i18n, search history, palette events for analytics, exported types, and how to test your palette. [i18n](https://github.com/Priyans-hu/cmdk-engine/blob/main/GUIDE.md#internationalization-i18n), [Search history](https://github.com/Priyans-hu/cmdk-engine/blob/main/GUIDE.md#search-history), [Events](https://github.com/Priyans-hu/cmdk-engine/blob/main/GUIDE.md#palette-events), [Types](https://github.com/Priyans-hu/cmdk-engine/blob/main/GUIDE.md#type-safety), [Testing](https://github.com/Priyans-hu/cmdk-engine/blob/main/GUIDE.md#testing) --- -## cmdk Issues We Solve - -| Issue | Description | How We Fix It | -|-------|-------------|---------------| -| [#264](https://github.com/dip/cmdk/issues/264) | Sort not restored after clearing search | We own filtering; restore original order when query is empty | -| [#280](https://github.com/dip/cmdk/issues/280) | First item not selected with dynamic content | Auto-select first item after each render cycle | -| [#375](https://github.com/dip/cmdk/issues/375) | Non-deterministic sorting | Deterministic: frecency → priority → registration order | -| [#267](https://github.com/dip/cmdk/issues/267) | Items not updating on async changes | Reactive pub/sub registry; items update immediately | +## Documentation -> With `@radix-ui/react-dialog` 1.1.x, which cmdk 1.1 can install, Radix logs -> "`DialogContent` requires a `DialogTitle`" and a missing `Description` warning -> each time the dialog opens, and points `aria-labelledby`/`aria-describedby` at -> ids that do not exist. The dialog is still named by its `aria-label`. cmdk -> exposes no dialog title, so the adapter cannot add one: update -> `@radix-ui/react-dialog` to 1.2 or later (`npm update @radix-ui/react-dialog`). +- **[Docs site](https://priyans-hu.github.io/cmdk-engine/docs/):** [Getting started](https://priyans-hu.github.io/cmdk-engine/docs/getting-started/), [Core](https://priyans-hu.github.io/cmdk-engine/docs/core/), [Search](https://priyans-hu.github.io/cmdk-engine/docs/search/), [Nested commands](https://priyans-hu.github.io/cmdk-engine/docs/nested-commands/), [Adapters](https://priyans-hu.github.io/cmdk-engine/docs/adapters/), [Next.js](https://priyans-hu.github.io/cmdk-engine/docs/nextjs/), [CLI](https://priyans-hu.github.io/cmdk-engine/docs/cli/), [API](https://priyans-hu.github.io/cmdk-engine/docs/api/), [Styling](https://priyans-hu.github.io/cmdk-engine/docs/styling/), [shadcn/ui](https://priyans-hu.github.io/cmdk-engine/docs/shadcn/), [Examples](https://priyans-hu.github.io/cmdk-engine/docs/examples/). +- **[GUIDE.md](https://github.com/Priyans-hu/cmdk-engine/blob/main/GUIDE.md):** the whole reference on one page. +- **[CHANGELOG.md](https://github.com/Priyans-hu/cmdk-engine/blob/main/CHANGELOG.md):** what changed in each release. Read "Check before upgrading" first. --- From fb5efcb76b08eedf7e04b509a06aacb979be4eec Mon Sep 17 00:00:00 2001 From: Priyanshu Garg Date: Mon, 12 Oct 2026 21:25:56 +0530 Subject: [PATCH 2/4] test(dist): type-check the marked fences in GUIDE.md too --- CLAUDE.md | 2 +- CONTRIBUTING.md | 2 +- tests-dist/readme.test.tsx | 52 +++++++++++++++++++++----------------- 3 files changed, 31 insertions(+), 25 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 26ce910..400011f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,7 @@ bun run size # Size budgets (after build) - `src/adapters/`: cmdk, base-ui, react-router and sitemap adapters - `src/cli/`: CLI tool (scan, init, validate commands) - `tests/`: Mirrors src/ structure -- `tests-dist/`: Tests of the built package, including the README fences marked `` +- `tests-dist/`: Tests of the built package, including the README and GUIDE fences marked `` - `examples/`: Runnable apps (Vite + React Router, Next.js App Router, shadcn/ui) - `docs/`: Next.js docs site. It runs this repo's build: `bun run build`, then `node scripts/local-build.mjs docs` (again after each build and after `bun install` in `docs/`) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index db8ce88..9131073 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -171,7 +171,7 @@ cmdk-engine/ - Add JSDoc comments to exported functions and types - Update docs if you change public API -- Put `` on the line directly above a README code fence that should keep compiling. `bun run test:dist` type-checks it against the built package. Exactly one fence, the Quick Start, is marked `render` and also runs +- Put `` on the line directly above a code fence in README.md or GUIDE.md that should keep compiling. `bun run test:dist` type-checks it against the built package. Exactly one fence, the README Quick Start, is marked `render` and also runs ## Commit Messages diff --git a/tests-dist/readme.test.tsx b/tests-dist/readme.test.tsx index e55f841..17ebc76 100644 --- a/tests-dist/readme.test.tsx +++ b/tests-dist/readme.test.tsx @@ -7,18 +7,22 @@ import { fileURLToPath, pathToFileURL } from 'node:url' import ts from 'typescript' import { render, screen, fireEvent, act, waitFor } from '@testing-library/react' -// Docs-as-test: README code fences marked with an HTML comment on the line before -// them run against the BUILT package. `` type-checks a -// fence; `` also renders it. Neither shows on GitHub or npm. +// Docs-as-test: code fences in README.md and GUIDE.md marked with an HTML comment on +// the line before them run against the BUILT package. `` +// type-checks a fence; `` also renders it. Neither shows on +// GitHub or npm. const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') -const README = readFileSync(path.join(ROOT, 'README.md'), 'utf8') +/** The docs whose marked fences run here. The README has the one `render` fence. */ +const DOCS = ['README.md', 'GUIDE.md'] // Outside lint's reach, and resolves `react` from the repo's node_modules. const CACHE_DIR = path.join(ROOT, 'node_modules', '.cache', 'cmdk-engine-readme-test') interface Fence { mode: 'render' | 'typecheck' - /** README line of the fence's first code line */ + /** The doc the fence is in, such as `GUIDE.md` */ + file: string + /** Line of the fence's first code line in that doc */ line: number code: string } @@ -26,32 +30,34 @@ interface Fence { const MARKER = //g const MARKED_FENCE = /\r?\n```tsx?\r?\n([\s\S]*?)\r?\n```/g -function lineOf(index: number) { - return README.slice(0, index).split('\n').length -} - -function markedFences(): Fence[] { - const fences = Array.from(README.matchAll(MARKED_FENCE), (m) => ({ +function markedFences(file: string): Fence[] { + const text = readFileSync(path.join(ROOT, file), 'utf8') + const lineOf = (index: number) => text.slice(0, index).split('\n').length + const fences = Array.from(text.matchAll(MARKED_FENCE), (m) => ({ mode: m[1] as Fence['mode'], + file, line: lineOf(m.index) + 2, code: m[2], })) // A marker that does not sit directly above a ts/tsx fence would be skipped silently. - const markers = Array.from(README.matchAll(MARKER), (m) => lineOf(m.index)) + const markers = Array.from(text.matchAll(MARKER), (m) => lineOf(m.index)) const used = new Set(fences.map((f) => f.line - 2)) const stray = markers.filter((line) => !used.has(line)) if (stray.length > 0) { - throw new Error( - `README.md: readme-test marker not directly above a ts/tsx fence, line ${stray}`, - ) + throw new Error(`${file}: readme-test marker not directly above a ts/tsx fence, line ${stray}`) } return fences } +/** `README.md` + line 85 -> `readme-line-85`, unique across the docs */ +function fenceName(fence: Fence) { + return `${path.parse(fence.file).name.toLowerCase()}-line-${fence.line}` +} + /** Type-checks the fences as in-memory files inside the package, so `cmdk-engine/*` resolves to dist/. */ function typeErrors(fences: Fence[]): string[] { const files = new Map( - fences.map((f) => [path.join(ROOT, 'tests-dist', `readme-line-${f.line}.tsx`), f]), + fences.map((f) => [path.join(ROOT, 'tests-dist', `${fenceName(f)}.tsx`), f]), ) const options: ts.CompilerOptions = { strict: true, @@ -80,7 +86,7 @@ function typeErrors(fences: Fence[]): string[] { const fence = d.file && files.get(d.file.fileName) if (!fence || d.start === undefined) return message const { line } = d.file!.getLineAndCharacterOfPosition(d.start) - return `README.md:${fence.line + line}: ${message}` + return `${fence.file}:${fence.line + line}: ${message}` }) } @@ -88,7 +94,7 @@ function typeErrors(fences: Fence[]): string[] { function resolveExport(specifier: string): string { const pkg = JSON.parse(readFileSync(path.join(ROOT, 'package.json'), 'utf8')) const target = pkg.exports['.' + specifier.slice('cmdk-engine'.length)]?.import?.default - if (!target) throw new Error(`README imports '${specifier}', which package.json does not export`) + if (!target) throw new Error(`A doc imports '${specifier}', which package.json does not export`) return pathToFileURL(path.join(ROOT, target)).href } @@ -106,7 +112,7 @@ async function importFence(fence: Fence) { (_, quote: string, specifier: string) => `from ${quote}${resolveExport(specifier)}${quote}`, ) mkdirSync(CACHE_DIR, { recursive: true }) - const file = path.join(CACHE_DIR, `readme-line-${fence.line}.mjs`) + const file = path.join(CACHE_DIR, `${fenceName(fence)}.mjs`) writeFileSync(file, code) return import(/* @vite-ignore */ pathToFileURL(file).href) } @@ -127,11 +133,11 @@ beforeAll(() => { afterAll(() => rmSync(CACHE_DIR, { recursive: true, force: true })) -describe('README fences against the built package', () => { - const fences = markedFences() +describe('README and GUIDE fences against the built package', () => { + const fences = DOCS.flatMap(markedFences) - it('marks the Quick Start for rendering', () => { - expect(fences.filter((f) => f.mode === 'render')).toHaveLength(1) + it('marks the README Quick Start for rendering', () => { + expect(fences.filter((f) => f.mode === 'render').map((f) => f.file)).toEqual(['README.md']) }) it('type-checks every marked fence', () => { From 0cc61be730964240f923008d53a38897839746a7 Mon Sep 17 00:00:00 2001 From: Priyanshu Garg Date: Mon, 12 Oct 2026 21:25:57 +0530 Subject: [PATCH 3/4] docs(site): serve README.md and GUIDE.md in llms-full.txt --- docs/app/llms-full.txt/route.ts | 10 ++++++---- llms.txt | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/app/llms-full.txt/route.ts b/docs/app/llms-full.txt/route.ts index 97f380b..39805e4 100644 --- a/docs/app/llms-full.txt/route.ts +++ b/docs/app/llms-full.txt/route.ts @@ -3,11 +3,13 @@ import path from 'node:path' export const dynamic = 'force-static' -/** Serves the README as plain text for tools that want the full reference in one file. */ +/** Serves the README and GUIDE as plain text for tools that want the full reference in one file. */ export function GET() { - const file = path.join(process.cwd(), '..', 'README.md') - // The README's test markers are comments for the repo's tests, not content. - const text = readFileSync(file, 'utf8').replace(/\r?\n/g, '') + const text = ['README.md', 'GUIDE.md'] + .map((name) => readFileSync(path.join(process.cwd(), '..', name), 'utf8')) + .join('\n') + // The docs' test markers are comments for the repo's tests, not content. + .replace(/\r?\n/g, '') return new Response(text, { headers: { 'Content-Type': 'text/plain; charset=utf-8' }, }) diff --git a/llms.txt b/llms.txt index c984589..c0e49df 100644 --- a/llms.txt +++ b/llms.txt @@ -54,7 +54,7 @@ npx cmdk-engine validate # Validate config - [Core API](https://priyans-hu.github.io/cmdk-engine/docs/core/): registry, frecency, groups and the other framework-agnostic parts - [Adapters](https://priyans-hu.github.io/cmdk-engine/docs/adapters/): CommandPalette props for cmdk and Base UI, scanRoutes for React Router - [Examples](https://priyans-hu.github.io/cmdk-engine/docs/examples/): runnable apps, React Router, RBAC, a custom UI, hooks and a test -- [The README as plain text](https://priyans-hu.github.io/cmdk-engine/llms-full.txt) +- [The README and the full guide as plain text](https://priyans-hu.github.io/cmdk-engine/llms-full.txt) ## Links From 18e12ba19c8b0b453d8d634cb865bea55a16b222 Mon Sep 17 00:00:00 2001 From: Priyanshu Garg Date: Mon, 12 Oct 2026 21:25:57 +0530 Subject: [PATCH 4/4] chore: point comments at the sections now in GUIDE.md --- docs/components/palette-demo.css | 2 +- examples/nextjs-app-router/app/globals.css | 2 +- examples/vite-react-router/src/palette.tsx | 2 +- examples/vite-react-router/src/styles.css | 2 +- src/core/route-defaults.ts | 2 +- tests/adapters/listbox.test.tsx | 2 +- tests/cli/config-loader.test.ts | 2 +- tests/react/synonym-query-expansion.test.tsx | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/components/palette-demo.css b/docs/components/palette-demo.css index ae942ce..2221366 100644 --- a/docs/components/palette-demo.css +++ b/docs/components/palette-demo.css @@ -1,4 +1,4 @@ -/* The README's Styling CSS on the site's colors, so light and dark both work. */ +/* The Styling CSS from GUIDE.md on the site's colors, so light and dark both work. */ [cmdk-overlay] { position: fixed; inset: 0; diff --git a/examples/nextjs-app-router/app/globals.css b/examples/nextjs-app-router/app/globals.css index cc49a96..81c609d 100644 --- a/examples/nextjs-app-router/app/globals.css +++ b/examples/nextjs-app-router/app/globals.css @@ -1,4 +1,4 @@ -/* The README's Styling CSS (the same rules, formatted). */ +/* The Styling CSS from GUIDE.md (the same rules, formatted). */ [cmdk-overlay] { position: fixed; inset: 0; diff --git a/examples/vite-react-router/src/palette.tsx b/examples/vite-react-router/src/palette.tsx index 187123e..7aafd9b 100644 --- a/examples/vite-react-router/src/palette.tsx +++ b/examples/vite-react-router/src/palette.tsx @@ -1,6 +1,6 @@ import { CommandPalette, useCommandPaletteShortcut } from 'cmdk-engine/adapters/cmdk' -// Styled by the README's Styling CSS (styles.css). +// Styled by the Styling CSS from GUIDE.md (styles.css). export function Palette() { useCommandPaletteShortcut() // Cmd+K / Ctrl+K return diff --git a/examples/vite-react-router/src/styles.css b/examples/vite-react-router/src/styles.css index e0490f8..c5718bc 100644 --- a/examples/vite-react-router/src/styles.css +++ b/examples/vite-react-router/src/styles.css @@ -1,4 +1,4 @@ -/* The README's Styling CSS (the same rules, formatted). */ +/* The Styling CSS from GUIDE.md (the same rules, formatted). */ [cmdk-overlay] { position: fixed; inset: 0; diff --git a/src/core/route-defaults.ts b/src/core/route-defaults.ts index d423f5a..1cdd55b 100644 --- a/src/core/route-defaults.ts +++ b/src/core/route-defaults.ts @@ -2,7 +2,7 @@ * Default route exclusion patterns shared between the runtime React Router * adapter and the CLI scanners. * - * Single source of truth — keeps the adapter and CLI in sync so the README's + * Single source of truth — keeps the adapter and CLI in sync so the documented * "smart defaults" claim holds for both. */ diff --git a/tests/adapters/listbox.test.tsx b/tests/adapters/listbox.test.tsx index 443b290..9c5c635 100644 --- a/tests/adapters/listbox.test.tsx +++ b/tests/adapters/listbox.test.tsx @@ -75,7 +75,7 @@ describe.each([ }) it('stays "Suggestions" when the translation echoes the key', () => { - // The README pattern: `(key) => dictionary[key] ?? key`, for a dictionary without the key + // The GUIDE's pattern: `(key) => dictionary[key] ?? key`, for a dictionary without the key const echo: TranslationFn = (key) => (key === 'palette.list' ? key : english(key)) renderPalette({ t: echo }) expect(listName()).toBe('Suggestions') diff --git a/tests/cli/config-loader.test.ts b/tests/cli/config-loader.test.ts index 36d23c1..363bbb0 100644 --- a/tests/cli/config-loader.test.ts +++ b/tests/cli/config-loader.test.ts @@ -107,7 +107,7 @@ describe('TS config: same result as the 0.5.1 parser for every config it accepte }) describe('TS config: static values the 0.5.1 parser ignored', () => { - it('loads the README example, RegExp included', async () => { + it('loads the GUIDE example, RegExp included', async () => { const file = write( 'readme.config.ts', `// cmdk-engine.config.ts diff --git a/tests/react/synonym-query-expansion.test.tsx b/tests/react/synonym-query-expansion.test.tsx index d4922c4..aaa7cb7 100644 --- a/tests/react/synonym-query-expansion.test.tsx +++ b/tests/react/synonym-query-expansion.test.tsx @@ -23,7 +23,7 @@ import type { type Load = AsyncSource['load'] -// The README Quick Start config and command. +// The GUIDE's Synonyms config, for the README Quick Start's command. const README_SYNONYMS = { billing: ['money', 'payment', 'credits'], settings: ['preferences', 'config', 'options'],