diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6d72aae..f76c9cf 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -102,6 +102,7 @@ jobs: # Install specific Sphinx and docutils versions, according to test matrix slots. uv pip install 'sphinx==${{ matrix.sphinx-version }}' uv pip install 'docutils==${{ matrix.docutils-version }}' + uv pip install 'types-docutils==${{ matrix.docutils-version }}' - name: Sphinx and docutils versions run: | diff --git a/CHANGES.md b/CHANGES.md index b6eece8..b3e4701 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -3,6 +3,7 @@ ## Unreleased - Dependencies: Added compatibility with docutils 0.19 - 0.22 - Dependencies: Added compatibility with sphinx 7 - 9 +- Add "link tree" element, using directive `linktree` ## v0.4.0 - 2024-06-27 - Dependencies: Update to sphinx-design 0.6.0 diff --git a/docs/_templates/linktree-demo.html b/docs/_templates/linktree-demo.html new file mode 100644 index 0000000..92c6139 --- /dev/null +++ b/docs/_templates/linktree-demo.html @@ -0,0 +1,9 @@ +

Classic toctree

+ + +

Custom linktree

+ diff --git a/docs/_templates/page.html b/docs/_templates/page.html new file mode 100644 index 0000000..66dbf62 --- /dev/null +++ b/docs/_templates/page.html @@ -0,0 +1,10 @@ +{%- extends "!page.html" %} + +{% block content %} +{{ super() }} + +{% if pagename == "linktree" %} +{% include "linktree-demo.html" %} +{% endif %} + +{% endblock %} diff --git a/docs/conf.py b/docs/conf.py index d7e1337..dd11b28 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,6 +1,12 @@ """Configuration file for the Sphinx documentation builder.""" import os +import traceback +import typing as t + +from sphinx.application import Sphinx + +from sphinx_design_elements.navigation import default_tree, demo_tree project = "Sphinx Design Elements" copyright = "2023-2025, Panodata Developers" @@ -23,6 +29,9 @@ # html_logo = "_static/logo_wide.svg" # html_favicon = "_static/logo_square.svg" +# Add any paths that contain templates here, relative to this directory. +templates_path = ["_templates"] + # if html_theme not in ("sphinx_book_theme", "pydata_sphinx_theme"): # html_css_files = [ # "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.1.1/css/all.min.css" @@ -33,6 +42,9 @@ "sidebar_hide_name": False, } +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = True + exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] myst_enable_extensions = [ "attrs_block", @@ -64,3 +76,29 @@ } todo_include_todos = True + + +def setup(app: Sphinx) -> None: + """Set up the sphinx extension.""" + app.require_sphinx("3.0") + app.connect("html-page-context", _html_page_context) + + +def _html_page_context( + app: Sphinx, + pagename: str, + templatename: str, + context: t.Dict[str, t.Any], + doctree: t.Any, +) -> None: + """ + Sphinx HTML page context provider. + """ + + # Initialize link tree navigation component. + try: + context["sde_linktree_primary"] = default_tree(builder=app.builder, context=context).render() + context["demo_synthetic_linktree"] = demo_tree(builder=app.builder, context=context).render() + except Exception as ex: + traceback.print_exception(ex) + raise diff --git a/docs/get_started.md b/docs/get_started.md index 5b80d59..33346a3 100644 --- a/docs/get_started.md +++ b/docs/get_started.md @@ -64,6 +64,7 @@ provided by this collection, and how to use them in your documentation markup. - [](#gridtable-directive) - [](#infocard-directive) +- [](#linktree-directive) - [](#tag-role) Both [reStructuredText] and [Markedly Structured Text] syntax are supported equally well. diff --git a/docs/index.md b/docs/index.md index 06075b1..7540037 100644 --- a/docs/index.md +++ b/docs/index.md @@ -74,6 +74,7 @@ get_started gridtable infocard shield +linktree ``` ```{toctree} @@ -134,6 +135,13 @@ Badge generator for Shields\.io, with optional target linking. A versatile hyperlink generator. ::: +:::{grid-item-card} {octicon}`workflow` Link tree +:link: linktree +:link-type: doc + +A programmable toctree component. +::: + :::{grid-item-card} {octicon}`tag` Special badges :link: tag :link-type: doc diff --git a/docs/linktree.md b/docs/linktree.md new file mode 100644 index 0000000..4921400 --- /dev/null +++ b/docs/linktree.md @@ -0,0 +1,128 @@ +(linktree-directive)= + +# Link Tree + + +## About + +Similar but different from a Toc Tree. + +```{attention} +This component is a work in progress. Breaking changes should be expected until a +1.0 release, so version pinning is recommended. +``` + +### Problem + +So much work went into the toctree mechanics, it is sad that it is not a reusable +component for building any kinds of navigation structures, and to be able to define +its contents more freely. + +### Solution + +This component implements a programmable toc tree component, the link tree. + + +## Details + +The link tree component builds upon the Sphinx [toc] and [toctree] subsystem. It provides +both a rendered primary navigation within the `sde_linktree_primary` context variable +for use from HTML templates, and a Sphinx directive, `linktree`, for rendering +navigation trees into pages, similar but different from the [toctree directive]. The +user interface mechanics and styles are based on [Furo]'s primary sidebar component. + + +## Customizing + +Link trees can be customized by creating them programmatically, similar to how +the `sde_linktree_primary` context variable is populated with the default Sphinx +toc tree. + +The section hidden behind the dropdown outlines how the "custom linktree" is +defined, which is displayed at the bottom of the page in a rendered variant. +:::{dropdown} Custom linktree example code + +```python +import typing as t + +from sphinx.application import Sphinx +from sphinx_design_elements.lib.linktree import LinkTree + + +def demo_tree(app: Sphinx, context: t.Dict[str, t.Any], docname: str = None) -> LinkTree: + """ + The demo link tree showcases some features what can be done. + + It uses regular page links to documents in the current project, a few + intersphinx references, and a few plain, regular, URL-based links. + """ + linktree = LinkTree.from_context(app=app, context=context) + doc = linktree.api.doc + ref = linktree.api.ref + link = linktree.api.link + + linktree \ + .title("Project-local page links") \ + .add( + doc(name="gridtable"), + doc(name="infocard"), + ) + + linktree \ + .title("Intersphinx links") \ + .add( + ref("sd:index"), + ref("sd:badges", label="sphinx{design} badges"), + ref("myst:syntax/images_and_figures", "MyST » Images and figures"), + ref("myst:syntax/referencing", "MyST » Cross references"), + ) + + linktree \ + .title("URL links") \ + .add( + link(uri="https://example.com"), + link(uri="https://example.com", label="A link to example.com, using a custom label ⚽."), + ) + + return linktree +``` +::: + +```{todo} +- Use the `linktree` directive to define custom link trees. +- Link to other examples of custom link trees. +- Maybe use `:link:` and `:link-type:` directive options of `grid-item-card` directive. +``` + + +## Directive examples + +### Example 1 + +The link tree of the `index` page, using a defined maximum depth, and a custom title. +```{linktree} +:docname: index +:maxdepth: 1 +:title: Custom title +``` + + +## Appendix + +Here, at the bottom of the page, different global template variables are presented, +which contain representations of navigation trees, rendered to HTML. + +- `sde_linktree_primary`: The classic toctree, like it will usually be rendered + into the primary sidebar. +- `demo_synthetic_linktree`: A customized link tree composed of links to project-local + pages, intersphinx links, and URLs, for demonstration purposes. + +```{hint} +The corresponding template, `linktree-demo.html` will exclusively be rendered +here, and not on other pages. +``` + +[Furo]: https://pradyunsg.me/furo/ +[toctree directive]: https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#directive-toctree +[toc]: https://www.sphinx-doc.org/en/master/development/templating.html#toc +[toctree]: https://www.sphinx-doc.org/en/master/development/templating.html#toctree diff --git a/pyproject.toml b/pyproject.toml index decfb35..ed602af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,6 +83,7 @@ dynamic = [ dependencies = [ "beautifulsoup4", "docutils<0.23", + "furo", "myst-parser", "sphinx<10", "sphinx-design==0.6.1", @@ -98,7 +99,6 @@ optional-dependencies.develop = [ "validate-pyproject<1", ] optional-dependencies.docs = [ - "furo", "myst-parser[linkify]>=0.18", "sphinx-autobuild", "sphinx-copybutton", @@ -215,7 +215,7 @@ warn_unused_ignores = false warn_redundant_casts = true [[tool.mypy.overrides]] -module = [ "docutils.*" ] +module = [ "docutils.*", "furo.*" ] ignore_missing_imports = true [tool.versioningit.vcs] diff --git a/sphinx_design_elements/compiled/style.css b/sphinx_design_elements/compiled/style.css index e4090d3..e9393f4 100644 --- a/sphinx_design_elements/compiled/style.css +++ b/sphinx_design_elements/compiled/style.css @@ -88,3 +88,10 @@ hr.docutils { .bottom-margin-generous { margin-bottom: 2em !important; } + +/** + * Fix appearance of page-rendered link tree. +**/ +article .sidebar-tree p.caption { + text-align: unset; +} diff --git a/sphinx_design_elements/extension.py b/sphinx_design_elements/extension.py index 149b1d2..1521038 100644 --- a/sphinx_design_elements/extension.py +++ b/sphinx_design_elements/extension.py @@ -12,6 +12,7 @@ from .gridtable import setup_gridtable from .hyper import setup_hyper from .infocard import setup_infocard +from .linktree import setup_linktree from .shield import setup_shield from .tag import setup_tags @@ -32,6 +33,7 @@ def setup_extension(app: Sphinx) -> None: setup_gridtable(app) setup_hyper(app) setup_infocard(app) + setup_linktree(app) setup_shield(app) setup_tags(app) diff --git a/sphinx_design_elements/hyper.py b/sphinx_design_elements/hyper.py index a5237e4..d50aad6 100644 --- a/sphinx_design_elements/hyper.py +++ b/sphinx_design_elements/hyper.py @@ -5,7 +5,7 @@ import yaml from docutils import nodes -from docutils.nodes import Node, system_message, unescape +from docutils.nodes import Node, system_message, unescape # type: ignore[attr-defined] from docutils.parsers.rst.states import Inliner from myst_parser.mocking import MockInliner from sphinx.application import Sphinx @@ -141,8 +141,8 @@ def __call__( else: error = ValueError("Unable to resolve reference") - msg = inliner.reporter.warning(error) - prb = inliner.problematic(rawtext, rawtext, msg) + msg = inliner.reporter.warning(error) # type: ignore[union-attr] + prb = inliner.problematic(rawtext, rawtext, msg) # type: ignore[union-attr] return [prb], [msg] return SphinxRole.__call__(self, name, rawtext, text, lineno, inliner, options, content) # type: ignore[arg-type] @@ -158,7 +158,7 @@ def resolve_page_title(self) -> str: except Exception: return self.target elif self.srh.is_traditional_intersphinx_reference(): - document = self.inliner.document + document = self.inliner.document # type: ignore[attr-defined] ref = resolve_reference(env=self.app.env, document=document, target=self.target) elif self.srh.is_myst_reference(): link = f"[]({self.target})" @@ -308,7 +308,7 @@ def render_snippet(self, snippet: str) -> Tuple[List[nodes.Node], List[nodes.sys Render a MyST snippet. """ directive_nodes, _ = self.inliner.parse_block( # type: ignore[attr-defined] - text=snippet, lineno=self.lineno, memo=self, parent=self.inliner.parent, with_container=self.with_container + text=snippet, lineno=self.lineno, memo=self, parent=self.inliner.parent, with_container=self.with_container # type: ignore[attr-defined] ) if not directive_nodes: return [], self.system_messages diff --git a/sphinx_design_elements/lib/__init__.py b/sphinx_design_elements/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sphinx_design_elements/lib/linktree.py b/sphinx_design_elements/lib/linktree.py new file mode 100644 index 0000000..7aa5237 --- /dev/null +++ b/sphinx_design_elements/lib/linktree.py @@ -0,0 +1,365 @@ +import typing as t + +from docutils import nodes +from furo import get_navigation_tree +from sphinx import addnodes +from sphinx.builders import Builder +from sphinx.builders.html import StandaloneHTMLBuilder + +try: + # Sphinx 8.x+ + from sphinx.environment.adapters.toctree import TocTree +except ImportError: + # Sphinx 7.x and earlier + from sphinx.environment import TocTree # type: ignore[attr-defined,no-redef] +from sphinx.errors import SphinxError +from sphinx.ext.intersphinx import resolve_reference_detect_inventory +from sphinx.util import logging + +logger = logging.getLogger(__name__) + + +class LinkTree: + """ + Link tree implementation. + + A link tree is a navigation tree component based on docutils, Sphinx toctree, and Furo. + It is similar to a toc tree, but programmable. + """ + + def __init__( + self, + builder: Builder, + docname: t.Optional[str] = None, + project_name: t.Optional[str] = None, + root_doc: t.Optional[str] = None, + pathto: t.Optional[t.Callable] = None, + ): + self.builder = builder + + self.docname = docname + self.project_name = project_name + self.root_doc = root_doc + self.pathto = pathto + + self.api = Api(root=self) + self.util = Util(root=self) + + # Which string to strip from each link label. + # Can be used to get rid of label/title prefixes. + self.strip_from_label: t.Optional[str] = None + + # Runtime setup. + self.setup() + + # The root node of a link tree is actually a toc tree. + self.container = addnodes.compact_paragraph(toctree=True) + + logger.info(f"Producing link tree for: {self.docname}") + + @classmethod + def from_context(cls, builder: Builder, context: t.Dict[str, t.Any]) -> "LinkTree": + """ + Create a link tree instance from the current Sphinx context. + """ + page_name = context.get("pagename") + project_name = context.get("project") + root_doc = context.get("root_doc", context.get("master_doc")) + pathto = context.get("pathto") + return cls(builder=builder, docname=page_name, project_name=project_name, root_doc=root_doc, pathto=pathto) + + @classmethod + def from_app(cls, builder: Builder, docname: t.Optional[str] = None) -> "LinkTree": + """ + Create a link tree instance without a Sphinx context. + """ + try: + if docname is None: + docname = builder.app.env.docname + except Exception: + logger.warning("Unable to derive docname from application environment") + + return cls(builder=builder, docname=docname) + + def setup(self) -> None: + """ + Link tree runtime setup. + """ + + # When not running on behalf of a Sphinx context, `pathto` is not available. + # TODO: Is there some other way to get it? + if self.pathto is None: + logger.info("WARNING: Running without Sphinx context, unable to compute links using `pathto`") + self.pathto = lambda x: None + + def remove_from_title(self, text: t.Optional[str]) -> None: + """ + Set the string which should be stripped from each link label. + """ + self.strip_from_label = text + + def title(self, text: str) -> "LinkTree": + """ + Add a title node to the link tree. + """ + self.container.append(self.util.title(text)) + return self + + def add(self, *items) -> None: + """ + Add one or many elements or nodes to the link tree. + """ + for item in items: + if hasattr(item, "container"): + real = item.container + else: + real = item + self.container.append(real) + + def project(self, docname: t.Optional[str] = None, title: t.Optional[str] = None) -> "ProjectSection": + """ + Add a project section to the link tree. + """ + docname = docname or self.docname + logger.info(f"New project with name={docname}, title={title}") + p = ProjectSection(root=self, name=docname, title=title) + self.add(p) + return p + + def render(self) -> str: + """ + Enhance and render link tree using Furo UI mechanics and styles. + + - https://github.com/pradyunsg/furo/blob/2023.05.20/src/furo/navigation.py + - https://github.com/pradyunsg/furo/blob/2023.05.20/src/furo/__init__.py#L164-L220 + """ + if not isinstance(self.builder, StandaloneHTMLBuilder): + raise SphinxError(f"Sphinx builder needs to be of type StandaloneHTMLBuilder: {type(self.builder)}") + linktree_html = self.builder.render_partial(self.container)["fragment"] + return get_navigation_tree(linktree_html) + + +class ProjectSection: + """ + A section within the link tree which represents a whole project. + """ + + def __init__(self, root: LinkTree, name: t.Optional[str], title: t.Optional[str]): + env = root.builder.app.env + + self.root = root + self.name = name + self.title = title + + # When no title is given, try to resolve it from the environment. + if self.title is None and name in env.titles: + self.title = env.titles[name].astext() + if self.title is None: + logger.warning(f"Unable to derive link label, document does not exist: {name}") + + # Create project node layout and root node. + self.container = nodes.bullet_list(classes=self.classes) + self.main = self.root.util.project(name=self.name, label=self.title, level=1) + self.inner = nodes.bullet_list() + self.container.append(self.main) + self.main.append(self.inner) + + @property + def classes(self) -> t.List[str]: + """ + Compute CSS classes based on runtime / selection info. + """ + if self.is_current_project(): + return ["current"] + return [] + + def is_current_project(self) -> bool: + """ + Whether the component is rendering the current project (self). + + This information will get used to add `current` CSS classes, when the project has been selected. + """ + return self.name == self.root.project_name + + def add(self, *items) -> "ProjectSection": + """ + Add one or many elements or nodes to the project section. + """ + self.inner.extend(items) + return self + + def toctree(self, docname: t.Optional[str] = None, maxdepth: int = -1) -> "ProjectSection": + """ + Generate a toctree node tree, and add it to the project section. + """ + logger.info(f"Generating toctree for document: {docname}") + if docname is None: + docname = self.root.docname + + toctree = self.root.util.toctree(docname=docname, maxdepth=maxdepth) + if toctree is not None: + self.add(toctree) + else: + logger.warning("WARNING: toctree is empty") + return self + + +class Api: + """ + An API to the low-level node factory functions. + """ + + def __init__(self, root: LinkTree): + self.root = root + + @staticmethod + def wrap_ul(elem): + ul = nodes.bullet_list() + ul.append(elem) + return ul + + def doc(self, name: str, label: t.Optional[str] = None, level: int = 2, **kwargs): + return self.wrap_ul(self.root.util.doc(name, label, level, **kwargs)) + + def link(self, uri: str, label: t.Optional[str] = None, level: int = 2, **kwargs): + return self.wrap_ul(self.root.util.link(uri, label, level, **kwargs)) + + def ref(self, target: str, label: t.Optional[str] = None, level: int = 2, **kwargs): + return self.wrap_ul(self.root.util.ref(target, label, level, **kwargs)) + + +class Util: + """ + Low-level node factory functions. + """ + + def __init__(self, root: LinkTree): + self.root = root + + @staticmethod + def title(text: str) -> nodes.Element: + return nodes.title(text=text) + + @staticmethod + def item(**kwargs) -> nodes.Element: + """ + Create node layout for all kinds of linked items. + """ + + # Compute CSS classes. + level = 1 + if "level" in kwargs: + level = int(kwargs["level"]) + del kwargs["level"] + toctree_class = f"toctree-l{level}" + classes = kwargs.get("classes", []) + effective_classes = [toctree_class] + classes + + # Container node:
  • . + container = nodes.list_item(classes=effective_classes) + + # Intermediary node. + content = addnodes.compact_paragraph(classes=effective_classes) + + # Inner node: The reference. + # An example call to `nodes.reference` looks like this. + # `nodes.reference(refuri="foobar.html", label="Foobar", internal=True)` + ref = nodes.reference(**kwargs) + + content.append(ref) + container.append(content) + return container + + def doc(self, name: str, label: t.Optional[str] = None, level: int = 2, **kwargs) -> nodes.Element: + """ + Create node layout for a link to a Sphinx document. + """ + if self.root.pathto is None: + raise SphinxError("pathto is not defined") + refuri = self.root.pathto(name) + if label is None: + titles = self.root.builder.app.env.titles + if name in titles: + label = self.root.builder.app.env.titles[name].astext() + else: + logger.warning(f"Unable to derive label from document: {name}") + kwargs.setdefault("classes", []) + if name == self.root.docname: + kwargs["classes"] += ["current", "current-page"] + return self.item(refuri=refuri, text=label, internal=True, level=level, **kwargs) + + def link(self, uri: str, label: t.Optional[str] = None, level: int = 2, **kwargs): + """ + Create node layout for a basic URL-based link. + """ + # FIXME: Fix visual appearance of `internal=False`, then start using it. + if label is None: + label = uri + return self.item(refuri=uri, text=label, internal=True, level=level, **kwargs) + + def ref(self, target: str, label: t.Optional[str] = None, level: int = 2, **kwargs) -> t.Optional[nodes.Element]: + """ + Create node layout for a link to a Sphinx intersphinx reference. + """ + refnode_content = nodes.TextElement(reftarget=target, reftype="any") + refnode_xref = addnodes.pending_xref(reftarget=target, reftype="any") + ref = resolve_reference_detect_inventory( + env=self.root.builder.app.env, + node=refnode_xref, + contnode=refnode_content, + ) + # TODO: Add option to handle unresolved intersphinx references gracefully. + if ref is None: + raise SphinxError(f"Unable to resolve intersphinx reference: {target}") + refuri = ref["refuri"] + if label is None: + txt = next(ref.findall(nodes.TextElement, include_self=False)) + label = txt.astext() + if self.root.strip_from_label is not None: + label = label.replace(self.root.strip_from_label, "").strip() + return self.item(refuri=refuri, text=label, internal=True, level=level, **kwargs) + + def project(self, name: t.Optional[str], label: t.Optional[str], level: int = 1, **kwargs) -> nodes.Element: + """ + Create project section node layout. + """ + if self.root.pathto is None: + raise SphinxError("pathto is not defined") + refuri = self.root.pathto(self.root.root_doc) + kwargs.setdefault("classes", []) + if name == self.root.project_name: + kwargs.setdefault("classes", []) + kwargs["classes"] += ["current"] + return self.item(refuri=refuri, text=label, internal=True, level=level, **kwargs) + + def toctree(self, docname: t.Optional[str], maxdepth: int = -1) -> t.Optional[nodes.Element]: + """ + Create node layout of classic Sphinx toctree. + """ + if docname is None: + raise SphinxError("Unable to compute toctree without docname") + return _get_local_toctree_unrendered( + builder=self.root.builder, + docname=docname, + maxdepth=maxdepth, + ) + + +def _get_local_toctree_unrendered( + builder, docname: str, collapse: bool = False, **kwargs: t.Any +) -> t.Optional[nodes.Element]: + """ + Build a toctree for the given document and options, without rendering it to HTML yet. + + From `sphinx.builders.html._get_local_toctree`. + + TODO: Also look at implementations from Executable Books Theme. + """ + """ + if 'includehidden' not in kwargs: + kwargs['includehidden'] = False + """ + if kwargs.get("maxdepth") == "": + kwargs.pop("maxdepth") + + return TocTree(builder.app.env).get_toctree_for(docname, builder, collapse, **kwargs) diff --git a/sphinx_design_elements/linktree.py b/sphinx_design_elements/linktree.py new file mode 100644 index 0000000..ea29edf --- /dev/null +++ b/sphinx_design_elements/linktree.py @@ -0,0 +1,136 @@ +import sys +import traceback +from typing import List + +from docutils import nodes +from docutils.parsers.rst import directives +from sphinx.application import Sphinx +from sphinx.errors import SphinxError +from sphinx.util import logging +from sphinx.util.docutils import SphinxDirective + +from sphinx_design_elements.lib.linktree import LinkTree + +logger = logging.getLogger(__name__) + + +def setup_linktree(app: Sphinx): + """ + Set up the `linktree` directive. + """ + app.add_node(linktree) + app.add_directive("linktree", LinkTreeDirective) + app.connect("doctree-resolved", LinkTreeProcessor) + + +class linktree(nodes.General, nodes.Element): + """ + The docutils node representing a `linktree` directive. + """ + + pass + + +class LinkTreeDirective(SphinxDirective): + """ + The link tree is similar to a toc tree, but programmable. + + The `linktree` directive works similar like the `toctree` directive, by first + collecting all occurrences, and serializing them into a surrogate representation. + After that, the `LinkTreeProcessor` will actually render the directive using the + LinkTree utility. + """ + + has_content = True + required_arguments = 0 + optional_arguments = 1 + # TODO: Maybe rename `title` to `caption`? + # TODO: Implement `target` directive option, in order to link to arbitrary places by ref or URI. + option_spec = { + "docname": directives.unchanged, + "title": directives.unchanged, + "maxdepth": directives.unchanged, + } + + def run(self) -> List[nodes.Node]: + """ + Translate `linktree` directives into surrogate representation, + carrying over all the directive options. + """ + + if self.content: + message = ( + f"The 'linktree' directive currently does not accept content. " + f"The offending node is:\n{self.block_text}" + ) + self.reporter.severe(message) # type: ignore[attr-defined] + raise SphinxError(message) + + # Create a surrogate node element. + surrogate = linktree("") + + # Set the `freeflow` flag, which will signal to wrap the result element into + # a corresponding container to make it render properly, like in the sidebar. + surrogate["freeflow"] = True + + # Propagate directive options 1:1. + for option in self.option_spec.keys(): + if option in self.options: + surrogate[option] = self.options[option] + + return [surrogate] + + +class LinkTreeProcessor: + """ + Process surrogate `linktree` nodes, and render them using the `LinkTree` utility. + """ + + def __init__(self, app: Sphinx, doctree: nodes.document, docname: str) -> None: + self.app = app + self.builder = app.builder + self.config = app.config + self.env = app.env + + try: + self.process(doctree, docname) + except Exception: + logger.exception("Unable to render LinkTree") + + def process(self, doctree: nodes.document, docname: str) -> None: + """ + Process surrogate nodes. + + TODO: In this rendering mode, somehow the mechanics provided by Furo get lost. + """ + for node in list(doctree.findall(linktree)): + if "docname" in node: + docname = node["docname"] + + # TODO: Discuss different container node type. + container: nodes.Element = nodes.section() + if "freeflow" in node and node["freeflow"]: + container["classes"].append("sidebar-tree") + container.append(self.produce(node, docname)) + node.replace_self(container) + + def produce(self, node: nodes.Element, docname: str) -> nodes.Element: + """ + Produce rendered node tree, effectively a document's toc tree. + """ + lt = LinkTree.from_app(builder=self.builder, docname=docname) + title = None + if "title" in node: + title = node["title"] + project = lt.project(title=title) + try: + project.toctree(maxdepth=int(node.get("maxdepth", -1))) + except Exception as ex: + exc_type, exc_value, exc_tb = sys.exc_info() + tb = "".join(traceback.format_exception(exc_type, exc_value, exc_tb)) + message = ( + f"Error producing a toc tree for document using the 'linktree' directive: {docname}. " + f"The offending node is:\n{node}\nThe exception was:\n{tb}" + ) + raise SphinxError(message) from ex + return lt.container diff --git a/sphinx_design_elements/navigation.py b/sphinx_design_elements/navigation.py new file mode 100644 index 0000000..e73ec76 --- /dev/null +++ b/sphinx_design_elements/navigation.py @@ -0,0 +1,66 @@ +""" +Link tree defaults and examples. + +A link tree is a navigation tree component based on docutils, Sphinx toctree, and Furo. +""" + +import typing as t + +from sphinx.builders import Builder + +from sphinx_design_elements.lib.linktree import LinkTree + + +def default_tree(builder: Builder, context: t.Dict[str, t.Any], docname: t.Optional[str] = None) -> LinkTree: + """ + The default link tree is just a toc tree. + """ + # Create LinkTree component. + linktree = LinkTree.from_context(builder=builder, context=context) + + if docname is not None: + linktree.docname = docname + + # Add section about current project (self). + project_name = context["project"] + project = linktree.project(docname=project_name, title=project_name) + + # Add project toctree. + project.toctree() + + return linktree + + +def demo_tree(builder: Builder, context: t.Dict[str, t.Any], docname: t.Optional[str] = None) -> LinkTree: + """ + The demo link tree showcases some features what can be done. + + It uses regular page links to documents in the current project, a few + intersphinx references, and a few plain, regular, URL-based links. + """ + linktree = LinkTree.from_context(builder=builder, context=context) + doc = linktree.api.doc + ref = linktree.api.ref + link = linktree.api.link + + linktree.title("Project-local page links").add( + doc(name="gridtable"), + doc(name="infocard"), + ) + + linktree.title("Intersphinx links").add( + ref("sd:index"), + ref("sd:badges", label="sphinx{design} badges"), + # rST link syntax. + ref("myst:syntax/images_and_figures", "MyST » Images and figures"), + ref("myst:syntax/referencing", "MyST » Cross references"), + # MyST link syntax. + # ref("myst#syntax/images_and_figures"), # noqa: ERA001 + ) + + linktree.title("URL links").add( + link(uri="https://example.com"), + link(uri="https://example.com", label="A link to example.com, using a custom label ⚽."), + ) + + return linktree diff --git a/sphinx_design_elements/util/role.py b/sphinx_design_elements/util/role.py index 6048544..91473a5 100644 --- a/sphinx_design_elements/util/role.py +++ b/sphinx_design_elements/util/role.py @@ -107,7 +107,7 @@ def parse_block_myst( if with_container: ref = container.next_node() else: - ref = cast("nodes.Node", container.next_node()).next_node() + ref = cast("nodes.Node", container.next_node()).next_node() # type: ignore[redundant-cast] if ref: return [ref], [] else: diff --git a/tests/conftest.py b/tests/conftest.py index f847598..d9a0c50 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,7 @@ from docutils import nodes from docutils.parsers.rst import roles from sphinx import addnodes +from sphinx.builders.html import StandaloneHTMLBuilder from sphinx.testing.util import SphinxTestApp from sphinx_design._compat import findall from verlib2 import Version @@ -134,3 +135,30 @@ def find_reference(node: nodes.Node, with_container: bool = False): if results: return results[0] return node + + +@pytest.fixture() +def sphinx_html_builder(tmp_path: Path, make_app, monkeypatch): + """ + Sphinx builder fixture entrypoint for pytest. + + TODO: Derived from `sphinx_builder`. Maybe generalize? + """ + + def _create_project(buildername: str = "html", conf_kwargs: Optional[Dict[str, Any]] = None): + src_path = tmp_path / "srcdir" + src_path.mkdir() + conf_kwargs = conf_kwargs or { + "extensions": ["myst_parser", "sphinx_design", "sphinx_design_elements", "sphinx.ext.intersphinx"], + "myst_enable_extensions": ["colon_fence"], + "intersphinx_mapping": { + "sd": ("https://sphinx-design.readthedocs.io/en/latest/", None), + "myst": ("https://myst-parser.readthedocs.io/en/latest/", None), + }, + } + content = "\n".join([f"{key} = {value!r}" for key, value in conf_kwargs.items()]) + src_path.joinpath("conf.py").write_text(content, encoding="utf8") + app = make_app(srcdir=sphinx_path(os.path.abspath(str(src_path))), buildername=buildername) + return StandaloneHTMLBuilder(app, app.env) + + yield _create_project diff --git a/tests/test_linktree.py b/tests/test_linktree.py new file mode 100644 index 0000000..2899af8 --- /dev/null +++ b/tests/test_linktree.py @@ -0,0 +1,47 @@ +from typing import Callable + +import docutils +from verlib2 import Version + +from sphinx_design_elements.navigation import demo_tree +from tests.conftest import SphinxBuilder + + +def test_directive(sphinx_builder: Callable[..., SphinxBuilder]): + """Quickly test `linktree` directive.""" + builder = sphinx_builder() + content = "# Heading" + "\n\n\n" + ":::{linktree}\n:::\n\n" + builder.src_path.joinpath("index.md").write_text(content, encoding="utf8") + builder.build(assert_pass=False) + assert "Producing link tree for: index" in builder.status + assert "build succeeded, 1 warning" in builder.status + assert "WARNING: toctree is empty" in builder.warnings + + +def test_linktree_demo_tree(sphinx_html_builder): + builder = sphinx_html_builder() + builder.init() + builder.init_templates() + builder.init_highlighter() + + tree = demo_tree(builder=builder, context={}) + html = tree.render() + + assert '

    Project-local page links

    ' in html + # FIXME: Apparently, references to documents can not be resolved yet, in testing mode. + if Version(docutils.__version__) >= Version("0.22"): + assert '
  • ' in html + else: + assert '
  • None
  • ' in html + + assert '

    Intersphinx links

    ' in html + assert ( + '
  • sphinx-design
  • ' + in html + ) + + assert '

    URL links

    ' in html + assert ( + '
  • https://example.com
  • ' + in html + )