munchboka-edutools 0.2.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of munchboka-edutools might be problematic. Click here for more details.
- munchboka_edutools/__init__.py +184 -0
- munchboka_edutools/_plotmath_shim.py +126 -0
- munchboka_edutools/_version.py +2 -0
- munchboka_edutools/directives/__init__.py +1 -0
- munchboka_edutools/directives/admonitions.py +389 -0
- munchboka_edutools/directives/cas_popup.py +428 -0
- munchboka_edutools/directives/clear.py +103 -0
- munchboka_edutools/directives/dialogue.py +137 -0
- munchboka_edutools/directives/escape_room.py +296 -0
- munchboka_edutools/directives/escape_room2.py +318 -0
- munchboka_edutools/directives/factor_tree.py +552 -0
- munchboka_edutools/directives/flashcards.py +233 -0
- munchboka_edutools/directives/ggb.py +209 -0
- munchboka_edutools/directives/ggb_icon.py +105 -0
- munchboka_edutools/directives/ggb_popup.py +308 -0
- munchboka_edutools/directives/horner.py +326 -0
- munchboka_edutools/directives/interactive_code.py +75 -0
- munchboka_edutools/directives/jeopardy.py +252 -0
- munchboka_edutools/directives/jeopardy2.py +636 -0
- munchboka_edutools/directives/multi_plot.py +2524 -0
- munchboka_edutools/directives/multi_plot2.py +252 -0
- munchboka_edutools/directives/pair_puzzle.py +191 -0
- munchboka_edutools/directives/parsons.py +109 -0
- munchboka_edutools/directives/plot.py +3758 -0
- munchboka_edutools/directives/poly_icon.py +111 -0
- munchboka_edutools/directives/polydiv.py +346 -0
- munchboka_edutools/directives/popup.py +245 -0
- munchboka_edutools/directives/quiz.py +291 -0
- munchboka_edutools/directives/quiz2.py +453 -0
- munchboka_edutools/directives/signchart.py +519 -0
- munchboka_edutools/directives/signchart2.py +1545 -0
- munchboka_edutools/directives/timed_quiz.py +436 -0
- munchboka_edutools/directives/turtle.py +157 -0
- munchboka_edutools/static/css/admonitions.css +2012 -0
- munchboka_edutools/static/css/cas_popup.css +242 -0
- munchboka_edutools/static/css/code_mirror_themes/github_dark_cm.css +112 -0
- munchboka_edutools/static/css/code_mirror_themes/github_dark_default_cm.css +40 -0
- munchboka_edutools/static/css/code_mirror_themes/github_dark_high_contrast_cm.css +141 -0
- munchboka_edutools/static/css/code_mirror_themes/github_light_cm.css +120 -0
- munchboka_edutools/static/css/code_mirror_themes/github_light_default_cm.css +108 -0
- munchboka_edutools/static/css/code_mirror_themes/one_dark_cm.css +121 -0
- munchboka_edutools/static/css/dialogue.css +92 -0
- munchboka_edutools/static/css/escapeRoom/escape-room.css +223 -0
- munchboka_edutools/static/css/figures.css +321 -0
- munchboka_edutools/static/css/flashcards.css +219 -0
- munchboka_edutools/static/css/general_style.css +74 -0
- munchboka_edutools/static/css/github-dark-high-contrast.css +141 -0
- munchboka_edutools/static/css/github-dark.css +147 -0
- munchboka_edutools/static/css/github-light.css +155 -0
- munchboka_edutools/static/css/interactive_code/style.css +575 -0
- munchboka_edutools/static/css/interactive_code.css +582 -0
- munchboka_edutools/static/css/jeopardy.css +553 -0
- munchboka_edutools/static/css/pairPuzzle/style.css +177 -0
- munchboka_edutools/static/css/parsons/parsonsPuzzle.css +331 -0
- munchboka_edutools/static/css/popup.css +115 -0
- munchboka_edutools/static/css/quiz.css +377 -0
- munchboka_edutools/static/css/timedQuiz.css +375 -0
- munchboka_edutools/static/icons/ggb/mode_evaluate.svg +1 -0
- munchboka_edutools/static/icons/ggb/mode_extremum.svg +1 -0
- munchboka_edutools/static/icons/ggb/mode_intersect.svg +1 -0
- munchboka_edutools/static/icons/ggb/mode_nsolve.svg +1 -0
- munchboka_edutools/static/icons/ggb/mode_numeric.svg +1 -0
- munchboka_edutools/static/icons/ggb/mode_point.svg +1 -0
- munchboka_edutools/static/icons/ggb/mode_solve.svg +1 -0
- munchboka_edutools/static/icons/misc/windows-logo.svg +1 -0
- munchboka_edutools/static/icons/outline/dark_mode/academic.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/backward.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/book.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/chat_bubble.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/check.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/cmd_line.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/file.svg +1 -0
- munchboka_edutools/static/icons/outline/dark_mode/fire.svg +4 -0
- munchboka_edutools/static/icons/outline/dark_mode/key.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/magnifying.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/pencil_square.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/play.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/question.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/square_check.svg +1 -0
- munchboka_edutools/static/icons/outline/dark_mode/stop.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/summary.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/undo.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/unlock.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/academic.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/backward.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/book.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/chat_bubble.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/check.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/cmd_line.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/file.svg +1 -0
- munchboka_edutools/static/icons/outline/light_mode/fire.svg +4 -0
- munchboka_edutools/static/icons/outline/light_mode/key.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/magnifying.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/pencil_square.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/play.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/question.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/square_check.svg +1 -0
- munchboka_edutools/static/icons/outline/light_mode/stop.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/summary.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/undo.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/unlock.svg +3 -0
- munchboka_edutools/static/icons/polyicons/cubicdown.svg +3 -0
- munchboka_edutools/static/icons/polyicons/cubicup.svg +3 -0
- munchboka_edutools/static/icons/polyicons/frown.svg +3 -0
- munchboka_edutools/static/icons/polyicons/smile.svg +3 -0
- munchboka_edutools/static/icons/solid/dark_mode/academic.svg +5 -0
- munchboka_edutools/static/icons/solid/dark_mode/backward.svg +3 -0
- munchboka_edutools/static/icons/solid/dark_mode/book.svg +3 -0
- munchboka_edutools/static/icons/solid/dark_mode/brain.svg +1 -0
- munchboka_edutools/static/icons/solid/dark_mode/file.svg +1 -0
- munchboka_edutools/static/icons/solid/dark_mode/fire.svg +3 -0
- munchboka_edutools/static/icons/solid/dark_mode/key.svg +3 -0
- munchboka_edutools/static/icons/solid/dark_mode/pencil_square.svg +4 -0
- munchboka_edutools/static/icons/solid/dark_mode/play.svg +3 -0
- munchboka_edutools/static/icons/solid/dark_mode/python.svg +1 -0
- munchboka_edutools/static/icons/solid/dark_mode/scroll.svg +1 -0
- munchboka_edutools/static/icons/solid/dark_mode/stop.svg +3 -0
- munchboka_edutools/static/icons/solid/light_mode/academic.svg +5 -0
- munchboka_edutools/static/icons/solid/light_mode/backward.svg +3 -0
- munchboka_edutools/static/icons/solid/light_mode/book.svg +3 -0
- munchboka_edutools/static/icons/solid/light_mode/brain.svg +1 -0
- munchboka_edutools/static/icons/solid/light_mode/file.svg +1 -0
- munchboka_edutools/static/icons/solid/light_mode/fire.svg +3 -0
- munchboka_edutools/static/icons/solid/light_mode/key.svg +3 -0
- munchboka_edutools/static/icons/solid/light_mode/pencil_square.svg +4 -0
- munchboka_edutools/static/icons/solid/light_mode/play.svg +3 -0
- munchboka_edutools/static/icons/solid/light_mode/python.svg +1 -0
- munchboka_edutools/static/icons/solid/light_mode/scroll.svg +1 -0
- munchboka_edutools/static/icons/solid/light_mode/stop.svg +3 -0
- munchboka_edutools/static/icons/stickers/edit.svg +1 -0
- munchboka_edutools/static/icons/stickers/pencil_square.svg +3 -0
- munchboka_edutools/static/js/casThemeManager.js +99 -0
- munchboka_edutools/static/js/escapeRoom/escape-room.js +219 -0
- munchboka_edutools/static/js/flashcards.js +199 -0
- munchboka_edutools/static/js/geogebra-setup.js +6 -0
- munchboka_edutools/static/js/highlight-init.js +6 -0
- munchboka_edutools/static/js/interactiveCode/codeEditor.js +648 -0
- munchboka_edutools/static/js/interactiveCode/interactiveCodeSetup.js +441 -0
- munchboka_edutools/static/js/interactiveCode/pythonRunner.js +336 -0
- munchboka_edutools/static/js/interactiveCode/turtleCode.js +203 -0
- munchboka_edutools/static/js/interactiveCode/workerManager.js +353 -0
- munchboka_edutools/static/js/jeopardy.js +560 -0
- munchboka_edutools/static/js/pairPuzzle/draggableItem.js +64 -0
- munchboka_edutools/static/js/pairPuzzle/dropZone.js +119 -0
- munchboka_edutools/static/js/pairPuzzle/game.js +160 -0
- munchboka_edutools/static/js/parsons/parsonsPuzzle.js +641 -0
- munchboka_edutools/static/js/popup.js +85 -0
- munchboka_edutools/static/js/quiz.js +566 -0
- munchboka_edutools/static/js/skulpt/skulpt.js +35721 -0
- munchboka_edutools/static/js/timedQuiz/multipleChoiceQuestion.js +184 -0
- munchboka_edutools/static/js/timedQuiz/timedMultipleChoiceQuiz.js +244 -0
- munchboka_edutools/static/js/timedQuiz/utils.js +6 -0
- munchboka_edutools/static/js/utils.js +3 -0
- munchboka_edutools-0.2.3.dist-info/METADATA +109 -0
- munchboka_edutools-0.2.3.dist-info/RECORD +157 -0
- munchboka_edutools-0.2.3.dist-info/WHEEL +4 -0
- munchboka_edutools-0.2.3.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Escape Room Directive
|
|
3
|
+
=====================
|
|
4
|
+
|
|
5
|
+
A Sphinx directive for creating interactive escape room puzzles where students must
|
|
6
|
+
solve problems sequentially by entering codes to unlock the next room.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
.. escape-room::
|
|
10
|
+
:case_insensitive:
|
|
11
|
+
|
|
12
|
+
Puzzle: Step 1 title
|
|
13
|
+
Code: ABC123
|
|
14
|
+
Q: Question text can include markdown images and code
|
|
15
|
+

