diff --git a/apps/www/pages/features.tsx b/apps/www/pages/features.tsx index 078f575d252..a783330e709 100644 --- a/apps/www/pages/features.tsx +++ b/apps/www/pages/features.tsx @@ -23,6 +23,40 @@ import { breadcrumbListSchema, serializeJsonLd } from '@/lib/json-ld' type ViewMode = 'grid' | 'matrix' +// Relevance weights used to rank search results. Title matches are considered +// far more important than body content, so a feature whose title matches the +// query surfaces above features where the term only appears in the description. +// Title weights are deliberately larger than the maximum combined body score +// (SUBTITLE_INCLUDES + DESCRIPTION_INCLUDES), so any title match always ranks +// above a feature that only matches in its subtitle and/or description. +const SEARCH_WEIGHT = { + TITLE_STARTS_WITH: 5, + TITLE_INCLUDES: 4, + SUBTITLE_INCLUDES: 2, + DESCRIPTION_INCLUDES: 1, +} as const + +// Score a feature against a (lowercased) search term. Higher is more relevant. +// Returns 0 when nothing matches. +function getSearchScore(feature: (typeof features)[number], term: string): number { + if (!term) return 0 + + const title = feature.title.toLowerCase() + const subtitle = feature.subtitle.toLowerCase() + const description = feature.description.toLowerCase() + + let score = 0 + if (title.startsWith(term)) { + score += SEARCH_WEIGHT.TITLE_STARTS_WITH + } else if (title.includes(term)) { + score += SEARCH_WEIGHT.TITLE_INCLUDES + } + if (subtitle.includes(term)) score += SEARCH_WEIGHT.SUBTITLE_INCLUDES + if (description.includes(term)) score += SEARCH_WEIGHT.DESCRIPTION_INCLUDES + + return score +} + function FeaturesPage() { const router = useRouter() const [searchTerm, setSearchTerm] = useState((router?.query.q as string) || '') @@ -89,20 +123,31 @@ function FeaturesPage() { } // Filter features based on search term and selected products - const filteredFeatures = features.filter((feature) => { - const matchesSearch = - feature.title.toLowerCase().includes(searchTerm.toLowerCase()) || - feature.subtitle.toLowerCase().includes(searchTerm.toLowerCase()) || - feature.description.toLowerCase().includes(searchTerm.toLowerCase()) + const normalizedSearchTerm = searchTerm.toLowerCase() + const filteredFeatures = features + .filter((feature) => { + const matchesSearch = + !normalizedSearchTerm || getSearchScore(feature, normalizedSearchTerm) > 0 - const matchesProduct = - selectedProducts.length === 0 || - feature.products.some((product) => selectedProducts.includes(product)) + const matchesProduct = + selectedProducts.length === 0 || + feature.products.some((product) => selectedProducts.includes(product)) - const matchesSelfHosted = !showSelfHostedOnly || feature.status?.availableOnSelfHosted === true + const matchesSelfHosted = + !showSelfHostedOnly || feature.status?.availableOnSelfHosted === true - return matchesSearch && matchesProduct && matchesSelfHosted - }) + return matchesSearch && matchesProduct && matchesSelfHosted + }) + // When searching, rank by relevance (title matches weigh more than body + // content), falling back to alphabetical order for ties and no search. + .sort((a, b) => { + if (normalizedSearchTerm) { + const scoreDiff = + getSearchScore(b, normalizedSearchTerm) - getSearchScore(a, normalizedSearchTerm) + if (scoreDiff !== 0) return scoreDiff + } + return a.title > b.title ? 1 : -1 + }) const meta = { title: 'Supabase Features', @@ -265,61 +310,59 @@ function FeaturesPage() {

) : (
- {filteredFeatures - .sort((a, b) => (a.title > b.title ? 1 : -1)) - .map((feature) => ( - ( + + - -
- - {feature.status && ( -
- - {stageLabel(feature.status.stage)} - -
- )} -
-
-

- {feature.title} -

-

- {feature.subtitle} -

-
- {feature.status && ( - - {stageLabel(feature.status.stage)} - - )} - {feature.products.map((product) => ( - - {productLabel(product)} - - ))} +
+ + {feature.status && ( +
+ + {stageLabel(feature.status.stage)} +
+ )} +
+
+

+ {feature.title} +

+

+ {feature.subtitle} +

+
+ {feature.status && ( + + {stageLabel(feature.status.stage)} + + )} + {feature.products.map((product) => ( + + {productLabel(product)} + + ))}
- - - ))} +
+ + + ))}
)}