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/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 (
+
+ )
+}
+```
+
+> `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

-**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 (
-
- )
-}
-```
-
-> `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.
---
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/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/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
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-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', () => {
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'],