|
|
16
|
+
|
|
17
|
+
Puzzle: Step 2 title
|
|
18
|
+
Code: 42, forty-two, XLII
|
|
19
|
+
Q: More content ...
|
|
20
|
+
|
|
21
|
+
Recognized headers (case-insensitive):
|
|
22
|
+
- Puzzle: or Step: - Starts a new puzzle/room
|
|
23
|
+
- Code: - Code(s) to unlock this room (comma-separated for multiple)
|
|
24
|
+
- Q: - Question/content for this room
|
|
25
|
+
|
|
26
|
+
MyST Syntax (colon-fence):
|
|
27
|
+
:::{escaperoom}
|
|
28
|
+
:case_insensitive:
|
|
29
|
+
|
|
30
|
+
Puzzle: Step 1 title
|
|
31
|
+
Code: ABC123
|
|
32
|
+
Q: Question text
|
|
33
|
+
:::
|
|
34
|
+
|
|
35
|
+
Note: Due to MyST limitations with hyphens in directive names when using colon-fence
|
|
36
|
+
syntax (:::), the directive is also registered as "escaperoom" (no hyphen).
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import json
|
|
42
|
+
import html as _html
|
|
43
|
+
import os
|
|
44
|
+
import re
|
|
45
|
+
import uuid
|
|
46
|
+
from typing import Any, Dict, List
|
|
47
|
+
|
|
48
|
+
from docutils import nodes
|
|
49
|
+
from docutils.parsers.rst import directives
|
|
50
|
+
from sphinx.util.docutils import SphinxDirective
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class EscapeRoomDirective(SphinxDirective):
|
|
54
|
+
"""Directive that renders an Escape-Room style sequence of puzzles.
|
|
55
|
+
|
|
56
|
+
Only one question is shown at a time; students must type a code to unlock
|
|
57
|
+
the next. Supports figures and code blocks like quiz/jeopardy.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
has_content = True
|
|
61
|
+
required_arguments = 0
|
|
62
|
+
option_spec = {
|
|
63
|
+
# Placeholder for future options (e.g., case-insensitive codes)
|
|
64
|
+
"case_insensitive": directives.flag,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
def run(self):
|
|
68
|
+
self.board_id = uuid.uuid4().hex
|
|
69
|
+
container_id = f"escape-room-{self.board_id}"
|
|
70
|
+
|
|
71
|
+
data = self._parse_content()
|
|
72
|
+
|
|
73
|
+
# Compute relative prefix to _static (same approach as jeopardy)
|
|
74
|
+
source_file = self.state.document["source"]
|
|
75
|
+
source_dir = os.path.dirname(source_file)
|
|
76
|
+
app_src_dir = self.env.srcdir
|
|
77
|
+
depth = os.path.relpath(source_dir, app_src_dir).count(os.sep)
|
|
78
|
+
rel_prefix = "../" * (depth + 1)
|
|
79
|
+
|
|
80
|
+
json_str = json.dumps(data, ensure_ascii=False)
|
|
81
|
+
# Escape JSON for safe embedding in a double-quoted attribute
|
|
82
|
+
attr_json = _html.escape(json_str, quote=True)
|
|
83
|
+
|
|
84
|
+
# Note: CSS and JS are registered in __init__.py with munchboka/ prefix
|
|
85
|
+
# KaTeX is also loaded globally, no need to load it per-directive
|
|
86
|
+
html = f"""
|
|
87
|
+
<div id="{container_id}" class="escape-room-container" data-config="{attr_json}">
|
|
88
|
+
<script type="application/json" class="escape-room-data">{json_str}</script>
|
|
89
|
+
</div>
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
return [nodes.raw("", html, format="html")]
|
|
93
|
+
|
|
94
|
+
# -------------------- parsing helpers --------------------
|
|
95
|
+
def _parse_content(self) -> Dict[str, Any]:
|
|
96
|
+
steps: List[Dict[str, Any]] = []
|
|
97
|
+
current: Dict[str, Any] | None = None
|
|
98
|
+
current_section: str | None = None # 'Q'
|
|
99
|
+
|
|
100
|
+
def flush():
|
|
101
|
+
nonlocal current
|
|
102
|
+
if current is not None:
|
|
103
|
+
# normalize
|
|
104
|
+
current.setdefault("title", "")
|
|
105
|
+
codes = current.get("codes") or []
|
|
106
|
+
current["codes"] = [str(c).strip() for c in codes if str(c).strip()]
|
|
107
|
+
current.setdefault("question", "")
|
|
108
|
+
# post-process code blocks
|
|
109
|
+
current["question"] = self._process_code_blocks(current["question"])
|
|
110
|
+
steps.append(current)
|
|
111
|
+
current = None
|
|
112
|
+
|
|
113
|
+
for raw in self.content:
|
|
114
|
+
line = self._process_figures(raw) or raw
|
|
115
|
+
s = line.rstrip("\n")
|
|
116
|
+
|
|
117
|
+
# Puzzle/Step header
|
|
118
|
+
m = re.match(r"^\s*(?:Puzzle|Step)\s*:\s*(.+?)\s*$", s, flags=re.IGNORECASE)
|
|
119
|
+
if m:
|
|
120
|
+
flush()
|
|
121
|
+
current = {"title": m.group(1).strip(), "codes": [], "question": ""}
|
|
122
|
+
current_section = None
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
# Code line (allow comma-separated)
|
|
126
|
+
m = re.match(r"^\s*Code\s*:\s*(.+?)\s*$", s, flags=re.IGNORECASE)
|
|
127
|
+
if m and current is not None:
|
|
128
|
+
codes_str = m.group(1).strip()
|
|
129
|
+
codes = [c.strip() for c in re.split(r",|;", codes_str)] if codes_str else []
|
|
130
|
+
current["codes"] = codes
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
# Q: starts question block
|
|
134
|
+
m = re.match(r"^\s*Q\s*:\s*(.*)$", s, flags=re.IGNORECASE)
|
|
135
|
+
if m and current is not None:
|
|
136
|
+
current_section = "Q"
|
|
137
|
+
current["question"] = (current.get("question") or "") + m.group(1)
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
# Continuation
|
|
141
|
+
if current_section == "Q" and current is not None:
|
|
142
|
+
current["question"] = (current.get("question") or "") + "\n" + s
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
flush()
|
|
146
|
+
|
|
147
|
+
case_insensitive = "case_insensitive" in self.options
|
|
148
|
+
return {"steps": steps, "caseInsensitive": bool(case_insensitive)}
|
|
149
|
+
|
|
150
|
+
def _process_figures(self, text):
|
|
151
|
+
"""Copy images to _static/figurer/... and rewrite to HTML, like quiz/jeopardy."""
|
|
152
|
+
import shutil
|
|
153
|
+
import json as _json
|
|
154
|
+
|
|
155
|
+
if not hasattr(self, "_image_counter"):
|
|
156
|
+
self._image_counter = 0
|
|
157
|
+
|
|
158
|
+
def _parse_figure_options(alt_text):
|
|
159
|
+
# Support JSON-like {width: 60%, class: adaptive-figure} and key=value forms
|
|
160
|
+
opts = {}
|
|
161
|
+
s = (alt_text or "").strip()
|
|
162
|
+
|
|
163
|
+
def parse_pairs(text: str):
|
|
164
|
+
for m in re.finditer(r'(\w+)\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s}]+))', text):
|
|
165
|
+
val = m.group(2) or m.group(3) or m.group(4) or ""
|
|
166
|
+
opts[m.group(1)] = val
|
|
167
|
+
|
|
168
|
+
if s.startswith("{") and s.endswith("}"):
|
|
169
|
+
inner = s[1:-1].strip()
|
|
170
|
+
ok = False
|
|
171
|
+
try:
|
|
172
|
+
js = s
|
|
173
|
+
js = re.sub(r"(\w+)\s*:", r'"\1":', js)
|
|
174
|
+
js = re.sub(r':\s*([^",}]+)', r': "\1"', js)
|
|
175
|
+
opts.update(_json.loads(js))
|
|
176
|
+
ok = True
|
|
177
|
+
except Exception:
|
|
178
|
+
ok = False
|
|
179
|
+
if not ok:
|
|
180
|
+
parse_pairs(inner)
|
|
181
|
+
else:
|
|
182
|
+
parse_pairs(s)
|
|
183
|
+
if not opts and s:
|
|
184
|
+
opts["alt"] = s
|
|
185
|
+
return opts
|
|
186
|
+
|
|
187
|
+
def _normalize_wh(val: Any) -> str:
|
|
188
|
+
s = str(val).strip()
|
|
189
|
+
if re.fullmatch(r"\d+(?:\.\d+)?", s):
|
|
190
|
+
return f"{s}px"
|
|
191
|
+
s = re.sub(r"\s+(?=(px|%|em|rem|vh|vw)$)", "", s)
|
|
192
|
+
return s
|
|
193
|
+
|
|
194
|
+
def _build_figure_html(html_img_path, options):
|
|
195
|
+
user_opts = dict(options or {})
|
|
196
|
+
user_class = user_opts.pop("class", "").strip()
|
|
197
|
+
classes = "escape-room-image adaptive-figure" + (f" {user_class}" if user_class else "")
|
|
198
|
+
alt_text = user_opts.pop("alt", "Figure")
|
|
199
|
+
title = user_opts.pop("title", None)
|
|
200
|
+
width = user_opts.pop("width", None)
|
|
201
|
+
height = user_opts.pop("height", None)
|
|
202
|
+
extra_style = user_opts.pop("style", None)
|
|
203
|
+
|
|
204
|
+
styles: List[str] = []
|
|
205
|
+
if width is not None:
|
|
206
|
+
styles.append(f"width: {_normalize_wh(width)};")
|
|
207
|
+
if height is not None:
|
|
208
|
+
styles.append(f"height: {_normalize_wh(height)};")
|
|
209
|
+
if extra_style:
|
|
210
|
+
styles.append(str(extra_style))
|
|
211
|
+
|
|
212
|
+
attrs = [
|
|
213
|
+
f'src="{html_img_path}"',
|
|
214
|
+
f'class="{classes}"',
|
|
215
|
+
f'alt="{alt_text}"',
|
|
216
|
+
]
|
|
217
|
+
if title:
|
|
218
|
+
attrs.append(f'title="{title}"')
|
|
219
|
+
if styles:
|
|
220
|
+
attrs.append(f'style="{' '.join(styles)}"')
|
|
221
|
+
for k, v in user_opts.items():
|
|
222
|
+
if k not in {
|
|
223
|
+
"src",
|
|
224
|
+
"class",
|
|
225
|
+
"alt",
|
|
226
|
+
"title",
|
|
227
|
+
"width",
|
|
228
|
+
"height",
|
|
229
|
+
"style",
|
|
230
|
+
}:
|
|
231
|
+
attrs.append(f'{k}="{v}"')
|
|
232
|
+
|
|
233
|
+
img = f"<img {' '.join(attrs)}>"
|
|
234
|
+
return f'<div class="escape-room-image-container">{img}</div>'
|
|
235
|
+
|
|
236
|
+
def replace(m):
|
|
237
|
+
alt_or_opts = m.group(1).strip()
|
|
238
|
+
raw_src = m.group(2)
|
|
239
|
+
|
|
240
|
+
self._image_counter += 1
|
|
241
|
+
|
|
242
|
+
options = _parse_figure_options(alt_or_opts)
|
|
243
|
+
|
|
244
|
+
source_file = self.state.document["source"]
|
|
245
|
+
source_dir = os.path.dirname(source_file)
|
|
246
|
+
app_src_dir = self.env.srcdir
|
|
247
|
+
|
|
248
|
+
abs_fig_src = os.path.normpath(os.path.join(source_dir, raw_src))
|
|
249
|
+
if not os.path.exists(abs_fig_src):
|
|
250
|
+
return f'<img src="{raw_src}" class="escape-room-image adaptive-figure" alt="Figure (missing)">' # noqa: E501
|
|
251
|
+
|
|
252
|
+
relative_doc_path = os.path.relpath(source_dir, app_src_dir)
|
|
253
|
+
figure_dest_dir = os.path.join(app_src_dir, "_static", "figurer", relative_doc_path)
|
|
254
|
+
os.makedirs(figure_dest_dir, exist_ok=True)
|
|
255
|
+
|
|
256
|
+
rel_path_from_source = os.path.relpath(abs_fig_src, source_dir)
|
|
257
|
+
safe_path = rel_path_from_source.replace(os.sep, "_").replace("/", "_")
|
|
258
|
+
base, ext = os.path.splitext(safe_path)
|
|
259
|
+
fig_filename = f"{self.board_id}_img{self._image_counter}_{base}{ext}"
|
|
260
|
+
fig_dest_path = os.path.join(figure_dest_dir, fig_filename)
|
|
261
|
+
shutil.copy2(abs_fig_src, fig_dest_path)
|
|
262
|
+
|
|
263
|
+
depth = os.path.relpath(source_dir, app_src_dir).count(os.sep)
|
|
264
|
+
rel_prefix = "../" * (depth + 1)
|
|
265
|
+
html_img_path = f"{rel_prefix}_static/figurer/{relative_doc_path}/{fig_filename}"
|
|
266
|
+
return _build_figure_html(html_img_path, options)
|
|
267
|
+
|
|
268
|
+
return re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", replace, text)
|
|
269
|
+
|
|
270
|
+
def _process_code_blocks(self, text: str) -> str:
|
|
271
|
+
"""Process HTML code blocks to handle escaped newlines (like quiz)."""
|
|
272
|
+
|
|
273
|
+
def replace_newlines(match):
|
|
274
|
+
lang = match.group(1)
|
|
275
|
+
code = match.group(2)
|
|
276
|
+
code = code.replace("\\n", "\n")
|
|
277
|
+
return f'<pre><code class="{lang}">{code}</code></pre>'
|
|
278
|
+
|
|
279
|
+
pattern = r'<pre><code class="([\w-]+)">(.*?)</code></pre>'
|
|
280
|
+
return re.sub(pattern, replace_newlines, text, flags=re.DOTALL)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def setup(app):
|
|
284
|
+
"""
|
|
285
|
+
Setup the escape-room directive.
|
|
286
|
+
|
|
287
|
+
Registers the directive under two names:
|
|
288
|
+
- "escape-room" for RST compatibility
|
|
289
|
+
- "escaperoom" for MyST colon-fence compatibility (no hyphens allowed)
|
|
290
|
+
|
|
291
|
+
Note: CSS and JS files are registered in __init__.py with the munchboka/ prefix
|
|
292
|
+
"""
|
|
293
|
+
app.add_directive("escape-room", EscapeRoomDirective)
|
|
294
|
+
app.add_directive("escaperoom", EscapeRoomDirective) # MyST compatibility
|
|
295
|
+
|
|
296
|
+
return {"version": "0.1", "parallel_read_safe": True, "parallel_write_safe": True}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Escape Room 2.0 Directive System
|
|
3
|
+
=================================
|
|
4
|
+
|
|
5
|
+
A modular escape room system using nested directives:
|
|
6
|
+
- escape-room-2: Main container
|
|
7
|
+
- room: Individual room/puzzle with nested content support
|
|
8
|
+
|
|
9
|
+
Features:
|
|
10
|
+
- Supports nested directives (plot, code, cas-popup, etc.) in rooms
|
|
11
|
+
- Markdown/MyST formatting support
|
|
12
|
+
- Math rendering with KaTeX
|
|
13
|
+
- Preserves existing escape-room JavaScript functionality
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
-----
|
|
17
|
+
::::::{escape-room-2}
|
|
18
|
+
|
|
19
|
+
::::{room}
|
|
20
|
+
---
|
|
21
|
+
code: 144
|
|
22
|
+
---
|
|
23
|
+
What is 12 × 12?
|
|
24
|
+
|
|
25
|
+
:::{plot}
|
|
26
|
+
:function: x**2
|
|
27
|
+
:::
|
|
28
|
+
::::
|
|
29
|
+
|
|
30
|
+
::::{room}
|
|
31
|
+
---
|
|
32
|
+
code: abc123
|
|
33
|
+
---
|
|
34
|
+
Next puzzle...
|
|
35
|
+
::::
|
|
36
|
+
|
|
37
|
+
::::::
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from docutils import nodes
|
|
41
|
+
from docutils.statemachine import StringList
|
|
42
|
+
from sphinx.util.docutils import SphinxDirective
|
|
43
|
+
from docutils.parsers.rst import directives
|
|
44
|
+
import json
|
|
45
|
+
import uuid
|
|
46
|
+
import html as _html
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class EscapeRoom2Directive(SphinxDirective):
|
|
50
|
+
"""Main container directive for Escape Room 2.0.
|
|
51
|
+
|
|
52
|
+
Collects nested room directives and generates the escape room HTML.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
has_content = True
|
|
56
|
+
required_arguments = 0
|
|
57
|
+
option_spec = {
|
|
58
|
+
"case_insensitive": directives.flag,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
def run(self):
|
|
62
|
+
# Generate unique ID for this escape room
|
|
63
|
+
self.board_id = f"escape-room-{uuid.uuid4().hex[:8]}"
|
|
64
|
+
|
|
65
|
+
# Store escape room ID in environment for nested directives
|
|
66
|
+
if not hasattr(self.env, "temp"):
|
|
67
|
+
self.env.temp = {}
|
|
68
|
+
|
|
69
|
+
self.env.temp["current_escaperoom2_id"] = self.board_id
|
|
70
|
+
|
|
71
|
+
# Parse nested content (room directives)
|
|
72
|
+
container = nodes.container()
|
|
73
|
+
self.state.nested_parse(self.content, self.content_offset, container)
|
|
74
|
+
|
|
75
|
+
# Get rooms from environment
|
|
76
|
+
rooms_key = f"escaperoom2_rooms_{self.board_id}"
|
|
77
|
+
rooms = self.env.temp.get(rooms_key, [])
|
|
78
|
+
|
|
79
|
+
# Generate HTML output
|
|
80
|
+
container_id = f"escape-room-{self.board_id}"
|
|
81
|
+
case_insensitive = "case_insensitive" in self.options
|
|
82
|
+
html = self._generate_escape_room_html(container_id, rooms, case_insensitive)
|
|
83
|
+
|
|
84
|
+
# Clean up environment
|
|
85
|
+
self.env.temp.pop(rooms_key, None)
|
|
86
|
+
if "current_escaperoom2_id" in self.env.temp:
|
|
87
|
+
self.env.temp.pop("current_escaperoom2_id")
|
|
88
|
+
|
|
89
|
+
return [nodes.raw("", html, format="html")]
|
|
90
|
+
|
|
91
|
+
def _generate_escape_room_html(
|
|
92
|
+
self, container_id: str, rooms: list, case_insensitive: bool
|
|
93
|
+
) -> str:
|
|
94
|
+
"""Generate the HTML for the escape room."""
|
|
95
|
+
data = {"steps": rooms, "caseInsensitive": case_insensitive}
|
|
96
|
+
|
|
97
|
+
# Escape </script> and </style> tags to prevent breaking the JSON script tag
|
|
98
|
+
json_str = json.dumps(data, ensure_ascii=False)
|
|
99
|
+
json_str = json_str.replace("</script>", "<\\/script>").replace("</style>", "<\\/style>")
|
|
100
|
+
|
|
101
|
+
# Escape JSON for safe embedding in a double-quoted attribute
|
|
102
|
+
attr_json = _html.escape(json_str, quote=True)
|
|
103
|
+
|
|
104
|
+
html = f"""
|
|
105
|
+
<div id="{container_id}" class="escape-room-container" data-config="{attr_json}">
|
|
106
|
+
<script type="application/json" class="escape-room-data">{json_str}</script>
|
|
107
|
+
</div>
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
return html
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class RoomDirective(SphinxDirective):
|
|
114
|
+
"""Individual room directive for Escape Room 2.0.
|
|
115
|
+
|
|
116
|
+
Accepts front matter (code, title) and content that can include any other directives.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
has_content = True
|
|
120
|
+
required_arguments = 0
|
|
121
|
+
option_spec = {
|
|
122
|
+
"code": directives.unchanged_required,
|
|
123
|
+
"title": directives.unchanged,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
def run(self):
|
|
127
|
+
# Get the current escape room ID from the environment
|
|
128
|
+
if not hasattr(self.env, "temp"):
|
|
129
|
+
self.env.temp = {}
|
|
130
|
+
|
|
131
|
+
escaperoom_id = self.env.temp.get("current_escaperoom2_id")
|
|
132
|
+
if escaperoom_id is None:
|
|
133
|
+
error_msg = self.state_machine.reporter.error(
|
|
134
|
+
"room directive must be used inside an escape-room-2 directive",
|
|
135
|
+
nodes.literal_block(self.block_text, self.block_text),
|
|
136
|
+
line=self.lineno,
|
|
137
|
+
)
|
|
138
|
+
return [error_msg]
|
|
139
|
+
|
|
140
|
+
# Parse front matter and content
|
|
141
|
+
code, title, content_lines = self._parse_content()
|
|
142
|
+
|
|
143
|
+
# Use options if provided, otherwise use front matter
|
|
144
|
+
code = self.options.get("code", code)
|
|
145
|
+
title = self.options.get("title", title or "")
|
|
146
|
+
|
|
147
|
+
if not code:
|
|
148
|
+
error_msg = self.state_machine.reporter.error(
|
|
149
|
+
"room directive requires a 'code' (either in front matter or as option)",
|
|
150
|
+
nodes.literal_block(self.block_text, self.block_text),
|
|
151
|
+
line=self.lineno,
|
|
152
|
+
)
|
|
153
|
+
return [error_msg]
|
|
154
|
+
|
|
155
|
+
# Parse codes (can be comma/semicolon separated)
|
|
156
|
+
import re
|
|
157
|
+
|
|
158
|
+
codes = [c.strip() for c in re.split(r"[,;]", code)] if code else []
|
|
159
|
+
|
|
160
|
+
# Render content to HTML
|
|
161
|
+
question_html = self._render_to_html(content_lines)
|
|
162
|
+
|
|
163
|
+
# Store room in environment
|
|
164
|
+
rooms_key = f"escaperoom2_rooms_{escaperoom_id}"
|
|
165
|
+
if rooms_key not in self.env.temp:
|
|
166
|
+
self.env.temp[rooms_key] = []
|
|
167
|
+
|
|
168
|
+
self.env.temp[rooms_key].append({"title": title, "codes": codes, "question": question_html})
|
|
169
|
+
|
|
170
|
+
# Return empty list - the parent escape-room-2 directive will render everything
|
|
171
|
+
return []
|
|
172
|
+
|
|
173
|
+
def _parse_content(self):
|
|
174
|
+
"""Parse front matter and content from the directive body."""
|
|
175
|
+
code = None
|
|
176
|
+
title = None
|
|
177
|
+
content_start = 0
|
|
178
|
+
|
|
179
|
+
# Check for YAML front matter (---)
|
|
180
|
+
if len(self.content) > 0 and self.content[0].strip() == "---":
|
|
181
|
+
# Find the closing ---
|
|
182
|
+
end_idx = None
|
|
183
|
+
for i in range(1, len(self.content)):
|
|
184
|
+
if self.content[i].strip() == "---":
|
|
185
|
+
end_idx = i
|
|
186
|
+
break
|
|
187
|
+
|
|
188
|
+
if end_idx is not None:
|
|
189
|
+
# Parse front matter
|
|
190
|
+
for i in range(1, end_idx):
|
|
191
|
+
line = self.content[i].strip()
|
|
192
|
+
if ":" in line:
|
|
193
|
+
key, value = line.split(":", 1)
|
|
194
|
+
key = key.strip().lower()
|
|
195
|
+
value = value.strip()
|
|
196
|
+
|
|
197
|
+
if key == "code":
|
|
198
|
+
code = value
|
|
199
|
+
elif key == "title":
|
|
200
|
+
title = value
|
|
201
|
+
|
|
202
|
+
content_start = end_idx + 1
|
|
203
|
+
|
|
204
|
+
# Get content lines after front matter
|
|
205
|
+
content_lines = self.content[content_start:] if content_start < len(self.content) else []
|
|
206
|
+
|
|
207
|
+
return code, title, content_lines
|
|
208
|
+
|
|
209
|
+
def _render_to_html(self, content_lines: StringList) -> str:
|
|
210
|
+
"""Render content lines to HTML, processing any nested directives."""
|
|
211
|
+
if not content_lines:
|
|
212
|
+
return ""
|
|
213
|
+
|
|
214
|
+
# Create a container node for the content
|
|
215
|
+
container = nodes.container()
|
|
216
|
+
|
|
217
|
+
# Parse the content, which will process nested directives
|
|
218
|
+
self.state.nested_parse(content_lines, self.content_offset, container)
|
|
219
|
+
|
|
220
|
+
# Convert to HTML
|
|
221
|
+
html_parts = []
|
|
222
|
+
for node in container.children:
|
|
223
|
+
html_parts.append(self._node_to_html(node))
|
|
224
|
+
|
|
225
|
+
return "\n".join(html_parts)
|
|
226
|
+
|
|
227
|
+
def _node_to_html(self, node) -> str:
|
|
228
|
+
"""Convert a docutils node to HTML string."""
|
|
229
|
+
if isinstance(node, nodes.paragraph):
|
|
230
|
+
content = "".join(self._node_to_html(child) for child in node.children)
|
|
231
|
+
return f"<p>{content}</p>"
|
|
232
|
+
|
|
233
|
+
elif isinstance(node, nodes.Text):
|
|
234
|
+
return _html.escape(str(node))
|
|
235
|
+
|
|
236
|
+
elif isinstance(node, nodes.raw):
|
|
237
|
+
if node.get("format") == "html":
|
|
238
|
+
return node.astext()
|
|
239
|
+
return ""
|
|
240
|
+
|
|
241
|
+
elif isinstance(node, nodes.math):
|
|
242
|
+
# Render inline math using KaTeX-compatible format
|
|
243
|
+
math_text = node.astext()
|
|
244
|
+
return f"${math_text}$"
|
|
245
|
+
|
|
246
|
+
elif isinstance(node, nodes.math_block):
|
|
247
|
+
# Render display math ($$...$$) using KaTeX-compatible format
|
|
248
|
+
math_text = node.astext()
|
|
249
|
+
return f"$${math_text}$$"
|
|
250
|
+
|
|
251
|
+
elif isinstance(node, nodes.image):
|
|
252
|
+
uri = node.get("uri", "")
|
|
253
|
+
alt = node.get("alt", "")
|
|
254
|
+
return f'<img src="{uri}" alt="{alt}" />'
|
|
255
|
+
|
|
256
|
+
elif isinstance(node, nodes.literal_block):
|
|
257
|
+
content = _html.escape(node.astext())
|
|
258
|
+
language = node.get("language", "")
|
|
259
|
+
return f'<pre><code class="{language}">{content}</code></pre>'
|
|
260
|
+
|
|
261
|
+
elif isinstance(node, nodes.figure):
|
|
262
|
+
# Handle figure nodes (e.g., from plot directive)
|
|
263
|
+
content = "".join(self._node_to_html(child) for child in node.children)
|
|
264
|
+
classes = " ".join(node.get("classes", []))
|
|
265
|
+
align = node.get("align", "center")
|
|
266
|
+
|
|
267
|
+
attrs = []
|
|
268
|
+
if classes:
|
|
269
|
+
attrs.append(f'class="{classes}"')
|
|
270
|
+
if align:
|
|
271
|
+
attrs.append(f'align="{align}"')
|
|
272
|
+
|
|
273
|
+
attrs_str = " ".join(attrs) if attrs else ""
|
|
274
|
+
return f"<figure {attrs_str}>{content}</figure>"
|
|
275
|
+
|
|
276
|
+
elif isinstance(node, nodes.caption):
|
|
277
|
+
content = "".join(self._node_to_html(child) for child in node.children)
|
|
278
|
+
return f"<figcaption>{content}</figcaption>"
|
|
279
|
+
|
|
280
|
+
elif isinstance(node, nodes.bullet_list):
|
|
281
|
+
content = "".join(self._node_to_html(child) for child in node.children)
|
|
282
|
+
return f"<ul>{content}</ul>"
|
|
283
|
+
|
|
284
|
+
elif isinstance(node, nodes.enumerated_list):
|
|
285
|
+
content = "".join(self._node_to_html(child) for child in node.children)
|
|
286
|
+
return f"<ol>{content}</ol>"
|
|
287
|
+
|
|
288
|
+
elif isinstance(node, nodes.list_item):
|
|
289
|
+
content = "".join(self._node_to_html(child) for child in node.children)
|
|
290
|
+
return f"<li>{content}</li>"
|
|
291
|
+
|
|
292
|
+
elif isinstance(node, nodes.container):
|
|
293
|
+
content = "".join(self._node_to_html(child) for child in node.children)
|
|
294
|
+
classes = " ".join(node.get("classes", []))
|
|
295
|
+
if classes:
|
|
296
|
+
return f'<div class="{classes}">{content}</div>'
|
|
297
|
+
return content
|
|
298
|
+
|
|
299
|
+
elif hasattr(node, "children"):
|
|
300
|
+
return "".join(self._node_to_html(child) for child in node.children)
|
|
301
|
+
|
|
302
|
+
else:
|
|
303
|
+
return ""
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def setup(app):
|
|
307
|
+
"""Register the directives with Sphinx."""
|
|
308
|
+
app.add_directive("escape-room-2", EscapeRoom2Directive)
|
|
309
|
+
app.add_directive("room", RoomDirective)
|
|
310
|
+
|
|
311
|
+
# Reuse the same CSS/JS as original escape-room
|
|
312
|
+
try:
|
|
313
|
+
app.add_css_file("munchboka/css/escapeRoom/escape-room.css")
|
|
314
|
+
app.add_js_file("munchboka/js/escapeRoom/escape-room.js")
|
|
315
|
+
except Exception:
|
|
316
|
+
pass
|
|
317
|
+
|
|
318
|
+
return {"version": "0.2", "parallel_read_safe": True, "parallel_write_safe": True}
|