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")],