Skip to content

Commit 8cf34d2

Browse files
authored
FEAT: Add solution_after_exercise style option (#81)
* 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. * Rename config option to exercise_style for clarity Changed configuration option name from 'style_solution_after_exercise' to 'exercise_style' to make it clearer that this belongs to sphinx-exercise. Changes: - Config option: exercise_style = 'solution_follow_exercise' (was: style_solution_after_exercise = True) - Default value: '' (empty string) instead of False - More descriptive and namespaced for clarity in config files This makes it clearer in configuration files that this is a sphinx-exercise specific setting, especially when used alongside other sphinx extensions like sphinx-proof. Updated: - sphinx_exercise/__init__.py - sphinx_exercise/post_transforms.py - tests/test_style_solution_after_exercise.py - docs/source/syntax.md All 113 tests pass. * Documentation improvements and cleanup - Renamed test file to test_exercise_style.py for consistency with new config name - Updated CHANGELOG.md to reflect correct exercise_style configuration - Fixed install.md to use modern pip editable install instead of deprecated setup.py - All 113 tests pass across Python 3.11-3.13 and Sphinx 6-8 * Remove dropdown text customization from sphinx-exercise CSS - Dropdown button text styling is better handled at theme level - Removes CSS override for toggle-button text from exercise.css - Updates documentation to suggest theme-level customization - Updates CHANGELOG to reflect architectural decision * Simplify solution titles and add order validation - Simplified solution title to just 'Solution' when exercise_style='solution_follow_exercise' (instead of 'Solution to Exercise #.#' since solution follows exercise) - Clean up redundant code in post_transforms.py title building - Add node order tracking and validation system - Tracks exercise/solution node order as documents are read - Validates solutions follow their exercises when config is set - Reports helpful warnings with line numbers and labels - Warning messages prefixed with [sphinx-exercise] for clarity - Warnings include: - 'Solution X does not follow exercise Y' with line numbers - Cross-document reference detection - Only runs when exercise_style='solution_follow_exercise' - Add comprehensive tests for order validation - Update test assertions to check for simplified titles * docs: update CHANGELOG and syntax docs for accuracy - Update CHANGELOG.md to include order validation system details - Fix syntax.md to accurately reflect simplified 'Solution' title - Add validation behavior documentation * fix: use 'Solution' instead of 'Solution to' when exercise_style is set When exercise_style='solution_follow_exercise', the solution title should be just 'Solution' not 'Solution to'. This fix updates the directive to use the correct default title text based on the config. - Update SolutionDirective to check exercise_style config - Set title_text to 'Solution' when config is set - Keep 'Solution to' for default behavior - Update tests to expect correct 'Solution' title
1 parent 6d7859f commit 8cf34d2

File tree

8 files changed

+430
-25
lines changed

8 files changed

+430
-25
lines changed

CHANGELOG.md

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

33
## [Unreleased]
44

5+
### New ✨
6+
7+
- Added `exercise_style` configuration option to control solution title styling
8+
- Set to `"solution_follow_exercise"` to simplify solution titles to just "Solution" (no hyperlinks, no exercise references)
9+
- Default is `""` (empty string) which maintains the original behavior: "Solution to Exercise #.#" with clickable hyperlink
10+
- Added order validation system when `exercise_style = "solution_follow_exercise"`
11+
- Validates that solutions appear after their referenced exercises
12+
- Validates that solutions are in the same document as their exercises
13+
- Provides helpful warnings with file paths and line numbers
14+
- Warnings are prefixed with `[sphinx-exercise]` for clarity
15+
16+
### Improved 👌
17+
18+
- Enhanced solution title styling options for better UX in lecture-style content where solutions follow exercises
19+
- Improved configuration option naming for better clarity when used alongside other Sphinx extensions
20+
- Cleaner code in `post_transforms.py` with removed redundant title building logic
21+
22+
### Changed 🔄
23+
24+
- Removed dropdown button text customization from `sphinx-exercise` CSS
25+
- This styling is better handled at the theme level (e.g., in `quantecon-book-theme`) for consistent global behavior
26+
- Projects can customize dropdown text in their theme or custom CSS if desired
27+
528
## [v1.1.1](https://github.com/executablebooks/sphinx-exercise/tree/v1.1.1) (2025-10-23)
629

730
### Improved 👌

docs/source/install.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,5 @@ git clone https://github.com/executablebooks/sphinx-exercise
1616
then run:
1717

1818
```bash
19-
python setup.py install
19+
pip install -e .
2020
```

docs/source/syntax.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,10 @@ To hide the content, simply add `:class: dropdown` as a directive option.
345345

346346
For more use cases see [sphinx-togglebutton](https://sphinx-togglebutton.readthedocs.io/en/latest/#usage).
347347

348+
```{tip}
349+
To customize the dropdown toggle button text (e.g., "Show" instead of "Click to show"), add custom CSS in your theme or project. This is typically handled at the theme level for consistent styling across all toggle buttons.
350+
```
351+
348352
**Example**
349353

350354
```{exercise}
@@ -407,6 +411,36 @@ sphinx:
407411
...
408412
```
409413

414+
### Solution Title Styling
415+
416+
By default, solution titles include a hyperlink to the corresponding exercise. This behavior can be modified using the `exercise_style` configuration option.
417+
418+
When solutions follow exercises directly in your content (common in lecture notes), you may want to simplify the solution title. Set `exercise_style` to `"solution_follow_exercise"` to display a simplified title without hyperlinks or exercise references.
419+
420+
For Sphinx projects, add the configuration key in the `conf.py` file:
421+
422+
```python
423+
# conf.py
424+
exercise_style = "solution_follow_exercise"
425+
```
426+
427+
For Jupyter Book projects, set the configuration key in `_config.yml`:
428+
429+
```yaml
430+
...
431+
sphinx:
432+
config:
433+
exercise_style: "solution_follow_exercise"
434+
...
435+
```
436+
437+
When `exercise_style` is set to `"solution_follow_exercise"`:
438+
- The solution title displays just "Solution" (plain text, no hyperlink)
439+
- The extension validates that solutions follow their referenced exercises and warns if they don't
440+
- Solutions must be in the same document as their exercises (warnings if not)
441+
442+
When empty `""` (default), the solution title shows "Solution to Exercise #.#" with a clickable hyperlink to the exercise.
443+
410444
## Custom CSS or JavaScript
411445

412446
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: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,13 @@ def purge_exercises(app: Sphinx, env: BuildEnvironment, docname: str) -> None:
8787
for label in remove_labels:
8888
del env.sphinx_exercise_registry[label]
8989

90+
# Purge node order tracking for this document
91+
if (
92+
hasattr(env, "sphinx_exercise_node_order")
93+
and docname in env.sphinx_exercise_node_order
94+
):
95+
del env.sphinx_exercise_node_order[docname]
96+
9097

9198
def merge_exercises(
9299
app: Sphinx, env: BuildEnvironment, docnames: Set[str], other: BuildEnvironment
@@ -103,6 +110,16 @@ def merge_exercises(
103110
**other.sphinx_exercise_registry,
104111
}
105112

113+
# Merge node order tracking
114+
if not hasattr(env, "sphinx_exercise_node_order"):
115+
env.sphinx_exercise_node_order = {}
116+
117+
if hasattr(other, "sphinx_exercise_node_order"):
118+
env.sphinx_exercise_node_order = {
119+
**env.sphinx_exercise_node_order,
120+
**other.sphinx_exercise_node_order,
121+
}
122+
106123

107124
def init_numfig(app: Sphinx, config: Config) -> None:
108125
"""Initialize numfig"""
@@ -127,31 +144,143 @@ def copy_asset_files(app: Sphinx, exc: Union[bool, Exception]):
127144
copy_asset(path, str(Path(app.outdir).joinpath("_static").absolute()))
128145

129146

147+
def validate_exercise_solution_order(app: Sphinx, env: BuildEnvironment) -> None:
148+
"""
149+
Validate that solutions follow their referenced exercises when
150+
exercise_style='solution_follow_exercise' is set.
151+
"""
152+
# Only validate if the config option is set
153+
if app.config.exercise_style != "solution_follow_exercise":
154+
return
155+
156+
if not hasattr(env, "sphinx_exercise_node_order"):
157+
return
158+
159+
logger = logging.getLogger(__name__)
160+
161+
# Process each document
162+
for docname, nodes in env.sphinx_exercise_node_order.items():
163+
# Build a map of exercise labels to their positions and info
164+
exercise_info = {}
165+
for i, node_info in enumerate(nodes):
166+
if node_info["type"] == "exercise":
167+
exercise_info[node_info["label"]] = {
168+
"position": i,
169+
"line": node_info.get("line"),
170+
}
171+
172+
# Check each solution
173+
for i, node_info in enumerate(nodes):
174+
if node_info["type"] == "solution":
175+
target_label = node_info["target_label"]
176+
solution_label = node_info["label"]
177+
solution_line = node_info.get("line")
178+
179+
if not target_label:
180+
continue
181+
182+
# Check if target exercise exists in this document
183+
if target_label not in exercise_info:
184+
# Exercise is in a different document or doesn't exist
185+
docpath = env.doc2path(docname)
186+
path = str(Path(docpath).with_suffix(""))
187+
188+
# Build location string with line number if available
189+
location = f"{path}:{solution_line}" if solution_line else path
190+
191+
logger.warning(
192+
f"[sphinx-exercise] Solution '{solution_label}' references exercise '{target_label}' "
193+
f"which is not in the same document. When exercise_style='solution_follow_exercise', "
194+
f"solutions should appear in the same document as their exercises.",
195+
location=location,
196+
color="yellow",
197+
)
198+
continue
199+
200+
# Check if solution comes after exercise
201+
exercise_data = exercise_info[target_label]
202+
exercise_pos = exercise_data["position"]
203+
exercise_line = exercise_data.get("line")
204+
205+
if i <= exercise_pos:
206+
docpath = env.doc2path(docname)
207+
path = str(Path(docpath).with_suffix(""))
208+
209+
# Build more informative message with line numbers
210+
if solution_line and exercise_line:
211+
location = f"{path}:{solution_line}"
212+
msg = (
213+
f"[sphinx-exercise] Solution '{solution_label}' (line {solution_line}) does not follow "
214+
f"exercise '{target_label}' (line {exercise_line}). "
215+
f"When exercise_style='solution_follow_exercise', solutions should "
216+
f"appear after their referenced exercises."
217+
)
218+
elif solution_line:
219+
location = f"{path}:{solution_line}"
220+
msg = (
221+
f"[sphinx-exercise] Solution '{solution_label}' does not follow exercise '{target_label}'. "
222+
f"When exercise_style='solution_follow_exercise', solutions should "
223+
f"appear after their referenced exercises."
224+
)
225+
else:
226+
location = path
227+
msg = (
228+
f"[sphinx-exercise] Solution '{solution_label}' does not follow exercise '{target_label}'. "
229+
f"When exercise_style='solution_follow_exercise', solutions should "
230+
f"appear after their referenced exercises."
231+
)
232+
233+
logger.warning(msg, location=location, color="yellow")
234+
235+
130236
def doctree_read(app: Sphinx, document: Node) -> None:
131237
"""
132238
Read the doctree and apply updates to sphinx-exercise nodes
133239
"""
134240

135241
domain = cast(StandardDomain, app.env.get_domain("std"))
136242

243+
# Initialize node order tracking for this document
244+
if not hasattr(app.env, "sphinx_exercise_node_order"):
245+
app.env.sphinx_exercise_node_order = {}
246+
247+
docname = app.env.docname
248+
if docname not in app.env.sphinx_exercise_node_order:
249+
app.env.sphinx_exercise_node_order[docname] = []
250+
137251
# Traverse sphinx-exercise nodes
138252
for node in findall(document):
139253
if is_extension_node(node):
140254
name = node.get("names", [])[0]
141255
label = document.nameids[name]
142-
docname = app.env.docname
143256
section_name = node.attributes.get("title")
144257
domain.anonlabels[name] = docname, label
145258
domain.labels[name] = docname, label, section_name
146259

260+
# Track node order for validation
261+
node_type = node.get("type", "unknown")
262+
node_label = node.get("label", "")
263+
target_label = node.get("target_label", None) # Only for solution nodes
264+
265+
app.env.sphinx_exercise_node_order[docname].append(
266+
{
267+
"type": node_type,
268+
"label": node_label,
269+
"target_label": target_label,
270+
"line": node.line if hasattr(node, "line") else None,
271+
}
272+
)
273+
147274

148275
def setup(app: Sphinx) -> Dict[str, Any]:
149276
app.add_config_value("hide_solutions", False, "env")
277+
app.add_config_value("exercise_style", "", "env")
150278

151279
app.connect("config-inited", init_numfig) # event order - 1
152280
app.connect("env-purge-doc", purge_exercises) # event order - 5 per file
153281
app.connect("doctree-read", doctree_read) # event order - 8
154282
app.connect("env-merge-info", merge_exercises) # event order - 9
283+
app.connect("env-updated", validate_exercise_solution_order) # event order - 10
155284
app.connect("build-finished", copy_asset_files) # event order - 16
156285

157286
app.add_node(

sphinx_exercise/directive.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,11 @@ class : str,
222222
solution_node = solution_node
223223

224224
def run(self) -> List[Node]:
225-
self.defaults = {"title_text": f"{translate('Solution to')}"}
225+
# Set default title based on exercise_style config
226+
if self.env.app.config.exercise_style == "solution_follow_exercise":
227+
self.defaults = {"title_text": f"{translate('Solution')}"}
228+
else:
229+
self.defaults = {"title_text": f"{translate('Solution to')}"}
226230
target_label = self.arguments[0]
227231
self.serial_number = self.env.new_serialno()
228232

sphinx_exercise/post_transforms.py

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -141,30 +141,45 @@ def resolve_solution_title(app, node, exercise_node):
141141
exercise_title = exercise_node.children[0]
142142
if isinstance(title, solution_title):
143143
entry_title_text = node.get("title")
144-
updated_title_text = " " + exercise_title.children[0].astext()
145-
if isinstance(exercise_node, exercise_enumerable_node):
146-
node_number = get_node_number(app, exercise_node, "exercise")
147-
updated_title_text += f" {node_number}"
144+
148145
# New Title Node
149146
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
147+
148+
# Check if exercise_style is set to "solution_follow_exercise"
149+
if app.config.exercise_style == "solution_follow_exercise":
150+
# Simple title: just "Solution" without reference to exercise
151+
updated_title += docutil_nodes.Text(entry_title_text)
152+
node["title"] = entry_title_text
153+
else:
154+
# Build full title with exercise reference
155+
updated_title_text = " " + exercise_title.children[0].astext()
156+
if isinstance(exercise_node, exercise_enumerable_node):
157+
node_number = get_node_number(app, exercise_node, "exercise")
158+
updated_title_text += f" {node_number}"
159+
160+
# Create hyperlink (original behavior)
161+
wrap_reference = build_reference_node(app, exercise_node)
162+
wrap_reference += docutil_nodes.Text(updated_title_text)
163+
164+
# Parse Custom Titles from Exercise
165+
if len(exercise_title.children) > 1:
166+
subtitle = exercise_title.children[1]
167+
if isinstance(subtitle, exercise_subtitle):
168+
wrap_reference += docutil_nodes.Text(" (")
169+
for child in subtitle.children:
170+
if isinstance(child, docutil_nodes.math):
171+
# Ensure mathjax is loaded for pages that only contain
172+
# references to nodes that contain math
173+
domain = app.env.get_domain("math")
174+
domain.data["has_equations"][app.env.docname] = True
175+
wrap_reference += child
176+
wrap_reference += docutil_nodes.Text(")")
177+
178+
# Build the title with entry text + hyperlinked reference
179+
updated_title += docutil_nodes.Text(entry_title_text)
180+
updated_title += wrap_reference
181+
node["title"] = entry_title_text + updated_title_text
182+
168183
updated_title.parent = title.parent
169184
node.children[0] = updated_title
170185
node.resolved_title = True

0 commit comments

Comments
 (0)