Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 👌
Expand Down
2 changes: 1 addition & 1 deletion docs/source/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,5 @@ git clone https://github.com/executablebooks/sphinx-exercise
then run:

```bash
python setup.py install
pip install -e .
```
34 changes: 34 additions & 0 deletions docs/source/syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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.
Expand Down
131 changes: 130 additions & 1 deletion sphinx_exercise/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"""
Expand All @@ -127,31 +144,143 @@ 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
"""

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(
Expand Down
6 changes: 5 additions & 1 deletion sphinx_exercise/directive.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
59 changes: 37 additions & 22 deletions sphinx_exercise/post_transforms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading