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 + )