Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
113 lines
3.1 KiB
TypeScript
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);
|
|
}
|