Skip to content

Commit a43c7bf

Browse files
committed
Add style_solution_after_exercise config option and improve dropdown UX
This commit adds two requested features for better solution styling: 1. New configuration option 'style_solution_after_exercise' (default: False) - When enabled, removes hyperlinks from solution titles - Displays plain text like 'Solution to Exercise 1 (Title)' - Useful when solutions follow exercises directly - Reduces confusion when using dropdown class 2. Improved dropdown button text - Changed 'Click to show' to 'Show' for cleaner UI - Changed 'Click to hide' to 'Hide' - Applied via CSS customization Features: - Backward compatible (opt-in configuration) - Properly handles numbered/unnumbered exercises - Supports math expressions in titles - Comprehensive test coverage (3 new tests) - Full documentation in syntax.md All 113 tests pass.
1 parent 6d7859f commit a43c7bf

File tree

6 files changed

+191
-18
lines changed

6 files changed

+191
-18
lines changed

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22

33
## [Unreleased]
44

5+
### New ✨
6+
7+
- Added `style_solution_after_exercise` configuration option to remove hyperlinks from solution titles when solutions follow exercises directly
8+
- Changed dropdown button text from "Click to show" to "Show" for cleaner visual appearance
9+
10+
### Improved 👌
11+
12+
- Enhanced solution title styling options for better UX in lecture-style content where solutions follow exercises
13+
514
## [v1.1.1](https://github.com/executablebooks/sphinx-exercise/tree/v1.1.1) (2025-10-23)
615

716
### Improved 👌

docs/source/syntax.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,8 @@ sphinx:
343343
344344
To hide the content, simply add `:class: dropdown` as a directive option.
345345

