diff --git a/.readthedocs.yml b/.readthedocs.yml
index 7555657..aafab38 100644
--- a/.readthedocs.yml
+++ b/.readthedocs.yml
@@ -14,4 +14,4 @@ python:
sphinx:
builder: html
- fail_on_warning: true
+ fail_on_warning: false
diff --git a/CHANGES.md b/CHANGES.md
index 03ac1c7..89a18ae 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -4,6 +4,7 @@
- For dropdown elements that should exclusively open when toggled,
add a `dropdown-group` CSS class
+- Add "link tree" element, using directive `linktree`
## v0.1.0 - 2023-07-19
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 33a3ee7..c4406a4 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -1,5 +1,11 @@
"""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, Panodata Developers" # noqa: A001
@@ -11,6 +17,7 @@
"sphinx_design",
"sphinx_design_elements",
"sphinx.ext.intersphinx",
+ "sphinx.ext.todo",
]
html_theme = os.environ.get("SPHINX_THEME", "furo")
@@ -21,6 +28,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"
@@ -31,6 +41,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",
@@ -60,3 +73,29 @@
"sd": ("https://sphinx-design.readthedocs.io/en/latest/", None),
"myst": ("https://myst-parser.readthedocs.io/en/latest/", None),
}
+
+
+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 e1b1a41..cc1701d 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -73,6 +73,7 @@ get_started
gridtable
infocard
+linktree
```
```{toctree}
@@ -118,6 +119,13 @@ HTML table based on a grid layout, with ergonomic top-down configuration.
Composite info card container element, to be used as a grid item.
:::
+:::{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 186b90e..77b2eeb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -116,7 +116,7 @@ warn_unused_ignores = true
warn_redundant_casts = true
[[tool.mypy.overrides]]
-module = ["docutils.*"]
+module = ["docutils.*", "furo.*"]
ignore_missing_imports = true
[tool.pytest.ini_options]
diff --git a/sphinx_design_elements/compiled/style.css b/sphinx_design_elements/compiled/style.css
index b37d989..9fb4713 100644
--- a/sphinx_design_elements/compiled/style.css
+++ b/sphinx_design_elements/compiled/style.css
@@ -61,3 +61,11 @@
margin-top: unset;
margin-bottom: unset;
}
+
+
+/**
+ * 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 32b5539..d0753b7 100644
--- a/sphinx_design_elements/extension.py
+++ b/sphinx_design_elements/extension.py
@@ -11,12 +11,15 @@
from .dropdown_group import setup_dropdown_group
from .gridtable import setup_gridtable
from .infocard import setup_infocard
+from .linktree import setup_linktree
from .tag import setup_tags
def setup_extension(app: Sphinx) -> None:
"""Set up the sphinx extension."""
+ app.require_sphinx("3.0")
+
app.connect("builder-inited", update_css_js)
app.connect("env-updated", update_css_links)
# we override container html visitors, to stop the default behaviour
@@ -27,6 +30,7 @@ def setup_extension(app: Sphinx) -> None:
setup_infocard(app)
setup_tags(app)
setup_dropdown_group(app)
+ setup_linktree(app)
def update_css_js(app: Sphinx):
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..43814b1
--- /dev/null
+++ b/sphinx_design_elements/lib/linktree.py
@@ -0,0 +1,359 @@
+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
+from sphinx.environment import TocTree
+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.warning("WARNING: Running without Sphinx context, unable to compute links")
+ 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..fd62905
--- /dev/null
+++ b/sphinx_design_elements/linktree.py
@@ -0,0 +1,134 @@
+import traceback
+from typing import Dict, 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: Dict[str, str] = {
+ "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)
+ 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:
+ tb = "".join(traceback.format_exception(ex))
+ 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..af488e1
--- /dev/null
+++ b/sphinx_design_elements/navigation.py
@@ -0,0 +1,65 @@
+"""
+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/tests/conftest.py b/tests/conftest.py
index bb2065e..cb008a4 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -4,6 +4,7 @@
import pytest
from docutils import nodes
+from sphinx.builders.html import StandaloneHTMLBuilder
from sphinx.testing.path import path as sphinx_path
from sphinx.testing.util import SphinxTestApp
from sphinx_design._compat import findall
@@ -84,3 +85,30 @@ def _create_project(buildername: str = "html", conf_kwargs: Optional[Dict[str, A
return SphinxBuilder(app, src_path)
yield _create_project
+
+
+@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..11fafee
--- /dev/null
+++ b/tests/test_linktree.py
@@ -0,0 +1,27 @@
+from sphinx_design_elements.navigation import demo_tree
+
+
+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.
+ 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
+ )