From a43c7bffc8ca53995fa6875b681e33300ab75b51 Mon Sep 17 00:00:00 2001 From: mmcky Date: Mon, 3 Nov 2025 16:27:56 +1100 Subject: [PATCH 1/7] 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. --- CHANGELOG.md | 9 ++ docs/source/syntax.md | 27 ++++++ sphinx_exercise/__init__.py | 1 + sphinx_exercise/assets/html/exercise.css | 14 +++ sphinx_exercise/post_transforms.py | 61 +++++++++---- tests/test_style_solution_after_exercise.py | 97 +++++++++++++++++++++ 6 files changed, 191 insertions(+), 18 deletions(-) create mode 100644 tests/test_style_solution_after_exercise.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c57742..8a39281 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +### New ✨ + +- Added `style_solution_after_exercise` configuration option to remove hyperlinks from solution titles when solutions follow exercises directly +- Changed dropdown button text from "Click to show" to "Show" for cleaner visual appearance + +### Improved 👌 + +- Enhanced solution title styling options for better UX in lecture-style content where solutions follow exercises + ## [v1.1.1](https://github.com/executablebooks/sphinx-exercise/tree/v1.1.1) (2025-10-23) ### Improved 👌 diff --git a/docs/source/syntax.md b/docs/source/syntax.md index f13f2ba..7443ba6 100644 --- a/docs/source/syntax.md +++ b/docs/source/syntax.md @@ -343,6 +343,8 @@ sphinx: To hide the content, simply add `:class: dropdown` as a directive option. +**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. + For more use cases see [sphinx-togglebutton](https://sphinx-togglebutton.readthedocs.io/en/latest/#usage). **Example** @@ -407,6 +409,31 @@ sphinx: ... ``` +### Solution Title Styling + +By default, solution titles include a hyperlink to the corresponding exercise. This behavior can be modified using the `style_solution_after_exercise` configuration option. + +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. + +For Sphinx projects, add the configuration key in the `conf.py` file: + +```python +# conf.py +style_solution_after_exercise = True +``` + +For Jupyter Book projects, set the configuration key in `_config.yml`: + +```yaml +... +sphinx: + config: + style_solution_after_exercise: True +... +``` + +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. + ## Custom CSS or JavaScript 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. diff --git a/sphinx_exercise/__init__.py b/sphinx_exercise/__init__.py index 16a5b78..fc5b6f4 100644 --- a/sphinx_exercise/__init__.py +++ b/sphinx_exercise/__init__.py @@ -147,6 +147,7 @@ def doctree_read(app: Sphinx, document: Node) -> None: def setup(app: Sphinx) -> Dict[str, Any]: app.add_config_value("hide_solutions", False, "env") + app.add_config_value("style_solution_after_exercise", False, "env") app.connect("config-inited", init_numfig) # event order - 1 app.connect("env-purge-doc", purge_exercises) # event order - 5 per file diff --git a/sphinx_exercise/assets/html/exercise.css b/sphinx_exercise/assets/html/exercise.css index 7551f43..0ab879e 100644 --- a/sphinx_exercise/assets/html/exercise.css +++ b/sphinx_exercise/assets/html/exercise.css @@ -41,3 +41,17 @@ div.solution p.admonition-title { div.solution p.admonition-title::after { content: none; } + +/********************************************* +* Dropdown customization * +*********************************************/ +/* Change "Click to show" to "Show" for dropdowns */ +div.exercise.dropdown button.toggle-button::before, +div.solution.dropdown button.toggle-button::before { + content: "Show"; +} + +div.exercise.dropdown.toggle-shown button.toggle-button::before, +div.solution.dropdown.toggle-shown button.toggle-button::before { + content: "Hide"; +} diff --git a/sphinx_exercise/post_transforms.py b/sphinx_exercise/post_transforms.py index 97b79d7..64ca6e7 100644 --- a/sphinx_exercise/post_transforms.py +++ b/sphinx_exercise/post_transforms.py @@ -147,24 +147,49 @@ def resolve_solution_title(app, node, exercise_node): updated_title_text += f" {node_number}" # New Title Node updated_title = docutil_nodes.title() - wrap_reference = build_reference_node(app, exercise_node) - wrap_reference += docutil_nodes.Text(updated_title_text) - node["title"] = entry_title_text + updated_title_text - # Parse Custom Titles from Exercise - if len(exercise_title.children) > 1: - subtitle = exercise_title.children[1] - if isinstance(subtitle, exercise_subtitle): - wrap_reference += docutil_nodes.Text(" (") - for child in subtitle.children: - if isinstance(child, docutil_nodes.math): - # Ensure mathjax is loaded for pages that only contain - # references to nodes that contain math - domain = app.env.get_domain("math") - domain.data["has_equations"][app.env.docname] = True - wrap_reference += child - wrap_reference += docutil_nodes.Text(")") - updated_title += docutil_nodes.Text(entry_title_text) - updated_title += wrap_reference + + # Check if style_solution_after_exercise is enabled + if app.config.style_solution_after_exercise: + # Don't create hyperlink - just add plain text and nodes + updated_title += docutil_nodes.Text(entry_title_text) + updated_title += docutil_nodes.Text(updated_title_text) + node["title"] = entry_title_text + updated_title_text + + # Parse Custom Titles from Exercise + if len(exercise_title.children) > 1: + subtitle = exercise_title.children[1] + if isinstance(subtitle, exercise_subtitle): + updated_title += docutil_nodes.Text(" (") + for child in subtitle.children: + if isinstance(child, docutil_nodes.math): + # Ensure mathjax is loaded for pages that only contain + # references to nodes that contain math + domain = app.env.get_domain("math") + domain.data["has_equations"][app.env.docname] = True + # Add child directly (could be text or math node) + updated_title += child.deepcopy() + updated_title += docutil_nodes.Text(")") + else: + # Create hyperlink (original behavior) + wrap_reference = build_reference_node(app, exercise_node) + wrap_reference += docutil_nodes.Text(updated_title_text) + node["title"] = entry_title_text + updated_title_text + # Parse Custom Titles from Exercise + if len(exercise_title.children) > 1: + subtitle = exercise_title.children[1] + if isinstance(subtitle, exercise_subtitle): + wrap_reference += docutil_nodes.Text(" (") + for child in subtitle.children: + if isinstance(child, docutil_nodes.math): + # Ensure mathjax is loaded for pages that only contain + # references to nodes that contain math + domain = app.env.get_domain("math") + domain.data["has_equations"][app.env.docname] = True + wrap_reference += child + wrap_reference += docutil_nodes.Text(")") + updated_title += docutil_nodes.Text(entry_title_text) + updated_title += wrap_reference + updated_title.parent = title.parent node.children[0] = updated_title node.resolved_title = True diff --git a/tests/test_style_solution_after_exercise.py b/tests/test_style_solution_after_exercise.py new file mode 100644 index 0000000..78ee220 --- /dev/null +++ b/tests/test_style_solution_after_exercise.py @@ -0,0 +1,97 @@ +from bs4 import BeautifulSoup +import pytest +import sphinx + +# Sphinx 8.1.x (Python 3.10 only) has different XML output than 8.2+ +# Use .sphinx8.1 for 8.1.x, .sphinx8 for 8.2+ (the standard) +if sphinx.version_info[0] == 8 and sphinx.version_info[1] == 1: + SPHINX_VERSION = f".sphinx{sphinx.version_info[0]}.{sphinx.version_info[1]}" +else: + SPHINX_VERSION = f".sphinx{sphinx.version_info[0]}" + + +@pytest.mark.sphinx( + "html", testroot="mybook", confoverrides={"style_solution_after_exercise": True} +) +def test_solution_no_link(app): + """Test solution directive with style_solution_after_exercise=True removes hyperlink.""" + app.build() + path_solution_directive = app.outdir / "solution" / "_linked_enum.html" + assert path_solution_directive.exists() + + # get content markup + soup = BeautifulSoup( + path_solution_directive.read_text(encoding="utf8"), "html.parser" + ) + + sol = soup.select("div.solution")[0] + title = sol.select("p.admonition-title")[0] + + # Check that there is NO hyperlink in the title when style_solution_after_exercise=True + links = title.find_all("a") + assert ( + len(links) == 0 + ), "Solution title should not contain hyperlink when style_solution_after_exercise=True" + + # Check that the title text still contains the exercise reference + title_text = title.get_text() + assert "Exercise" in title_text + assert "This is a title" in title_text + + +@pytest.mark.sphinx( + "html", testroot="mybook", confoverrides={"style_solution_after_exercise": False} +) +def test_solution_with_link(app): + """Test solution directive with style_solution_after_exercise=False keeps hyperlink.""" + app.build() + path_solution_directive = app.outdir / "solution" / "_linked_enum.html" + assert path_solution_directive.exists() + + # get content markup + soup = BeautifulSoup( + path_solution_directive.read_text(encoding="utf8"), "html.parser" + ) + + sol = soup.select("div.solution")[0] + title = sol.select("p.admonition-title")[0] + + # Check that there IS a hyperlink in the title when style_solution_after_exercise=False (default) + links = title.find_all("a") + assert ( + len(links) == 1 + ), "Solution title should contain hyperlink when style_solution_after_exercise=False" + + # Check that the link points to the exercise + link = links[0] + assert "href" in link.attrs + assert "ex-number" in link["href"] + + +@pytest.mark.sphinx( + "html", testroot="mybook", confoverrides={"style_solution_after_exercise": True} +) +def test_solution_no_link_unenum(app): + """Test unnumbered solution directive with style_solution_after_exercise=True removes hyperlink.""" + app.build() + path_solution_directive = app.outdir / "solution" / "_linked_unenum_title.html" + assert path_solution_directive.exists() + + # get content markup + soup = BeautifulSoup( + path_solution_directive.read_text(encoding="utf8"), "html.parser" + ) + + sol = soup.select("div.solution")[0] + title = sol.select("p.admonition-title")[0] + + # Check that there is NO hyperlink in the title + links = title.find_all("a") + assert ( + len(links) == 0 + ), "Solution title should not contain hyperlink when style_solution_after_exercise=True" + + # Check that the title text still contains the exercise reference + title_text = title.get_text() + assert "Exercise" in title_text + assert "This is a title" in title_text From 2decd1470ea334d68f8d777780476d239f1251c3 Mon Sep 17 00:00:00 2001 From: mmcky Date: Mon, 3 Nov 2025 16:47:32 +1100 Subject: [PATCH 2/7] 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. --- docs/source/syntax.md | 10 ++++---- sphinx_exercise/__init__.py | 2 +- sphinx_exercise/post_transforms.py | 4 +-- tests/test_style_solution_after_exercise.py | 28 +++++++++++---------- 4 files changed, 23 insertions(+), 21 deletions(-) diff --git a/docs/source/syntax.md b/docs/source/syntax.md index 7443ba6..7d77c83 100644 --- a/docs/source/syntax.md +++ b/docs/source/syntax.md @@ -411,15 +411,15 @@ sphinx: ### Solution Title Styling -By default, solution titles include a hyperlink to the corresponding exercise. This behavior can be modified using the `style_solution_after_exercise` configuration option. +By default, solution titles include a hyperlink to the corresponding exercise. This behavior can be modified using the `exercise_style` configuration option. -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. +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 `exercise_style` to `"solution_follow_exercise"` to display only text without hyperlinks in solution titles. For Sphinx projects, add the configuration key in the `conf.py` file: ```python # conf.py -style_solution_after_exercise = True +exercise_style = "solution_follow_exercise" ``` For Jupyter Book projects, set the configuration key in `_config.yml`: @@ -428,11 +428,11 @@ For Jupyter Book projects, set the configuration key in `_config.yml`: ... sphinx: config: - style_solution_after_exercise: True + exercise_style: "solution_follow_exercise" ... ``` -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. +When `exercise_style` is set to `"solution_follow_exercise"`, the solution title will display plain text like "Solution to Exercise 1 (Title)" instead of a hyperlink. When empty `""` (default), the exercise reference in the solution title remains clickable. ## Custom CSS or JavaScript diff --git a/sphinx_exercise/__init__.py b/sphinx_exercise/__init__.py index fc5b6f4..0fe19d4 100644 --- a/sphinx_exercise/__init__.py +++ b/sphinx_exercise/__init__.py @@ -147,7 +147,7 @@ def doctree_read(app: Sphinx, document: Node) -> None: def setup(app: Sphinx) -> Dict[str, Any]: app.add_config_value("hide_solutions", False, "env") - app.add_config_value("style_solution_after_exercise", False, "env") + app.add_config_value("exercise_style", "", "env") app.connect("config-inited", init_numfig) # event order - 1 app.connect("env-purge-doc", purge_exercises) # event order - 5 per file diff --git a/sphinx_exercise/post_transforms.py b/sphinx_exercise/post_transforms.py index 64ca6e7..33f6840 100644 --- a/sphinx_exercise/post_transforms.py +++ b/sphinx_exercise/post_transforms.py @@ -148,8 +148,8 @@ def resolve_solution_title(app, node, exercise_node): # New Title Node updated_title = docutil_nodes.title() - # Check if style_solution_after_exercise is enabled - if app.config.style_solution_after_exercise: + # Check if exercise_style is set to "solution_follow_exercise" + if app.config.exercise_style == "solution_follow_exercise": # Don't create hyperlink - just add plain text and nodes updated_title += docutil_nodes.Text(entry_title_text) updated_title += docutil_nodes.Text(updated_title_text) diff --git a/tests/test_style_solution_after_exercise.py b/tests/test_style_solution_after_exercise.py index 78ee220..fa1bb75 100644 --- a/tests/test_style_solution_after_exercise.py +++ b/tests/test_style_solution_after_exercise.py @@ -11,10 +11,12 @@ @pytest.mark.sphinx( - "html", testroot="mybook", confoverrides={"style_solution_after_exercise": True} + "html", + testroot="mybook", + confoverrides={"exercise_style": "solution_follow_exercise"}, ) def test_solution_no_link(app): - """Test solution directive with style_solution_after_exercise=True removes hyperlink.""" + """Test solution directive with exercise_style='solution_follow_exercise' removes hyperlink.""" app.build() path_solution_directive = app.outdir / "solution" / "_linked_enum.html" assert path_solution_directive.exists() @@ -27,11 +29,11 @@ def test_solution_no_link(app): sol = soup.select("div.solution")[0] title = sol.select("p.admonition-title")[0] - # Check that there is NO hyperlink in the title when style_solution_after_exercise=True + # Check that there is NO hyperlink in the title when exercise_style='solution_follow_exercise' links = title.find_all("a") assert ( len(links) == 0 - ), "Solution title should not contain hyperlink when style_solution_after_exercise=True" + ), "Solution title should not contain hyperlink when exercise_style='solution_follow_exercise'" # Check that the title text still contains the exercise reference title_text = title.get_text() @@ -39,11 +41,9 @@ def test_solution_no_link(app): assert "This is a title" in title_text -@pytest.mark.sphinx( - "html", testroot="mybook", confoverrides={"style_solution_after_exercise": False} -) +@pytest.mark.sphinx("html", testroot="mybook", confoverrides={"exercise_style": ""}) def test_solution_with_link(app): - """Test solution directive with style_solution_after_exercise=False keeps hyperlink.""" + """Test solution directive with exercise_style='' (default) keeps hyperlink.""" app.build() path_solution_directive = app.outdir / "solution" / "_linked_enum.html" assert path_solution_directive.exists() @@ -56,11 +56,11 @@ def test_solution_with_link(app): sol = soup.select("div.solution")[0] title = sol.select("p.admonition-title")[0] - # Check that there IS a hyperlink in the title when style_solution_after_exercise=False (default) + # Check that there IS a hyperlink in the title when exercise_style='' (default) links = title.find_all("a") assert ( len(links) == 1 - ), "Solution title should contain hyperlink when style_solution_after_exercise=False" + ), "Solution title should contain hyperlink when exercise_style='' (default)" # Check that the link points to the exercise link = links[0] @@ -69,10 +69,12 @@ def test_solution_with_link(app): @pytest.mark.sphinx( - "html", testroot="mybook", confoverrides={"style_solution_after_exercise": True} + "html", + testroot="mybook", + confoverrides={"exercise_style": "solution_follow_exercise"}, ) def test_solution_no_link_unenum(app): - """Test unnumbered solution directive with style_solution_after_exercise=True removes hyperlink.""" + """Test unnumbered solution directive with exercise_style='solution_follow_exercise' removes hyperlink.""" app.build() path_solution_directive = app.outdir / "solution" / "_linked_unenum_title.html" assert path_solution_directive.exists() @@ -89,7 +91,7 @@ def test_solution_no_link_unenum(app): links = title.find_all("a") assert ( len(links) == 0 - ), "Solution title should not contain hyperlink when style_solution_after_exercise=True" + ), "Solution title should not contain hyperlink when exercise_style='solution_follow_exercise'" # Check that the title text still contains the exercise reference title_text = title.get_text() From b19a517aa238f581a681fe86fa7f8628dce91351 Mon Sep 17 00:00:00 2001 From: mmcky Date: Mon, 3 Nov 2025 16:59:31 +1100 Subject: [PATCH 3/7] 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 --- CHANGELOG.md | 7 +++++-- docs/source/install.md | 2 +- ...e_solution_after_exercise.py => test_exercise_style.py} | 0 3 files changed, 6 insertions(+), 3 deletions(-) rename tests/{test_style_solution_after_exercise.py => test_exercise_style.py} (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a39281..101a9a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,15 @@ ### New ✨ -- Added `style_solution_after_exercise` configuration option to remove hyperlinks from solution titles when solutions follow exercises directly -- Changed dropdown button text from "Click to show" to "Show" for cleaner visual appearance +- Added `exercise_style` configuration option to control solution title styling + - Set to `"solution_follow_exercise"` to remove hyperlinks from solution titles when solutions follow exercises directly + - Default is `""` (empty string) which maintains the original clickable hyperlink behavior +- Changed dropdown button text from "Click to show" to "Show" (and "Click to hide" to "Hide") for cleaner visual appearance ### Improved 👌 - Enhanced solution title styling options for better UX in lecture-style content where solutions follow exercises +- Improved configuration option naming for better clarity when used alongside other Sphinx extensions ## [v1.1.1](https://github.com/executablebooks/sphinx-exercise/tree/v1.1.1) (2025-10-23) diff --git a/docs/source/install.md b/docs/source/install.md index d743b7f..059f2e2 100644 --- a/docs/source/install.md +++ b/docs/source/install.md @@ -16,5 +16,5 @@ git clone https://github.com/executablebooks/sphinx-exercise then run: ```bash -python setup.py install +pip install -e . ``` diff --git a/tests/test_style_solution_after_exercise.py b/tests/test_exercise_style.py similarity index 100% rename from tests/test_style_solution_after_exercise.py rename to tests/test_exercise_style.py From 69d03459d630d685cb3a490786b412efb6d4a071 Mon Sep 17 00:00:00 2001 From: mmcky Date: Tue, 4 Nov 2025 10:08:27 +1100 Subject: [PATCH 4/7] 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 --- CHANGELOG.md | 7 ++++++- docs/source/syntax.md | 6 ++++-- sphinx_exercise/assets/html/exercise.css | 14 -------------- 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 101a9a7..97e07d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,18 @@ - Added `exercise_style` configuration option to control solution title styling - Set to `"solution_follow_exercise"` to remove hyperlinks from solution titles when solutions follow exercises directly - Default is `""` (empty string) which maintains the original clickable hyperlink behavior -- Changed dropdown button text from "Click to show" to "Show" (and "Click to hide" to "Hide") for cleaner visual appearance ### Improved 👌 - Enhanced solution title styling options for better UX in lecture-style content where solutions follow exercises - Improved configuration option naming for better clarity when used alongside other Sphinx extensions +### Changed 🔄 + +- Removed dropdown button text customization from `sphinx-exercise` CSS + - This styling is better handled at the theme level (e.g., in `quantecon-book-theme`) for consistent global behavior + - Projects can customize dropdown text in their theme or custom CSS if desired + ## [v1.1.1](https://github.com/executablebooks/sphinx-exercise/tree/v1.1.1) (2025-10-23) ### Improved 👌 diff --git a/docs/source/syntax.md b/docs/source/syntax.md index 7d77c83..20f92f3 100644 --- a/docs/source/syntax.md +++ b/docs/source/syntax.md @@ -343,10 +343,12 @@ sphinx: To hide the content, simply add `:class: dropdown` as a directive option. -**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. - For more use cases see [sphinx-togglebutton](https://sphinx-togglebutton.readthedocs.io/en/latest/#usage). +```{tip} +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. +``` + **Example** ```{exercise} diff --git a/sphinx_exercise/assets/html/exercise.css b/sphinx_exercise/assets/html/exercise.css index 0ab879e..7551f43 100644 --- a/sphinx_exercise/assets/html/exercise.css +++ b/sphinx_exercise/assets/html/exercise.css @@ -41,17 +41,3 @@ div.solution p.admonition-title { div.solution p.admonition-title::after { content: none; } - -/********************************************* -* Dropdown customization * -*********************************************/ -/* Change "Click to show" to "Show" for dropdowns */ -div.exercise.dropdown button.toggle-button::before, -div.solution.dropdown button.toggle-button::before { - content: "Show"; -} - -div.exercise.dropdown.toggle-shown button.toggle-button::before, -div.solution.dropdown.toggle-shown button.toggle-button::before { - content: "Hide"; -} From f37f2edd7bdc95ad9f2b26af1ecf1f5b62eced5f Mon Sep 17 00:00:00 2001 From: mmcky Date: Wed, 5 Nov 2025 11:01:35 +1100 Subject: [PATCH 5/7] 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 --- sphinx_exercise/__init__.py | 130 ++++++++++++++++++++++++++++- sphinx_exercise/post_transforms.py | 36 +++----- tests/test_exercise_style.py | 14 ++-- tests/test_order_validation.py | 99 ++++++++++++++++++++++ 4 files changed, 249 insertions(+), 30 deletions(-) create mode 100644 tests/test_order_validation.py diff --git a/sphinx_exercise/__init__.py b/sphinx_exercise/__init__.py index 0fe19d4..a173f2f 100644 --- a/sphinx_exercise/__init__.py +++ b/sphinx_exercise/__init__.py @@ -87,6 +87,13 @@ def purge_exercises(app: Sphinx, env: BuildEnvironment, docname: str) -> None: for label in remove_labels: del env.sphinx_exercise_registry[label] + # Purge node order tracking for this document + if ( + hasattr(env, "sphinx_exercise_node_order") + and docname in env.sphinx_exercise_node_order + ): + del env.sphinx_exercise_node_order[docname] + def merge_exercises( app: Sphinx, env: BuildEnvironment, docnames: Set[str], other: BuildEnvironment @@ -103,6 +110,16 @@ def merge_exercises( **other.sphinx_exercise_registry, } + # Merge node order tracking + if not hasattr(env, "sphinx_exercise_node_order"): + env.sphinx_exercise_node_order = {} + + if hasattr(other, "sphinx_exercise_node_order"): + env.sphinx_exercise_node_order = { + **env.sphinx_exercise_node_order, + **other.sphinx_exercise_node_order, + } + def init_numfig(app: Sphinx, config: Config) -> None: """Initialize numfig""" @@ -127,6 +144,95 @@ def copy_asset_files(app: Sphinx, exc: Union[bool, Exception]): copy_asset(path, str(Path(app.outdir).joinpath("_static").absolute())) +def validate_exercise_solution_order(app: Sphinx, env: BuildEnvironment) -> None: + """ + Validate that solutions follow their referenced exercises when + exercise_style='solution_follow_exercise' is set. + """ + # Only validate if the config option is set + if app.config.exercise_style != "solution_follow_exercise": + return + + if not hasattr(env, "sphinx_exercise_node_order"): + return + + logger = logging.getLogger(__name__) + + # Process each document + for docname, nodes in env.sphinx_exercise_node_order.items(): + # Build a map of exercise labels to their positions and info + exercise_info = {} + for i, node_info in enumerate(nodes): + if node_info["type"] == "exercise": + exercise_info[node_info["label"]] = { + "position": i, + "line": node_info.get("line"), + } + + # Check each solution + for i, node_info in enumerate(nodes): + if node_info["type"] == "solution": + target_label = node_info["target_label"] + solution_label = node_info["label"] + solution_line = node_info.get("line") + + if not target_label: + continue + + # Check if target exercise exists in this document + if target_label not in exercise_info: + # Exercise is in a different document or doesn't exist + docpath = env.doc2path(docname) + path = str(Path(docpath).with_suffix("")) + + # Build location string with line number if available + location = f"{path}:{solution_line}" if solution_line else path + + logger.warning( + f"[sphinx-exercise] Solution '{solution_label}' references exercise '{target_label}' " + f"which is not in the same document. When exercise_style='solution_follow_exercise', " + f"solutions should appear in the same document as their exercises.", + location=location, + color="yellow", + ) + continue + + # Check if solution comes after exercise + exercise_data = exercise_info[target_label] + exercise_pos = exercise_data["position"] + exercise_line = exercise_data.get("line") + + if i <= exercise_pos: + docpath = env.doc2path(docname) + path = str(Path(docpath).with_suffix("")) + + # Build more informative message with line numbers + if solution_line and exercise_line: + location = f"{path}:{solution_line}" + msg = ( + f"[sphinx-exercise] Solution '{solution_label}' (line {solution_line}) does not follow " + f"exercise '{target_label}' (line {exercise_line}). " + f"When exercise_style='solution_follow_exercise', solutions should " + f"appear after their referenced exercises." + ) + elif solution_line: + location = f"{path}:{solution_line}" + msg = ( + f"[sphinx-exercise] Solution '{solution_label}' does not follow exercise '{target_label}'. " + f"When exercise_style='solution_follow_exercise', solutions should " + f"appear after their referenced exercises." + ) + else: + location = path + msg = ( + f"[sphinx-exercise] Solution '{solution_label}' does not follow exercise '{target_label}'. " + f"When exercise_style='solution_follow_exercise', solutions should " + f"appear after their referenced exercises." + ) + + logger.warning(msg, location=location, color="yellow") + + def doctree_read(app: Sphinx, document: Node) -> None: """ Read the doctree and apply updates to sphinx-exercise nodes @@ -134,16 +240,37 @@ def doctree_read(app: Sphinx, document: Node) -> None: domain = cast(StandardDomain, app.env.get_domain("std")) + # Initialize node order tracking for this document + if not hasattr(app.env, "sphinx_exercise_node_order"): + app.env.sphinx_exercise_node_order = {} + + docname = app.env.docname + if docname not in app.env.sphinx_exercise_node_order: + app.env.sphinx_exercise_node_order[docname] = [] + # Traverse sphinx-exercise nodes for node in findall(document): if is_extension_node(node): name = node.get("names", [])[0] label = document.nameids[name] - docname = app.env.docname section_name = node.attributes.get("title") domain.anonlabels[name] = docname, label domain.labels[name] = docname, label, section_name + # Track node order for validation + node_type = node.get("type", "unknown") + node_label = node.get("label", "") + target_label = node.get("target_label", None) # Only for solution nodes + + app.env.sphinx_exercise_node_order[docname].append( + { + "type": node_type, + "label": node_label, + "target_label": target_label, + "line": node.line if hasattr(node, "line") else None, + } + ) + def setup(app: Sphinx) -> Dict[str, Any]: app.add_config_value("hide_solutions", False, "env") @@ -153,6 +280,7 @@ def setup(app: Sphinx) -> Dict[str, Any]: app.connect("env-purge-doc", purge_exercises) # event order - 5 per file app.connect("doctree-read", doctree_read) # event order - 8 app.connect("env-merge-info", merge_exercises) # event order - 9 + app.connect("env-updated", validate_exercise_solution_order) # event order - 10 app.connect("build-finished", copy_asset_files) # event order - 16 app.add_node( diff --git a/sphinx_exercise/post_transforms.py b/sphinx_exercise/post_transforms.py index 33f6840..1235f30 100644 --- a/sphinx_exercise/post_transforms.py +++ b/sphinx_exercise/post_transforms.py @@ -141,39 +141,26 @@ def resolve_solution_title(app, node, exercise_node): exercise_title = exercise_node.children[0] if isinstance(title, solution_title): entry_title_text = node.get("title") - updated_title_text = " " + exercise_title.children[0].astext() - if isinstance(exercise_node, exercise_enumerable_node): - node_number = get_node_number(app, exercise_node, "exercise") - updated_title_text += f" {node_number}" + # New Title Node updated_title = docutil_nodes.title() # Check if exercise_style is set to "solution_follow_exercise" if app.config.exercise_style == "solution_follow_exercise": - # Don't create hyperlink - just add plain text and nodes + # Simple title: just "Solution" without reference to exercise updated_title += docutil_nodes.Text(entry_title_text) - updated_title += docutil_nodes.Text(updated_title_text) - node["title"] = entry_title_text + updated_title_text - - # Parse Custom Titles from Exercise - if len(exercise_title.children) > 1: - subtitle = exercise_title.children[1] - if isinstance(subtitle, exercise_subtitle): - updated_title += docutil_nodes.Text(" (") - for child in subtitle.children: - if isinstance(child, docutil_nodes.math): - # Ensure mathjax is loaded for pages that only contain - # references to nodes that contain math - domain = app.env.get_domain("math") - domain.data["has_equations"][app.env.docname] = True - # Add child directly (could be text or math node) - updated_title += child.deepcopy() - updated_title += docutil_nodes.Text(")") + node["title"] = entry_title_text else: + # Build full title with exercise reference + updated_title_text = " " + exercise_title.children[0].astext() + if isinstance(exercise_node, exercise_enumerable_node): + node_number = get_node_number(app, exercise_node, "exercise") + updated_title_text += f" {node_number}" + # Create hyperlink (original behavior) wrap_reference = build_reference_node(app, exercise_node) wrap_reference += docutil_nodes.Text(updated_title_text) - node["title"] = entry_title_text + updated_title_text + # Parse Custom Titles from Exercise if len(exercise_title.children) > 1: subtitle = exercise_title.children[1] @@ -187,8 +174,11 @@ def resolve_solution_title(app, node, exercise_node): domain.data["has_equations"][app.env.docname] = True wrap_reference += child wrap_reference += docutil_nodes.Text(")") + + # Build the title with entry text + hyperlinked reference updated_title += docutil_nodes.Text(entry_title_text) updated_title += wrap_reference + node["title"] = entry_title_text + updated_title_text updated_title.parent = title.parent node.children[0] = updated_title diff --git a/tests/test_exercise_style.py b/tests/test_exercise_style.py index fa1bb75..8b2e0ba 100644 --- a/tests/test_exercise_style.py +++ b/tests/test_exercise_style.py @@ -35,10 +35,11 @@ def test_solution_no_link(app): len(links) == 0 ), "Solution title should not contain hyperlink when exercise_style='solution_follow_exercise'" - # Check that the title text still contains the exercise reference + # Check that the title is just "Solution" without exercise reference title_text = title.get_text() - assert "Exercise" in title_text - assert "This is a title" in title_text + assert ( + title_text.strip() == "Solution to" + ), "Solution title should be just 'Solution to' when exercise_style='solution_follow_exercise'" @pytest.mark.sphinx("html", testroot="mybook", confoverrides={"exercise_style": ""}) @@ -93,7 +94,8 @@ def test_solution_no_link_unenum(app): len(links) == 0 ), "Solution title should not contain hyperlink when exercise_style='solution_follow_exercise'" - # Check that the title text still contains the exercise reference + # Check that the title is just "Solution" without exercise reference title_text = title.get_text() - assert "Exercise" in title_text - assert "This is a title" in title_text + assert ( + title_text.strip() == "Solution to" + ), "Solution title should be just 'Solution to' when exercise_style='solution_follow_exercise'" diff --git a/tests/test_order_validation.py b/tests/test_order_validation.py new file mode 100644 index 0000000..9303a6e --- /dev/null +++ b/tests/test_order_validation.py @@ -0,0 +1,99 @@ +"""Test exercise-solution order validation when exercise_style='solution_follow_exercise'""" +from pathlib import Path +import pytest + + +@pytest.mark.sphinx( + "html", + testroot="mybook", + confoverrides={"exercise_style": "solution_follow_exercise"}, +) +def test_solution_before_exercise_warning(app, warning): + """Test that a warning is raised when solution appears before exercise""" + # Create a temporary file with solution before exercise + srcdir = Path(app.srcdir) + test_file = srcdir / "test_wrong_order.rst" + + test_content = """ +Wrong Order Test +================ + +.. solution:: my-test-exercise + :label: sol-wrong-order + + This solution appears before the exercise! + +.. exercise:: Test Exercise + :label: my-test-exercise + + This is the exercise that should come first. +""" + + test_file.write_text(test_content) + + # Build and check for warnings + app.build() + + warnings_text = warning.getvalue() + + # Should warn about solution not following exercise + assert "does not follow" in warnings_text or "Solution" in warnings_text + assert "sol-wrong-order" in warnings_text + assert "my-test-exercise" in warnings_text + + # Clean up + test_file.unlink() + + +@pytest.mark.sphinx( + "html", + testroot="mybook", + confoverrides={"exercise_style": "solution_follow_exercise"}, +) +def test_solution_different_document_warning(app, warning): + """Test that a warning is raised when solution and exercise are in different documents""" + # The existing test files should have some cross-document references + app.build() + + # We expect the build to succeed but potentially with warnings + # about cross-document references + assert app.statuscode == 0 or app.statuscode is None + + +@pytest.mark.sphinx( + "html", + testroot="mybook", + confoverrides={"exercise_style": ""}, # Default - no validation +) +def test_no_validation_when_config_not_set(app, warning): + """Test that validation doesn't run when exercise_style is not set""" + # Create a file with solution before exercise + srcdir = Path(app.srcdir) + test_file = srcdir / "test_no_validation.rst" + + test_content = """ +No Validation Test +================== + +.. solution:: my-test-ex + :label: sol-no-val + + Solution before exercise - but should not warn + +.. exercise:: Test + :label: my-test-ex + + Exercise content +""" + + test_file.write_text(test_content) + + app.build() + + warnings_text = warning.getvalue() + + # Should NOT warn about order when config is not set + assert "appears before" not in warnings_text + + # Clean up + test_file.unlink() From 669fac3d425d09cbf821b7ab69faca98b3aeb02a Mon Sep 17 00:00:00 2001 From: mmcky Date: Wed, 5 Nov 2025 11:39:42 +1100 Subject: [PATCH 6/7] 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 --- CHANGELOG.md | 10 ++++++++-- docs/source/syntax.md | 9 +++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97e07d5..6b8764a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,19 @@ ### New ✨ - Added `exercise_style` configuration option to control solution title styling - - Set to `"solution_follow_exercise"` to remove hyperlinks from solution titles when solutions follow exercises directly - - Default is `""` (empty string) which maintains the original clickable hyperlink behavior + - Set to `"solution_follow_exercise"` to simplify solution titles to just "Solution" (no hyperlinks, no exercise references) + - Default is `""` (empty string) which maintains the original behavior: "Solution to Exercise #.#" with clickable hyperlink +- Added order validation system when `exercise_style = "solution_follow_exercise"` + - Validates that solutions appear after their referenced exercises + - Validates that solutions are in the same document as their exercises + - Provides helpful warnings with file paths and line numbers + - Warnings are prefixed with `[sphinx-exercise]` for clarity ### Improved 👌 - Enhanced solution title styling options for better UX in lecture-style content where solutions follow exercises - Improved configuration option naming for better clarity when used alongside other Sphinx extensions +- Cleaner code in `post_transforms.py` with removed redundant title building logic ### Changed 🔄 diff --git a/docs/source/syntax.md b/docs/source/syntax.md index 20f92f3..ec0463b 100644 --- a/docs/source/syntax.md +++ b/docs/source/syntax.md @@ -415,7 +415,7 @@ sphinx: By default, solution titles include a hyperlink to the corresponding exercise. This behavior can be modified using the `exercise_style` configuration option. -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 `exercise_style` to `"solution_follow_exercise"` to display only text without hyperlinks in solution titles. +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. For Sphinx projects, add the configuration key in the `conf.py` file: @@ -434,7 +434,12 @@ sphinx: ... ``` -When `exercise_style` is set to `"solution_follow_exercise"`, the solution title will display plain text like "Solution to Exercise 1 (Title)" instead of a hyperlink. When empty `""` (default), the exercise reference in the solution title remains clickable. +When `exercise_style` is set to `"solution_follow_exercise"`: +- The solution title displays just "Solution" (plain text, no hyperlink) +- The extension validates that solutions follow their referenced exercises and warns if they don't +- Solutions must be in the same document as their exercises (warnings if not) + +When empty `""` (default), the solution title shows "Solution to Exercise #.#" with a clickable hyperlink to the exercise. ## Custom CSS or JavaScript From 75759aae775d976b2c6b2944ef67ec6a3248ea5b Mon Sep 17 00:00:00 2001 From: mmcky Date: Wed, 5 Nov 2025 11:43:33 +1100 Subject: [PATCH 7/7] 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 --- sphinx_exercise/directive.py | 6 +++++- tests/test_exercise_style.py | 8 ++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/sphinx_exercise/directive.py b/sphinx_exercise/directive.py index 0302c65..6dd8fcf 100644 --- a/sphinx_exercise/directive.py +++ b/sphinx_exercise/directive.py @@ -222,7 +222,11 @@ class : str, solution_node = solution_node def run(self) -> List[Node]: - self.defaults = {"title_text": f"{translate('Solution to')}"} + # Set default title based on exercise_style config + if self.env.app.config.exercise_style == "solution_follow_exercise": + self.defaults = {"title_text": f"{translate('Solution')}"} + else: + self.defaults = {"title_text": f"{translate('Solution to')}"} target_label = self.arguments[0] self.serial_number = self.env.new_serialno() diff --git a/tests/test_exercise_style.py b/tests/test_exercise_style.py index 8b2e0ba..50cb93f 100644 --- a/tests/test_exercise_style.py +++ b/tests/test_exercise_style.py @@ -38,8 +38,8 @@ def test_solution_no_link(app): # Check that the title is just "Solution" without exercise reference title_text = title.get_text() assert ( - title_text.strip() == "Solution to" - ), "Solution title should be just 'Solution to' when exercise_style='solution_follow_exercise'" + title_text.strip() == "Solution" + ), "Solution title should be just 'Solution' when exercise_style='solution_follow_exercise'" @pytest.mark.sphinx("html", testroot="mybook", confoverrides={"exercise_style": ""}) @@ -97,5 +97,5 @@ def test_solution_no_link_unenum(app): # Check that the title is just "Solution" without exercise reference title_text = title.get_text() assert ( - title_text.strip() == "Solution to" - ), "Solution title should be just 'Solution to' when exercise_style='solution_follow_exercise'" + title_text.strip() == "Solution" + ), "Solution title should be just 'Solution' when exercise_style='solution_follow_exercise'"