diff --git a/docs/src/theme-options.md b/docs/src/theme-options.md
index 4f6f8c7..b525741 100644
--- a/docs/src/theme-options.md
+++ b/docs/src/theme-options.md
@@ -105,6 +105,20 @@ Python, Anaconda, and JFrog logos are bundled from
[Simple Icons](https://github.com/simple-icons/simple-icons) (CC0). All navigation
icons are inline SVGs that inherit the theme's text color.
+## LLM documentation menu
+
+When Yardang's llms output is enabled, a robot button appears in the sidebar
+toolbar and header. Its menu links to `llms.txt` and, when `full-build` is enabled,
+`llms-full.txt`. Links work from nested documentation pages.
+
+The menu appears automatically when the `yardang.sphinx.llms` extension is loaded.
+For projects built with Yardang, this is controlled by:
+
+```toml
+[tool.yardang.llms]
+enabled = true
+```
+
## Section navigator
Each `sidebar_tabs` mapping accepts these fields:
diff --git a/pyproject.toml b/pyproject.toml
index b721de5..c7be865 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -56,6 +56,7 @@ develop = [
# tests
"myst-parser",
"sphinx-design",
+ "yardang",
]
[project.scripts]
diff --git a/sphinx_fuma/__init__.py b/sphinx_fuma/__init__.py
index f462163..6c27002 100644
--- a/sphinx_fuma/__init__.py
+++ b/sphinx_fuma/__init__.py
@@ -45,6 +45,12 @@ def _on_page_context(app, pagename, templatename, context, doctree) -> None:
context["fuma_sidebar"] = sidebar_tree(app, pagename)
context["fuma_toc"] = page_toc(app, pagename)
context["fuma_breadcrumbs"] = breadcrumbs(context)
+ context["fuma_llms_links"] = []
+ if "yardang.sphinx.llms" in app.extensions:
+ filenames = ["llms.txt"]
+ if app.config.yardang_llms_full_build:
+ filenames.append("llms-full.txt")
+ context["fuma_llms_links"] = [{"title": filename, "url": context["pathto"](filename, resource=True)} for filename in filenames]
def setup(app) -> dict[str, object]:
diff --git a/sphinx_fuma/partials/icons.html b/sphinx_fuma/partials/icons.html
index 58dac50..3b9a1d3 100644
--- a/sphinx_fuma/partials/icons.html
+++ b/sphinx_fuma/partials/icons.html
@@ -26,6 +26,7 @@
"album": '',
"blocks": '',
"book": '',
+ "bot": '',
"bookmark": '',
"box": '',
"braces": '',
diff --git a/sphinx_fuma/partials/llms.html b/sphinx_fuma/partials/llms.html
new file mode 100644
index 0000000..c33f0b5
--- /dev/null
+++ b/sphinx_fuma/partials/llms.html
@@ -0,0 +1,13 @@
+{% import "partials/icons.html" as icons with context %}
+{%- if fuma_llms_links %}
+
+
+ {{ icons.named("bot", "fd-icon-bot") }}
+
+
+
+{%- endif %}
diff --git a/sphinx_fuma/partials/sidebar.html b/sphinx_fuma/partials/sidebar.html
index 46a2665..930cac0 100644
--- a/sphinx_fuma/partials/sidebar.html
+++ b/sphinx_fuma/partials/sidebar.html
@@ -86,6 +86,7 @@
{%- if fuma_github_url %}
{{ icons.github() }}
{%- endif %}
+ {%- include "partials/llms.html" %}
diff --git a/sphinx_fuma/partials/subnav.html b/sphinx_fuma/partials/subnav.html
index 06407a6..c3489b4 100644
--- a/sphinx_fuma/partials/subnav.html
+++ b/sphinx_fuma/partials/subnav.html
@@ -23,6 +23,7 @@
{%- if fuma_github_url %}
{{ icons.github() }}
{%- endif %}
+ {%- include "partials/llms.html" %}
diff --git a/sphinx_fuma/static/fuma.css b/sphinx_fuma/static/fuma.css
index 70912d5..dd78613 100644
--- a/sphinx_fuma/static/fuma.css
+++ b/sphinx_fuma/static/fuma.css
@@ -301,6 +301,52 @@ body[data-layout="notebook"] #fd-subnav {
background: var(--color-fd-accent);
}
+.fd-llms {
+ position: relative;
+ flex-shrink: 0;
+}
+
+.fd-llms summary {
+ list-style: none;
+}
+
+.fd-llms summary::-webkit-details-marker {
+ display: none;
+}
+
+.fd-llms-menu {
+ position: absolute;
+ z-index: 20;
+ inset-inline-start: 0;
+ bottom: calc(100% + 0.25rem);
+ min-width: 10rem;
+ padding: 0.25rem;
+ border: 1px solid var(--color-fd-border);
+ border-radius: var(--fd-radius);
+ background: var(--color-fd-popover);
+ color: var(--color-fd-popover-foreground);
+ box-shadow: 0 8px 24px rgb(0 0 0 / 0.16);
+}
+
+#fd-subnav .fd-llms-menu {
+ inset-inline-start: auto;
+ inset-inline-end: 0;
+ top: calc(100% + 0.25rem);
+ bottom: auto;
+}
+
+.fd-llms-menu a {
+ display: block;
+ padding: 0.5rem;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+}
+
+.fd-llms-menu a:hover,
+.fd-llms-menu a:focus-visible {
+ background: var(--color-fd-accent);
+}
+
.fd-theme-toggle .fd-icon-dark,
html.dark .fd-theme-toggle .fd-icon-light {
display: none;
diff --git a/sphinx_fuma/static/fuma.js b/sphinx_fuma/static/fuma.js
index 42c4406..b57b9c7 100644
--- a/sphinx_fuma/static/fuma.js
+++ b/sphinx_fuma/static/fuma.js
@@ -8,6 +8,19 @@
var body = document.body;
var docEl = document.documentElement;
+ document.querySelectorAll(".fd-llms").forEach(function (menu) {
+ document.addEventListener("click", function (event) {
+ if (!menu.contains(event.target) || event.target.closest("a")) menu.open = false;
+ });
+ menu.addEventListener("keydown", function (event) {
+ if (event.key === "Escape" && menu.open) {
+ menu.open = false;
+ menu.querySelector("summary").focus();
+ event.preventDefault();
+ }
+ });
+ });
+
/* Theme -------------------------------------------------------------- */
function isDark() {
diff --git a/sphinx_fuma/tests/test_llms.py b/sphinx_fuma/tests/test_llms.py
new file mode 100644
index 0000000..ccc40f5
--- /dev/null
+++ b/sphinx_fuma/tests/test_llms.py
@@ -0,0 +1,40 @@
+import re
+from pathlib import Path
+
+import pytest
+from sphinx.application import Sphinx
+
+
+@pytest.fixture(scope="module", params=[True, False])
+def built_llms(tmp_path_factory, request) -> tuple[Path, bool]:
+ src = tmp_path_factory.mktemp("llms")
+ (src / "conf.py").write_text(
+ 'extensions = ["myst_parser", "sphinx_fuma", "yardang.sphinx.llms"]\n'
+ 'project = "Demo"\nhtml_theme = "fuma"\n'
+ f"yardang_llms_full_build = {request.param}\n"
+ )
+ (src / "index.md").write_text("# Demo\n\n```{toctree}\n\nguides/start\n```\n")
+ (src / "guides").mkdir()
+ (src / "guides/start.md").write_text("# Getting started\n\nInstall the package.\n")
+ out = src / "_build"
+ Sphinx(str(src), str(src), str(out), str(out / ".doctrees"), "html", freshenv=True, status=None).build()
+ return out, request.param
+
+
+@pytest.mark.parametrize("page", ["index.html", "guides/start.html"])
+@pytest.mark.parametrize("region", ["fd-subnav", "fd-sidebar"])
+def test_llms_menu_links_to_generated_files(built_llms, page, region):
+ out, full_build = built_llms
+ html = (out / page).read_text()
+ region_html = html.split(f'id="{region}"', 1)[1].split("" if region == "fd-subnav" else "", 1)[0]
+ menu = re.search(r'
(.*?) ', region_html, re.DOTALL)
+ assert menu is not None
+ assert 'aria-label="LLM documentation"' in menu.group(1)
+ assert 'title="LLM documentation"' in menu.group(1)
+ assert "fd-icon-bot" in menu.group(1)
+ links = re.findall(r'
([^<]+)', menu.group(1))
+ filenames = ["llms.txt", "llms-full.txt"] if full_build else ["llms.txt"]
+ assert [label for _, label in links] == filenames
+ for href, label in links:
+ assert (out / page).parent.joinpath(href).resolve() == out / label
+ assert (out / label).is_file()
diff --git a/sphinx_fuma/tests/test_theme.py b/sphinx_fuma/tests/test_theme.py
index 99f683f..a9956d3 100644
--- a/sphinx_fuma/tests/test_theme.py
+++ b/sphinx_fuma/tests/test_theme.py
@@ -217,6 +217,9 @@ def test_no_edit_url_without_a_page(self):
class TestRenderedOutput:
+ def test_llms_menu_is_absent_without_llms_output(self, built):
+ assert 'class="fd-llms"' not in (built / "index.html").read_text()
+
@pytest.mark.parametrize(
("region", "closing_tag", "links_container"),
[("fd-subnav", "header", "fd-nav-links"), ("fd-sidebar", "aside", "fd-toolbar")],