Search
q.search asks an environment's published content a question in words and answers with the pages that match, the heading on each page the match sits under, and a plain-text snippet around it.
Why a result lands on a heading, not a page
A reader with a question wants to land on the paragraph that answers it, not on a page that mentions the word somewhere. Quillet keeps a search index of what is published, section by section, so it can say "this page, under this heading". The SDK puts that index behind one method. It also renders pages whose heading ids are the same strings the index points at, so a link from a result lands on the right heading.
If you are a content manager, what you publish becomes searchable within moments, and what you have only saved does not. You can leave a document or a whole folder out of search with its Searchable setting. An owner can turn search off for the whole project under Settings → Project.
What a search answers and how it matches words
q.search(text, options) sends one request to the delivery API's search route and answers with a SearchResult. The result has three parts:
hits, best first. Each hit has the document'skeyandtitle, theheadingthe match was found under and that heading'sanchor, asnippet, ascore, and thelocalethe text was written in.headingandanchorare bothnullon a hit about the page as a whole.total, the number of hits in the window you can page through._meta, theq,locale,limitandoffsetthe CMS used.
One page can answer several times: once for each section that matches, and once as a whole. A results page usually shows the best hit per key, or groups the hits by key.
The text you search for is 1 to 200 characters long, written in the index's own grammar. That grammar has plain words, "a quoted phrase", and -word to exclude a word. In English, words match by stem, so promote, promoted and promotions all find promotion. Very common words such as the are ignored. There is no fuzzy matching and no prefix matching, so promo and promotoin find nothing.
These are the options:
| Option | Meaning | Default |
|---|---|---|
locale |
The locale to search. A key with nothing published in it is answered from the project's default locale, and that hit carries fallbackLocale. |
the client's locale |
prefix |
Only documents under this folder. Must end in /; the SDK checks it before any request is sent. |
the whole environment |
tags |
Only documents carrying every one of these tags. | none |
limit |
Hits per answer, 1 to 50. | 10 |
offset |
Hits to skip, from 0. offset + limit may not pass 200. |
0 |
snippet is plain text with no markup, so you mark the words yourself, in whatever way your page shows them. score sorts the hits within one answer and is good for nothing else. Comparing it across two answers, or cutting off the hits below some value, means nothing.
An answer is cached the way a listing is. The client's own store keeps it under its URL, and that URL carries every option. The Next.js client tags every search quillet:search:<project>/<env>.
How to
Ask a question and turn each hit into a link. The anchor becomes the fragment on the page's URL, and a hit about the whole page has none:
// ask.ts: the first five answers to a question, as links.
import { quillet } from '@quilletcms/sdk/node'
const q = quillet()
const answer = await q.search('promotion', { prefix: 'docs/', limit: 5 })
for (const hit of answer.hits) {
const href = hit.anchor ? `/${hit.key}/#${hit.anchor}` : `/${hit.key}/`
console.log(href, '—', hit.heading ?? hit.title)
console.log(' ', hit.snippet)
}
To mark the words of the question in each snippet, escape the snippet first and mark it second. In that order, a snippet cannot inject HTML:
// mark.ts: snippets with the searched words in <mark>.
import { quillet } from '@quilletcms/sdk/node'
const q = quillet()
const escape = (text: string) => text.replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`)
export function mark(snippet: string, question: string): string {
const words = question.split(/\s+/).filter((w) => w.length > 1 && !w.startsWith('-'))
const safe = escape(snippet)
if (words.length === 0) return safe
const pattern = new RegExp(`(${words.map((w) => escape(w).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})`, 'gi')
return safe.replace(pattern, '<mark>$1</mark>')
}
const question = 'promotion'
const answer = await q.search(question)
for (const hit of answer.hits) console.log(mark(hit.snippet, question))
This marks the words as the reader typed them. When a section was found through a word's stem, such as Promoting for promotion, that word stays unmarked.
To show one result per page, put the best section first and the page's other sections under it:
// grouped.ts: hits grouped by page, in the order the best hit of each arrived.
import type { SearchHit } from '@quilletcms/sdk'
import { quillet } from '@quilletcms/sdk/node'
const q = quillet()
const answer = await q.search('promotion', { limit: 50 })
const pages = new Map<string, SearchHit[]>()
for (const hit of answer.hits) pages.set(hit.key, [...(pages.get(hit.key) ?? []), hit])
for (const [key, hits] of pages) {
console.log(hits[0]?.title, `(/${key}/)`)
for (const hit of hits) if (hit.heading) console.log(' §', hit.heading)
}
Page through results ten at a time, and stop where the window ends:
// pages.ts: page 2 of the results, and whether there is a page 3.
import { quillet } from '@quilletcms/sdk/node'
const q = quillet()
const perPage = 10
const page = 2
const answer = await q.search('promotion', { limit: perPage, offset: (page - 1) * perPage })
const hasNext = page * perPage < Math.min(answer.total, 200)
console.log(`${answer.hits.length} of ${answer.total}`, hasNext ? 'more after this' : 'last page')
A bad question should become a message, not an error page. A 422 names the field at fault in details.fields, and a 404 means search is off for the project:
// safe-search.ts: an empty or overlong question, or search turned off.
import { NotFoundError, QuilletError, type SearchResult } from '@quilletcms/sdk'
import { quillet } from '@quilletcms/sdk/node'
const q = quillet()
export async function find(question: string): Promise<SearchResult | string> {
try {
return await q.search(question)
} catch (error) {
if (error instanceof NotFoundError) return 'Search is not available on this site.'
if (error instanceof QuilletError && error.status === 422) {
const fields = (error.details.fields ?? {}) as Record<string, string>
return Object.values(fields).join(' ')
}
throw error
}
}
console.log(await find(''))
For the empty question, it prints the CMS's own sentence: "Enter something to search for."
When you render the page a hit points into, pass headingIds: true, so that the hit's anchor exists on the page. Variables and Markdown covers the option.
Limits
- Search covers published content only.
searchtakes nopreviewand noat, the CMS refuses both, and no draft is ever indexed. - There is no fuzzy matching and no prefix matching. Search-as-you-type on partial words therefore finds nothing until a word is complete.
- You cannot page past the first 200 results. An
offset + limitabove 200 is answered 422, "Search reaches the first 200 results." - References are not inlined into the index. The text of a component that a page references is found under the component's own key, not under the page's.
- The index stores the text as written, so
{{ variables }}appear in snippets unfilled. - If the CMS stops between a publish and the index write, the index misses that change until it is rebuilt. Rebuild search index under Settings → Project rebuilds the default environment, and the management API's reindex route rebuilds any one.
- A project with search turned off answers 404 to every search, and the SDK throws that as
NotFoundError. - The dashboard's own search finds documents by title and key. It does not use this index.
Related
- Search index: turning search on and off, what is indexed, and rebuilding.
- Variables and Markdown:
headingIds,headingSlugandheadingSlugs. - Delivery API: the search route and its parameters.
- Next.js: the search tag, and when to revalidate it.
- Content model: tags, and the Searchable setting on documents and folders.
- Search: how a question is matched and ranked, and why.