/** * Pure command-palette search helpers (filter + rank). * No Svelte / i18n / $app — safe for node:test. */ export type PaletteSearchItem = { id: string; label: string; keywords: string; }; /** Trim + lowercase; empty when the user has not typed a query yet. */ export function normalizePaletteQuery(query: string): string { return query.trim().toLowerCase(); } function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** True when `token` starts a whole word in `hay` (space-separated). */ function wordStartsWith(hay: string, token: string): boolean { if (!token) return false; return new RegExp(`(?:^|\\s)${escapeRegExp(token)}`).test(hay); } /** * Score one query token against label + keywords. * Higher = better match. 0 = no match. */ export function scorePaletteToken(token: string, label: string, keywords: string): number { const t = token.trim().toLowerCase(); if (!t) return 0; const lab = label.toLowerCase(); const keys = keywords.toLowerCase(); if (lab === t) return 100; if (lab.startsWith(t)) return 80; if (wordStartsWith(lab, t)) return 70; if (lab.includes(t)) return 50; if (wordStartsWith(keys, t)) return 40; if (keys.includes(t)) return 20; return 0; } /** * Score an item for a full query. Multi-token queries use AND: * every token must score > 0; total is the sum. * Empty query scores 1 (preserve input order when idle). */ export function scorePaletteItem(query: string, item: PaletteSearchItem): number { const q = normalizePaletteQuery(query); if (!q) return 1; const tokens = q.split(/\s+/).filter(Boolean); let total = 0; for (const token of tokens) { const part = scorePaletteToken(token, item.label, item.keywords); if (part <= 0) return 0; total += part; } return total; } /** * Filter out non-matches and rank by score (desc), then label (asc). * Empty / whitespace query returns items in original order. */ export function filterAndRankPaletteItems( query: string, items: readonly T[] ): T[] { const q = normalizePaletteQuery(query); if (!q) return [...items]; return items .map((item) => ({ item, score: scorePaletteItem(q, item) })) .filter((row) => row.score > 0) .sort((a, b) => { if (b.score !== a.score) return b.score - a.score; return a.item.label.localeCompare(b.item.label); }) .map((row) => row.item); } /** * Platform-aware shortcut hint for discoverability (⌘K vs Ctrl+K). * Pass `platform` in tests; defaults to `navigator.platform` / `userAgent` in browser. */ export function commandPaletteShortcutLabel( platform?: string | null, userAgent?: string | null ): string { const p = (platform ?? "").toLowerCase(); const ua = (userAgent ?? "").toLowerCase(); const hay = `${p} ${ua}`; if ( hay.includes("mac") || hay.includes("iphone") || hay.includes("ipad") || hay.includes("ipod") ) { return "⌘K"; } return "Ctrl+K"; } /** Resolve shortcut from the current environment (SSR-safe). */ export function commandPaletteShortcutLabelFromEnv(): string { if (typeof navigator === "undefined") return "Ctrl+K"; return commandPaletteShortcutLabel(navigator.platform, navigator.userAgent); }