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,636 @@
1
+ """Jeopardy 2.0 - Redesigned jeopardy board with nested question structure.
2
+
3
+ This module provides a modular Jeopardy board implementation where:
4
+ - jeopardy-2: Main container directive
5
+ - jeopardy-question: Individual question directive that can contain any other directives
6
+
7
+ Example usage:
8
+ :::::{jeopardy-2}
9
+ ::::{jeopardy-question}
10
+ ---
11
+ category: Asymptotes
12
+ points: 100
13
+ ---
14
+ What is the asymptotes of the function $f$ shown in the graph below?
15
+
16
+ :::{plot}
17
+ function: (2*x - 1) / (x + 3), f
18
+ :::
19
+ ::::
20
+ :::::
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import html as _html
26
+ import json
27
+ import os
28
+ import uuid
29
+ from typing import Any, Dict, List
30
+
31
+ from docutils import nodes
32
+ from docutils.parsers.rst import directives
33
+ from docutils.statemachine import StringList
34
+ from sphinx.util.docutils import SphinxDirective
35
+ from sphinx.util.nodes import nested_parse_with_titles
36
+
37
+
38
+ class Jeopardy2Directive(SphinxDirective):
39
+ """Main container directive for Jeopardy 2.0 board.
40
+
41
+ Collects all nested jeopardy-question directives and renders them
42
+ as an interactive Jeopardy board.
43
+ """
44
+
45
+ has_content = True
46
+ required_arguments = 0
47
+ option_spec = {
48
+ "teams": directives.unchanged,
49
+ }
50
+
51
+ def run(self):
52
+ # Generate unique board ID
53
+ self.board_id = uuid.uuid4().hex
54
+ container_id = f"jeopardy2-{self.board_id}"
55
+
56
+ # Store the board ID in the environment for nested questions to access
57
+ if not hasattr(self.env, "temp"):
58
+ self.env.temp = {}
59
+ self.env.temp["current_jeopardy2_id"] = self.board_id
60
+ self.env.temp[f"jeopardy2_questions_{self.board_id}"] = []
61
+
62
+ # Parse the content to process nested jeopardy-question directives
63
+ container_node = nodes.container()
64
+ container_node["classes"].append("jeopardy2-container")
65
+
66
+ # Parse nested content
67
+ self.state.nested_parse(self.content, self.content_offset, container_node)
68
+
69
+ # Collect all questions from the environment
70
+ questions = self.env.temp.get(f"jeopardy2_questions_{self.board_id}", [])
71
+
72
+ # Get answers from environment
73
+ answers = self.env.temp.get(f"jeopardy2_answers_{self.board_id}", [])
74
+
75
+ # Organize questions and answers by category and points
76
+ data = self._organize_board(questions, answers)
77
+
78
+ # Parse teams option
79
+ teams_opt = self.options.get("teams")
80
+ try:
81
+ teams = max(1, int(str(teams_opt).strip())) if teams_opt is not None else 2
82
+ except Exception:
83
+ teams = 2
84
+ data["teams"] = teams
85
+
86
+ # Generate HTML output
87
+ html = self._generate_board_html(container_id, data)
88
+
89
+ # Clean up environment
90
+ self.env.temp.pop(f"jeopardy2_questions_{self.board_id}", None)
91
+ self.env.temp.pop(f"jeopardy2_answers_{self.board_id}", None)
92
+ if "current_jeopardy2_id" in self.env.temp:
93
+ self.env.temp.pop("current_jeopardy2_id")
94
+
95
+ return [nodes.raw("", html, format="html")]
96
+
97
+ def _organize_board(
98
+ self, questions: List[Dict[str, Any]], answers: List[Dict[str, Any]]
99
+ ) -> Dict[str, Any]:
100
+ """Organize questions and answers into categories and point values.
101
+
102
+ Matches answers to questions based on category and points.
103
+ """
104
+ categories: Dict[str, Dict[str, Any]] = {}
105
+ all_points = set()
106
+
107
+ # Create a lookup for answers by (category, points)
108
+ answer_lookup: Dict[tuple, str] = {}
109
+ for a in answers:
110
+ cat_name = a.get("category", "General")
111
+ points = a.get("points", 100)
112
+ answer_lookup[(cat_name, points)] = a.get("answer", "")
113
+
114
+ # Process questions and match with answers
115
+ for q in questions:
116
+ cat_name = q.get("category", "General")
117
+ points = q.get("points", 100)
118
+ all_points.add(points)
119
+
120
+ if cat_name not in categories:
121
+ categories[cat_name] = {"name": cat_name, "tiles": []}
122
+
123
+ # Try to find matching answer
124
+ matched_answer = answer_lookup.get((cat_name, points), q.get("answer", ""))
125
+
126
+ categories[cat_name]["tiles"].append(
127
+ {"value": points, "question": q.get("question", ""), "answer": matched_answer}
128
+ )
129
+
130
+ # Sort points and organize tiles
131
+ sorted_points = sorted(all_points)
132
+ for cat in categories.values():
133
+ # Sort tiles by points
134
+ cat["tiles"].sort(key=lambda t: t["value"])
135
+
136
+ return {"categories": list(categories.values()), "values": sorted_points}
137
+
138
+ def _generate_board_html(self, container_id: str, data: Dict[str, Any]) -> str:
139
+ """Generate the HTML for the Jeopardy board."""
140
+ cfg_str_attr = _html.escape(json.dumps(data, ensure_ascii=False), quote=True)
141
+ json_str = json.dumps(data, ensure_ascii=False)
142
+ # Escape </script> and </style> tags to prevent breaking the JSON script tag
143
+ json_str = json_str.replace("</script>", "<\\/script>").replace("</style>", "<\\/style>")
144
+
145
+ html = f"""
146
+ <div id="{container_id}" class="jeopardy2-container jeopardy-container" lang="no" data-config="{cfg_str_attr}">
147
+ <script type="application/json" class="jeopardy-data">{json_str}</script>
148
+ </div>
149
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex/dist/katex.min.css">
150
+ <script defer src="https://cdn.jsdelivr.net/npm/katex/dist/katex.min.js"></script>
151
+ <script defer src="https://cdn.jsdelivr.net/npm/katex/dist/contrib/auto-render.min.js"></script>
152
+ """
153
+ return html
154
+
155
+
156
+ class JeopardyQuestionDirective(SphinxDirective):
157
+ """Individual question directive for Jeopardy 2.0.
158
+
159
+ Accepts front matter (category, points) and content that can include
160
+ any other directives (plot, interactive-code, etc.).
161
+ """
162
+
163
+ has_content = True
164
+ required_arguments = 0
165
+ option_spec = {
166
+ "category": directives.unchanged,
167
+ "points": directives.positive_int,
168
+ }
169
+
170
+ def run(self):
171
+ # Get the current board ID from the environment
172
+ if not hasattr(self.env, "temp"):
173
+ self.env.temp = {}
174
+
175
+ board_id = self.env.temp.get("current_jeopardy2_id")
176
+ if board_id is None:
177
+ # Not inside a jeopardy-2 directive
178
+ error_msg = self.state_machine.reporter.error(
179
+ "jeopardy-question directive must be used inside a jeopardy-2 directive",
180
+ nodes.literal_block(self.block_text, self.block_text),
181
+ line=self.lineno,
182
+ )
183
+ return [error_msg]
184
+
185
+ # Parse front matter and content
186
+ category, points, content_lines = self._parse_content()
187
+
188
+ # Use options if provided, otherwise use front matter
189
+ category = self.options.get("category", category or "General")
190
+ points = self.options.get("points", points or 100)
191
+
192
+ # Split content into question and answer
193
+ question_html, answer_html = self._split_question_answer(content_lines)
194
+
195
+ # Store question in environment
196
+ questions_key = f"jeopardy2_questions_{board_id}"
197
+ if questions_key not in self.env.temp:
198
+ self.env.temp[questions_key] = []
199
+
200
+ self.env.temp[questions_key].append(
201
+ {
202
+ "category": category,
203
+ "points": points,
204
+ "question": question_html,
205
+ "answer": answer_html,
206
+ }
207
+ )
208
+
209
+ # Return empty list - the parent jeopardy-2 directive will render everything
210
+ return []
211
+
212
+ def _parse_content(self):
213
+ """Parse front matter and content from the directive body."""
214
+ category = None
215
+ points = None
216
+ content_start = 0
217
+
218
+ # Check for YAML front matter (---)
219
+ if len(self.content) > 0 and self.content[0].strip() == "---":
220
+ # Find the closing ---
221
+ end_idx = None
222
+ for i in range(1, len(self.content)):
223
+ if self.content[i].strip() == "---":
224
+ end_idx = i
225
+ break
226
+
227
+ if end_idx is not None:
228
+ # Parse front matter
229
+ for i in range(1, end_idx):
230
+ line = self.content[i].strip()
231
+ if ":" in line:
232
+ key, value = line.split(":", 1)
233
+ key = key.strip().lower()
234
+ value = value.strip()
235
+
236
+ if key == "category":
237
+ category = value
238
+ elif key == "points":
239
+ try:
240
+ points = int(value)
241
+ except ValueError:
242
+ pass
243
+
244
+ content_start = end_idx + 1
245
+
246
+ # Get content lines after front matter
247
+ content_lines = self.content[content_start:] if content_start < len(self.content) else []
248
+
249
+ return category, points, content_lines
250
+
251
+ def _split_question_answer(self, content_lines: StringList):
252
+ """Split content into question and answer sections.
253
+
254
+ Looks for 'Answer:' marker. Everything before is question, after is answer.
255
+ If no marker, everything is the question.
256
+ """
257
+ answer_start = None
258
+
259
+ for i, line in enumerate(content_lines):
260
+ stripped = line.strip().lower()
261
+ if stripped.startswith("answer:"):
262
+ answer_start = i
263
+ # Check if there's content after 'Answer:' on the same line
264
+ after_marker = line[line.lower().find("answer:") + 7 :].strip()
265
+ if after_marker:
266
+ # Content on same line - include it
267
+ pass
268
+ break
269
+
270
+ if answer_start is None:
271
+ # No answer section, everything is question
272
+ question_lines = content_lines
273
+ answer_lines = StringList()
274
+ else:
275
+ question_lines = content_lines[:answer_start]
276
+ # Get content after 'Answer:' marker
277
+ first_answer_line = content_lines[answer_start]
278
+ after_marker = first_answer_line[
279
+ first_answer_line.lower().find("answer:") + 7 :
280
+ ].strip()
281
+
282
+ answer_lines = StringList()
283
+ if after_marker:
284
+ # Content on the same line as 'Answer:'
285
+ answer_lines.append(after_marker, content_lines.source(answer_start))
286
+ # Add remaining lines
287
+ for i in range(answer_start + 1, len(content_lines)):
288
+ answer_lines.append(content_lines[i], content_lines.source(i))
289
+
290
+ # Render both sections to HTML
291
+ question_html = self._render_to_html(question_lines)
292
+ answer_html = self._render_to_html(answer_lines)
293
+
294
+ return question_html, answer_html
295
+
296
+ def _render_to_html(self, content_lines: StringList) -> str:
297
+ """Render content lines to HTML, processing any nested directives."""
298
+ if not content_lines:
299
+ return ""
300
+
301
+ # Create a container node for the content
302
+ container = nodes.container()
303
+
304
+ # Parse the content, which will process nested directives
305
+ self.state.nested_parse(content_lines, self.content_offset, container)
306
+
307
+ # Convert to HTML
308
+ from sphinx.writers.html5 import HTML5Translator
309
+ from io import StringIO
310
+
311
+ # Get the builder
312
+ builder = self.env.app.builder
313
+
314
+ # Simple approach: convert nodes to pseudo-HTML
315
+ html_parts = []
316
+ for node in container.children:
317
+ html_parts.append(self._node_to_html(node))
318
+
319
+ return "\n".join(html_parts)
320
+
321
+ def _node_to_html(self, node) -> str:
322
+ """Convert a docutils node to HTML string.
323
+
324
+ This is a simplified converter. For production, you'd want to use
325
+ the proper Sphinx HTML translator.
326
+ """
327
+ if isinstance(node, nodes.paragraph):
328
+ content = "".join(self._node_to_html(child) for child in node.children)
329
+ return f"<p>{content}</p>"
330
+
331
+ elif isinstance(node, nodes.Text):
332
+ return _html.escape(str(node))
333
+
334
+ elif isinstance(node, nodes.raw):
335
+ if node.get("format") == "html":
336
+ return node.astext()
337
+ return ""
338
+
339
+ elif isinstance(node, nodes.math):
340
+ # Render inline math using KaTeX-compatible format
341
+ math_text = node.astext()
342
+ return f"${math_text}$"
343
+
344
+ elif isinstance(node, nodes.math_block):
345
+ # Render display math ($$...$$) using KaTeX-compatible format
346
+ math_text = node.astext()
347
+ return f"$${math_text}$$"
348
+
349
+ elif isinstance(node, nodes.image):
350
+ uri = node.get("uri", "")
351
+ alt = node.get("alt", "")
352
+ return f'<img src="{uri}" alt="{alt}" />'
353
+
354
+ elif isinstance(node, nodes.literal_block):
355
+ content = _html.escape(node.astext())
356
+ language = node.get("language", "")
357
+ return f'<pre><code class="{language}">{content}</code></pre>'
358
+
359
+ elif isinstance(node, nodes.figure):
360
+ # Handle figure nodes (e.g., from plot directive)
361
+ # Preserve the figure element and its classes for proper CSS styling
362
+ content = "".join(self._node_to_html(child) for child in node.children)
363
+ classes = " ".join(node.get("classes", []))
364
+ align = node.get("align", "center")
365
+
366
+ # Build figure element with all necessary classes and attributes
367
+ attrs = []
368
+ if classes:
369
+ attrs.append(f'class="{classes}"')
370
+ if align:
371
+ attrs.append(f'align="{align}"')
372
+
373
+ attrs_str = " ".join(attrs) if attrs else ""
374
+ return f"<figure {attrs_str}>{content}</figure>"
375
+
376
+ elif isinstance(node, nodes.caption):
377
+ # Handle figure captions
378
+ content = "".join(self._node_to_html(child) for child in node.children)
379
+ return f"<figcaption>{content}</figcaption>"
380
+
381
+ elif isinstance(node, nodes.bullet_list):
382
+ # Handle bullet (unordered) lists
383
+ content = "".join(self._node_to_html(child) for child in node.children)
384
+ return f"<ul>{content}</ul>"
385
+
386
+ elif isinstance(node, nodes.enumerated_list):
387
+ # Handle enumerated (ordered) lists
388
+ content = "".join(self._node_to_html(child) for child in node.children)
389
+ return f"<ol>{content}</ol>"
390
+
391
+ elif isinstance(node, nodes.list_item):
392
+ # Handle list items
393
+ content = "".join(self._node_to_html(child) for child in node.children)
394
+ return f"<li>{content}</li>"
395
+
396
+ elif isinstance(node, nodes.container):
397
+ # Recursively process container contents
398
+ content = "".join(self._node_to_html(child) for child in node.children)
399
+ classes = " ".join(node.get("classes", []))
400
+ if classes:
401
+ return f'<div class="{classes}">{content}</div>'
402
+ return content
403
+
404
+ elif hasattr(node, "children"):
405
+ # Generic handler for nodes with children
406
+ return "".join(self._node_to_html(child) for child in node.children)
407
+
408
+ else:
409
+ # Fallback for unknown node types
410
+ return ""
411
+
412
+
413
+ class JeopardyAnswerDirective(SphinxDirective):
414
+ """Individual answer directive for Jeopardy 2.0.
415
+
416
+ Accepts front matter (category, points) and content that can include
417
+ any other directives (plot, interactive-code, etc.).
418
+
419
+ This directive is matched with jeopardy-question based on category and points.
420
+ """
421
+
422
+ has_content = True
423
+ required_arguments = 0
424
+ option_spec = {
425
+ "category": directives.unchanged,
426
+ "points": directives.positive_int,
427
+ }
428
+
429
+ def run(self):
430
+ # Get the current board ID from the environment
431
+ if not hasattr(self.env, "temp"):
432
+ self.env.temp = {}
433
+
434
+ board_id = self.env.temp.get("current_jeopardy2_id")
435
+ if board_id is None:
436
+ # Not inside a jeopardy-2 directive
437
+ error_msg = self.state_machine.reporter.error(
438
+ "jeopardy-answer directive must be used inside a jeopardy-2 directive",
439
+ nodes.literal_block(self.block_text, self.block_text),
440
+ line=self.lineno,
441
+ )
442
+ return [error_msg]
443
+
444
+ # Parse front matter and content
445
+ category, points, content_lines = self._parse_content()
446
+
447
+ # Use options if provided, otherwise use front matter
448
+ category = self.options.get("category", category or "General")
449
+ points = self.options.get("points", points or 100)
450
+
451
+ # Render content to HTML
452
+ answer_html = self._render_to_html(content_lines)
453
+
454
+ # Add "Fasit" heading to the answer
455
+ answer_html = f"<h3>Fasit</h3>\n{answer_html}"
456
+
457
+ # Store answer in environment
458
+ answers_key = f"jeopardy2_answers_{board_id}"
459
+ if answers_key not in self.env.temp:
460
+ self.env.temp[answers_key] = []
461
+
462
+ self.env.temp[answers_key].append(
463
+ {
464
+ "category": category,
465
+ "points": points,
466
+ "answer": answer_html,
467
+ }
468
+ )
469
+
470
+ # Return empty list - the parent jeopardy-2 directive will render everything
471
+ return []
472
+
473
+ def _parse_content(self):
474
+ """Parse front matter and content from the directive body."""
475
+ category = None
476
+ points = None
477
+ content_start = 0
478
+
479
+ # Check for YAML front matter (---)
480
+ if len(self.content) > 0 and self.content[0].strip() == "---":
481
+ # Find the closing ---
482
+ end_idx = None
483
+ for i in range(1, len(self.content)):
484
+ if self.content[i].strip() == "---":
485
+ end_idx = i
486
+ break
487
+
488
+ if end_idx is not None:
489
+ # Parse front matter
490
+ for i in range(1, end_idx):
491
+ line = self.content[i].strip()
492
+ if ":" in line:
493
+ key, value = line.split(":", 1)
494
+ key = key.strip().lower()
495
+ value = value.strip()
496
+
497
+ if key == "category":
498
+ category = value
499
+ elif key == "points":
500
+ try:
501
+ points = int(value)
502
+ except ValueError:
503
+ pass
504
+
505
+ content_start = end_idx + 1
506
+
507
+ # Get content lines after front matter
508
+ content_lines = self.content[content_start:] if content_start < len(self.content) else []
509
+
510
+ return category, points, content_lines
511
+
512
+ def _render_to_html(self, content_lines: StringList) -> str:
513
+ """Render content lines to HTML, processing any nested directives."""
514
+ if not content_lines:
515
+ return ""
516
+
517
+ # Create a container node for the content
518
+ container = nodes.container()
519
+
520
+ # Parse the content, which will process nested directives
521
+ self.state.nested_parse(content_lines, self.content_offset, container)
522
+
523
+ # Convert to HTML
524
+ html_parts = []
525
+ for node in container.children:
526
+ html_parts.append(self._node_to_html(node))
527
+
528
+ return "\n".join(html_parts)
529
+
530
+ def _node_to_html(self, node) -> str:
531
+ """Convert a docutils node to HTML string.
532
+
533
+ This is a simplified converter. For production, you'd want to use
534
+ the proper Sphinx HTML translator.
535
+ """
536
+ if isinstance(node, nodes.paragraph):
537
+ content = "".join(self._node_to_html(child) for child in node.children)
538
+ return f"<p>{content}</p>"
539
+
540
+ elif isinstance(node, nodes.Text):
541
+ return _html.escape(str(node))
542
+
543
+ elif isinstance(node, nodes.raw):
544
+ if node.get("format") == "html":
545
+ return node.astext()
546
+ return ""
547
+
548
+ elif isinstance(node, nodes.math):
549
+ # Render inline math using KaTeX-compatible format
550
+ math_text = node.astext()
551
+ return f"${math_text}$"
552
+
553
+ elif isinstance(node, nodes.math_block):
554
+ # Render display math ($$...$$) using KaTeX-compatible format
555
+ math_text = node.astext()
556
+ return f"$${math_text}$$"
557
+
558
+ elif isinstance(node, nodes.image):
559
+ uri = node.get("uri", "")
560
+ alt = node.get("alt", "")
561
+ return f'<img src="{uri}" alt="{alt}" />'
562
+
563
+ elif isinstance(node, nodes.literal_block):
564
+ content = _html.escape(node.astext())
565
+ language = node.get("language", "")
566
+ return f'<pre><code class="{language}">{content}</code></pre>'
567
+
568
+ elif isinstance(node, nodes.figure):
569
+ # Handle figure nodes (e.g., from plot directive)
570
+ # Preserve the figure element and its classes for proper CSS styling
571
+ content = "".join(self._node_to_html(child) for child in node.children)
572
+ classes = " ".join(node.get("classes", []))
573
+ align = node.get("align", "center")
574
+
575
+ # Build figure element with all necessary classes and attributes
576
+ attrs = []
577
+ if classes:
578
+ attrs.append(f'class="{classes}"')
579
+ if align:
580
+ attrs.append(f'align="{align}"')
581
+
582
+ attrs_str = " ".join(attrs) if attrs else ""
583
+ return f"<figure {attrs_str}>{content}</figure>"
584
+
585
+ elif isinstance(node, nodes.caption):
586
+ # Handle figure captions
587
+ content = "".join(self._node_to_html(child) for child in node.children)
588
+ return f"<figcaption>{content}</figcaption>"
589
+
590
+ elif isinstance(node, nodes.bullet_list):
591
+ # Handle bullet (unordered) lists
592
+ content = "".join(self._node_to_html(child) for child in node.children)
593
+ return f"<ul>{content}</ul>"
594
+
595
+ elif isinstance(node, nodes.enumerated_list):
596
+ # Handle enumerated (ordered) lists
597
+ content = "".join(self._node_to_html(child) for child in node.children)
598
+ return f"<ol>{content}</ol>"
599
+
600
+ elif isinstance(node, nodes.list_item):
601
+ # Handle list items
602
+ content = "".join(self._node_to_html(child) for child in node.children)
603
+ return f"<li>{content}</li>"
604
+
605
+ elif isinstance(node, nodes.container):
606
+ # Recursively process container contents
607
+ content = "".join(self._node_to_html(child) for child in node.children)
608
+ classes = " ".join(node.get("classes", []))
609
+ if classes:
610
+ return f'<div class="{classes}">{content}</div>'
611
+ return content
612
+
613
+ elif hasattr(node, "children"):
614
+ # Generic handler for nodes with children
615
+ return "".join(self._node_to_html(child) for child in node.children)
616
+
617
+ else:
618
+ # Fallback for unknown node types
619
+ return ""
620
+
621
+
622
+ def setup(app):
623
+ """Register the directives with Sphinx."""
624
+ app.add_directive("jeopardy-2", Jeopardy2Directive)
625
+ app.add_directive("jeopardy-question", JeopardyQuestionDirective)
626
+ app.add_directive("jeopardy-answer", JeopardyAnswerDirective)
627
+
628
+ # Reuse the same CSS/JS as original jeopardy
629
+ try:
630
+ app.add_css_file("munchboka/css/jeopardy.css")
631
+ app.add_js_file("munchboka/js/jeopardy.js")
632
+ app.add_css_file("munchboka/css/general_style.css")
633
+ except Exception:
634
+ pass
635
+
636
+ return {"version": "0.2", "parallel_read_safe": True, "parallel_write_safe": True}