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)