Files
descrybe/apps/web/src/lib/command-palette-search.ts
T
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

113 lines
3.1 KiB
TypeScript

/**
* 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<T extends PaletteSearchItem>(
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);
}