Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add sphinx.ext.collapse #10532

Open
wants to merge 20 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------
Expand Down
1 change: 1 addition & 0 deletions doc/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down
82 changes: 82 additions & 0 deletions doc/usage/extensions/collapse.rst
Original file line number Diff line number Diff line change
@@ -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:: <summary description>

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
1 change: 1 addition & 0 deletions doc/usage/extensions/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
139 changes: 139 additions & 0 deletions sphinx/ext/collapse.py
Original file line number Diff line number Diff line change
@@ -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'))
AA-Turner marked this conversation as resolved.
Show resolved Hide resolved
else:
translator.body.append(translator.starttag(node, 'details'))


def depart_collapsible(translator: HTML5Translator, node: nodes.Element) -> None:
translator.body.append('</details>\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('</summary>\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,
}
3 changes: 3 additions & 0 deletions tests/roots/test-ext-collapse/conf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
project = 'test-directive-only'
exclude_patterns = ['_build']
extensions = ['sphinx.ext.collapse']
36 changes: 36 additions & 0 deletions tests/roots/test-ext-collapse/index.rst
Original file line number Diff line number Diff line change
@@ -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 <https://link2.example/>`__.

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.
96 changes: 96 additions & 0 deletions tests/test_extensions/test_ext_collapse.py
Original file line number Diff line number Diff line change
@@ -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)
Loading