Search
One search index and one callable serve every search box: the public header, the /search page, the admin header and any box you add. Content is searchable out of the box; you make any other collection searchable by naming it in one file.
What it is
Search is a type-ahead engine built on Firestore. It is good at short fields: names, titles, summaries, cities, product names. Typing "kar" finds "Karun", words match in any order, accents and case are ignored, and a typo fallback retries a query that finds nothing. It is not full-text search over long prose: there is no stemming, no synonyms and no match in the middle of a word.
| Part | What it does |
|---|---|
| Sources | A source tells the engine which collection to read, which fields to index, who may search it and what a result shows. Arc CMS has two: content (published content, anyone) and content-drafts (drafts, admins). You add more. |
| The index | The SearchIndex collection: one small document per document per language. Clients cannot read it. |
The search callable | The only way into the index. It checks the caller may search every source asked for, queries the index and ranks the results. |
| Search boxes | The public header box and the /search page, the admin header box (Cmd+K or Ctrl+K) and the /admin/search page, and <arc-search-box> for your own pages. |
| Built on search | Link suggestions in the content editor's Checks tab, and the Related items on content pages. |
For a website walkthrough, see Add search.
Turn it on or off
Search is the switchable feature search. It is on by default and needs no other feature. To turn it off, list it in src/custom/features.ts:
export const CUSTOM_FEATURES: FeatureChoice = {
off: ['search'],
};
When it is off:
- Admin, Settings, Search, the admin header box and
/admin/searchgo. - The public header box shows nothing, and
/searchand/{lang}/searchshow the not-found page. - The editor's Checks tab offers no link suggestions, and content pages have no Related items.
- Every search function goes at the next deploy of the whole functions codebase.
- The
SearchIndexcollection stays. Turn search back on, deploy, and press Rebuild everything.
The public header box also needs content: without content there is nothing public to find, so an app without content places its own box. See Feature ids for every feature.
Set it up
Content needs no setup. Published content and drafts are indexed as they are saved and published, in every language the item has. Each content entry indexes its title, summary and author name.
Choose extra content fields (optional). In Content, Content types, edit a type and tick fields under Searchable custom fields. Only plain text fields can be ticked; rich text is never indexed. Saving the type rebuilds its part of the index.
Build the index once. On a site that has content from before search was deployed, open Admin, Settings, Search and press Rebuild everything. Do the same whenever results look wrong: a rebuild rewrites every entry and deletes entries whose document is gone.
To make another collection searchable, see Extend it.
Use it
Public search
The site header carries <arc-search>. On a published page it becomes a plain form that submits to /search?q= (or /{lang}/search?q=), so it works without JavaScript, and public/assets/js/arc-search.js adds the type-ahead. In the Angular app it is the arc-search-box component. Both search the language being viewed. The box text comes from the site's strings files (src/custom/site/strings/{lang}.json over public/_site/strings/{lang}.json) (keys starting with search_); see Languages.
Admin search
Admins get a search box in the page header. Cmd+K or Ctrl+K focuses it. It searches drafts (not published content, which would show each item twice) and every source admins may read, in every language at once, showing each document once. Enter opens /admin/search with up to 20 results. Without content, the box appears once some source has been rebuilt.
Search settings
Admin, Settings, Search lists what is searchable (with the fields each source indexes, its document and entry counts and a Rebuild button) and every collection in the database with its state:
| State | Meaning |
|---|---|
| Searchable | Named in code and set up here. |
| Needs setup | Named in code, not set up yet. Press Set up. |
| Waiting for deploy | Set up here, not named in code yet, so nothing indexes it. |
| Set up in code | A source written in code. |
| Not searchable | Neither. Copy the line to add copies its name for the developer. |
| Never searchable | Logs, events, one-time codes, settings, queues and the index itself. See Troubleshooting. |
How results are ranked
For each word of the query, the best match in any indexed field counts: a whole word scores the field's weight, the start of a word scores 0.6 times the weight. Then:
- A bonus of 4 when every word matched.
- A bonus of twice the highest field weight when the whole query appears in the title.
- A small bonus for short titles fully matched, so they beat long titles partly matched.
- The total is multiplied by the source's
boost. Ties go to the newest document.
When nothing matches, the shortest query word of four or more letters loses its last letter and the search runs again, at most twice and never below three letters. The box then says "Showing results for" the shortened word.
Text is lowercased, Latin accents are removed (other scripts keep their marks, so Hindi works), apostrophes are dropped ("karun's" becomes "karuns"), other punctuation becomes a space, and a short list of English stop words is ignored. The same code tokenizes documents and queries, in functions/src/search/tokenizer.ts.
Limits
| Limit | Value |
|---|---|
| Shortest query the box sends | 2 characters, after a 250 ms pause |
| Query length | 120 characters, 10 words (the longest are kept) |
| Results per call | 8 by default, 20 at most |
| Sources named per call | 5 |
| Candidates read per query | 50, newest first, before ranking |
| Tokens per index entry | 600 (fields are tokenized in order, so the last fields are cut first) |
| Words indexed per field | 80 |
| Type-ahead prefixes | 2 to 12 characters |
| Snippet | 200 characters |
Extend it
An app makes its own collections searchable in functions/src/custom/search-sources.ts. It exports two lists:
| Export | Use it for |
|---|---|
SEARCH_COLLECTIONS | Collection names. You choose the fields, the result and who may search in Admin, Settings, Search. Most collections need only this. |
CUSTOM_SEARCH_SOURCES | Sources written in code, for what the settings cannot express: leaving some documents out, several languages, a computed badge, custom ranking. |
Each named collection, and each code source on one collection, gets its own trigger, arccms-searchSync-{Collection}, which keeps the index in step with every write. A write to any other collection starts no search function. Adding or removing a collection therefore needs a functions deploy; changing its setup in the admin does not.
Name a collection
-
Add its name to
functions/src/custom/search-sources.ts:export const SEARCH_COLLECTIONS: string[] = ['Directory']; -
Deploy the functions:
npm run deploy -- --only functions:arccmsTo deploy less, see Deploy only what changed.
In Admin, Settings, Search, press Set up on the collection. It reads up to 20 documents and lists their text fields (strings and lists of strings, at the top level and one level into maps, such as
address.city). It ticks a first guess: fields named liketitle,name,subject,headingorlabelat High, fields named likesummary,description,text,bodyornotesat Normal, the first of each as the result's title and snippet. Nothing else is ticked.Check the fields (High counts three times as much as Normal and matches as you type), the title and snippet, the link, and Who can search it (Admins by default). The link is a pattern such as
/directory/{slug}:{id}is the document id and{field}a field's value. Leave it empty for results that do not open. Look at the preview, then press Save and rebuild.
An admin can also set a collection up before the developer names it: it then waits, and is indexed after the name is added and the functions are deployed.
Write a source in code
Copy functions/src/search/sources/_template.ts into functions/src/custom/. For a Directory collection whose documents look like { name, tagline, city, slug, status, createdAt }:
// functions/src/custom/directory-source.ts
import type { SearchSource } from '../search/source.js';
export const directorySource: SearchSource = {
id: 'directory',
label: 'Directory',
collection: 'Directory',
scope: 'public',
fields: [
{ path: 'name', weight: 3, prefix: true },
{ path: 'tagline', weight: 2, prefix: true },
{ path: 'city', weight: 1 },
],
display: (doc) => ({
title: String(doc['name'] ?? ''),
snippet: String(doc['tagline'] ?? ''),
badge: 'Directory',
link: `/directory/${String(doc['slug'] ?? '')}`,
sortAt: doc['createdAt'],
}),
include: (doc) => doc['status'] === 'active',
};
List it in functions/src/custom/search-sources.ts, deploy the functions, then press Rebuild on it in Admin, Settings, Search, where it shows as Set up in code:
import type { SearchSource } from '../search/source.js';
import { directorySource } from './directory-source.js';
export const SEARCH_COLLECTIONS: string[] = [];
export const CUSTOM_SEARCH_SOURCES: SearchSource[] = [directorySource];
Arc CMS ships one ready-made source you can list the same way: productsSource in functions/src/search/sources/products.ts makes plans on the pricing page searchable.
The source fields
The contract is SearchSource in functions/src/search/source.ts.
| Field | What it does |
|---|---|
id | Required. Part of every index entry id: after renaming it, rebuild. |
collection | Required. A top-level collection name, or a RegExp for a family of collections (then set trigger: false). |
scope | Required. public (anyone), authenticated (anyone signed in) or admin. |
fields | Required. { path, weight, prefix? } per field, or a function of the document. Dotted paths reach into maps; lists of strings are joined. prefix: true makes type-ahead work; keep it to short fields. Names and titles: weight 3; a one-line description: 2; the rest: 1. |
display | Required. (doc, ctx, lang) => { title, snippet?, badge?, link, meta?, sortAt? }. link is root-relative. sortAt breaks ties and falls back to the time of indexing. |
include | Return false to leave a document out, such as one with no page to link to. |
lang | The document's language, when each document carries one. Default: every language. |
variants | One { lang, doc } per language, for a document with translations. The content sources use it. |
boost | Multiplies the score. Default 1. |
label | The name in Search settings and on admin results. Default: the id. |
trigger | Set false when your own function indexes the collection (below). Default true. |
expandCollections | For a RegExp source: the collections a rebuild walks. |
Everything display returns is sent to whoever searched, meta included. A public source's result must be safe to show a stranger.
Index from your own function
When your Cloud Function writes the data, you can index in the same operation and set trigger: false on the source:
import { buildSearchContext } from '../search/context.js';
import { indexDocument, removeSearchEntries } from '../search/writer.js';
import { directorySource } from './directory-source.js';
const ctx = await buildSearchContext('Directory', docId);
await indexDocument(directorySource, data, ctx); // write or update
await removeSearchEntries('directory', 'Directory', docId); // on delete
Put a search box on a page
Import SearchBoxComponent from src/shared/components/search-box/search-box.component.ts:
<arc-search-box
scope="public"
[sources]="['directory']"
resultsUrl="/directory/search"
navigation="router"
placeholder="Search the directory"
(picked)="onPicked($event)"></arc-search-box>
| Input | Meaning |
|---|---|
scope | Who is asking: public, authenticated or admin. It must reach every source named. |
sources | Only these source ids. Omit for every source the scope may read. |
except | With no sources: every source but these. |
lang | The language being viewed, or all for every language with each document once. |
limit | Results in the dropdown. Default 8. |
resultsUrl | Where Enter goes, with ?q= added. Omit to turn Enter off. |
navigation | router for app routes, location for static pages, none to handle (picked) yourself. |
hotkey | Cmd+K or Ctrl+K focuses the box. |
Without the component, inject SearchService from src/app/core/services/search.service.ts and call search({ q, lang, scope, sources, limit }). It returns { results, tookMs, fallbackUsed? }, or null when a newer search overtook it. Each result has title, snippet, badge, link, meta, score and highlights (character ranges to bold). The types are in src/shared/models/search.model.ts.
Test a source
A source is plain data. A spec can call buildEntries from functions/src/search/writer.ts with a fixture document and check the tokens and the display.
What it stores
| Where | What |
|---|---|
SearchIndex/{source}:{collection}:{docId}:{lang} | One entry per document per language: tokens, field text, weights, and the title, snippet, badge, link and meta a result shows. Language-neutral entries have lang * and appear in every language. Its four composite indexes ship in firestore.indexes.json; a new source needs no index. |
Settings/search_collections | The setups made in Admin, Settings, Search, keyed by collection. |
Settings/search_status | Per source: label, scope, collections, counts and when it was last rebuilt. |
_search_queue | One short-lived entry per draft save. Drafts live in collections created at run time, so the editor queues each save and a function indexes it. |
Search uses no Storage. Its Cloud Functions:
| Function | Kind | Does |
|---|---|---|
search | Callable | Answers every search box. |
reindexSearch | Callable | Rebuilds one source, one collection of it ({ source, collection }), or everything. A full rebuild also removes sources that no longer exist. |
listSearchCollections | Callable | What Search settings lists. |
sampleCollectionFields | Callable | The text fields and preview rows for Set up. |
searchSync-{Collection} | Trigger | One per searchable collection. |
onSearchQueued | Trigger | Indexes a queued draft. Only with content on. |
Each deploys with the arccms- prefix. Publishing, unpublishing and deleting a content type update the content entries themselves. See Cloud Functions.
Who can do what
| Action | Who |
|---|---|
Search a public source | Anyone, signed in or not. |
Search an authenticated source | Anyone signed in, with scope: 'authenticated' or higher. |
Search an admin source, open /admin/search | Admins. |
| Rebuild, list collections, set a collection up | Admins. The callables check the admin claim; Settings/search_collections is admin only. |
Read Settings/search_status | Admins. The rebuild writes it. |
Read or write SearchIndex | No client. Only functions, so the callable is where scope is enforced. |
Write _search_queue | Content staff, and only a draft collection name and a document id. No client reads it. |
A request that names a source its scope cannot reach is refused with permission-denied, not quietly trimmed. See Roles and claims.
Troubleshooting
A collection is set up in Search settings but finds nothing. It says Waiting for deploy: no trigger watches it until its name is in SEARCH_COLLECTIONS and the functions are deployed. Add the name, deploy, then press Rebuild.
The functions deploy fails with a message from functions/src/custom/search-sources.ts. The functions refuse to load when a named collection can never be searchable (a name starting with _, or one listed in functions/src/search/refused.ts such as EmailLogs or Settings), is not a plain name, shares a trigger name with another (my-list and my_list), or when a code source on a RegExp does not set trigger: false. The message names the problem.
A removed collection still has a trigger. A targeted deploy never deletes functions. Deploy the whole functions codebase (npm run deploy -- --only functions:arccms lists what it will delete and asks first), then press Rebuild everything to delete its entries.
| Symptom | Fix |
|---|---|
| New source finds nothing | Press Rebuild on it. Existing documents are indexed only by a rebuild or when they are next written. |
| Results look stale or wrong | Press Rebuild everything. Setups are cached for a minute, so a change can take that long to apply to new writes. |
permission-denied: "Source ... is not searchable in scope ..." | The box's scope is lower than the source's. Raise the box's scope or lower the source's. |
not-found: "Unknown search source" | The id is not a source: a named collection that is not set up yet, or a typo. Set-up collections have the id collection-{Collection}. |
| A document is missing from results | Its include rejects it, it has no title, or none of its indexed fields has text. |
| A word in a long field is not found | Only the first 80 words of a field are indexed, and an entry holds 600 tokens. Index short fields. |
| The admin header has no search box | With content off, it appears after some source has been rebuilt. |