Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 0 additions & 45 deletions .github/workflows/deploy-search.yaml

This file was deleted.

50 changes: 48 additions & 2 deletions src/app/(root)/blog/search.json/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,29 @@
import MiniSearch, { type SearchResult } from 'minisearch'
import { NextResponse } from 'next/server'
import { getPostsMetadata, importPost } from '~/utils/blog/posts'

export type BlogSearchType = {
id: string
title: string
description: string
content: string
authors: string[]
tags: string[]
readingTime: string
date: string
}

const storedFields = [
'title',
'description',
'authors',
'tags',
] as const satisfies (keyof BlogSearchType)[]

export type BlogSearchResult = SearchResult & {
[key in (typeof storedFields)[number]]: BlogSearchType[key]
}

export const dynamic = 'force-static'

export async function GET() {
Expand All @@ -14,10 +37,33 @@ export async function GET() {
)

const data = posts.map((post) => ({
...post.meta,
id: post.meta.slug,
title: post.meta.title,
description: post.meta.description,
content: post.content.plainContent,
authors: post.meta.authors,
tags: post.meta.tags,
readingTime: post.content.readingTime,
date: post.meta.date,
}))

return NextResponse.json(data)
const miniSearch = new MiniSearch<BlogSearchType>({
fields: ['title', 'description', 'content', 'authors', 'tags'],
storeFields: storedFields,
searchOptions: {
fuzzy: true,
prefix: true,
boost: {
title: 3,
description: 2,
content: 1,
authors: 2,
tags: 2,
},
},
})

miniSearch.addAll(data)

return NextResponse.json(miniSearch)
}
6 changes: 6 additions & 0 deletions src/app/(root)/ensv2/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { ReactNode } from 'react'
import { QueryClientProvider } from '~/utils/queryclient'

export default function Layout({ children }: { children: ReactNode }) {
return <QueryClientProvider>{children}</QueryClientProvider>
}
8 changes: 7 additions & 1 deletion src/app/(root)/ensv2/search.json/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import { NextResponse } from 'next/server'
import type { ReactNode } from 'react'
import { faqs } from '~/components/pages/ensv2/FAQ/entries'

export type FaqSearchType = {
question: string
answer: string
tags?: string[]
}

export const dynamic = 'force-static'

