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 19 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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions .ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,7 @@ exclude = [
"tests/test_extensions/test_ext_githubpages.py",
"tests/test_extensions/test_ext_intersphinx.py",
"tests/test_extensions/test_ext_napoleon.py",
"tests/test_extensions/test_ext_collapse.py",
"tests/test_extensions/test_ext_coverage.py",
"tests/test_extensions/ext_napoleon_pep526_data_numpy.py",
"tests/test_extensions/test_ext_autodoc_configs.py",
Expand Down
3 changes: 3 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ Features added
* Flatten ``Union[Literal[T], Literal[U], ...]`` to ``Literal[T, U, ...]``
when turning annotations into strings.
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 @@ -18,6 +18,7 @@
'sphinx.ext.viewcode',
'sphinx.ext.inheritance_diagram',
'sphinx.ext.coverage',
'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
197 changes: 197 additions & 0 deletions sphinx/ext/collapse.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
"""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 docutils.parsers.rst.states import RSTState
from docutils.statemachine import StringList

from sphinx.application import Sphinx
from sphinx.util.typing import ExtensionMetadata, OptionSpec
from sphinx.writers.html5 import HTML5Translator


class collapsible(nodes.General, 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 len(self.arguments) > 0:
# parse the argument as reST
trimmed_summary = self._prepare_argument_string(self.arguments[0].strip())
textnodes, messages = self.state.inline_text(trimmed_summary, self.lineno)
node.append(summary(trimmed_summary, '', *textnodes))
node += messages
else:
label = 'Collapsed Content:'
node.append(summary(label, label))

return self._parse_content(self.state, node, self.content, self.content_offset)

@staticmethod
def _prepare_argument_string(s: str) -> str:
"""Prepare a directive argument string.

Remove common leading indentation, where the indentation of the first
line is ignored.

Return a list of lines usable for inserting into a docutils StringList.
"""
lines = s.expandtabs().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:]])

@staticmethod
def _parse_content(
state: RSTState,
node: nodes.Element,
content: StringList,
offset: int,
) -> list[nodes.Node]:
# Same as util.nested_parse_with_titles but try to handle nested
# sections which should be raised higher up the doctree.
Comment on lines +122 to +123
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have to say I'm completely against this kind of "hacking" into docutils.
If you want to do something like this, then either it needs to be upstreamed to docutils, or it needs much, much more testing than is in this PR.
simple example: what happens if you put your collapse directive inside a note directive?

Not only is it already brittle in rst, but also its definitely not supported by myst 😅

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I certainly get the desire to have nested parsing with sections (in fact I am trying to achieve it in https://sphinx-rust.readthedocs.io)

but this should be a "core" function within sphinx/docutils, with good testing, rather than having multiple implementations scattered across the code base

memo = state.memo
surrounding_title_styles = memo.title_styles
surrounding_section_level = memo.section_level
memo.title_styles = []
memo.section_level = 0
try:
state.nested_parse(content, offset, node, match_titles=True)
title_styles = memo.title_styles
if (
not surrounding_title_styles
or not title_styles
or title_styles[0] not in surrounding_title_styles
or not state.parent
):
# No nested sections so no special handling needed.
return [node]
# Calculate the depths of the current and nested sections.
current_depth = 0
parent = state.parent
while parent:
current_depth += 1
parent = parent.parent
current_depth -= 2
title_style = title_styles[0]
nested_depth = len(surrounding_title_styles)
if title_style in surrounding_title_styles:
nested_depth = surrounding_title_styles.index(title_style)
# Use these depths to determine where the nested sections should
# be placed in the doctree.
n_sects_to_raise = current_depth - nested_depth + 1
parent = state.parent
for _i in range(n_sects_to_raise):
if parent.parent:
parent = parent.parent
parent.append(node)
return []
finally:
memo.title_styles = surrounding_title_styles
memo.section_level = surrounding_section_level


# 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.