Skip to content

Commit 6d643a0

Browse files
authored
Add research posts to navbar global search, unify all search behaviour and remove algolia dependency. (#575)
* remove algoliasearch and update search with weight (title=100, tags=50, tldr=25, content=10) * fix project search and update search with regex \bword\b or \bword * remove algolia dependencies from package.json, yarn.lock, etc. * Base search on fuse.js, implement same search in projects/ blog/ and navbar search * add research to navbar search, improve search functionality, update test.
1 parent f408f86 commit 6d643a0

File tree

10 files changed

+559
-595
lines changed

10 files changed

+559
-595
lines changed

app/api/search/indexes/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server"
22

33
// These should be the same indexes used in the search route
44
// to ensure consistency
5-
const allIndexes = ["blog", "projects"]
5+
const allIndexes = ["blog", "projects", "research"]
66

77
export async function GET() {
88
try {

app/api/search/route.ts

Lines changed: 86 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,10 @@
1-
import algoliasearch from "algoliasearch"
21
import { NextRequest, NextResponse } from "next/server"
2+
import { getArticles, getProjects } from "@/lib/content"
3+
import { searchArticles, searchProjects } from "@/lib/search"
34

4-
// Cache search results for better performance
5-
export const revalidate = 900 // Revalidate cache after 15 minutes
5+
export const revalidate = 300 // 5 minutes
66

7-
const appId =
8-
process.env.ALGOLIA_APP_ID || process.env.NEXT_PUBLIC_ALGOLIA_APP_ID || ""
9-
const apiKey =
10-
process.env.ALGOLIA_SEARCH_API_KEY ||
11-
process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY ||
12-
""
13-
const additionalIndexes = (
14-
process.env.ALGOLIA_ADDITIONAL_INDEXES ||
15-
process.env.NEXT_PUBLIC_ALGOLIA_ADDITIONAL_INDEXES ||
16-
""
17-
)
18-
.split(",")
19-
.map((index) => index.trim())
20-
.filter(Boolean)
21-
22-
const allIndexes = [...additionalIndexes].filter(Boolean) || [
23-
"blog",
24-
"projects",
25-
]
26-
const searchClient = appId && apiKey ? algoliasearch(appId, apiKey) : null
27-
28-
function transformQuery(query: string) {
29-
if (query.toLowerCase().includes("intmax")) {
30-
return query.replace(/intmax/i, "\"intmax\"")
31-
}
32-
return query
33-
}
7+
const allIndexes = ["blog", "projects", "research"]
348

359
export async function GET(request: NextRequest) {
3610
const searchParams = request.nextUrl.searchParams
@@ -46,79 +20,104 @@ export async function GET(request: NextRequest) {
4620
})
4721
}
4822

49-
if (!searchClient) {
50-
return NextResponse.json(
51-
{
52-
error: "Search client not initialized - missing Algolia credentials",
53-
availableIndexes: [],
54-
},
55-
{ status: 500 }
56-
)
57-
}
58-
59-
try {
60-
const transformedQuery = transformQuery(query)
23+
const results = []
6124

62-
// If an index is specified, search only that index
63-
if (indexName && indexName.trim() !== "") {
64-
const index = searchClient.initIndex(indexName)
65-
const response = await index.search(transformedQuery, { hitsPerPage })
25+
// Search articles
26+
if (!indexName || indexName === "blog") {
27+
const articles = getArticles()
28+
const matches = searchArticles(articles, query)
29+
.slice(0, hitsPerPage)
30+
.map((article: any) => ({
31+
objectID: article.id,
32+
title: article.title,
33+
content: article.tldr || article.content.slice(0, 200),
34+
url: `/blog/${article.id}`,
35+
}))
6636

67-
return NextResponse.json(
68-
{
69-
hits: response.hits,
70-
status: "success",
71-
availableIndexes: allIndexes,
72-
},
73-
{
74-
headers: {
75-
"Cache-Control":
76-
"public, s-maxage=900, stale-while-revalidate=1800",
77-
},
78-
}
79-
)
37+
if (matches.length > 0) {
38+
results.push({
39+
indexName: "blog",
40+
hits: matches,
41+
})
8042
}
43+
}
8144

82-
// Otherwise search across all configured indexes
83-
const searchPromises = allIndexes.map((idxName) => {
84-
return searchClient!
85-
.initIndex(idxName)
86-
.search(transformedQuery, { hitsPerPage })
87-
.then((response) => ({
88-
indexName: idxName,
89-
hits: response.hits,
90-
}))
91-
.catch((err) => {
92-
console.error(`Search error for index ${idxName}:`, err)
93-
return { indexName: idxName, hits: [] }
94-
})
95-
})
45+
// Search projects (applications and devtools only)
46+
if (!indexName || indexName === "projects") {
47+
const allProjects = getProjects()
48+
const projectsOnly = allProjects.filter(
49+
(p: any) =>
50+
p.category?.toLowerCase() === "application" ||
51+
p.category?.toLowerCase() === "devtools"
52+
)
53+
const matches = searchProjects(projectsOnly, query)
54+
.slice(0, hitsPerPage)
55+
.map((project: any) => ({
56+
objectID: project.id,
57+
title: project.name || project.title,
58+
description: project.description || project.tldr,
59+
url: `/projects/${project.id}`,
60+
}))
9661

97-
const indexResults = await Promise.all(searchPromises)
98-
const nonEmptyResults = indexResults.filter(
99-
(result) => result.hits && result.hits.length > 0
62+
if (matches.length > 0) {
63+
results.push({
64+
indexName: "projects",
65+
hits: matches,
66+
})
67+
}
68+
}
69+
70+
// Search research (research category only)
71+
if (!indexName || indexName === "research") {
72+
const allProjects = getProjects()
73+
const researchOnly = allProjects.filter(
74+
(p: any) => p.category?.toLowerCase() === "research"
10075
)
76+
const matches = searchProjects(researchOnly, query)
77+
.slice(0, hitsPerPage)
78+
.map((project: any) => ({
79+
objectID: project.id,
80+
title: project.name || project.title,
81+
description: project.description || project.tldr,
82+
url: `/projects/${project.id}`,
83+
}))
10184

85+
if (matches.length > 0) {
86+
results.push({
87+
indexName: "research",
88+
hits: matches,
89+
})
90+
}
91+
}
92+
93+
// If searching specific index, return single index format
94+
if (indexName) {
95+
const indexResult = results.find((r) => r.indexName === indexName)
10296
return NextResponse.json(
10397
{
104-
results: nonEmptyResults,
98+
hits: indexResult?.hits || [],
10599
status: "success",
106100
availableIndexes: allIndexes,
107101
},
108102
{
109103
headers: {
110-
"Cache-Control": "public, s-maxage=900, stale-while-revalidate=1800",
104+
"Cache-Control": "public, s-maxage=300, stale-while-revalidate=600",
111105
},
112106
}
113107
)
114-
} catch (error: any) {
115-
console.error("Global search error:", error)
116-
return NextResponse.json(
117-
{
118-
error: error.message || "Search failed",
119-
availableIndexes: [],
120-
},
121-
{ status: 500 }
122-
)
123108
}
109+
110+
// Return multi-index format
111+
return NextResponse.json(
112+
{
113+
results,
114+
status: "success",
115+
availableIndexes: allIndexes,
116+
},
117+
{
118+
headers: {
119+
"Cache-Control": "public, s-maxage=300, stale-while-revalidate=600",
120+
},
121+
}
122+
)
124123
}

app/layout.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -158,10 +158,6 @@ export default function RootLayout({ children }: RootLayoutProps) {
158158

159159
{/* External service optimization */}
160160
<link rel="dns-prefetch" href="https://www.googletagmanager.com" />
161-
162-
{/* Algolia search preconnect for faster search */}
163-
<link rel="preconnect" href="https://latency-dsn.algolia.net" />
164-
<link rel="dns-prefetch" href="https://search.algolia.com" />
165161
</head>
166162
<body suppressHydrationWarning>
167163
<GlobalProviderLayout>

app/providers/ProjectsProvider.tsx

Lines changed: 25 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
import { LABELS } from "@/app/labels"
44
import { ProjectCategory, ProjectInterface } from "@/lib/types"
55
import { uniq } from "@/lib/utils"
6+
import { searchProjects as fuseSearchProjects } from "@/lib/search"
67
import { useQuery } from "@tanstack/react-query"
7-
import Fuse from "fuse.js"
88
import {
99
createContext,
1010
useContext,
@@ -91,74 +91,43 @@ const filterProjects = ({
9191
findAnyMatch?: boolean
9292
projects?: ProjectInterface[]
9393
}) => {
94-
const projectList = projectListItems.map((project: any) => ({
94+
let projectList = projectListItems.map((project: any) => ({
9595
...project,
9696
id: project?.id?.toLowerCase(),
9797
}))
9898

99-
const keys = [
100-
"name",
101-
"tldr",
102-
"tags.themes",
103-
"tags.keywords",
104-
"tags.builtWith",
105-
"projectStatus",
106-
]
99+
const noActiveFilters =
100+
Object.keys(activeFilters).length === 0 && searchPattern.length === 0
101+
if (noActiveFilters) return projectList
102+
103+
// Apply tag filters first
104+
projectList = projectList.filter((project: any) => {
105+
return Object.entries(activeFilters).every(([filterKey, filterValues]) => {
106+
if (!filterValues || filterValues.length === 0) return true
107107

108-
const tagsFiltersQuery: Record<string, string>[] = []
108+
const projectTags = project.tags?.[filterKey] || []
109109

110-
Object.entries(activeFilters).forEach(([key, values]) => {
111-
values.forEach((value) => {
112-
if (!value) return
113-
tagsFiltersQuery.push({
114-
[`tags.${key}`]: value,
110+
return filterValues.some((filterValue: string) => {
111+
if (Array.isArray(projectTags)) {
112+
return projectTags.some((tag: string) =>
113+
tag.toLowerCase() === filterValue.toLowerCase()
114+
)
115+
}
116+
return false
115117
})
116118
})
117119
})
118120

119-
const noActiveFilters =
120-
tagsFiltersQuery.length === 0 && searchPattern.length === 0
121-
if (noActiveFilters) return projectList
122-
123-
let query: any = {}
124-
125-
if (findAnyMatch) {
126-
query = {
127-
$or: [...tagsFiltersQuery, { name: searchPattern }],
128-
}
129-
} else if (searchPattern?.length === 0) {
130-
query = {
131-
$and: [...tagsFiltersQuery],
132-
}
133-
} else if (tagsFiltersQuery.length === 0) {
134-
query = {
135-
name: searchPattern,
136-
}
137-
} else {
138-
query = {
139-
$and: [
140-
{
141-
$and: [...tagsFiltersQuery],
142-
},
143-
{ name: searchPattern },
144-
],
145-
}
121+
// Apply text search
122+
if (searchPattern.length > 0) {
123+
projectList = fuseSearchProjects(projectList, searchPattern)
146124
}
147125

148-
const fuse = new Fuse(projectList, {
149-
threshold: 0.3,
150-
useExtendedSearch: true,
151-
includeScore: true,
152-
findAllMatches: true,
153-
distance: 200,
154-
keys,
155-
})
156-
157-
const result = fuse.search(query)?.map(({ item, score }) => ({
158-
...item,
159-
score,
126+
// Add score for sorting
127+
return projectList.map((project: any) => ({
128+
...project,
129+
score: 0,
160130
}))
161-
return result ?? []
162131
}
163132

164133
const sortProjectByFn = ({

components/blog/articles-list.tsx

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ArticleInEvidenceCard } from "./article-in-evidance-card"
66
import { ArticleListCard } from "./article-list-card"
77
import { LABELS } from "@/app/labels"
88
import { Article, ArticleTag } from "@/lib/content"
9+
import { searchArticles } from "@/lib/search"
910
import { useQuery } from "@tanstack/react-query"
1011
import { Search as SearchIcon } from "lucide-react"
1112
import { useRouter, useSearchParams } from "next/navigation"
@@ -77,16 +78,7 @@ export const ArticlesList: React.FC<ArticlesListProps> = ({
7778
if (searchQuery === "all") {
7879
otherArticles = articles
7980
} else if (searchQuery?.length > 0) {
80-
otherArticles = articles.filter((article: Article) => {
81-
const title = article.title.toLowerCase()
82-
const content = article.content.toLowerCase()
83-
const tags =
84-
article.tags?.map((tag: ArticleTag) => tag.name.toLowerCase()) ?? []
85-
return (
86-
title.includes(searchQuery.toLowerCase()) ||
87-
tags.some((tag: string) => tag.includes(searchQuery.toLowerCase()))
88-
)
89-
})
81+
otherArticles = searchArticles(articles, searchQuery)
9082
}
9183

9284
const hasTag = tag !== undefined

0 commit comments

Comments
 (0)