From bebc73bb011eaeb43147833e6b692b4a0d5d210f Mon Sep 17 00:00:00 2001 From: pdmurray Date: Sun, 19 Nov 2023 10:58:04 -0800 Subject: [PATCH 1/2] Add ability to set per-page secondary sidebars --- docs/conf.py | 5 +- docs/examples/no-sidebar.md | 7 +- docs/user_guide/page-toc.rst | 29 +++++ src/pydata_sphinx_theme/__init__.py | 36 ++---- .../sections/sidebar-secondary.html | 5 +- src/pydata_sphinx_theme/utils.py | 105 +++++++++++++++++- tests/sites/sidebars/index.rst | 5 + tests/sites/sidebars/section1/index.rst | 5 + .../sidebars/section1/subsection1/page2.rst | 4 + tests/sites/sidebars/section2/index.rst | 6 + tests/test_build.py | 70 ++++++++++++ 11 files changed, 244 insertions(+), 33 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 0a9076c64..b2c0771d3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -180,7 +180,10 @@ # "content_footer_items": ["test", "test"], "footer_start": ["copyright"], "footer_center": ["sphinx-version"], - # "secondary_sidebar_items": ["page-toc"], # Remove the source buttons + "secondary_sidebar_items": { + "**/*": ["page-toc", "edit-this-page", "sourcelink"], + "examples/no-sidebar": [], + }, "switcher": { "json_url": json_url, "version_match": version_match, diff --git a/docs/examples/no-sidebar.md b/docs/examples/no-sidebar.md index 487fb5a17..2e6518968 100644 --- a/docs/examples/no-sidebar.md +++ b/docs/examples/no-sidebar.md @@ -6,6 +6,11 @@ This page shows off what the documentation looks like when you explicitly tell S html_sidebars = { "path/to/page": [], } +html_theme_options = { + "secondary_sidebar_items": { + "path/to/page": [], + }, +} ``` -The primary sidebar should be entirely gone, and the main content should expand slightly to make up the extra space. +Both the primary and secondary sidebars should be entirely gone, and the main content should expand slightly to make up the extra space. diff --git a/docs/user_guide/page-toc.rst b/docs/user_guide/page-toc.rst index 60a3a423a..52f9e5415 100644 --- a/docs/user_guide/page-toc.rst +++ b/docs/user_guide/page-toc.rst @@ -24,3 +24,32 @@ Remove the Table of Contents To remove the Table of Contents, add ``:html_theme.sidebar_secondary.remove:`` to the `file-wide metadata `_ at the top of a page. This will remove the Table of Contents from that page only. + +Per-page secondary-sidebar content +---------------------------------- + +``html_theme_options['secondary_sidebar_items']`` accepts either a ``list`` of secondary sidebar +templates to render on every page: + +.. code-block:: python + + html_theme_options = { + "secondary_sidebar_items": ["page-toc", "sourcelink"] + } + +or a ``dict`` which maps page names to ``list`` of secondary sidebar templates: + +.. code-block:: python + + html_theme_options = { + "secondary_sidebar_items": { + "**": ["page-toc", "sourcelink"], + "index": ["page-toc"], + } + } + +If a ``dict`` is specified, the keys can contain glob-style patterns; page names which +match the pattern will contain the sidebar templates specified. This closely follows the behavior of +the ``html_sidebars`` option that is part of Sphinx itself, except that it operates on the +secondary sidebar instead of the primary sidebar. For more information, see `the Sphinx +documentation `__. diff --git a/src/pydata_sphinx_theme/__init__.py b/src/pydata_sphinx_theme/__init__.py index 981c889bb..508b9c22b 100644 --- a/src/pydata_sphinx_theme/__init__.py +++ b/src/pydata_sphinx_theme/__init__.py @@ -1,7 +1,6 @@ """Bootstrap-based sphinx theme from the PyData community.""" import json -import os from functools import partial from pathlib import Path from typing import Dict @@ -203,37 +202,19 @@ def update_and_remove_templates( "theme_footer_start", "theme_footer_center", "theme_footer_end", - "theme_secondary_sidebar_items", "theme_primary_sidebar_end", "sidebars", ] for section in template_sections: if context.get(section): - # Break apart `,` separated strings so we can use , in the defaults - if isinstance(context.get(section), str): - context[section] = [ - ii.strip() for ii in context.get(section).split(",") - ] - - # Add `.html` to templates with no suffix - for ii, template in enumerate(context.get(section)): - if not os.path.splitext(template)[1]: - context[section][ii] = template + ".html" - - # If this is the page TOC, check if it is empty and remove it if so - def _remove_empty_templates(tname): - # These templates take too long to render, so skip them. - # They should never be empty anyway. - SKIP_EMPTY_TEMPLATE_CHECKS = ["sidebar-nav-bs.html", "navbar-nav.html"] - if not any(tname.endswith(temp) for temp in SKIP_EMPTY_TEMPLATE_CHECKS): - # Render the template and see if it is totally empty - rendered = app.builder.templates.render(tname, context) - if len(rendered.strip()) == 0: - return False - return True - - context[section] = list(filter(_remove_empty_templates, context[section])) - # + context[section] = utils._update_and_remove_templates( + app=app, + context=context, + templates=context.get(section, []), + section=section, + templates_skip_empty_check=["sidebar-nav-bs.html", "navbar-nav.html"], + ) + # Remove a duplicate entry of the theme CSS. This is because it is in both: # - theme.conf # - manually linked in `webpack-macros.html` @@ -296,6 +277,7 @@ def setup(app: Sphinx) -> Dict[str, str]: app.connect("html-page-context", toctree.add_toctree_functions) app.connect("html-page-context", update_and_remove_templates) app.connect("html-page-context", logo.setup_logo_path) + app.connect("html-page-context", utils.set_secondary_sidebar_items) app.connect("build-finished", pygment.overwrite_pygments_css) app.connect("build-finished", logo.copy_logo_images) diff --git a/src/pydata_sphinx_theme/theme/pydata_sphinx_theme/sections/sidebar-secondary.html b/src/pydata_sphinx_theme/theme/pydata_sphinx_theme/sections/sidebar-secondary.html index b3f880fd0..9f36cc470 100644 --- a/src/pydata_sphinx_theme/theme/pydata_sphinx_theme/sections/sidebar-secondary.html +++ b/src/pydata_sphinx_theme/theme/pydata_sphinx_theme/sections/sidebar-secondary.html @@ -1,6 +1,7 @@ -{% if theme_secondary_sidebar_items -%} +{% if secondary_sidebar_items -%} diff --git a/src/pydata_sphinx_theme/utils.py b/src/pydata_sphinx_theme/utils.py index d56b71430..80edb47ce 100644 --- a/src/pydata_sphinx_theme/utils.py +++ b/src/pydata_sphinx_theme/utils.py @@ -1,11 +1,13 @@ """General helpers for the management of config parameters.""" +import copy +import os import re -from typing import Any, Dict, Iterator +from typing import Any, Dict, Iterator, List, Optional, Union from docutils.nodes import Node from sphinx.application import Sphinx -from sphinx.util import logging +from sphinx.util import logging, matching def get_theme_options_dict(app: Sphinx) -> Dict[str, Any]: @@ -58,3 +60,102 @@ def maybe_warn(app: Sphinx, msg, *args, **kwargs): should_warn = theme_options.get("surface_warnings", False) if should_warn: SPHINX_LOGGER.warning(msg, *args, **kwargs) + + +def set_secondary_sidebar_items( + app: Sphinx, pagename: str, templatename: str, context, doctree +) -> None: + """Set the secondary sidebar items to render for the given pagename.""" + if "theme_secondary_sidebar_items" in context: + templates = context["theme_secondary_sidebar_items"] + if isinstance(templates, dict): + templates = _get_matching_sidebar_items(pagename, templates) + + context["secondary_sidebar_items"] = _update_and_remove_templates( + app, + context, + templates, + "theme_secondary_sidebar_items", + ) + + +def _update_and_remove_templates( + app: Sphinx, + context: Dict[str, Any], + templates: Union[List, str], + section: str, + templates_skip_empty_check: Optional[List[str]] = None, +) -> List[str]: + """Update templates to include html suffix if needed; remove templates which render empty. + + Args: + app: Sphinx application passed to the html page context + context: The html page context; dictionary of values passed to the templating engine + templates: A list of template names, or a string of comma separated template names + section: Name of the template section where the templates are to be rendered + templates_skip_empty_check: Names of any templates which should never be removed from the list + of filtered templates returned by this function. These templates aren't checked if they + render empty, which can save time if the template is slow to render. + + Returns: + A list of template names (including '.html' suffix) to render into the section + """ + if templates_skip_empty_check is None: + templates_skip_empty_check = [] + + # Break apart `,` separated strings so we can use , in the defaults + if isinstance(templates, str): + templates = [template.strip() for template in templates.split(",")] + + # Add `.html` to templates with no suffix + suffixed_templates = [] + for template in templates: + if os.path.splitext(template)[1]: + suffixed_templates.append(template) + else: + suffixed_templates.append(f"{template}.html") + + ctx = copy.copy(context) + ctx.update({section: suffixed_templates}) + + # Check whether the template renders to an empty string; remove if this is the case + # Skip templates that are slow to render with templates_skip_empty_check + filtered_templates = [] + for template in suffixed_templates: + if any(template.endswith(item) for item in templates_skip_empty_check): + filtered_templates.append(template) + else: + rendered = app.builder.templates.render(template, ctx) + if len(rendered.strip()) != 0: + filtered_templates.append(template) + + return filtered_templates + + +def _get_matching_sidebar_items( + pagename: str, sidebars: Dict[str, List[str]] +) -> List[str]: + """Get the matching sidebar templates to render for the given pagename. + + This function was adapted from sphinx.builders.html.StandaloneHTMLBuilder.add_sidebars. + """ + matched = None + secondary_sidebar_items = [] + for pattern, sidebar_items in sidebars.items(): + if matching.patmatch(pagename, pattern): + if matched and _has_wildcard(pattern) and _has_wildcard(matched): + SPHINX_LOGGER.warning( + f"Page {pagename} matches two wildcard patterns in secondary_sidebar_items: {matched} and {pattern}" + ), + + matched = pattern + secondary_sidebar_items = sidebar_items + return secondary_sidebar_items + + +def _has_wildcard(pattern: str) -> bool: + """Check whether the pattern contains a wildcard. + + Taken from sphinx.builders.StandaloneHTMLBuilder.add_sidebars. + """ + return any(char in pattern for char in "*?[") diff --git a/tests/sites/sidebars/index.rst b/tests/sites/sidebars/index.rst index d5596838d..d4318a7e5 100644 --- a/tests/sites/sidebars/index.rst +++ b/tests/sites/sidebars/index.rst @@ -11,3 +11,8 @@ Sidebar depth variations :caption: Caption 2 section2/index + +Other content +------------- + +This is some other content. diff --git a/tests/sites/sidebars/section1/index.rst b/tests/sites/sidebars/section1/index.rst index 9c9eeb89d..71d90df86 100644 --- a/tests/sites/sidebars/section1/index.rst +++ b/tests/sites/sidebars/section1/index.rst @@ -6,3 +6,8 @@ Section 1 index subsection1/index page2 + +Other Content +------------- + +This is some other content diff --git a/tests/sites/sidebars/section1/subsection1/page2.rst b/tests/sites/sidebars/section1/subsection1/page2.rst index c229b0796..9291751ae 100644 --- a/tests/sites/sidebars/section1/subsection1/page2.rst +++ b/tests/sites/sidebars/section1/subsection1/page2.rst @@ -1,2 +1,6 @@ Section 1 sub 1 page 2 ====================== + + +Section A +--------- diff --git a/tests/sites/sidebars/section2/index.rst b/tests/sites/sidebars/section2/index.rst index 942cb0e8f..94ab69deb 100644 --- a/tests/sites/sidebars/section2/index.rst +++ b/tests/sites/sidebars/section2/index.rst @@ -5,3 +5,9 @@ Section 2 index page1 https://google.com + + +Other Content +------------- + +This is some other content diff --git a/tests/test_build.py b/tests/test_build.py index 1815bcc93..2c2e950a9 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -986,3 +986,73 @@ def test_translations(sphinx_build_factory) -> None: # Search bar # TODO: Add translations where there are english phrases below assert "Search the docs" in str(index.select(".bd-search")[0]) + + +def test_render_secondary_sidebar_list(sphinx_build_factory) -> None: + """Test that the secondary sidebar can be built with a list of templates.""" + confoverrides = { + "html_context": { + "github_user": "pydata", + "github_repo": "pydata-sphinx-theme", + "github_version": "main", + }, + "html_theme_options": { + "use_edit_page_button": True, + "secondary_sidebar_items": ["page-toc", "edit-this-page"], + }, + } + sphinx_build = sphinx_build_factory("sidebars", confoverrides=confoverrides) + # Basic build with defaults + sphinx_build.build() + + # Check that the page-toc template gets rendered + assert sphinx_build.html_tree("index.html").select("div.page-toc") + assert sphinx_build.html_tree("section1/index.html").select("div.page-toc") + assert sphinx_build.html_tree("section2/index.html").select("div.page-toc") + + # Check that the edit-this-page template gets rendered + assert sphinx_build.html_tree("index.html").select("div.editthispage") + assert sphinx_build.html_tree("section1/index.html").select("div.editthispage") + assert sphinx_build.html_tree("section2/index.html").select("div.editthispage") + + # Check that sourcelink is not rendered + assert not sphinx_build.html_tree("index.html").select("div.sourcelink") + assert not sphinx_build.html_tree("section1/index.html").select("div.sourcelink") + assert not sphinx_build.html_tree("section2/index.html").select("div.sourcelink") + + +def test_render_secondary_sidebar_dict(sphinx_build_factory) -> None: + """Test that the secondary sidebar can be built with a dict of templates.""" + confoverrides = { + "html_context": { + "github_user": "pydata", + "github_repo": "pydata-sphinx-theme", + "github_version": "main", + }, + "html_theme_options": { + "use_edit_page_button": True, + "secondary_sidebar_items": { + "**": ["page-toc", "edit-this-page"], + "section1/index": [], + "section2/index": ["sourcelink"], + }, + }, + } + sphinx_build = sphinx_build_factory("sidebars", confoverrides=confoverrides) + # Basic build with defaults + sphinx_build.build() + + # Check that the page-toc template gets rendered + assert sphinx_build.html_tree("index.html").select("div.page-toc") + assert not sphinx_build.html_tree("section1/index.html").select("div.page-toc") + assert not sphinx_build.html_tree("section2/index.html").select("div.page-toc") + + # Check that the edit-this-page template gets rendered + assert sphinx_build.html_tree("index.html").select("div.editthispage") + assert not sphinx_build.html_tree("section1/index.html").select("div.editthispage") + assert not sphinx_build.html_tree("section2/index.html").select("div.editthispage") + + # Check that sourcelink is not rendered + assert not sphinx_build.html_tree("index.html").select("div.sourcelink") + assert not sphinx_build.html_tree("section1/index.html").select("div.sourcelink") + assert sphinx_build.html_tree("section2/index.html").select("div.sourcelink") From e630cdde79039b1a828ca5fde6f56b7b4f7f1429 Mon Sep 17 00:00:00 2001 From: pdmurray Date: Sun, 24 Dec 2023 11:58:42 -0800 Subject: [PATCH 2/2] Add additional secondary sidebar content tests; expanded utils docstring --- src/pydata_sphinx_theme/utils.py | 12 +++- tests/test_build.py | 104 ++++++++++++++++++++++++++++++- 2 files changed, 114 insertions(+), 2 deletions(-) diff --git a/src/pydata_sphinx_theme/utils.py b/src/pydata_sphinx_theme/utils.py index 80edb47ce..94fd7108c 100644 --- a/src/pydata_sphinx_theme/utils.py +++ b/src/pydata_sphinx_theme/utils.py @@ -92,7 +92,14 @@ def _update_and_remove_templates( app: Sphinx application passed to the html page context context: The html page context; dictionary of values passed to the templating engine templates: A list of template names, or a string of comma separated template names - section: Name of the template section where the templates are to be rendered + section: Name of the template section where the templates are to be rendered. Valid + section names include any of the ``sphinx`` or ``html_theme_options`` that take templates + or lists of templates as arguments, for example: ``theme_navbar_start``, + ``theme_primary_sidebar_end``, ``theme_secondary_sidebar_items``, ``sidebars``, etc. For + a complete list of valid section names, see the source for + :py:func:`pydata_sphinx_theme.update_and_remove_templates` and + :py:func:`pydata_sphinx_theme.utils.set_secondary_sidebar_items`, both of which call + this function. templates_skip_empty_check: Names of any templates which should never be removed from the list of filtered templates returned by this function. These templates aren't checked if they render empty, which can save time if the template is slow to render. @@ -137,6 +144,9 @@ def _get_matching_sidebar_items( ) -> List[str]: """Get the matching sidebar templates to render for the given pagename. + If a page matches more than one pattern, a warning is emitted, and the templates for the + last matching pattern are used. + This function was adapted from sphinx.builders.html.StandaloneHTMLBuilder.add_sidebars. """ matched = None diff --git a/tests/test_build.py b/tests/test_build.py index 2c2e950a9..90f6dd914 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -1030,6 +1030,7 @@ def test_render_secondary_sidebar_dict(sphinx_build_factory) -> None: "github_version": "main", }, "html_theme_options": { + **COMMON_CONF_OVERRIDES, "use_edit_page_button": True, "secondary_sidebar_items": { "**": ["page-toc", "edit-this-page"], @@ -1043,16 +1044,117 @@ def test_render_secondary_sidebar_dict(sphinx_build_factory) -> None: sphinx_build.build() # Check that the page-toc template gets rendered + # (but not for section1/index or section2/*) assert sphinx_build.html_tree("index.html").select("div.page-toc") assert not sphinx_build.html_tree("section1/index.html").select("div.page-toc") assert not sphinx_build.html_tree("section2/index.html").select("div.page-toc") # Check that the edit-this-page template gets rendered + # (but not for section1/index or section2/*) assert sphinx_build.html_tree("index.html").select("div.editthispage") assert not sphinx_build.html_tree("section1/index.html").select("div.editthispage") assert not sphinx_build.html_tree("section2/index.html").select("div.editthispage") - # Check that sourcelink is not rendered + # Check that sourcelink is only rendered for section2/* + assert not sphinx_build.html_tree("index.html").select("div.sourcelink") + assert not sphinx_build.html_tree("section1/index.html").select("div.sourcelink") + assert sphinx_build.html_tree("section2/index.html").select("div.sourcelink") + + +def test_render_secondary_sidebar_dict_glob_subdir(sphinx_build_factory) -> None: + """Test that the secondary sidebar can be built with a dict of templates that globs a subdir.""" + confoverrides = { + "html_context": { + "github_user": "pydata", + "github_repo": "pydata-sphinx-theme", + "github_version": "main", + }, + "html_theme_options": { + **COMMON_CONF_OVERRIDES, + "use_edit_page_button": True, + "secondary_sidebar_items": { + "section1/index": [], + "section2/*": ["sourcelink"], + }, + }, + } + sphinx_build = sphinx_build_factory("sidebars", confoverrides=confoverrides) + # Basic build with defaults + sphinx_build.build() + + # Check that the no page-toc template gets rendered + assert not sphinx_build.html_tree("section1/index.html").select("div.page-toc") + assert not sphinx_build.html_tree("section2/index.html").select("div.page-toc") + assert not sphinx_build.html_tree("section2/page1.html").select("div.page-toc") + + # Check that no edit-this-page template gets rendered + assert not sphinx_build.html_tree("section1/index.html").select("div.editthispage") + assert not sphinx_build.html_tree("section2/index.html").select("div.editthispage") + assert not sphinx_build.html_tree("section2/page1.html").select("div.editthispage") + + # Check that sourcelink is only rendered for section2/* + assert not sphinx_build.html_tree("section1/index.html").select("div.sourcelink") + assert sphinx_build.html_tree("section2/index.html").select("div.sourcelink") + assert sphinx_build.html_tree("section2/page1.html").select("div.sourcelink") + + +def test_render_secondary_sidebar_dict_multiple_glob_matches( + sphinx_build_factory, +) -> None: + """Test that the secondary sidebar builds with a template dict with two conflicting globs. + + The last specified glob pattern should win, but a warning should be emitted with the + offending pattern and affected pagenames. + """ + confoverrides = { + "html_context": { + "github_user": "pydata", + "github_repo": "pydata-sphinx-theme", + "github_version": "main", + }, + "html_theme_options": { + **COMMON_CONF_OVERRIDES, + "use_edit_page_button": True, + "secondary_sidebar_items": { + "**": [ + "page-toc", + "edit-this-page", + ], # <-- Some pages match both patterns + "section1/index": [], + "section2/*": ["sourcelink"], # <-- Some pages match both patterns + }, + }, + } + sphinx_build = sphinx_build_factory( + "sidebars", + confoverrides=confoverrides, + ) + # Basic build with defaults + sphinx_build.build(no_warning=False) + + # Check that the proper warnings are emitted for the affected pages + for page in ["section2/index", "section2/page1"]: + assert ( + f"WARNING: Page {page} matches two wildcard patterns " + "in secondary_sidebar_items: ** and section2/*" + ) in sphinx_build.warnings + + # Check that the page-toc template gets rendered + # (but not for section1/index or section2/*) + assert sphinx_build.html_tree("index.html").select("div.page-toc") + assert not sphinx_build.html_tree("section1/index.html").select("div.page-toc") + assert not sphinx_build.html_tree("section2/index.html").select("div.page-toc") + assert not sphinx_build.html_tree("section2/page1.html").select("div.page-toc") + + # Check that the edit-this-page template gets rendered + # (but not for section1/index or section2/*) + assert sphinx_build.html_tree("index.html").select("div.editthispage") + assert not sphinx_build.html_tree("section1/index.html").select("div.editthispage") + assert not sphinx_build.html_tree("section2/index.html").select("div.editthispage") + assert not sphinx_build.html_tree("section2/page1.html").select("div.editthispage") + + # Check that sourcelink is only rendered for section2/* assert not sphinx_build.html_tree("index.html").select("div.sourcelink") assert not sphinx_build.html_tree("section1/index.html").select("div.sourcelink") assert sphinx_build.html_tree("section2/index.html").select("div.sourcelink") + assert sphinx_build.html_tree("section2/page1.html").select("div.sourcelink")