Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 26 additions & 80 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,93 +1,39 @@
# sphinx-searchlite

Client-side documentation search for any Sphinx theme. No search service, no
runtime dependency, no build step beyond Sphinx itself.
Client-side documentation search for any Sphinx theme. It needs no search
service, no runtime dependency, and no build step beyond Sphinx itself.

```python
extensions = ["sphinx_searchlite"]
```

That is the whole setup. A `⌘K` / `/` dialog appears on every page, backed by a
JSON index emitted next to the build.

## Why not Sphinx's own search?

Sphinx ships `searchindex.js`, but it is coupled to `searchtools.js` and to the
dedicated `search.html` page. There is no supported way to query it from your
own UI. `sphinx-searchlite` emits plain records instead, so a theme can render
results however it likes.

## Ranking

Results are scored with BM25 over two fields:

- the record's own heading, boosted heavily
- the title of the page it belongs to, boosted lightly

The distinction matters. Boosting both together makes every section of a page
inherit that page's relevance, so searching for a page title surfaces its
subsections above the page itself.

The word you are still typing is matched as a prefix *in addition to* an exact
match, so `install` also reaches `installation`. All query words must match, so
extra words narrow the result set.

## Configuration
[![Build Status](https://github.com/python-project-templates/sphinx-searchlite/actions/workflows/build.yaml/badge.svg?branch=main&event=push)](https://github.com/python-project-templates/sphinx-searchlite/actions/workflows/build.yaml)
[![codecov](https://codecov.io/gh/python-project-templates/sphinx-searchlite/branch/main/graph/badge.svg)](https://codecov.io/gh/python-project-templates/sphinx-searchlite)
[![License](https://img.shields.io/github/license/python-project-templates/sphinx-searchlite)](https://github.com/python-project-templates/sphinx-searchlite)
[![PyPI](https://img.shields.io/pypi/v/sphinx-searchlite.svg)](https://pypi.python.org/pypi/sphinx-searchlite)

| Option | Default | Description |
| ------------------------------- | ----------------------- | ------------------------------------------------------------------------------- |
| `searchlite_index_filename` | `searchlite-index.json` | Written to `_static/`. |
| `searchlite_max_text` | `1200` | Characters of body text kept per record. |
| `searchlite_ui` | `True` | Ship the bundled dialog. Set `False` to supply your own. |
| `searchlite_adopt_theme_search` | `True` | Rebind the theme's own search box to the dialog. Set `False` to leave it alone. |
## Quick start

## Working with an existing theme
Install the package:

Most themes already render a search box wired to Sphinx's `search.html`. Left
alone that gives a page two different searches, and the dialog has no visible
trigger of its own. By default searchlite takes that box over: it is made
read-only, and focusing, clicking, or submitting it opens the dialog instead.

The dialog also adopts the page's own colours, so it follows a theme's
light/dark toggle rather than the `prefers-color-scheme` media query — themes
typically switch on a class or attribute, which that query does not track.

## Driving it yourself

Set `searchlite_ui = False` and use the engine directly:

```js
const engine = SearchLite.create({ url: SearchLite.indexUrl });

await engine.load();
const { items, terms } = engine.search("query");
```bash
pip install sphinx-searchlite
```

Each item is `{ u, t, s, x }` — url, page title, section heading, text.
`terms` includes prefix expansions, so it can be passed to the bundled
`SearchLite.highlight(text, terms)` and `SearchLite.excerpt(record, terms)`
helpers.

Add `data-searchlite-open` to any element to make it a trigger.
Add it to `conf.py`:

## Styling

The bundled dialog reads `--searchlite-*` custom properties, so a theme can
recolour it without overriding rules:

```css
:root {
--searchlite-background: #111;
--searchlite-border: #333;
}
```python
extensions = ["sphinx_searchlite"]
```

`--searchlite-muted`, `--searchlite-border`, and `--searchlite-accent` are
derived from `--searchlite-foreground`, so setting the background and
foreground pair is usually enough. Note that the adopted page colours are set
on the dialog element itself and so win over `:root`; style
`#searchlite-dialog` directly to override them.
Build your docs and open any page. Press `/` or `⌘K` (`Ctrl+K` on Linux and
Windows) to open the search dialog. If your theme has its own search box,
clicking it opens the same dialog.

## Documentation

## Licence
- [How-to guides](docs/src/how-to.md): keep the theme's search box, write your
own UI, add triggers, and restyle the dialog.
- [Reference](docs/src/reference.md): configuration options, the JavaScript
API, the index format, and CSS custom properties.
- [Explanation](docs/src/explanation.md): why searchlite exists, how ranking
works, and how the dialog picks its colors.

Apache-2.0.
> [!NOTE]
> This library was generated using [copier](https://copier.readthedocs.io/en/stable/) from the [Base Python Project Template repository](https://github.com/python-project-templates/base).
42 changes: 42 additions & 0 deletions docs/src/explanation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Explanation

## Why not Sphinx's own search?

Sphinx ships `searchindex.js`, but it is tied to `searchtools.js` and the
dedicated `search.html` page, and there is no supported way to query it from
your own UI. searchlite writes plain JSON records instead, so a theme can score
and render results however it likes.

## How ranking works

Results are scored with BM25 over the record text, with extra weight for two
fields:

- the record's own heading, weighted heavily
- the title of the page it belongs to, weighted lightly

These are kept separate because every section of a page shares that page's
title. If both were weighted heavily, searching for a page title would rank
its subsections alongside the page itself, often above it.

The last word of the query is matched as a prefix as well as exactly, since
you are probably still typing it. That way `install` also finds
`installation`. Results must match every query word that appears in the index,
so adding words narrows the results. Words that appear nowhere in the index are
ignored.

## Why the dialog takes over the theme's search box

Most themes render a search box that submits to `search.html`. Left alone, a
page would have two different searches, and the dialog would have no visible
way to open it. searchlite makes that box read-only and opens the dialog when
it is clicked, activated with `Enter` or `Space`, or its form is submitted.
It does not open on focus, because closing the dialog returns focus to the box,
which would open it again.

## How the dialog picks its colors

The dialog reads the page's background and text colors and copies them onto
itself. It watches for changes, so it follows the theme's light and dark
toggle. Most themes switch modes with a class or attribute, which
`prefers-color-scheme` does not detect.
64 changes: 64 additions & 0 deletions docs/src/how-to.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# How-to guides

## Keep the theme's own search box

By default searchlite takes over the theme's search box so that it opens the
dialog. To leave the box wired to Sphinx's `search.html`, set:

```python
searchlite_adopt_theme_search = False
```

The dialog still opens with `/` and `⌘K`, and from any element with
`data-searchlite-open`.

## Add a button that opens the dialog

Add the `data-searchlite-open` attribute to any element in your templates:

```html
<button type="button" data-searchlite-open>Search</button>
```

## Use your own search UI

Turn off the bundled dialog so only the engine is shipped:

```python
searchlite_ui = False
```

Then query the engine from your own script:

```js
const engine = SearchLite.create({ url: SearchLite.indexUrl });

await engine.load();
const { items, terms } = engine.search("query");

for (const item of items) {
const snippet = SearchLite.highlight(SearchLite.excerpt(item, terms), terms);
// render item.u, item.t, item.s, and snippet
}
```

See the [JavaScript API](reference.md#javascript-api) for what each call
returns.

## Restyle the dialog

Set `--searchlite-*` custom properties on `#searchlite-dialog`:

```css
#searchlite-dialog {
--searchlite-background: #111;
--searchlite-foreground: #eee;
--searchlite-radius: 0.25rem;
}
```

Target the dialog element rather than `:root`. The dialog copies the page's
colors onto itself at runtime, and those inline values override anything
inherited from `:root`. Setting the background and foreground is usually
enough, because the muted, border, and accent colors are mixed from the
foreground.
92 changes: 92 additions & 0 deletions docs/src/reference.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Reference

## Configuration

Set these in `conf.py`.

| Option | Default | Description |
| ------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------- |
| `searchlite_index_filename` | `searchlite-index.json` | Name of the index file written to `_static/`. |
| `searchlite_max_text` | `1200` | Maximum characters of body text kept per record. |
| `searchlite_ui` | `True` | Ship the bundled dialog and its CSS. When `False`, only the engine is shipped. |
| `searchlite_adopt_theme_search` | `True` | Make the theme's search box open the dialog. Has no effect when `searchlite_ui` is `False`. |

## Index format

The index is a JSON array of records. Each page produces one record for the
page and one for each section that has an anchor.

| Key | Description |
| --- | --------------------------------------------------------------------------- |
| `u` | URL relative to the documentation root, with `#anchor` for sections. |
| `t` | Title of the page. |
| `s` | Section heading. Absent on page records. |
| `x` | Text, truncated to `searchlite_max_text`. Sections exclude nested sections. |

`engine.load()` rewrites `u` to an absolute URL.

## JavaScript API

The engine defines a global `SearchLite` object.

### `SearchLite.create({ url })`

Returns an engine for the index at `url`. Nothing is fetched until `load()` is
called.

### `engine.load()`

Fetches and indexes the records. Returns a promise. Repeated calls return the
same promise. A failed fetch produces an empty index.

### `engine.ready()`

Returns `true` once the index is built.

### `engine.search(query)`

Returns `{ items, terms }`. `items` is an array of up to 20 records, best match
first. `terms` lists the index terms that matched, including prefix expansions.
Returns empty arrays if the index is not loaded or the query has no words.

### `SearchLite.indexUrl`

Absolute URL of the index for the current page, or `null` if it cannot be
resolved.

### `SearchLite.tokenize(text)`

Returns the lowercase words in `text`, split on anything other than `a-z`,
`0-9`, and `_`.

### `SearchLite.excerpt(record, terms, length = 120)`

Returns about `length` characters of `record.x`, starting just before the first
matching term. Prefixed with `…` when it does not start at the beginning.

### `SearchLite.highlight(text, terms)`

Returns a `DocumentFragment` of `text` with each match wrapped in `<mark>`.

## Dialog

| Key | Action |
| ------------------- | -------------------------- |
| `/`, `⌘K`, `Ctrl+K` | Open the dialog. |
| `↑`, `↓` | Move between results. |
| `Enter` | Go to the selected result. |
| `Escape` | Close the dialog. |

Any element with the `data-searchlite-open` attribute opens the dialog when
clicked.

## CSS custom properties

| Property | Default | Set on |
| ------------------------- | --------------------------------- | ------------------------ |
| `--searchlite-background` | `#fff`, or the page background | `:root`, then the dialog |
| `--searchlite-foreground` | `#18181b`, or the page text color | `:root`, then the dialog |
| `--searchlite-radius` | `0.75rem` | `:root` |
| `--searchlite-muted` | foreground at 60% | `#searchlite-dialog` |
| `--searchlite-border` | foreground at 20% | `#searchlite-dialog` |
| `--searchlite-accent` | foreground at 10% | `#searchlite-dialog` |
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,6 @@ section-order = [
[tool.yardang]
title = "sphinx-searchlite"
root = "README.md"
pages = []
pages = ["docs/src/how-to.md", "docs/src/reference.md", "docs/src/explanation.md"]
use-autoapi = true