Repository navigation
Add solution_collapsed option to render solutions folded by default #85
Description
Sub-issue of #84, covering the first of the two options proposed there: rendering solutions folded by default.
Motivation
Reader feedback on book-dp1, which uses exercise_style = "solution_follow_exercise":
Currently solutions to exercises sit immediately after exercises, making it too tempting to look at the solution. Would it be possible to have them folded by default and open in a drop-down, similar to the lectures?
The layout that is best for authoring (solution next to exercise) is not the one that is best for reading (solution out of sight until wanted). This option lets authors keep writing solutions inline while choosing a folded presentation at publish time.
Proposal
Updated: the design settled on making this part of the
solution_follow_exercisestyle rather than a standalone opt-in, since that style is exactly the layout the feedback is about.solution_collapsedis therefore tri-state: unset followsexercise_style(collapsed undersolution_follow_exercise),Truealways collapses,Falsenever does.The
Nonedefault is necessary rather than stylistic — Sphinx cannot distinguish "unset" from "explicitlyFalse" through the public config API, so a plainFalsedefault would leave no way to switch the style's implied collapsing back off. This is a documented behaviour change for projects on that style, withsolution_collapsed = Falseas the one-line opt-out.
A config option, solution_collapsed, set in conf.py or _config.yml. When True it adds the dropdown class to every solution node — equivalent to writing :class: dropdown on every solution directive, which the docs already describe as the manual way to do this (docs/source/syntax.md, "Hide Content").
Research
Everything below was verified against this checkout by reading the source and running real Sphinx builds, not inferred.
The dropdown class does reach the HTML output
visit_solution_node in sphinx_exercise/nodes.py:172 emits self.starttag(node, "div", CLASS="admonition"). docutils' HTMLTranslator.starttag unions the node's own classes with the CLASS= keyword (docutils/writers/_html_base.py, the for cls in node.get('classes', []) + atts.pop('class', '').split(): loop), and it de-duplicates as it goes. Sphinx does not override starttag. So a node carrying dropdown renders as <div class="solution dropdown admonition" id="...">.
This is already visible in a committed fixture: tests/test_solution/_linked_enum_class.html is <div class="solution test-solution admonition" id="solution-label">, where test-solution came from :class: test-solution in the source.
dropdown is the correct class name
sphinx-togglebutton registers togglebutton_selector with the default value ".toggle, .admonition.dropdown". Our div carries both admonition and dropdown, so it matches (CSS selector matching is order-independent). Its JavaScript has an explicit item.classList.contains("admonition") branch for admonition-style toggling, and treats toggle-shown as "start expanded" — which gives authors a per-directive escape hatch to keep one solution open.
sphinx-design does not count as a provider. Its dropdown is a directive emitting .sd-dropdown, and its compiled stylesheet ships no rule for a bare dropdown class (verified — the only matching selector is .sd-dropdown). This matters for the warning below: Jupyter Book loads sphinx-design by default, so treating it as a provider would suppress the warning for exactly the projects that need it.
There are exactly three places solution classes are assigned
| Location | Role |
|---|---|
sphinx_exercise/directive.py:265-268 |
SolutionDirective.run() builds ["solution", *user classes]. This is the single insertion point. |
sphinx_exercise/directive.py:384-407 |
SolutionStartDirective sets name = "solution-start" and ends in return super().run(), so it inherits the injection with no separate edit. |
sphinx_exercise/transforms.py:102-105 |
MergeGatedSolutions rebuilds the class list when merging a solution-start/solution-end pair. |
The third one is the trap, and it is where the #82 class-list bug lived. It survives only because it is a per-element substring replace rather than a hard-coded list:
new_node["classes"] = [attr.replace("solution-start", "solution") for attr in node.attributes["classes"]]
"dropdown" contains no "solution-start" substring, so it passes through untouched. Verified end-to-end: a gated pair with :class: dropdown renders <div class="solution dropdown admonition">.
No post-transform mutates solution classes.
Implementation notes
- Register with the
"env"rebuild trigger, matchinghide_solutionsandexercise_style. The class is injected at read time and baked into the pickled doctree, so"html"would leave stale doctrees on incremental builds and the option would appear broken. - De-duplicate. If the author already wrote
:class: dropdown, blind concatenation givesclasses="solution dropdown dropdown"in the doctree XML. docutils hides this in HTML output but any doctree regression fixture or downstream consumer sees it. - Keep the default
False. ATruedefault would invalidate roughly 40 committed regression baselines acrosstests/test_solution/,tests/test_exercise/,tests/test_gateddirective/andtests/test_hiddendirective/, which recordclasses="solution"verbatim across.sphinx4/.5/.6/.7/.8/.8.1variants. - LaTeX is provably unaffected. The
LaTeXTranslatorbranch ofvisit_solution_node(sphinx_exercise/nodes.py:160-170) emits only\phantomsection\label{...}plus a constant\begin{sphinxadmonition}{note}fromsphinx_exercise/latex.py, and never readsnode["classes"]. Inline rendering is the intended non-HTML fallback, and the committedtests/test_latex/*.texfixtures cannot change. - Type the warning with
type="exercise",subtype="solution_collapsed"so projects that supply their own.admonition.dropdownCSS can setsuppress_warnings = ["exercise.solution_collapsed"]. An untyped warning is unsuppressible and makes-Wbuilds fail for them. - Document the interactive-output caveat. sphinx-togglebutton collapses by setting the content's height to zero rather than removing it, so outputs that measure themselves at load time (plotly, bokeh, ipywidgets, altair) render at zero size inside a collapsed solution. Static images including matplotlib are unaffected. This matters for the executable-book audience the option targets.
- Warn when the class would be inert. The extension ships no fold CSS/JS of its own —
sphinx_exercise/assets/html/exercise.csshas only staticdiv.solutionstyling. Withsolution_collapsed = Trueand no sphinx-togglebutton loaded, solutions silently render expanded and the option looks broken. Abuilder-initedcheck gated onapp.builder.format == "html"catches this without adding noise to LaTeX builds. sphinx_togglebuttonis not currently a test dependency. It is listed only under thertdextra inpyproject.toml; thetestingextra omits it. It needs adding there to cover the no-warning path.
Acceptance criteria
-
solution_collapsed = Trueaddsdropdownto{solution}directives - The same applies to gated
{solution-start}/{solution-end}pairs - Directive-level
:class:values are preserved - An explicit
:class: dropdownis not duplicated -
:class: toggle-shownkeeps an individual solution expanded - Default
Falseleaves all existing output and fixtures unchanged - A warning is issued on HTML builds when no extension provides
dropdown - No warning on LaTeX builds, and no warning when sphinx-togglebutton is loaded
- Documented in
docs/source/syntax.mdalongsidehide_solutionsandexercise_style
Out of scope
Relocating solutions to an appendix — that is the other half of #84 and is tracked separately.
Related pre-existing bugs found while researching this
Now filed separately as #89, #90 and #91. These are not introduced by this change and are not fixed by it:
hide_solutions = Truehard-crashes any build containing a gated solution.SolutionDirective.run()returns[]whenhide_solutionsis set (directive.py:238-239), so nosolution_start_nodeis emitted andMergeGatedSolutionsnever runs — butSolutionEndDirectivestill returns asolution_end_node(directive.py:438), which has no visitors (__init__.pyregisters it with a bareapp.add_node). Result:NotImplementedError: <class 'sphinx.writers.html5.HTML5Translator'> departing unknown node type: solution_end_node. Non-gatedhide_solutionsworks correctly.- The per-directive
:hidden:flag crashes the same way on{solution-start}and on{exercise-start}. - Class names containing
-startare silently mangled by the gated merges.:class: quick-starton an{exercise-start}renders asquick, becauseMergeGatedExercisesdoescls.replace("-start", "")(transforms.py:162); the solution merge has the same shape. - There is zero test coverage for
hide_solutionsanywhere in the suite.
Activity
TST: Move gated collapsed test off the sphinx_book_theme testroot
FIX: Correct the togglebutton check and document collapse caveats
CHORE: Remove stray Sphinx 9 regression fixtures
FEAT: Make solution_follow_exercise collapse solutions by default
TST: Move gated collapsed test off the sphinx_book_theme testroot
FIX: Correct the togglebutton check and document collapse caveats
CHORE: Remove stray Sphinx 9 regression fixtures
FEAT: Make solution_follow_exercise collapse solutions by default
TST: Cover the gated collapsed path from MyST source as well