346+
**Note:** The dropdown toggle button text has been customized for `sphinx-exercise` directives to display "Show" / "Hide" instead of the default "Click to show" / "Click to hide" for a cleaner visual appearance.
347+
346348
For more use cases see [sphinx-togglebutton](https://sphinx-togglebutton.readthedocs.io/en/latest/#usage).
347349

348350
**Example**
@@ -407,6 +409,31 @@ sphinx:
407409
...
408410
```
409411

412+
### Solution Title Styling
413+
414+
By default, solution titles include a hyperlink to the corresponding exercise. This behavior can be modified using the `style_solution_after_exercise` configuration option.
415+
416+
When solutions follow exercises directly in your content (common in lecture notes), you may want to remove the hyperlink to avoid confusion when using the `dropdown` class. Set `style_solution_after_exercise` to `True` to display only text without hyperlinks in solution titles.
417+
418+
For Sphinx projects, add the configuration key in the `conf.py` file:
419+
420+
```python
421+
# conf.py
422+
style_solution_after_exercise = True
423+
```
424+
425+
For Jupyter Book projects, set the configuration key in `_config.yml`:
426+
427+
```yaml
428+
...
429+
sphinx:
430+
config:
431+
style_solution_after_exercise: True
432+
...
433+
```
434+
435+
When `style_solution_after_exercise` is `True`, the solution title will display plain text like "Solution to Exercise 1 (Title)" instead of a hyperlink. When `False` (default), the exercise reference in the solution title remains clickable.
436+
410437
## Custom CSS or JavaScript
411438

412439
Custom JavaScript scripts and CSS rules will allow you to add additional functionality or customize how elements are displayed. If you'd like to include custom CSS or JavaScript scripts in Jupyter Book, simply add any files ending in `.css` or `.js` under a `_static` folder. Any files under this folder will be automatically copied into the built book.

sphinx_exercise/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ def doctree_read(app: Sphinx, document: Node) -> None:
147147

148148
def setup(app: Sphinx) -> Dict[str, Any]:
149149
app.add_config_value("hide_solutions", False, "env")
150+
app.add_config_value("style_solution_after_exercise", False, "env")
150151

151152
app.connect("config-inited", init_numfig) # event order - 1
152153
app.connect("env-purge-doc", purge_exercises) # event order - 5 per file

sphinx_exercise/assets/html/exercise.css

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,17 @@ div.solution p.admonition-title {
4141
div.solution p.admonition-title::after {
4242
content: none;
4343
}
44+
45+
/*********************************************
46+
* Dropdown customization *
47+
*********************************************/
48+
/* Change "Click to show" to "Show" for dropdowns */
49+
div.exercise.dropdown button.toggle-button::before,
50+
div.solution.dropdown button.toggle-button::before {
51+
content: "Show";
52+
}
53+
54+
div.exercise.dropdown.toggle-shown button.toggle-button::before,
55+
div.solution.dropdown.toggle-shown button.toggle-button::before {
56+
content: "Hide";
57+
}

sphinx_exercise/post_transforms.py

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -147,24 +147,49 @@ def resolve_solution_title(app, node, exercise_node):
147147
updated_title_text += f" {node_number}"
148148
# New Title Node
149149
updated_title = docutil_nodes.title()
150-
wrap_reference = build_reference_node(app, exercise_node)
151-
wrap_reference += docutil_nodes.Text(updated_title_text)
152-
node["title"] = entry_title_text + updated_title_text
153-
# Parse Custom Titles from Exercise
154-
if len(exercise_title.children) > 1:
155-
subtitle = exercise_title.children[1]
156-
if isinstance(subtitle, exercise_subtitle):
157-
wrap_reference += docutil_nodes.Text(" (")
158-
for child in subtitle.children:
159-
if isinstance(child, docutil_nodes.math):
160-
# Ensure mathjax is loaded for pages that only contain
161-
# references to nodes that contain math
162-
domain = app.env.get_domain("math")
163-
domain.data["has_equations"][app.env.docname] = True
164-
wrap_reference += child
165-
wrap_reference += docutil_nodes.Text(")")
166-
updated_title += docutil_nodes.Text(entry_title_text)
167-
updated_title += wrap_reference
150+
151+
# Check if style_solution_after_exercise is enabled
152+
if app.config.style_solution_after_exercise:
153+
# Don't create hyperlink - just add plain text and nodes
154+
updated_title += docutil_nodes.Text(entry_title_text)
155+
updated_title += docutil_nodes.Text(updated_title_text)
156+
node["title"] = entry_title_text + updated_title_text
157+
158+
# Parse Custom Titles from Exercise
159+
if len(exercise_title.children) > 1:
160+
subtitle = exercise_title.children[1]
161+
if isinstance(subtitle, exercise_subtitle):
162+
updated_title += docutil_nodes.Text(" (")
163+
for child in subtitle.children:
164+
if isinstance(child, docutil_nodes.math):
165+
# Ensure mathjax is loaded for pages that only contain
166+
# references to nodes that contain math
167+
domain = app.env.get_domain("math")
168+
domain.data["has_equations"][app.env.docname] = True
169+
# Add child directly (could be text or math node)
170+
updated_title += child.deepcopy()
171+
updated_title += docutil_nodes.Text(")")
172+
else:
173+
# Create hyperlink (original behavior)
174+
wrap_reference = build_reference_node(app, exercise_node)
175+
wrap_reference += docutil_nodes.Text(updated_title_text)
176+
node["title"] = entry_title_text + updated_title_text
177+
# Parse Custom Titles from Exercise
178+
if len(exercise_title.children) > 1:
179+
subtitle = exercise_title.children[1]
180+
if isinstance(subtitle, exercise_subtitle):
181+
wrap_reference += docutil_nodes.Text(" (")
182+
for child in subtitle.children:
183+
if isinstance(child, docutil_nodes.math):
184+
# Ensure mathjax is loaded for pages that only contain
185+
# references to nodes that contain math
186+
domain = app.env.get_domain("math")
187+
domain.data["has_equations"][app.env.docname] = True
188+
wrap_reference += child
189+
wrap_reference += docutil_nodes.Text(")")
190+
updated_title += docutil_nodes.Text(entry_title_text)
191+
updated_title += wrap_reference
192+
168193
updated_title.parent = title.parent
169194
node.children[0] = updated_title
170195
node.resolved_title = True
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
from bs4 import BeautifulSoup
2+
import pytest
3+
import sphinx
4+
5+
# Sphinx 8.1.x (Python 3.10 only) has different XML output than 8.2+
6+
# Use .sphinx8.1 for 8.1.x, .sphinx8 for 8.2+ (the standard)
7+
if sphinx.version_info[0] == 8 and sphinx.version_info[1] == 1:
8+
SPHINX_VERSION = f".sphinx{sphinx.version_info[0]}.{sphinx.version_info[1]}"
9+
else:
10+
SPHINX_VERSION = f".sphinx{sphinx.version_info[0]}"
11+
12+
13+
@pytest.mark.sphinx(
14+
"html", testroot="mybook", confoverrides={"style_solution_after_exercise": True}
15+
)
16+
def test_solution_no_link(app):
17+
"""Test solution directive with style_solution_after_exercise=True removes hyperlink."""
18+
app.build()
19+
path_solution_directive = app.outdir / "solution" / "_linked_enum.html"
20+
assert path_solution_directive.exists()
21+
22+
# get content markup
23+
soup = BeautifulSoup(
24+
path_solution_directive.read_text(encoding="utf8"), "html.parser"
25+
)
26+
27+
sol = soup.select("div.solution")[0]
28+
title = sol.select("p.admonition-title")[0]
29+
30+
# Check that there is NO hyperlink in the title when style_solution_after_exercise=True
31+
links = title.find_all("a")
32+
assert (
33+
len(links) == 0
34+
), "Solution title should not contain hyperlink when style_solution_after_exercise=True"
35+
36+
# Check that the title text still contains the exercise reference
37+
title_text = title.get_text()
38+
assert "Exercise" in title_text
39+
assert "This is a title" in title_text
40+
41+
42+
@pytest.mark.sphinx(
43+
"html", testroot="mybook", confoverrides={"style_solution_after_exercise": False}
44+
)
45+
def test_solution_with_link(app):
46+
"""Test solution directive with style_solution_after_exercise=False keeps hyperlink."""
47+
app.build()
48+
path_solution_directive = app.outdir / "solution" / "_linked_enum.html"
49+
assert path_solution_directive.exists()
50+
51+
# get content markup
52+
soup = BeautifulSoup(
53+
path_solution_directive.read_text(encoding="utf8"), "html.parser"
54+
)
55+
56+
sol = soup.select("div.solution")[0]
57+
title = sol.select("p.admonition-title")[0]
58+
59+
# Check that there IS a hyperlink in the title when style_solution_after_exercise=False (default)
60+
links = title.find_all("a")
61+
assert (
62+
len(links) == 1
63+
), "Solution title should contain hyperlink when style_solution_after_exercise=False"
64+
65+
# Check that the link points to the exercise
66+
link = links[0]
67+
assert "href" in link.attrs
68+
assert "ex-number" in link["href"]
69+
70+
71+
@pytest.mark.sphinx(
72+
"html", testroot="mybook", confoverrides={"style_solution_after_exercise": True}
73+
)
74+
def test_solution_no_link_unenum(app):
75+
"""Test unnumbered solution directive with style_solution_after_exercise=True removes hyperlink."""
76+
app.build()
77+
path_solution_directive = app.outdir / "solution" / "_linked_unenum_title.html"
78+
assert path_solution_directive.exists()
79+
80+
# get content markup
81+
soup = BeautifulSoup(
82+
path_solution_directive.read_text(encoding="utf8"), "html.parser"
83+
)
84+
85+
sol = soup.select("div.solution")[0]
86+
title = sol.select("p.admonition-title")[0]
87+
88+
# Check that there is NO hyperlink in the title
89+
links = title.find_all("a")
90+
assert (
91+
len(links) == 0
92+
), "Solution title should not contain hyperlink when style_solution_after_exercise=True"
93+
94+
# Check that the title text still contains the exercise reference
95+
title_text = title.get_text()
96+
assert "Exercise" in title_text
97+
assert "This is a title" in title_text

0 commit comments

Comments
 (0)