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,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}
|