docs: add full-text search (#2735)
### Change Type - [x] `patch` — Bug fix ### Test Plan 1. Make sure search (AI and regular) still works as expected. ### Release Notes - Docs: Add full-text search.
This commit is contained in:
parent
538734782c
commit
b50cda0a6e
7 changed files with 64 additions and 47 deletions
|
@ -154,6 +154,10 @@ export async function GET(req: NextRequest) {
|
||||||
.filter((a) => a.score > bottomScore)
|
.filter((a) => a.score > bottomScore)
|
||||||
.sort((a, b) => b.score - a.score)
|
.sort((a, b) => b.score - a.score)
|
||||||
.sort((a, b) => (b.type === 'heading' ? -1 : 1) - (a.type === 'heading' ? -1 : 1))
|
.sort((a, b) => (b.type === 'heading' ? -1 : 1) - (a.type === 'heading' ? -1 : 1))
|
||||||
|
results[section as keyof Data['results']] = results[section as keyof Data['results']].slice(
|
||||||
|
0,
|
||||||
|
10
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
|
|
|
@ -14,10 +14,6 @@ type Data = {
|
||||||
|
|
||||||
const BANNED_HEADINGS = ['new', 'constructor', 'properties', 'example', 'methods']
|
const BANNED_HEADINGS = ['new', 'constructor', 'properties', 'example', 'methods']
|
||||||
|
|
||||||
function scoreResultBasedOnLengthSimilarity(title: string, query: string) {
|
|
||||||
return 1 - Math.min(1, Math.max(0, Math.abs(title.length - query.length) / 12))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const { searchParams } = new URL(req.url)
|
const { searchParams } = new URL(req.url)
|
||||||
const query = searchParams.get('q')?.toLowerCase()
|
const query = searchParams.get('q')?.toLowerCase()
|
||||||
|
@ -37,22 +33,17 @@ export async function GET(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const results: Data['results'] = structuredClone(SEARCH_RESULTS)
|
const results: Data['results'] = structuredClone(SEARCH_RESULTS)
|
||||||
const db = await getDb()
|
const db = await getDb()
|
||||||
const queryWithoutSpaces = query.replace(/\s/g, '')
|
|
||||||
const searchForArticle = await db.db.prepare(
|
const searchForArticle = await db.db.prepare(
|
||||||
`
|
`
|
||||||
SELECT id, title, sectionId, categoryId, content
|
SELECT id, title, sectionId, categoryId, content
|
||||||
FROM articles
|
FROM ftsArticles
|
||||||
WHERE title LIKE '%' || ? || '%'
|
WHERE ftsArticles MATCH ?
|
||||||
OR description LIKE '%' || ? || '%'
|
ORDER BY bm25(ftsArticles, 1000.0)
|
||||||
OR content LIKE '%' || ? || '%'
|
|
||||||
OR keywords LIKE '%' || ? || '%';
|
|
||||||
`,
|
`,
|
||||||
queryWithoutSpaces,
|
query
|
||||||
queryWithoutSpaces,
|
|
||||||
queryWithoutSpaces
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await searchForArticle.all(query).then(async (queryResults) => {
|
await searchForArticle.all().then(async (queryResults) => {
|
||||||
for (const article of queryResults) {
|
for (const article of queryResults) {
|
||||||
const section = await db.getSection(article.sectionId)
|
const section = await db.getSection(article.sectionId)
|
||||||
const category = await db.getCategory(article.categoryId)
|
const category = await db.getCategory(article.categoryId)
|
||||||
|
@ -67,7 +58,7 @@ export async function GET(req: NextRequest) {
|
||||||
url: isUncategorized
|
url: isUncategorized
|
||||||
? `${section.id}/${article.id}`
|
? `${section.id}/${article.id}`
|
||||||
: `${section.id}/${category.id}/${article.id}`,
|
: `${section.id}/${category.id}/${article.id}`,
|
||||||
score: scoreResultBasedOnLengthSimilarity(article.title, query),
|
score: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -75,15 +66,14 @@ export async function GET(req: NextRequest) {
|
||||||
const searchForArticleHeadings = await db.db.prepare(
|
const searchForArticleHeadings = await db.db.prepare(
|
||||||
`
|
`
|
||||||
SELECT id, title, articleId, slug
|
SELECT id, title, articleId, slug
|
||||||
FROM headings
|
FROM ftsHeadings
|
||||||
WHERE title LIKE '%' || ? || '%'
|
WHERE ftsHeadings MATCH ?
|
||||||
OR slug LIKE '%' || ? || '%'
|
ORDER BY bm25(ftsHeadings, 1000.0)
|
||||||
`,
|
`,
|
||||||
queryWithoutSpaces,
|
query
|
||||||
queryWithoutSpaces
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await searchForArticleHeadings.all(queryWithoutSpaces).then(async (queryResults) => {
|
await searchForArticleHeadings.all().then(async (queryResults) => {
|
||||||
for (const heading of queryResults) {
|
for (const heading of queryResults) {
|
||||||
if (BANNED_HEADINGS.some((h) => heading.slug.endsWith(h))) continue
|
if (BANNED_HEADINGS.some((h) => heading.slug.endsWith(h))) continue
|
||||||
|
|
||||||
|
@ -104,14 +94,17 @@ export async function GET(req: NextRequest) {
|
||||||
url: isUncategorized
|
url: isUncategorized
|
||||||
? `${section.id}/${article.id}#${heading.slug}`
|
? `${section.id}/${article.id}#${heading.slug}`
|
||||||
: `${section.id}/${category.id}/${article.id}#${heading.slug}`,
|
: `${section.id}/${category.id}/${article.id}#${heading.slug}`,
|
||||||
score: scoreResultBasedOnLengthSimilarity(article.title, query),
|
score: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
Object.keys(results).forEach((section: string) =>
|
Object.keys(results).forEach((section: string) => {
|
||||||
results[section as keyof Data['results']].sort((a, b) => b.score - a.score)
|
results[section as keyof Data['results']] = results[section as keyof Data['results']].slice(
|
||||||
)
|
0,
|
||||||
|
20
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
results.articles.sort(
|
results.articles.sort(
|
||||||
(a, b) => (b.type === 'heading' ? -1 : 1) - (a.type === 'heading' ? -1 : 1)
|
(a, b) => (b.type === 'heading' ? -1 : 1) - (a.type === 'heading' ? -1 : 1)
|
||||||
|
|
|
@ -98,21 +98,14 @@ const Autocomplete = forwardRef(function Autocomplete(
|
||||||
<Icon className="autocomplete__icon" icon="search" small />
|
<Icon className="autocomplete__icon" icon="search" small />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Combobox
|
<Combobox placeholder="Search…" ref={ref} className="autocomplete__input" value={value} />
|
||||||
autoSelect
|
|
||||||
placeholder="Search…"
|
|
||||||
ref={ref}
|
|
||||||
className="autocomplete__input"
|
|
||||||
value={value}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{value && <ComboboxCancel className="autocomplete__cancel" />}
|
{value && <ComboboxCancel className="autocomplete__cancel" />}
|
||||||
|
|
||||||
{value && (
|
{value && options.length !== 0 && (
|
||||||
<ComboboxPopover className="autocomplete__popover">
|
<ComboboxPopover sameWidth className="autocomplete__popover">
|
||||||
{customUI}
|
{customUI}
|
||||||
{options.length === 0 && <span className="autocomplete__empty">No results found.</span>}
|
{renderedGroups}
|
||||||
{options.length !== 0 && renderedGroups}
|
|
||||||
</ComboboxPopover>
|
</ComboboxPopover>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -39,17 +39,22 @@ export function Search() {
|
||||||
const res = await fetch(endPoint)
|
const res = await fetch(endPoint)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
|
const topExamples = json.results.examples.slice(0, 5)
|
||||||
const topArticles = json.results.articles.slice(0, 10)
|
const topArticles = json.results.articles.slice(0, 10)
|
||||||
const topAPI = json.results.apiDocs.slice(0, 10)
|
const topAPI = json.results.apiDocs.slice(0, 20)
|
||||||
const topExamples = json.results.examples.slice(0, 10)
|
|
||||||
const allResults = topExamples.concat(topArticles).concat(topAPI)
|
const allResults = topExamples.concat(topArticles).concat(topAPI)
|
||||||
setSearchResults(
|
|
||||||
allResults.map((result: SearchResult) => ({
|
if (allResults.length) {
|
||||||
label: result.title,
|
setSearchResults(
|
||||||
value: result.url,
|
allResults.map((result: SearchResult) => ({
|
||||||
group: result.sectionType,
|
label: result.title,
|
||||||
}))
|
value: result.url,
|
||||||
)
|
group: result.sectionType,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setSearchResults([{ label: 'No results found.', value: '#', group: 'docs' }])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
|
@ -66,6 +71,7 @@ export function Search() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSearchTypeChange = () => {
|
const handleSearchTypeChange = () => {
|
||||||
|
setSearchResults([])
|
||||||
setSearchType(searchType === SEARCH_TYPE.AI ? SEARCH_TYPE.NORMAL : SEARCH_TYPE.AI)
|
setSearchType(searchType === SEARCH_TYPE.AI ? SEARCH_TYPE.NORMAL : SEARCH_TYPE.AI)
|
||||||
handleInputChange(query)
|
handleInputChange(query)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { addAuthors } from '@/utils/addAuthors'
|
import { addAuthors } from '@/utils/addAuthors'
|
||||||
import { addContentToDb } from '@/utils/addContent'
|
import { addContentToDb, addFTS } from '@/utils/addContent'
|
||||||
import { autoLinkDocs } from '@/utils/autoLinkDocs'
|
import { autoLinkDocs } from '@/utils/autoLinkDocs'
|
||||||
import { nicelog } from '@/utils/nicelog'
|
import { nicelog } from '@/utils/nicelog'
|
||||||
import { connect } from './connect'
|
import { connect } from './connect'
|
||||||
|
@ -23,6 +23,9 @@ export async function refreshContent(opts = {} as { silent: boolean }) {
|
||||||
if (!opts.silent) nicelog('◦ Generating / adding API content to db...')
|
if (!opts.silent) nicelog('◦ Generating / adding API content to db...')
|
||||||
await addContentToDb(db, await generateApiContent())
|
await addContentToDb(db, await generateApiContent())
|
||||||
|
|
||||||
|
if (!opts.silent) nicelog('◦ Adding full-text search...')
|
||||||
|
await addFTS(db)
|
||||||
|
|
||||||
if (!opts.silent) nicelog('◦ Fixing links to API docs...')
|
if (!opts.silent) nicelog('◦ Fixing links to API docs...')
|
||||||
await autoLinkDocs(db)
|
await autoLinkDocs(db)
|
||||||
|
|
||||||
|
|
|
@ -1068,6 +1068,8 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar__section__links {
|
.sidebar__section__links {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
padding: 16px 0;
|
padding: 16px 0;
|
||||||
|
|
|
@ -119,6 +119,22 @@ export async function addContentToDb(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function addFTS(db: Database<sqlite3.Database, sqlite3.Statement>) {
|
||||||
|
await db.run(`DROP TABLE IF EXISTS ftsArticles`)
|
||||||
|
await db.run(
|
||||||
|
`CREATE VIRTUAL TABLE ftsArticles USING fts5(title, content, description, keywords, id, sectionId, categoryId, tokenize="trigram")`
|
||||||
|
)
|
||||||
|
await db.run(
|
||||||
|
`INSERT INTO ftsArticles SELECT title, content, description, keywords, id, sectionId, categoryId FROM articles;`
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.run(`DROP TABLE IF EXISTS ftsHeadings`)
|
||||||
|
await db.run(
|
||||||
|
`CREATE VIRTUAL TABLE ftsHeadings USING fts5(title, slug, id, articleId, tokenize="trigram")`
|
||||||
|
)
|
||||||
|
await db.run(`INSERT INTO ftsHeadings SELECT title, slug, id, articleId FROM headings;`)
|
||||||
|
}
|
||||||
|
|
||||||
const slugs = new GithubSlugger()
|
const slugs = new GithubSlugger()
|
||||||
|
|
||||||
const MATCH_HEADINGS = /(?:^|\n)(#{1,6})\s+(.+?)(?=\n|$)/g
|
const MATCH_HEADINGS = /(?:^|\n)(#{1,6})\s+(.+?)(?=\n|$)/g
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue