Developer blog
Search that suggests the right tool, not just a page
The movahedi.ca search bar now runs on a small, build-time index of tools, MCP endpoints, glossary terms, and articles, with a deterministic router and a full results page. No query leaves the browser, and no paid service is involved.
On this page
Key takeaways
- Search now suggests tools by task, MCP tools by name, glossary terms by synonym, and articles by title, in one dropdown.
- The index is generated at build time from data already in the repo. It is 738 records in English and 725 in French, about 83 KB gzipped in English.
- No query is sent to a third party, no key is copied, and a search suggestion never runs an MCP tool.
What was wrong with the old search
The old search bar searched page titles and English insights. It did not know that “calculate breach deadline” is a tool, that classify_cookie is an MCP tool, or that DPIA is a glossary term with a PIA worksheet behind it. A visitor who typed a task got an article about the task, if they were lucky, and no tool at all.
The fix was not a smarter model. The corpus is small and curated, so a model would have added a network call, a vendor, and a privacy question, for an answer a set of rules already gives. The new search is a router over a curated index, with fuzzy matching only for typos.
The index, and where it comes from
A script, scripts/generate-search-index.mjs, runs before the Astro build and writes two JSON files, one per language, to public/. It reads tools from src/data/tools.ts, glossary terms from src/data/glossary.ts, enforcement actions from src/data/cai-enforcement.json, crawlers from src/data/crawlers.json, cookie patterns from src/data/cookie-kb.ts, Law 25 checks from the PolicyGrader, MCP tool names from the MCP server source, REST endpoints from the API source, and articles from the insights and devblog collections. Draft articles are skipped, exactly as the old search skipped them.
Every record carries the same fields: kind, title, href, one-line description, keywords, language, and category. Glossary records use the first sentence of the definition only, which keeps the whole English file under the 150 KB gzipped budget. If a record has no stable href, the build fails instead of shipping a dead link. If an MCP tool name in the index does not match a tool in the MCP server source, the build fails for the same reason.
The counts at launch are worth stating plainly, because they will drift as content grows: 21 tools, 10 enforcement actions, 11 Law 25 checks, 9 MCP tools, 12 REST endpoints, 51 crawlers, and 202 cookie patterns, plus published articles. The glossary count at launch was 320. The build log prints those counts on every deploy, so a future drift is visible without digging.
How routing works
The router, src/lib/search-router.ts, runs in the browser, on the already-downloaded index. It normalizes the query first: lowercase, accent folding, so donnees and données give the same French results. Then it applies rules in order.
Exact matches win. A cookie name, a glossary term, or an MCP tool name typed exactly becomes the best match. Synonym targets come next, from a curated file, src/data/search-synonyms.ts. DPIA maps to the DPIA glossary term and the PIA tools, consent maps to the Law 25 consent check, and a cookie name maps to the auditor and to classify_cookie. Title matches come after that, with a small stem rule so “generate” matches “generator” and “calculate” matches “calculator”. Fuse.js handles the rest, plus a typo fallback that uses edit distance on title tokens, which is how “cooky banner” still finds the Cookie-Banner Auditor.
A cookie query is special. The index holds 202 cookie patterns, but the dropdown shows one cookie card, never 202. That card is followed by the auditor and the classifier as related steps, which is what a visitor who typed _ga actually needs.
The dropdown caps at eight visible results: a best match card if the top score is clear, then up to two tools, two MCP or API records, two articles, and two glossary or law records. Enter opens the best match. A “Show all results” row goes to /search/?q=, where the same Tier 1 results appear without the cap, plus Pagefind results for full article text. Pagefind runs after the Astro build, on the built HTML, and its chunks load only on that page, never in the dropdown.
What search does not do
Search never executes an MCP tool. An MCP result opens the developers docs, and its Copy button copies only the tool name, for example classify_cookie. No key, token, or credential appears in the index, in the UI, or in a copied snippet. Search stores nothing by default: no recent searches, no query log, no personalization. The query is rendered as text everywhere it appears, including the “No suggestions for” line, so a query that looks like markup cannot become markup.
French search is a separate file, routed by page language. A French tool record uses its French title and description, and falls back to the English tool page with an “English only” label when no French tool page exists. That label is honest about the gap instead of hiding it.
How to test it
The router has unit tests in tests/search-suggestions-unit.mjs. They check the cases that matter: _ga to the cookie card, classify_cookie to the MCP record, DPIA to the glossary term, “calculate breach deadline” to the calculator, “generate rights request” to the generator, “Law 25 consent” to the consent check, a typo, French accents, an empty query, and a markup-style query treated as text. They also check that every synonym target exists in the index, that no record contains an em dash, and that both language files stay under the size budget.
To try it live, open any page, press Cmd or Ctrl and K, and type a task. Type _ga, DPIA, classify_cookie, or redact pii and check that the best match is the one you expected, not just a plausible one. If it is not, the synonym file is the first place to look, and the test file is the second.
What is left out, on purpose
A model-based router is not in this release. It would need a fixed test set of at least 50 real queries, a baseline score for the deterministic router, and a clear win on that set, plus a first-party deployment with no key in browser code and no raw query sent to a paid third party. Until those gates are met, the deterministic router stays. That is a decision, not a placeholder: at this corpus size, rules plus fuzzy matching are the accurate, auditable choice, and a model would hide the ranking logic that this article just described.