How I implemented search on this website - pt. 2 / query and UI implementation
DATE : 07.04.2026
This is a continuance to the Part 1 of the series. If you have not read the previous post yet, I strongly recommend you to do so.
Why I chose TypeScript and Preact
I didn’t really look too much into possible stack choices, and instead rather quickly settled on a stack based on TypeScript and Preact for following reasons:
- TypeScript is already familiar to me, and in my opinion, is a marked improvement over plain dynamic JavaScript.
- Then again – I generally prefer strongly typed languages, so TS naturally follows from that.
- Preact is very lightweight. According to Syncfusion, the difference to React is roughly 10x!
- This comes at an “expense” of having some behavioral differences to React. Not a major concern for my use case – I do not need 100% React compatibility.
With the addition of Preact Signals and parts of Lodash, the total gzipped size is now around 24 kilobytes. Could probably be optimized still, but it is sufficiently good for an initial MVP.
Structure
I divided the implementation into two distinct parts: data for index processing and querying logic, and ui for all logic related to the user interface. Let’s look at some central elements from both.
Data
Directory grouping
I decided to implement, for the benefit of visitors who don’t know exactly what they want to search for, a directory that displays all known topics in an alphabetical order, grouped by the first letter.
This is relatively straightforward to implement in JS. We first need both a collator and a segmenter.
These should be specifically configured for their use-cases: the collator should ignore letter casing as it is not relevant for grouping purposes, and the segmenter should split by graphemes – which may be composed of multiple Unicode code points, not necessarily just one!
With these, the implementation is straightforward: assuming you receive a list of unsorted topics, do:
- Sort topics to ascending order, using the collator specified above. Prepare a list for results; each result is a pairing of a grouping key and a list of topics
- Start iterating topics one by one. Initialize a buffer for topics, and a variable for the (previous) initial grapheme
- If no initial grapheme has been set, set it to the one derived (with the segmenter) from the current topic
- If the set initial grapheme differs from the first grapheme in the current topic (as tested with the collator):
- Copy the current buffer to results, with the grouping key set to the initial grapheme value
- Empty the buffer, and set the variable to the first grapheme of the current topic
- Add the topic to the current buffer
- If the buffer is non-empty, flush its contents the same way as described above (when the initial grapheme of a topic differs from the previous initial grapheme variable)
Query engine
Actual querying works as follows: assuming you receive a query string, do:
- Trim the search query (remove extra whitespace)
- Find all topics that contain the search query (ignoring letter casing). If there are too many topics (search is insufficiently selective) or none at all, exit returning the error.
- Map found topics to their logical numeric identifiers and deduplicate
- Prepare a result list containing either pages or interstitial markers. Prepare a set for encountered identifiers. Initialize three different queues, containing numeric identifiers each. Identifiers can reference either pages or topics. Queues should be defined as follows:
- Forward-link queue, initialized as containing all previously selected topics
- Topic-backward-link queue, as empty
- Page-backward-link queue, as empty
- While any of the queues defined has items
- Check for exit conditions, and exit if either one of the following is true:
- Too many pages listed in total
- If this applies and we have not traversed any backward links, add an interstitial indicating so.
- Sufficiently many backward-link pages listed
- Too many pages listed in total
- Pick an identifier from the first non-empty queue, in the order defined above
- If the identifier has already been encountered, skip. Otherwise, mark it as encountered.
- Determine the type of the identifier
- If it refers to a page:
- If this page was derived (directly or indirectly) from a backward link, add an interstitial indicating so to the results, unless one had been previously added
- Add the page to results
- Add referenced identifiers to the page-backward-link queue
- If it refers to a topic
- Add referenced identifiers to forward-link and topic-backward-link queues as specified in the route specification
- If it refers to a page:
- Check for exit conditions, and exit if either one of the following is true:
- Return results to the user
Quite a mouthful. In essence, the intent is to prefer more strongly related results first – primarily by forward links (subtypes), then by backward links (supertypes), and only after those by page-related topics (may have very little relation to the original query).
UI
Search box model
Perhaps the most interesting tidbit is the Preact signal Model structure used:
/**
* Interface for the search box model
*/
export interface SearchBoxModelInterface {
/**
* Latest query results, if available
*/
latestQueryResults: ReadonlySignal<QueryResult | null>,
/**
* Load error / index failed to fetch
*/
loadError: ReadonlySignal<boolean>,
/**
* Query engine has been successfully loaded
*/
queryEngineLoaded: ReadonlySignal<boolean>,
/**
* Directory data, if available
*/
directory: ReadonlySignal<GroupedData<GroupableData>[] | null>,
/**
* Generate a random topic from the dataset
*/
nextRandomTopic(): string,
/**
* Execute a query; query results will be stored in `latestQueryResults`
* @param query
*/
executeQuery(query: string): void
}
It is initialized as follows:
import uniq from "lodash-es/uniq"
// ---
/**
* A single result row - either a single page or an interstitial that indicates some characteristic in the results, particularly related to the position at which the interstitial appears
*/
export type QueryResultRow =
| {
type: "page";
name: string;
description: string;
path: string;
date?: string;
}
| {
type: "interstitial";
kind: "lessRelevantResults" | "tooManyResultsToList";
};
/**
* Concrete query results; result rows and navigated topics
*/
export interface QueryResults {
/**
* Rows to display
*/
rows: QueryResultRow[];
/**
* Topics that were navigated through, and should be considered "already seen" for purposes of random topic picking
*/
navigatedTopics: number[];
}
/**
* Query result; either an concrete result object, or a specific error message
*/
export type QueryResult =
| QueryResults
| "noResults"
| "narrowYourQuery"
| "internalError";
// ---
/**
* Central search box model. Handles internal state for operations related to the query engine. It does not concern itself with presentation
*/
export const SearchBoxModel = createModel((indexUrl: string) => {
const queryEngine = signal<"pending" | "failed_to_load" | QueryEngine>("pending")
const latestQueryResults = signal<null | QueryResult>(null)
const queryEngineLoaded = computed(() => {
return typeof queryEngine.value !== "string"
})
const loadError = computed(() => {
return queryEngine.value === "failed_to_load"
})
const directory = computed(() => {
const maybeQueryEngine = queryEngine.value
if (typeof maybeQueryEngine !== "string") {
return maybeQueryEngine.generateDirectoryGroupings();
}
return null;
})
const alreadySeenTopics = signal<number[]>([])
const nextRandomTopic = (): string => {
const maybeQueryEngine = queryEngine.value
if (typeof maybeQueryEngine !== "string") {
const result = maybeQueryEngine.getRandomTopic(alreadySeenTopics.value)
alreadySeenTopics.value = result.alreadySeenTopicIdents
return result.topic
} else {
throw new Error("bug: cannot request a new random topic if no query engine is loaded")
}
}
const executeQuery = (query: string): void => {
const maybeQueryEngine = queryEngine.value
if (typeof maybeQueryEngine !== "string") {
const result = maybeQueryEngine.executeQuery(query)
if (typeof result !== "string") {
alreadySeenTopics.value = uniq([...alreadySeenTopics.value, ...result.navigatedTopics])
}
latestQueryResults.value = result
} else {
throw new Error("bug: cannot execute a query if no query engine is loaded")
}
}
effect(() => {
// This is only done once upon initialization
if (queryEngine.value !== "pending") {
return;
}
fetch(indexUrl, {
headers: {
"Accept": "application/json"
}
}).then(async response => {
if (!response.ok) {
console.warn(`SearchBox: response returned status code ${response.status}, treating as a failed request`)
queryEngine.value = "failed_to_load"
return;
}
try {
// Try to parse into a JSON
const jsonValue = await response.json()
// Construct a new query engine from the value
queryEngine.value = new QueryEngine(jsonValue)
} catch (e) {
console.warn("SearchBox: received a response but failed to construct a query engine from it", e)
queryEngine.value = "failed_to_load"
}
}).catch(error => {
console.warn("SearchBox: failed to load index", error);
queryEngine.value = "failed_to_load"
})
})
const bundle: SearchBoxModelInterface = {
queryEngineLoaded,
loadError,
directory,
latestQueryResults,
nextRandomTopic,
executeQuery
}
return bundle
})
Observe how various subparts of the model are built. Preact tracks how signals are used, and automatically triggers updates (to computed signals and HTML elements) as appropriate. You might also notice that this implementation also keeps track of already-seen topics, in an attempt to ensure random topics selections are as useful as they can be to the end user.
This can be combined with interaction logic – for example:
import {debounce} from "lodash-es";
// ---
const model = useModel(() => new SearchBoxModel(indexurl))
const executeQueryDebounced = debounce((query: string) => {
model.executeQuery(query)
}, 100)
// The current contextual query; this will be reflected in the input box, and also will (with debounce) be reflected in the latest results
const currentContextualQuery = useSignal<string>("")
effect(() => {
if (model.queryEngineLoaded.value && currentContextualQuery.value.length > 0) {
executeQueryDebounced(currentContextualQuery.value)
}
})
The currentContextualQuery signal is then:
- bound more or less directly to a text input box (with a
valueparameter and anonInputevent handler); when the query text is updated, the changed query is reflected in the signal, and the results are updated automatically - indirectly bound to the directory: when a directory link is clicked, the query text is changed; this automatically reflects both in the query entry box, and in the results shown
Quite a bit easier than fiddling manually with various interconnected dependencies!
That’s about it. I hope you gained insights on how and why I designed the search engine the way I did. If you have comments or other thoughts about this, I’d be glad to hear them!