Skip to content

Commit 8119fb3

Browse files
feat: Allow customizing emphasis and bold text colors via html_theme_options (#356)
* feat: Allow customizing emphasis and bold text colors via html_theme_options Add four new theme options to customize emphasis (em) and definition (strong/b) text colors for both light and dark modes: - emphasis_color: Color for em tags in light mode (default: #2d9f42) - emphasis_color_dark: Color for em tags in dark mode (default: #66bb6a) - definition_color: Color for strong/b tags in light mode (default: #8b4513) - definition_color_dark: Color for strong/b tags in dark mode (default: #cd853f) Implementation approach: - Replace hardcoded SCSS colors with CSS custom properties (var()) with fallback values matching the original defaults - Register new options in theme.conf with empty defaults - Inject custom property overrides via inline style in layout.html when theme options are set This is fully backward-compatible: existing sites see no visual change. When options are left empty, CSS var() fallbacks provide the original colors. Closes #355 * refactor: rename definition_color to strong_color for accuracy The option applies to all <strong>/<b> elements, not just definitions. Sphinx renders actual definitions via <dl>/<dt> elements which can be targeted separately. Renamed across SCSS, theme.conf, layout.html, tests, and documentation. * Add definition_color option targeting Sphinx definition list terms Add definition_color and definition_color_dark theme options that target actual Sphinx definition list elements (dl.simple dt, dl.glossary dt, dl.field-list dt) separately from strong/bold text. The definition color falls back to the strong color via CSS variable chain: var(--qe-definition-color, var(--qe-strong-color, fallback)), so definitions inherit from strong_color by default but can be independently customized. * Fix black formatting in test_custom_colors.py * Add visual regression tests for bold and italic typography Add 5 Playwright visual tests in a new 'Typography Styling' describe block: - bold text styling (light mode) - italic text styling (light mode) - mixed bold and italic styling - bold text in dark mode - italic text in dark mode Uses .qe-page__content selectors to target paragraphs containing <strong> and <em> elements on names.html, numpy.html, and python_by_example.html. Note: definition list (<dl>) tests not included as the lecture site does not currently contain definition lists. * UPDATE: Visual regression snapshots [skip ci] * Remove fragile mixed bold/italic visual test and snapshot * Add CSS color value validation to prevent injection - Validate color theme options against safe CSS patterns (hex, named colors, rgb/hsl functions) at build time - Invalid values are ignored with a warning logged - Add 14 tests for validation regex (valid and malicious patterns) - Add security note to documentation Addresses Copilot code review feedback on PR #356. * Add unit tests for validate_color_options to improve patch coverage --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent c25b251 commit 8119fb3

File tree

17 files changed

+598
-7
lines changed

17 files changed

+598
-7
lines changed

.github/copilot-instructions.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,13 @@ src/quantecon_book_theme/
9292
- **Alternative**: Try `pip install --timeout=120` for longer timeout
9393

9494
### GitHub CLI (gh) Issues
95+
- **Shell Escaping**: zsh has many issues with shell escaping, heredocs, and multiline strings. **ALWAYS** use the `create_file` tool to write content to a temporary file first, then pass it to `gh` with the `--body-file` flag. **NEVER** pass multiline body content directly via `--body` in the terminal.
96+
- Example workflow for updating a PR description:
97+
1. Use `create_file` to write the body to `/tmp/pr_body.md`
98+
2. Run: `gh pr edit 123 --body-file /tmp/pr_body.md`
99+
- Example workflow for creating a release:
100+
1. Use `create_file` to write release notes to `/tmp/release_notes.md`
101+
2. Run: `gh release create vX.Y.Z --title "Title" --notes-file /tmp/release_notes.md`
95102
- **Output Capture**: Always write gh output to `/tmp` file for reliable capture: `gh pr view 123 2>&1 | tee /tmp/gh_output.txt`
96103

97104
### Missing Python 3.13
@@ -240,12 +247,14 @@ git push && git push origin vX.Y.Z
240247

241248
### 5. Create GitHub Release
242249
```bash
250+
# Write release notes to a temp file first (use create_file tool)
251+
# Then create the release using --notes-file
243252
gh release create vX.Y.Z \
244253
--title "vX.Y.Z - Release Title" \
245-
--notes "Release notes from CHANGELOG.md"
254+
--notes-file /tmp/release_notes.md
246255
```
247256

248-
**IMPORTANT**: Creating the GitHub release triggers the PyPI publish workflow automatically.
257+
**IMPORTANT**: Always use `--notes-file` with a temp file instead of `--notes` to avoid zsh shell escaping issues. Creating the GitHub release triggers the PyPI publish workflow automatically.
249258

250259
### 6. Verify PyPI Publication
251260
- Check GitHub Actions for successful PyPI publish workflow

docs/configure.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,3 +323,60 @@ This is a collapsible note that will show "Show" when collapsed and "Hide" when
323323
```{toggle}
324324
This is a toggle directive that will use the configured button text.
325325
```
326+
327+
## Customizing Emphasis and Definition Colors
328+
329+
The theme applies custom colors to emphasis (italic), bold/strong, and definition list terms. By default:
330+
331+
- **Emphasis** (`em`): Green (`#2d9f42`) in light mode, lighter green (`#66bb6a`) in dark mode
332+
- **Bold/Strong** (`strong`, `b`): Brown (`#8b4513`) in light mode, lighter brown (`#cd853f`) in dark mode
333+
- **Definitions** (`dl dt`): Inherits from bold/strong color by default
334+
335+
You can override these colors using `html_theme_options`:
336+
337+
```python
338+
html_theme_options = {
339+
...
340+
"emphasis_color": "#1a73e8",
341+
"emphasis_color_dark": "#8ab4f8",
342+
"strong_color": "#d93025",
343+
"strong_color_dark": "#f28b82",
344+
"definition_color": "#6a1b9a",
345+
"definition_color_dark": "#ce93d8",
346+
...
347+
}
348+
```
349+
350+
For Jupyter Book projects, add to your `_config.yml`:
351+
352+
```yaml
353+
sphinx:
354+
config:
355+
html_theme_options:
356+
emphasis_color: "#1a73e8"
357+
emphasis_color_dark: "#8ab4f8"
358+
strong_color: "#d93025"
359+
strong_color_dark: "#f28b82"
360+
definition_color: "#6a1b9a"
361+
definition_color_dark: "#ce93d8"
362+
```
363+
364+
| Option | Description | Default |
365+
|---|---|---|
366+
| `emphasis_color` | Color for `em` tags in light mode | `#2d9f42` (green) |
367+
| `emphasis_color_dark` | Color for `em` tags in dark mode | `#66bb6a` (light green) |
368+
| `strong_color` | Color for `strong`/`b` tags in light mode | `#8b4513` (brown) |
369+
| `strong_color_dark` | Color for `strong`/`b` tags in dark mode | `#cd853f` (peru) |
370+
| `definition_color` | Color for definition list terms (`dl dt`) in light mode | Inherits from `strong_color` |
371+
| `definition_color_dark` | Color for definition list terms (`dl dt`) in dark mode | Inherits from `strong_color_dark` |
372+
373+
Any option left empty will use the theme's built-in default color. The `definition_color`
374+
options target Sphinx definition lists, glossary terms, and field lists specifically,
375+
while `strong_color` applies to all inline bold/strong text.
376+
377+
```{note}
378+
Color values are validated at build time against safe CSS color patterns
379+
(hex codes, named colors, `rgb()`/`hsl()` functions). Invalid values are
380+
ignored and a warning is logged. Only use trusted color values from your
381+
own configuration files.
382+
```

src/quantecon_book_theme/__init__.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from pathlib import Path
44
import os
55
import hashlib
6+
import re
67
from functools import lru_cache
78
import subprocess
89
from datetime import datetime, timezone
@@ -594,6 +595,48 @@ def _string_or_bool(var):
594595
return var is None
595596

596597

598+
# Valid CSS color pattern: hex (#RGB, #RRGGBB, #RRGGBBAA), named colors,
599+
# rgb/rgba/hsl/hsla functions, or CSS keywords
600+
_CSS_COLOR_RE = re.compile(
601+
r"^("
602+
r"#[0-9a-fA-F]{3,8}"
603+
r"|[a-zA-Z]+"
604+
r"|rgba?\([^)]+\)"
605+
r"|hsla?\([^)]+\)"
606+
r"|var\(--[a-zA-Z0-9-]+\)"
607+
r")$"
608+
)
609+
610+
_COLOR_OPTIONS = [
611+
"emphasis_color",
612+
"emphasis_color_dark",
613+
"strong_color",
614+
"strong_color_dark",
615+
"definition_color",
616+
"definition_color_dark",
617+
]
618+
619+
620+
def validate_color_options(app):
621+
"""Validate that color theme options contain safe CSS color values.
622+
623+
Prevents CSS injection by ensuring color values match a known-safe pattern
624+
(hex colors, named colors, rgb/hsl functions). Invalid values are replaced
625+
with empty strings and a warning is logged.
626+
"""
627+
theme_options = app.config.html_theme_options
628+
for option in _COLOR_OPTIONS:
629+
value = theme_options.get(option, "")
630+
if value and not _CSS_COLOR_RE.match(value.strip()):
631+
SPHINX_LOGGER.warning(
632+
"Ignoring invalid CSS color value for %s: %r. "
633+
"Use hex (#RRGGBB), named colors, or rgb()/hsl() functions.",
634+
option,
635+
value,
636+
)
637+
theme_options[option] = ""
638+
639+
597640
def setup(app):
598641
# Configuration for Juypter Book
599642
app.setup_extension("sphinx_book_theme")
@@ -603,6 +646,7 @@ def setup(app):
603646

604647
app.connect("html-page-context", add_hub_urls)
605648
app.connect("builder-inited", add_plugins_list)
649+
app.connect("builder-inited", validate_color_options)
606650
app.connect("builder-inited", setup_pygments_css)
607651
app.connect("html-page-context", hash_html_assets)
608652
app.connect("html-page-context", add_pygments_style_class)

src/quantecon_book_theme/assets/styles/_base.scss

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,14 +151,21 @@ h4 {
151151
em {
152152
font-style: normal;
153153
font-weight: 550;
154-
color: colors.$emphasis;
154+
color: var(--qe-emphasis-color, colors.$emphasis);
155155
}
156156

157-
// Strong/bold styling - semi-bold weight with color for definitions
157+
// Strong/bold styling - semi-bold weight with color
158158
strong,
159159
b {
160160
font-weight: 550;
161-
color: colors.$definition;
161+
color: var(--qe-strong-color, colors.$definition);
162+
}
163+
164+
// Definition list term styling - inherits from strong color by default
165+
dl.simple dt,
166+
dl.glossary dt,
167+
dl.field-list dt {
168+
color: var(--qe-definition-color, var(--qe-strong-color, colors.$definition));
162169
}
163170

164171
li {

src/quantecon_book_theme/assets/styles/_dark-theme.scss

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,18 @@ body.dark-theme {
2323

2424
// Lighter colors for emphasis and strong in dark mode
2525
em {
26-
color: #66bb6a; // Lighter green for dark mode
26+
color: var(--qe-emphasis-color, #66bb6a); // Lighter green for dark mode
2727
}
2828

2929
strong,
3030
b {
31-
color: #cd853f; // Lighter brown for dark mode
31+
color: var(--qe-strong-color, #cd853f); // Lighter brown for dark mode
32+
}
33+
34+
dl.simple dt,
35+
dl.glossary dt,
36+
dl.field-list dt {
37+
color: var(--qe-definition-color, var(--qe-strong-color, #cd853f));
3238
}
3339

3440
.qe-toolbar__inner > ul > li > a,

src/quantecon_book_theme/theme/quantecon_book_theme/layout.html

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,36 @@
119119
}
120120
}
121121
</style>
122+
{%- endif %}
123+
124+
<!-- Custom emphasis, strong/bold, and definition colors -->
125+
{%- if theme_emphasis_color or theme_strong_color or theme_definition_color %}
126+
<style>
127+
:root {
128+
{%- if theme_emphasis_color %}
129+
--qe-emphasis-color: {{ theme_emphasis_color }};
130+
{%- endif %}
131+
{%- if theme_strong_color %}
132+
--qe-strong-color: {{ theme_strong_color }};
133+
{%- endif %}
134+
{%- if theme_definition_color %}
135+
--qe-definition-color: {{ theme_definition_color }};
136+
{%- endif %}
137+
}
138+
{%- if theme_emphasis_color_dark or theme_strong_color_dark or theme_definition_color_dark %}
139+
body.dark-theme {
140+
{%- if theme_emphasis_color_dark %}
141+
--qe-emphasis-color: {{ theme_emphasis_color_dark }};
142+
{%- endif %}
143+
{%- if theme_strong_color_dark %}
144+
--qe-strong-color: {{ theme_strong_color_dark }};
145+
{%- endif %}
146+
{%- if theme_definition_color_dark %}
147+
--qe-definition-color: {{ theme_definition_color_dark }};
148+
{%- endif %}
149+
}
150+
{%- endif %}
151+
</style>
122152
{%- endif %}
123153

124154
<span id="top"></span>

src/quantecon_book_theme/theme/quantecon_book_theme/theme.conf

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,10 @@ twitter =
3636
twitter_logo_url =
3737
use_issues_button = False
3838
use_repository_button = False
39+
40+
emphasis_color =
41+
emphasis_color_dark =
42+
strong_color =
43+
strong_color_dark =
44+
definition_color =
45+
definition_color_dark =

0 commit comments

Comments
 (0)