diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c57742..6b8764a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ ## [Unreleased] +### New ✨ + +- Added `exercise_style` configuration option to control solution title styling + - 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 🔄 + +- 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/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/docs/source/syntax.md b/docs/source/syntax.md index f13f2ba..ec0463b 100644 --- a/docs/source/syntax.md +++ b/docs/source/syntax.md @@ -345,6 +345,10 @@ To hide the content, simply add `:class: dropdown` as a directive option. 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} @@ -407,6 +411,36 @@ sphinx: ... ``` +### Solution Title Styling + +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 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: + +```python +# conf.py +exercise_style = "solution_follow_exercise" +``` + +For Jupyter Book projects, set the configuration key in `_config.yml`: + +```yaml +... +sphinx: + config: + exercise_style: "solution_follow_exercise" +... +``` + +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 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..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,24 +240,47 @@ 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") + 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 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/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/sphinx_exercise/post_transforms.py b/sphinx_exercise/post_transforms.py index 97b79d7..1235f30 100644 --- a/sphinx_exercise/post_transforms.py +++ b/sphinx_exercise/post_transforms.py @@ -141,30 +141,45 @@ 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() - 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 exercise_style is set to "solution_follow_exercise" + if app.config.exercise_style == "solution_follow_exercise": + # Simple title: just "Solution" without reference to exercise + updated_title += docutil_nodes.Text(entry_title_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) + + # 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(")") + + # 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 node.resolved_title = True diff --git a/tests/test_exercise_style.py b/tests/test_exercise_style.py new file mode 100644 index 0000000..50cb93f --- /dev/null +++ b/tests/test_exercise_style.py @@ -0,0 +1,101 @@ +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={"exercise_style": "solution_follow_exercise"}, +) +def test_solution_no_link(app): + """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() + + # 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 exercise_style='solution_follow_exercise' + links = title.find_all("a") + assert ( + len(links) == 0 + ), "Solution title should not contain hyperlink when exercise_style='solution_follow_exercise'" + + # Check that the title is just "Solution" without exercise reference + title_text = title.get_text() + assert ( + 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": ""}) +def test_solution_with_link(app): + """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() + + # 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 exercise_style='' (default) + links = title.find_all("a") + assert ( + len(links) == 1 + ), "Solution title should contain hyperlink when exercise_style='' (default)" + + # 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={"exercise_style": "solution_follow_exercise"}, +) +def test_solution_no_link_unenum(app): + """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() + + # 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 exercise_style='solution_follow_exercise'" + + # Check that the title is just "Solution" without exercise reference + title_text = title.get_text() + assert ( + title_text.strip() == "Solution" + ), "Solution title should be just 'Solution' 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()