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..94fd7108c 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,112 @@ 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. 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. + + 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. + + 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 + 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..90f6dd914 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -986,3 +986,175 @@ 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": { + **COMMON_CONF_OVERRIDES, + "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 + # (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 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")