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
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Changelog

All notable changes to FreeUnit Documentation are documented in this file.

## [1.1.0] - 2026-05-05

### Added
- Full-text search with lunr.js library
- Global search box in sidebar with keyboard shortcut (Cmd+K / Ctrl+K)
- Search autocomplete with popular page suggestions
- Keyboard navigation (Enter to search, Escape to close)
- Responsive dark/light mode search UI
- Search index generation via Sphinx extension

---

## [1.0.0] - 2026-05-05

### Added
- Initial FreeUnit documentation website
- Comprehensive guides for installation, configuration, and usage
- Control API documentation
- Status API documentation
- Scripting and security sections
- Community resources and troubleshooting guides
- Docker and deployment examples
- Theme and static assets
- Build system with Sphinx and Make

2 changes: 1 addition & 1 deletion source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,5 @@
suppress_warnings = ['misc.highlighting_failure']

sys.path.append(os.path.abspath('./exts'))
extensions = ['inline', 'nxt', 'subs', 'github']
extensions = ['inline', 'nxt', 'subs', 'github', 'lunr_search']
smartquotes_action = 'qe'
124 changes: 124 additions & 0 deletions source/exts/lunr_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"""
Sphinx extension: generate a lunr.js-compatible search index.

At build time this collects all HTML pages and writes
``search_index.json`` to the output directory. The JSON file is an
array of objects:

{ "id": "<pagename>", "title": "<plain-text title>", "body": "<plain text>" }

The ``id`` is the Sphinx ``pagename`` (e.g. ``"configuration/index"``).
The client-side JS converts it to a URL with :func:`pageUrl`.

Usage (conf.py):
extensions += ['lunr_search']
"""

import json
import re

from pathlib import Path

from sphinx.application import Sphinx
from sphinx.util import logging as sphinx_logging

logger = sphinx_logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

_TAG_RE = re.compile(r"<[^>]+>")
_SPACE_RE = re.compile(r"\s+")

# Maximum body characters stored per page (keeps the JSON manageable).
_MAX_BODY = 5_000


def _strip_html(html: str) -> str:
text = _TAG_RE.sub(" ", html)
return _SPACE_RE.sub(" ", text).strip()


def _normalize_title(pagename: str, title: str) -> str:
"""Normalize and clean up page title, fallback to pagename if needed."""
title = title.strip()
if not title or title == "/" or title in ("<no title>", "&lt;no title&gt;"):
if pagename in ("index", "contents"):
title = "Home"
else:
parts = pagename.split('/')
title = " ".join(p.replace('-', ' ').title() for p in parts if p)
return title


# ---------------------------------------------------------------------------
# Extension state
# ---------------------------------------------------------------------------

class _State:
def __init__(self) -> None:
self.pages: list[dict] = []

def reset(self) -> None:
self.pages.clear()


_state = _State()


# ---------------------------------------------------------------------------
# Event handlers
# ---------------------------------------------------------------------------

def _on_env_before_read_docs(app, env, docnames): # noqa: unused args
_state.reset()


def _on_html_page_context(app, pagename, templatename, context, doctree):
# Skip the Sphinx search page itself and the root index redirect.
if pagename == "search":
return

raw_title = context.get("title", "") or ""
body = context.get("body", "") or ""

if not body:
return

title = _normalize_title(pagename, _strip_html(raw_title))
text = _strip_html(body)[:_MAX_BODY]

_state.pages.append({
"id": pagename,
"title": title,
"body": text,
})


def _on_build_finished(app, exception):
if exception:
return

out_path = Path(app.outdir) / "search_index.json"
out_path.write_text(
json.dumps(_state.pages, ensure_ascii=False, separators=(",", ":")),
encoding="utf-8",
)
logger.info(f"lunr_search: wrote {len(_state.pages)} entries → {out_path}")


# ---------------------------------------------------------------------------
# Setup
# ---------------------------------------------------------------------------

def setup(app: Sphinx) -> dict:
app.connect("env-before-read-docs", _on_env_before_read_docs)
app.connect("html-page-context", _on_html_page_context)
app.connect("build-finished", _on_build_finished)

return {
"version": "1.0",
"parallel_read_safe": True,
}

6 changes: 6 additions & 0 deletions source/theme/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
<link rel="stylesheet" href="{{ pathto('_static/' + styles[-1], 1) + '?' + md5('theme/static/' + styles[-1]) }}" />
<link rel="icon" href="{{ pathto('_static/icon.png', 1) + '?' + md5('theme/static/icon.png') }}" />
<link rel="alternate" type="application/rss+xml" title="Subscribe to FreeUnit News" href="{{ nxt_baseurl + nxt_rss_file }}" />
<script src="{{ pathto('_static/lunr.min.js', 1) + '?' + md5('theme/static/lunr.min.js') }}"></script>
<script async src="{{ pathto('_static/script.js', 1) + '?' + md5('theme/static/script.js') }}"></script>

{%- if next %}
Expand Down Expand Up @@ -96,6 +97,11 @@ <h1>
<a style="text-decoration:none;" href="{{ pathto('news') }}{{release_year}}/unit-{{version}}-released/">v. {{version}}</a>
</div>
</h1>
<div id="nxt_search_wrap">
<input id="nxt_search_input" type="search" placeholder="Search docs… (Cmd+K)" autocomplete="off" aria-label="Search documentation" />
<div id="nxt_search_results" aria-live="polite"></div>
<div id="nxt_search_suggestions" aria-label="Popular searches"></div>
</div>
{{ toctree(maxdepth = 4) }}
</div>
<div id="content">
Expand Down
5 changes: 5 additions & 0 deletions source/theme/search.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{% extends "layout.html" %}
{% block body %}
<h1>Search</h1>
<p>Use the search box in the sidebar to search the documentation.</p>
{% endblock %}
6 changes: 6 additions & 0 deletions source/theme/static/lunr.min.js

Large diffs are not rendered by default.

Loading
Loading