diff --git a/README.md b/README.md index 87fc9c2..75a7436 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/docs/src/explanation.md b/docs/src/explanation.md new file mode 100644 index 0000000..5f705f0 --- /dev/null +++ b/docs/src/explanation.md @@ -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. diff --git a/docs/src/how-to.md b/docs/src/how-to.md new file mode 100644 index 0000000..a46d01c --- /dev/null +++ b/docs/src/how-to.md @@ -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 + +``` + +## 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. diff --git a/docs/src/reference.md b/docs/src/reference.md new file mode 100644 index 0000000..ba4fab6 --- /dev/null +++ b/docs/src/reference.md @@ -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 ``. + +## 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` | diff --git a/pyproject.toml b/pyproject.toml index 171c4cc..5e8d9c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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