diff --git a/CHANGES.rst b/CHANGES.rst index 39a6d2c6a14..4f942d7efee 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -46,6 +46,9 @@ Features added Patch by Adam Turner. +* #10532: Add a new extension to support collapsible content in HTML, + ``sphinx.ext.collapse``, which enables the :rst:dir:`collapse` directive. + Patch by Adam Turner. Bugs fixed ---------- diff --git a/doc/conf.py b/doc/conf.py index 3fb4e8ceaf5..890572b5b00 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -20,6 +20,7 @@ 'sphinx.ext.inheritance_diagram', 'sphinx.ext.coverage', 'sphinx.ext.graphviz', + 'sphinx.ext.collapse', ] coverage_statistics_to_report = coverage_statistics_to_stdout = True templates_path = ['_templates'] diff --git a/doc/usage/extensions/collapse.rst b/doc/usage/extensions/collapse.rst new file mode 100644 index 00000000000..21a2241649d --- /dev/null +++ b/doc/usage/extensions/collapse.rst @@ -0,0 +1,82 @@ +.. _collapsible: + +:mod:`sphinx.ext.collapse` -- HTML collapsible content +====================================================== + +.. module:: sphinx.ext.collapse + :synopsis: Support for collapsible content in HTML output. + +.. versionadded:: 7.4 + +.. index:: single: collapse + single: collapsible + single: details + single: summary + pair: collapse; directive + pair: details; directive + pair: summary; directive + +This extension provides a :rst:dir:`collapse` directive to provide support for +`collapsible content`_ in HTML output. + +.. _collapsible content: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/details + +This extension is quite simple, and features only one directive: + +.. rst:directive:: .. collapse:: + + For HTML builders, this directive places the content of the directive + into an HTML `details disclosure`_ element, + with the *summary description* text included as the summary for the element. + The *summary description* text is parsed as reStructuredText, + and can be broken over multiple lines if required. + + Only the HTML 5 output format supports collapsible content. + For other builders, the *summary description* text + and the body of the directive are rendered in the document. + + .. _details disclosure: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/details + + An example and the equivalent output are shown below: + + .. code-block:: reStructuredText + + .. collapse:: ``literal`` and **bold** content, + split over multiple lines. + :open: + + This is the body of the directive. + + It is open by default as the ``:open:`` option was used. + + Markup Demonstration + -------------------- + + The body can also contain *markup*, including sections. + + .. collapse:: ``literal`` and **bold** content, + split over multiple lines. + :open: + + This is the body of the directive. + + It is open by default as the ``:open:`` option was used. + + Markup Demonstration + -------------------- + + The body can also contain *markup*, including sections. + + .. versionadded:: 7.4 + + .. rst:directive:option:: open + + Expand the collapsible content by default. + +Internal node classes +--------------------- + +.. note:: These classes are only relevant to extension and theme developers. + +.. autoclass:: collapsible +.. autoclass:: summary diff --git a/doc/usage/extensions/index.rst b/doc/usage/extensions/index.rst index 929f2b604b2..31a3d0c1672 100644 --- a/doc/usage/extensions/index.rst +++ b/doc/usage/extensions/index.rst @@ -24,6 +24,7 @@ These extensions are built in and can be activated by respective entries in the autodoc autosectionlabel autosummary + collapse coverage doctest duration diff --git a/sphinx/ext/collapse.py b/sphinx/ext/collapse.py new file mode 100644 index 00000000000..5fe4245cfca --- /dev/null +++ b/sphinx/ext/collapse.py @@ -0,0 +1,139 @@ +"""Support for collapsible content in HTML.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, ClassVar + +from docutils import nodes +from docutils.parsers.rst import directives + +import sphinx +from sphinx.transforms.post_transforms import SphinxPostTransform +from sphinx.util.docutils import SphinxDirective + +if TYPE_CHECKING: + from sphinx.application import Sphinx + from sphinx.util.typing import ExtensionMetadata, OptionSpec + from sphinx.writers.html5 import HTML5Translator + + +class collapsible(nodes.Structural, nodes.Element): + """Node for collapsible content. + + This is used by the :rst:dir:`collapse` directive. + """ + + +class summary(nodes.General, nodes.TextElement): + """Node for the description for collapsible content. + + This is used by the :rst:dir:`collapse` directive. + """ + + +def visit_collapsible(translator: HTML5Translator, node: nodes.Element) -> None: + if node.get('open'): + translator.body.append(translator.starttag(node, 'details', open='open')) + else: + translator.body.append(translator.starttag(node, 'details')) + + +def depart_collapsible(translator: HTML5Translator, node: nodes.Element) -> None: + translator.body.append('\n') + + +def visit_summary(translator: HTML5Translator, node: nodes.Element) -> None: + translator.body.append(translator.starttag(node, 'summary')) + + +def depart_summary(translator: HTML5Translator, node: nodes.Element) -> None: + translator.body.append('\n') + + +class Collapsible(SphinxDirective): + """ + Directive to mark collapsible content, with an optional summary line. + """ + + has_content = True + optional_arguments = 1 + final_argument_whitespace = True + option_spec: ClassVar[OptionSpec] = { + 'class': directives.class_option, + 'name': directives.unchanged, + 'open': directives.flag, + } + + def run(self) -> list[nodes.Node]: + node = collapsible(classes=['collapsible'], open='open' in self.options) + if 'class' in self.options: + node['classes'] += self.options['class'] + self.add_name(node) + node.document = self.state.document + self.set_source_info(node) + + if self.arguments: + # parse the argument as reST + trimmed_summary = self._dedent_string(self.arguments[0].strip()) + textnodes, messages = self.parse_inline(trimmed_summary, lineno=self.lineno) + node.append(summary(trimmed_summary, '', *textnodes)) + node += messages + else: + label = 'Collapsed Content:' + node.append(summary(label, label)) + + return self.parse_content_to_nodes(allow_section_headings=True) + + @staticmethod + def _dedent_string(s: str) -> str: + """Remove common leading indentation.""" + lines = s.expandtabs(4).splitlines() + + # Find minimum indentation of any non-blank lines after the first. + # If your indent is larger than a million spaces, there's a problem… + margin = 10**6 + for line in lines[1:]: + content = len(line.lstrip()) + if content: + indent = len(line) - content + margin = min(margin, indent) + + if margin == 10**6: + return s + + return '\n'.join(lines[:1] + [line[margin:] for line in lines[1:]]) + + +#: This constant can be modified by programmers that create their own +#: HTML builders outside the Sphinx core. +HTML_5_BUILDERS = frozenset({'html', 'dirhtml'}) + + +class CollapsibleNodeTransform(SphinxPostTransform): + default_priority = 55 + + def run(self, **kwargs: Any) -> None: + """Filter collapsible and collapsible_summary nodes based on HTML 5 support.""" + if self.app.builder.name in HTML_5_BUILDERS: + return + + for summary_node in self.document.findall(summary): + summary_para = nodes.paragraph('', '', *summary_node) + summary_node.replace_self(summary_para) + + for collapsible_node in self.document.findall(collapsible): + container = nodes.container('', *collapsible_node.children) + collapsible_node.replace_self(container) + + +def setup(app: Sphinx) -> ExtensionMetadata: + app.add_node(collapsible, html=(visit_collapsible, depart_collapsible)) + app.add_node(summary, html=(visit_summary, depart_summary)) + app.add_directive('collapse', Collapsible) + app.add_post_transform(CollapsibleNodeTransform) + + return { + 'version': sphinx.__display_version__, + 'parallel_read_safe': True, + 'parallel_write_safe': True, + } diff --git a/tests/roots/test-ext-collapse/conf.py b/tests/roots/test-ext-collapse/conf.py new file mode 100644 index 00000000000..e8a124d29c7 --- /dev/null +++ b/tests/roots/test-ext-collapse/conf.py @@ -0,0 +1,3 @@ +project = 'test-directive-only' +exclude_patterns = ['_build'] +extensions = ['sphinx.ext.collapse'] diff --git a/tests/roots/test-ext-collapse/index.rst b/tests/roots/test-ext-collapse/index.rst new file mode 100644 index 00000000000..a9469d7048a --- /dev/null +++ b/tests/roots/test-ext-collapse/index.rst @@ -0,0 +1,36 @@ +Collapsible directive tests +=========================== + +.. collapse:: + + Default section summary line + +.. collapse:: Custom summary line for the collapsible content: + + Collapsible sections can also have custom summary lines + +.. collapse:: Summary text here with **bold** and *em* and a :rfc:`2324` + reference! That was a newline in the reST source! We can also + have links_ and `more links `__. + + This is some body text! + +.. collapse:: Collapsible section with no content. + :name: collapse-no-content + :class: spam + +.. collapse:: Collapsible section with reStructuredText content: + + Collapsible sections can have normal reST content such as **bold** and + *emphasised* text, and also links_! + + .. _links: https://link.example/ + +.. collapse:: Collapsible section with titles: + + Collapsible sections can have sections: + + A Section + --------- + + Some words within a section, as opposed to outwith the section. diff --git a/tests/test_extensions/test_ext_collapse.py b/tests/test_extensions/test_ext_collapse.py new file mode 100644 index 00000000000..75f12c0cec1 --- /dev/null +++ b/tests/test_extensions/test_ext_collapse.py @@ -0,0 +1,96 @@ +"""Test the collapsible directive with the test root.""" + +import pytest +from docutils import nodes + +from sphinx.ext.collapse import collapsible, summary + + +@pytest.mark.sphinx('text', testroot='ext-collapse') +def test_non_html(app): + app.build(force_all=True) + + # The content is inlined into the document: + assert (app.outdir / 'index.txt').read_text(encoding='utf8') == """\ +Collapsible directive tests +*************************** + +Collapsed Content: + +Default section summary line + +Custom summary line for the collapsible content: + +Collapsible sections can also have custom summary lines + +Summary text here with **bold** and *em* and a **RFC 2324** reference! +That was a newline in the reST source! We can also have links and more +links. + +This is some body text! + +Collapsible section with no content. + +Collapsible section with reStructuredText content: + +Collapsible sections can have normal reST content such as **bold** and +*emphasised* text, and also links! + +Collapsible section with titles: + +Collapsible sections can have sections: + + +A Section +========= + +Some words within a section, as opposed to outwith the section. +""" + + +@pytest.mark.sphinx('text', testroot='ext-collapse') +def test_non_html_post_transform(app): + app.build(force_all=True) + doctree = app.env.get_doctree('index') + app.env.apply_post_transforms(doctree, 'index') + assert list(doctree.findall(collapsible)) == [] + + collapsible_nodes = list(doctree.findall(nodes.container)) + no_content = collapsible_nodes[3] + assert len(no_content) == 1 + assert no_content[0].astext() == 'Collapsible section with no content.' + + +@pytest.mark.sphinx('html', testroot='ext-collapse') +def test_html(app): + app.build(force_all=True) + doctree = app.env.get_doctree('index') + app.env.apply_post_transforms(doctree, 'index') + collapsible_nodes = list(doctree.findall(collapsible)) + assert len(collapsible_nodes) == 6 + + default_summary = collapsible_nodes[0] + assert isinstance(default_summary[0], summary) + assert collapsible_nodes[0][0].astext() == 'Collapsed Content:' + + custom_summary = collapsible_nodes[1] + assert isinstance(custom_summary[0], summary) + assert custom_summary[0].astext() == 'Custom summary line for the collapsible content:' + assert custom_summary[1].astext() == 'Collapsible sections can also have custom summary lines' + + rst_summary = collapsible_nodes[2] + assert isinstance(rst_summary[0], summary) + assert 'RFC 2324' in rst_summary[0].astext() + assert 'We can also\nhave ' in rst_summary[0][8] # type: ignore[operator] + + no_content = collapsible_nodes[3] + assert isinstance(no_content[0], summary) + assert no_content[0].astext() == 'Collapsible section with no content.' + assert len(no_content) == 1 + + rst_content = collapsible_nodes[4] + assert isinstance(rst_content[0], summary) + + nested_titles = collapsible_nodes[5] + assert isinstance(nested_titles[0], summary) + assert isinstance(nested_titles[2], nodes.section)