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.

Files changed (157) hide show
  1. munchboka_edutools/__init__.py +184 -0
  2. munchboka_edutools/_plotmath_shim.py +126 -0
  3. munchboka_edutools/_version.py +2 -0
  4. munchboka_edutools/directives/__init__.py +1 -0
  5. munchboka_edutools/directives/admonitions.py +389 -0
  6. munchboka_edutools/directives/cas_popup.py +428 -0
  7. munchboka_edutools/directives/clear.py +103 -0
  8. munchboka_edutools/directives/dialogue.py +137 -0
  9. munchboka_edutools/directives/escape_room.py +296 -0
  10. munchboka_edutools/directives/escape_room2.py +318 -0
  11. munchboka_edutools/directives/factor_tree.py +552 -0
  12. munchboka_edutools/directives/flashcards.py +233 -0
  13. munchboka_edutools/directives/ggb.py +209 -0
  14. munchboka_edutools/directives/ggb_icon.py +105 -0
  15. munchboka_edutools/directives/ggb_popup.py +308 -0
  16. munchboka_edutools/directives/horner.py +326 -0
  17. munchboka_edutools/directives/interactive_code.py +75 -0
  18. munchboka_edutools/directives/jeopardy.py +252 -0
  19. munchboka_edutools/directives/jeopardy2.py +636 -0
  20. munchboka_edutools/directives/multi_plot.py +2524 -0
  21. munchboka_edutools/directives/multi_plot2.py +252 -0
  22. munchboka_edutools/directives/pair_puzzle.py +191 -0
  23. munchboka_edutools/directives/parsons.py +109 -0
  24. munchboka_edutools/directives/plot.py +3758 -0
  25. munchboka_edutools/directives/poly_icon.py +111 -0
  26. munchboka_edutools/directives/polydiv.py +346 -0
  27. munchboka_edutools/directives/popup.py +245 -0
  28. munchboka_edutools/directives/quiz.py +291 -0
  29. munchboka_edutools/directives/quiz2.py +453 -0
  30. munchboka_edutools/directives/signchart.py +519 -0
  31. munchboka_edutools/directives/signchart2.py +1545 -0
  32. munchboka_edutools/directives/timed_quiz.py +436 -0
  33. munchboka_edutools/directives/turtle.py +157 -0
  34. munchboka_edutools/static/css/admonitions.css +2012 -0
  35. munchboka_edutools/static/css/cas_popup.css +242 -0
  36. munchboka_edutools/static/css/code_mirror_themes/github_dark_cm.css +112 -0
  37. munchboka_edutools/static/css/code_mirror_themes/github_dark_default_cm.css +40 -0
  38. munchboka_edutools/static/css/code_mirror_themes/github_dark_high_contrast_cm.css +141 -0
  39. munchboka_edutools/static/css/code_mirror_themes/github_light_cm.css +120 -0
  40. munchboka_edutools/static/css/code_mirror_themes/github_light_default_cm.css +108 -0
  41. munchboka_edutools/static/css/code_mirror_themes/one_dark_cm.css +121 -0
  42. munchboka_edutools/static/css/dialogue.css +92 -0
  43. munchboka_edutools/static/css/escapeRoom/escape-room.css +223 -0
  44. munchboka_edutools/static/css/figures.css +321 -0
  45. munchboka_edutools/static/css/flashcards.css +219 -0
  46. munchboka_edutools/static/css/general_style.css +74 -0
  47. munchboka_edutools/static/css/github-dark-high-contrast.css +141 -0
  48. munchboka_edutools/static/css/github-dark.css +147 -0
  49. munchboka_edutools/static/css/github-light.css +155 -0
  50. munchboka_edutools/static/css/interactive_code/style.css +575 -0
  51. munchboka_edutools/static/css/interactive_code.css +582 -0
  52. munchboka_edutools/static/css/jeopardy.css +553 -0
  53. munchboka_edutools/static/css/pairPuzzle/style.css +177 -0
  54. munchboka_edutools/static/css/parsons/parsonsPuzzle.css +331 -0
  55. munchboka_edutools/static/css/popup.css +115 -0
  56. munchboka_edutools/static/css/quiz.css +377 -0
  57. munchboka_edutools/static/css/timedQuiz.css +375 -0
  58. munchboka_edutools/static/icons/ggb/mode_evaluate.svg +1 -0
  59. munchboka_edutools/static/icons/ggb/mode_extremum.svg +1 -0
  60. munchboka_edutools/static/icons/ggb/mode_intersect.svg +1 -0
  61. munchboka_edutools/static/icons/ggb/mode_nsolve.svg +1 -0
  62. munchboka_edutools/static/icons/ggb/mode_numeric.svg +1 -0
  63. munchboka_edutools/static/icons/ggb/mode_point.svg +1 -0
  64. munchboka_edutools/static/icons/ggb/mode_solve.svg +1 -0
  65. munchboka_edutools/static/icons/misc/windows-logo.svg +1 -0
  66. munchboka_edutools/static/icons/outline/dark_mode/academic.svg +3 -0
  67. munchboka_edutools/static/icons/outline/dark_mode/backward.svg +3 -0
  68. munchboka_edutools/static/icons/outline/dark_mode/book.svg +3 -0
  69. munchboka_edutools/static/icons/outline/dark_mode/chat_bubble.svg +3 -0
  70. munchboka_edutools/static/icons/outline/dark_mode/check.svg +3 -0
  71. munchboka_edutools/static/icons/outline/dark_mode/cmd_line.svg +3 -0
  72. munchboka_edutools/static/icons/outline/dark_mode/file.svg +1 -0
  73. munchboka_edutools/static/icons/outline/dark_mode/fire.svg +4 -0
  74. munchboka_edutools/static/icons/outline/dark_mode/key.svg +3 -0
  75. munchboka_edutools/static/icons/outline/dark_mode/magnifying.svg +3 -0
  76. munchboka_edutools/static/icons/outline/dark_mode/pencil_square.svg +3 -0
  77. munchboka_edutools/static/icons/outline/dark_mode/play.svg +3 -0
  78. munchboka_edutools/static/icons/outline/dark_mode/question.svg +3 -0
  79. munchboka_edutools/static/icons/outline/dark_mode/square_check.svg +1 -0
  80. munchboka_edutools/static/icons/outline/dark_mode/stop.svg +3 -0
  81. munchboka_edutools/static/icons/outline/dark_mode/summary.svg +3 -0
  82. munchboka_edutools/static/icons/outline/dark_mode/undo.svg +3 -0
  83. munchboka_edutools/static/icons/outline/dark_mode/unlock.svg +3 -0
  84. munchboka_edutools/static/icons/outline/light_mode/academic.svg +3 -0
  85. munchboka_edutools/static/icons/outline/light_mode/backward.svg +3 -0
  86. munchboka_edutools/static/icons/outline/light_mode/book.svg +3 -0
  87. munchboka_edutools/static/icons/outline/light_mode/chat_bubble.svg +3 -0
  88. munchboka_edutools/static/icons/outline/light_mode/check.svg +3 -0
  89. munchboka_edutools/static/icons/outline/light_mode/cmd_line.svg +3 -0
  90. munchboka_edutools/static/icons/outline/light_mode/file.svg +1 -0
  91. munchboka_edutools/static/icons/outline/light_mode/fire.svg +4 -0
  92. munchboka_edutools/static/icons/outline/light_mode/key.svg +3 -0
  93. munchboka_edutools/static/icons/outline/light_mode/magnifying.svg +3 -0
  94. munchboka_edutools/static/icons/outline/light_mode/pencil_square.svg +3 -0
  95. munchboka_edutools/static/icons/outline/light_mode/play.svg +3 -0
  96. munchboka_edutools/static/icons/outline/light_mode/question.svg +3 -0
  97. munchboka_edutools/static/icons/outline/light_mode/square_check.svg +1 -0
  98. munchboka_edutools/static/icons/outline/light_mode/stop.svg +3 -0
  99. munchboka_edutools/static/icons/outline/light_mode/summary.svg +3 -0
  100. munchboka_edutools/static/icons/outline/light_mode/undo.svg +3 -0
  101. munchboka_edutools/static/icons/outline/light_mode/unlock.svg +3 -0
  102. munchboka_edutools/static/icons/polyicons/cubicdown.svg +3 -0
  103. munchboka_edutools/static/icons/polyicons/cubicup.svg +3 -0
  104. munchboka_edutools/static/icons/polyicons/frown.svg +3 -0
  105. munchboka_edutools/static/icons/polyicons/smile.svg +3 -0
  106. munchboka_edutools/static/icons/solid/dark_mode/academic.svg +5 -0
  107. munchboka_edutools/static/icons/solid/dark_mode/backward.svg +3 -0
  108. munchboka_edutools/static/icons/solid/dark_mode/book.svg +3 -0
  109. munchboka_edutools/static/icons/solid/dark_mode/brain.svg +1 -0
  110. munchboka_edutools/static/icons/solid/dark_mode/file.svg +1 -0
  111. munchboka_edutools/static/icons/solid/dark_mode/fire.svg +3 -0
  112. munchboka_edutools/static/icons/solid/dark_mode/key.svg +3 -0
  113. munchboka_edutools/static/icons/solid/dark_mode/pencil_square.svg +4 -0
  114. munchboka_edutools/static/icons/solid/dark_mode/play.svg +3 -0
  115. munchboka_edutools/static/icons/solid/dark_mode/python.svg +1 -0
  116. munchboka_edutools/static/icons/solid/dark_mode/scroll.svg +1 -0
  117. munchboka_edutools/static/icons/solid/dark_mode/stop.svg +3 -0
  118. munchboka_edutools/static/icons/solid/light_mode/academic.svg +5 -0
  119. munchboka_edutools/static/icons/solid/light_mode/backward.svg +3 -0
  120. munchboka_edutools/static/icons/solid/light_mode/book.svg +3 -0
  121. munchboka_edutools/static/icons/solid/light_mode/brain.svg +1 -0
  122. munchboka_edutools/static/icons/solid/light_mode/file.svg +1 -0
  123. munchboka_edutools/static/icons/solid/light_mode/fire.svg +3 -0
  124. munchboka_edutools/static/icons/solid/light_mode/key.svg +3 -0
  125. munchboka_edutools/static/icons/solid/light_mode/pencil_square.svg +4 -0
  126. munchboka_edutools/static/icons/solid/light_mode/play.svg +3 -0
  127. munchboka_edutools/static/icons/solid/light_mode/python.svg +1 -0
  128. munchboka_edutools/static/icons/solid/light_mode/scroll.svg +1 -0
  129. munchboka_edutools/static/icons/solid/light_mode/stop.svg +3 -0
  130. munchboka_edutools/static/icons/stickers/edit.svg +1 -0
  131. munchboka_edutools/static/icons/stickers/pencil_square.svg +3 -0
  132. munchboka_edutools/static/js/casThemeManager.js +99 -0
  133. munchboka_edutools/static/js/escapeRoom/escape-room.js +219 -0
  134. munchboka_edutools/static/js/flashcards.js +199 -0
  135. munchboka_edutools/static/js/geogebra-setup.js +6 -0
  136. munchboka_edutools/static/js/highlight-init.js +6 -0
  137. munchboka_edutools/static/js/interactiveCode/codeEditor.js +648 -0
  138. munchboka_edutools/static/js/interactiveCode/interactiveCodeSetup.js +441 -0
  139. munchboka_edutools/static/js/interactiveCode/pythonRunner.js +336 -0
  140. munchboka_edutools/static/js/interactiveCode/turtleCode.js +203 -0
  141. munchboka_edutools/static/js/interactiveCode/workerManager.js +353 -0
  142. munchboka_edutools/static/js/jeopardy.js +560 -0
  143. munchboka_edutools/static/js/pairPuzzle/draggableItem.js +64 -0
  144. munchboka_edutools/static/js/pairPuzzle/dropZone.js +119 -0
  145. munchboka_edutools/static/js/pairPuzzle/game.js +160 -0
  146. munchboka_edutools/static/js/parsons/parsonsPuzzle.js +641 -0
  147. munchboka_edutools/static/js/popup.js +85 -0
  148. munchboka_edutools/static/js/quiz.js +566 -0
  149. munchboka_edutools/static/js/skulpt/skulpt.js +35721 -0
  150. munchboka_edutools/static/js/timedQuiz/multipleChoiceQuestion.js +184 -0
  151. munchboka_edutools/static/js/timedQuiz/timedMultipleChoiceQuiz.js +244 -0
  152. munchboka_edutools/static/js/timedQuiz/utils.js +6 -0
  153. munchboka_edutools/static/js/utils.js +3 -0
  154. munchboka_edutools-0.2.3.dist-info/METADATA +109 -0
  155. munchboka_edutools-0.2.3.dist-info/RECORD +157 -0
  156. munchboka_edutools-0.2.3.dist-info/WHEEL +4 -0
  157. 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
+ ![{width="60%" class="adaptive-figure"}](path/to/img.png)
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}