function isIterable(node: unknown): node is Iterable<ReactNode> {
Expand Down Expand Up @@ -49,7 +55,7 @@ function renderReactNodeToPlainText(node: ReactNode): string {
}

export async function GET() {
const miniSearch = new MiniSearch({
const miniSearch = new MiniSearch<FaqSearchType>({
fields: ['question', 'answer', 'tags'],
})

Expand Down
33 changes: 13 additions & 20 deletions src/components/features/blog/Search/SearchResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,24 @@ import clsx from 'clsx'
import Image from 'next/image'
import Link from 'next/link'
import { Fragment } from 'react'
import {
fetchPostMetadata,
getSearchResults,
type SearchEntry,
} from '~/utils/blog/search'
import type { BlogSearchResult } from '~/app/(root)/blog/search.json/route'
import { useBlogSearch } from '~/components/features/blog/Search/useBlogSearch'
import { fetchPostMetadata } from '~/utils/blog/search'
import { useDebounce } from '~/utils/useDebounce'
import { BlogPostAuthor, BlogPostAuthorSeparator } from '../PostAuthor'
import styles from './SearchResults.module.css'

const SearchHit = ({ entry }: { entry: SearchEntry }) => {
const SearchHit = ({ entry }: { entry: BlogSearchResult }) => {
const { data, isLoading, isError } = useQuery({
queryKey: ['post', entry.slug, 'metadata'],
queryFn: () => fetchPostMetadata(entry.slug),
queryKey: ['post', entry.id, 'metadata'],
queryFn: () => fetchPostMetadata(entry.id),
})

if (isLoading) return <SearchHitSkeleton />
if (isError || !data) return <></>

return (
<Link href={`/blog/post/${entry.slug}`} className={styles.hit}>
<Link href={`/blog/post/${entry.id}`} className={styles.hit}>
{data.assets.post['cover-thumb'] && (
<Image
src={data.assets.post['cover-thumb']}
Expand All @@ -35,7 +33,7 @@ const SearchHit = ({ entry }: { entry: SearchEntry }) => {
<div className={styles['hit-data']}>
<div className={styles['hit-title']}>{entry.title}</div>
<div className={styles['hit-authors']}>
{data.authors?.slice(0, 2).map((author, index) => (
{entry.authors?.slice(0, 2).map((author, index) => (
<Fragment key={author}>
{!!index && <BlogPostAuthorSeparator size="small" />}
<BlogPostAuthor
Expand Down Expand Up @@ -67,15 +65,12 @@ const SearchHitSkeleton = () => {

export const SearchResults = ({ search }: { search: string }) => {
const debouncedSearch = useDebounce(search, 500)

const { data, isLoading, isError } = useQuery({
queryKey: ['search', debouncedSearch],
queryFn: () => getSearchResults(debouncedSearch),
})
const { search: performSearch, isLoading } = useBlogSearch()

if (search.length < 3) return <></>

const isDebouncing = debouncedSearch !== search
const results = performSearch(debouncedSearch)

if (isLoading || isDebouncing)
return (
Expand All @@ -86,9 +81,7 @@ export const SearchResults = ({ search }: { search: string }) => {
</div>
)

if (isError || !data) return <></>

if (!data.hits.length)
if (!results.length)
return (
<div className={styles.results}>
<div className={styles.noResults}>No results</div>
Expand All @@ -97,8 +90,8 @@ export const SearchResults = ({ search }: { search: string }) => {

return (
<div className={styles.results}>
{data.hits.map((hit) => (
<SearchHit key={hit.slug} entry={hit} />
{results.map((hit) => (
<SearchHit key={hit.id} entry={hit} />
))}
</div>
)
Expand Down
47 changes: 47 additions & 0 deletions src/components/features/blog/Search/useBlogSearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
'use client'

import { useMemo } from 'react'
import type {
BlogSearchResult,
BlogSearchType,
} from '~/app/(root)/blog/search.json/route'
import { useSearchIndex } from '../../../../hooks/useSearchIndex'

const searchIndexOptions = {
fields: ['title', 'description', 'content', 'authors', 'tags'],
searchOptions: {
fuzzy: true,
prefix: true,
boost: {
title: 3,
description: 2,
content: 1,
authors: 2,
tags: 2,
},
},
}

export function useBlogSearch() {
const searchIndex = useSearchIndex<BlogSearchType>(
'/blog/search.json',
searchIndexOptions,
)

const search = useMemo(
() =>
(query: string): BlogSearchResult[] => {
if (!searchIndex || !query || query.length < 3) return []

const results = searchIndex.search(query)
console.log(results)

return results
.sort((a, b) => b.score - a.score)
.slice(0, 5) as BlogSearchResult[]
},
[searchIndex],
)

return { search, isLoading: !searchIndex }
}
4 changes: 2 additions & 2 deletions src/components/pages/ensv2/FAQ/FAQ.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ import {
AccordionTrigger,
} from '~/components/ui/primitives/Accordion/Accordion'
import { faqs, tags } from './entries'
import { useSearchIndex } from './useSearchIndex'
import { useFaqIndex } from './useFaqIndex'

export const FAQ = () => {
const searchIndex = useSearchIndex()
const searchIndex = useFaqIndex()

const [search, setSearch] = useState('')
const [selectedTag, setSelectedTag] = useState<string | undefined>(undefined)
Expand Down
19 changes: 19 additions & 0 deletions src/components/pages/ensv2/FAQ/useFaqIndex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
'use client'
import type MiniSearch from 'minisearch'
import type { FaqSearchType } from '~/app/(root)/ensv2/search.json/route'
import { useSearchIndex as useReusableSearchIndex } from '~/hooks/useSearchIndex'

export const faqIndexOptions = {
fields: ['question', 'answer', 'tags'],
searchOptions: {
fuzzy: true,
prefix: true,
boost: {
question: 2,
},
},
}

export function useFaqIndex(): MiniSearch<FaqSearchType> | null {
return useReusableSearchIndex('/ensv2/search.json', faqIndexOptions)
}
36 changes: 0 additions & 36 deletions src/components/pages/ensv2/FAQ/useSearchIndex.ts

This file was deleted.

35 changes: 35 additions & 0 deletions src/hooks/useSearchIndex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
'use client'

import { useQuery } from '@tanstack/react-query'
import MiniSearch, { type Options } from 'minisearch'

export interface SearchIndexOptions<T> extends Options<T> {
fields: string[]
}

/**
* A reusable hook for loading and managing search indices using TanStack Query
* @param searchPath - The path to the search JSON file
* @param options - MiniSearch configuration options
* @returns The search index instance or null if not loaded
*/
export function useSearchIndex<T extends Record<string, unknown>>(
searchPath: string,
options: SearchIndexOptions<T>,
): MiniSearch<T> | null {
const { data: searchIndex } = useQuery({
queryKey: ['searchIndex', searchPath],
queryFn: async () => {
const response = await fetch(searchPath)
if (!response.ok) {
throw new Error(`Failed to fetch search index from ${searchPath}`)
}
const data = await response.text()
return MiniSearch.loadJSON(data, options as Options<T>)
},
staleTime: Infinity, // Search indices don't change often
gcTime: 1000 * 60 * 60, // Keep in cache for 1 hour
})

return searchIndex || null
}