export type CommandPaletteItemKind = 'route' | 'machine' export type CommandPaletteMachineLike = object export interface CommandPaletteItem { readonly id: string readonly kind: CommandPaletteItemKind readonly label: string readonly subtitle?: string readonly href: string readonly category?: string readonly difficulty?: string readonly keywords: readonly string[] readonly ariaLabel: string } export interface CommandPaletteSearchResult extends CommandPaletteItem { readonly score: number readonly matches: readonly string[] } export interface CreateCommandPaletteItemsOptions { readonly includeCatalogue?: boolean readonly additionalItems?: readonly CommandPaletteItem[] } export interface CommandPaletteFilterOptions { readonly maxResults?: number } type SearchFieldName = | 'label' | 'category' | 'difficulty' | 'subtitle' | 'keywords' | 'href' interface SearchField { readonly name: SearchFieldName readonly normalised: string } interface FieldWeights { readonly exact: number readonly prefix: number readonly wordPrefix: number readonly contains: number } const DEFAULT_MAX_RESULTS = 9 const FIELD_WEIGHTS: Record = { label: { exact: 150, prefix: 120, wordPrefix: 96, contains: 76, }, category: { exact: 74, prefix: 62, wordPrefix: 54, contains: 40, }, difficulty: { exact: 58, prefix: 48, wordPrefix: 40, contains: 32, }, subtitle: { exact: 46, prefix: 40, wordPrefix: 34, contains: 26, }, keywords: { exact: 82, prefix: 68, wordPrefix: 56, contains: 44, }, href: { exact: 30, prefix: 24, wordPrefix: 20, contains: 14, }, } export const CATALOGUE_COMMAND_ITEM: CommandPaletteItem = { id: 'route:catalogue', kind: 'route', label: 'Catalogue overview', subtitle: 'Browse, filter, sort, and compare all mechanical systems.', href: '/', keywords: [ 'catalogue', 'catalog', 'home', 'browse', 'machines', 'systems', 'filters', 'grid', 'library', ], ariaLabel: 'Open the mechanical systems catalogue overview', } export function normaliseForCommandSearch(value: string): string { return value .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .toLocaleLowerCase() .replace(/&/g, ' and ') .replace(/['’]/g, '') .replace(/[^a-z0-9]+/g, ' ') .trim() .replace(/\s+/g, ' ') } export function createMachineCommandItem( machine: CommandPaletteMachineLike, ): CommandPaletteItem | null { const record = asRecord(machine) const routeKey = readFirstString(record, ['slug', 'id']) if (!routeKey) { return null } const label = readFirstString(record, ['name', 'title', 'label']) || routeKey const category = readFirstString(record, ['category', 'type']) const difficulty = readFirstString(record, ['difficulty', 'level']) const summary = truncateText( readFirstString(record, [ 'summary', 'shortDescription', 'description', 'overview', ]), 112, ) const subtitle = compactStrings([category, difficulty, summary]).join(' · ') const id = readFirstString(record, ['id']) const slug = readFirstString(record, ['slug']) const keywords = dedupeStrings([ 'machine', label, routeKey, id, slug, category, difficulty, summary, readFirstString(record, ['description', 'overview']), ...readStringList(record, [ 'tags', 'aliases', 'keywords', 'features', 'useCases', 'disciplines', 'industries', ]), ]) return { id: `machine:${routeKey}`, kind: 'machine', label, subtitle: subtitle || undefined, href: `/machines/${encodeURIComponent(routeKey)}`, category: category || undefined, difficulty: difficulty || undefined, keywords, ariaLabel: `Open ${label}${category ? ` in ${category}` : ''}`, } } export function createCommandPaletteItems( machines: readonly CommandPaletteMachineLike[], options: CreateCommandPaletteItemsOptions = {}, ): CommandPaletteItem[] { const includeCatalogue = options.includeCatalogue ?? true const output: CommandPaletteItem[] = [] const seen = new Set() const pushItem = (item: CommandPaletteItem | null) => { if (!item) { return } const hrefKey = `${item.kind}:${item.href}` if (seen.has(item.id) || seen.has(hrefKey)) { return } seen.add(item.id) seen.add(hrefKey) output.push(item) } if (includeCatalogue) { pushItem(CATALOGUE_COMMAND_ITEM) } for (const item of options.additionalItems ?? []) { pushItem(item) } for (const machine of machines) { pushItem(createMachineCommandItem(machine)) } return output } export function filterCommandPaletteItems( items: readonly CommandPaletteItem[], query: string, options: CommandPaletteFilterOptions = {}, ): CommandPaletteSearchResult[] { const maxResults = options.maxResults === undefined ? DEFAULT_MAX_RESULTS : Math.max(0, Math.floor(options.maxResults)) if (maxResults === 0) { return [] } const normalisedQuery = normaliseForCommandSearch(query) if (!normalisedQuery) { return items .map((item, index) => ({ item, index })) .sort((left, right) => compareDefaultItems(left.item, right.item, left.index, right.index), ) .slice(0, maxResults) .map(({ item }) => toSearchResult(item, 0, [])) } return items .map((item, index) => { const scored = scoreCommandPaletteItem(item, normalisedQuery) if (!scored) { return null } return { item, index, score: scored.score, matches: scored.matches, } }) .filter(isDefined) .sort((left, right) => { const scoreDifference = right.score - left.score if (scoreDifference !== 0) { return scoreDifference } return compareDefaultItems( left.item, right.item, left.index, right.index, ) }) .slice(0, maxResults) .map(({ item, score, matches }) => toSearchResult(item, score, matches)) } function scoreCommandPaletteItem( item: CommandPaletteItem, normalisedQuery: string, ): { score: number; matches: string[] } | null { const tokens = normalisedQuery.split(' ').filter(Boolean) const fields = createSearchFields(item) const matches = new Set() let score = 0 for (const token of tokens) { let bestField: SearchFieldName | null = null let bestScore = 0 for (const field of fields) { const fieldScore = scoreNormalisedField( field.normalised, token, FIELD_WEIGHTS[field.name], ) if (fieldScore > bestScore) { bestField = field.name bestScore = fieldScore } } if (!bestField || bestScore === 0) { return null } matches.add(bestField) score += bestScore } const normalisedLabel = normaliseForCommandSearch(item.label) const normalisedCategory = normaliseForCommandSearch(item.category ?? '') const normalisedDifficulty = normaliseForCommandSearch(item.difficulty ?? '') const normalisedSubtitle = normaliseForCommandSearch(item.subtitle ?? '') const normalisedKeywords = normaliseForCommandSearch(item.keywords.join(' ')) if (normalisedLabel === normalisedQuery) { score += 220 matches.add('label') } else if (normalisedLabel.startsWith(normalisedQuery)) { score += 135 matches.add('label') } else if (normalisedLabel.includes(normalisedQuery)) { score += 82 matches.add('label') } if (normalisedKeywords.includes(normalisedQuery)) { score += 48 matches.add('keywords') } if (normalisedCategory === normalisedQuery) { score += 34 matches.add('category') } if (normalisedDifficulty === normalisedQuery) { score += 24 matches.add('difficulty') } if (normalisedSubtitle.includes(normalisedQuery)) { score += 18 matches.add('subtitle') } if (item.kind === 'route') { score += 6 } return { score, matches: Array.from(matches), } } function createSearchFields(item: CommandPaletteItem): SearchField[] { const fields: SearchField[] = [] const addField = (name: SearchFieldName, value: string | undefined) => { const normalised = normaliseForCommandSearch(value ?? '') if (normalised) { fields.push({ name, normalised }) } } addField('label', item.label) addField('category', item.category) addField('difficulty', item.difficulty) addField('subtitle', item.subtitle) addField('href', item.href) for (const keyword of item.keywords) { addField('keywords', keyword) } return fields } function scoreNormalisedField( normalisedField: string, token: string, weights: FieldWeights, ): number { if (normalisedField === token) { return weights.exact } if (normalisedField.startsWith(token)) { return weights.prefix } if (normalisedField.split(' ').some((word) => word.startsWith(token))) { return weights.wordPrefix } if (normalisedField.includes(token)) { return weights.contains } return 0 } function compareDefaultItems( left: CommandPaletteItem, right: CommandPaletteItem, leftIndex: number, rightIndex: number, ): number { const kindDifference = getKindWeight(left.kind) - getKindWeight(right.kind) if (kindDifference !== 0) { return kindDifference } const labelDifference = left.label.localeCompare(right.label, undefined, { sensitivity: 'base', }) if (labelDifference !== 0) { return labelDifference } return leftIndex - rightIndex } function getKindWeight(kind: CommandPaletteItemKind): number { return kind === 'route' ? 0 : 1 } function toSearchResult( item: CommandPaletteItem, score: number, matches: readonly string[], ): CommandPaletteSearchResult { return { ...item, score, matches, } } function asRecord(value: object): Record { return value as Record } function readFirstString( record: Record, keys: readonly string[], ): string { for (const key of keys) { const value = valueToString(record[key]) if (value) { return value } } return '' } function readStringList( record: Record, keys: readonly string[], ): string[] { const output: string[] = [] for (const key of keys) { const value = record[key] if (Array.isArray(value)) { output.push(...value.map(valueToString).filter(Boolean)) continue } const stringValue = valueToString(value) if (stringValue) { output.push(stringValue) } } return output } function valueToString(value: unknown): string { if (typeof value === 'string') { return value.trim() } if (typeof value === 'number' && Number.isFinite(value)) { return String(value) } return '' } function compactStrings(values: readonly string[]): string[] { return values.map((value) => value.trim()).filter(Boolean) } function dedupeStrings(values: readonly string[]): string[] { const seen = new Set() const output: string[] = [] for (const value of compactStrings(values)) { const normalised = normaliseForCommandSearch(value) if (!normalised || seen.has(normalised)) { continue } seen.add(normalised) output.push(value) } return output } function truncateText(value: string, maxLength: number): string { const trimmed = value.trim() if (trimmed.length <= maxLength) { return trimmed } return `${trimmed.slice(0, maxLength - 1).trimEnd()}…` } function isDefined(value: T | null): value is T { return value !== null }