Skip to content

Gated directives crash the build when the start directive is suppressed (hide_solutions or :hidden:) #89

Description

@mmcky

Suppressing a gated directive crashes the build instead of hiding it. Reproduced on main (f721c3f) with Sphinx 8.2.3.

Symptom

NotImplementedError: <class 'sphinx.writers.html5.HTML5Translator'> departing unknown node type: solution_end_node

The build aborts. There is no warning first — the extension reports nothing and Sphinx raises from the writer.

Three ways to trigger it

All three share one root cause. Minimal reproductions, each a complete project:

1. hide_solutions = True with any gated solution. This is the one most likely to bite users, since hide_solutions is a documented project-wide switch — turning it on breaks the build for any book that uses gated solutions anywhere.

```{solution-start} ex1
:label: sol1

Gated answer.


**2. `:hidden:` on `{solution-start}`** → same `solution_end_node` crash.

**3. `:hidden:` on `{exercise-start}`** → the same crash with `exercise_end_node`.

Non-gated `hide_solutions` and non-gated `:hidden:` both work correctly; only the gated forms crash.

## Root cause

`SolutionDirective.run()` returns `[]` early when `hide_solutions` is set, and again later when `node["hidden"]` is true. `SolutionStartDirective` inherits that `run()`, so in both cases **no `solution_start_node` is emitted**.

`MergeGatedSolutions` finds no start node, so it never runs and never consumes the end marker. But `SolutionEndDirective.run()` unconditionally returns a `solution_end_node`, which is now orphaned in the doctree.

`solution_end_node` is registered with a bare `app.add_node(solution_end_node)` and has no visitor for any builder, because it is only ever meant to be consumed by the merge transform. The writer reaches it and raises.

`exercise_end_node` has the identical shape, which is why `:hidden:` on `{exercise-start}` fails the same way.

## Suggested fix

Two candidate approaches:

- Register no-op visitors for `solution_end_node` and `exercise_end_node` that raise `SkipNode`. Cheap, and defends against any other path that leaves a marker orphaned.
- Or have the `-end` directives consult the same config/flag and return nothing when the corresponding start was suppressed. More precise, but the end directive does not currently know its partner's options.

The first is the smaller, more robust change; the second is closer to intent. Either way, the intervening content also needs a decision — with `hide_solutions` the author expects the solution body gone, and today that content sits between the markers as ordinary body text rather than inside a solution node.

## Test coverage

`grep -rn hide_solutions tests/` returns nothing — **`hide_solutions` has no test coverage at all**, gated or otherwise, which is why this has gone unnoticed. Worth adding alongside the fix.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions