mdbook_exercises/
render.rs

1//! HTML rendering for exercises.
2//!
3//! This module transforms parsed exercises into HTML suitable for
4//! display in mdBook.
5
6use crate::types::*;
7use pulldown_cmark::{html, Parser};
8
9/// Errors that can occur during rendering.
10#[derive(Debug, thiserror::Error)]
11pub enum RenderError {
12    #[error("Template error: {0}")]
13    TemplateError(String),
14
15    #[error("Missing required field: {0}")]
16    MissingField(String),
17}
18
19/// Configuration for rendering.
20#[derive(Debug, Clone)]
21pub struct RenderConfig {
22    /// Show all hints expanded by default
23    pub reveal_hints: bool,
24
25    /// Show solution expanded by default
26    pub reveal_solution: bool,
27
28    /// Enable Rust Playground integration
29    pub enable_playground: bool,
30
31    /// Custom playground URL
32    pub playground_url: String,
33
34    /// Enable progress tracking via localStorage
35    pub enable_progress: bool,
36
37    /// Enable or disable this preprocessor (checked in preprocessor run)
38    pub enabled: bool,
39
40    /// If true, copy CSS/JS assets into the book's theme directory
41    pub manage_assets: bool,
42}
43
44impl Default for RenderConfig {
45    fn default() -> Self {
46        Self {
47            reveal_hints: false,
48            reveal_solution: false,
49            enable_playground: true,
50            playground_url: "https://play.rust-lang.org".to_string(),
51            enable_progress: true,
52            enabled: true,
53            manage_assets: false,
54        }
55    }
56}
57
58/// Render an exercise to HTML.
59pub fn render_exercise(parsed: &ParsedExercise) -> Result<String, RenderError> {
60    render_exercise_with_config(parsed, &RenderConfig::default())
61}
62
63/// Render an exercise to HTML with custom configuration.
64pub fn render_exercise_with_config(
65    parsed: &ParsedExercise,
66    config: &RenderConfig,
67) -> Result<String, RenderError> {
68    match parsed {
69        ParsedExercise::Code(exercise) => render_code_exercise(exercise, config),
70        ParsedExercise::UseCase(exercise) => render_usecase_exercise(exercise, config),
71    }
72}
73
74// --- Code Exercise Renderer ---
75
76fn render_code_exercise(
77    exercise: &Exercise,
78    config: &RenderConfig,
79) -> Result<String, RenderError> {
80    let mut html = String::new();
81
82    html.push_str(&format!(
83        r#"<article class="exercise" data-exercise-id="{}" data-difficulty="{}">"#,
84        escape_html(&exercise.metadata.id),
85        exercise.metadata.difficulty
86    ));
87    html.push('\n');
88
89    html.push_str(&render_code_header(exercise));
90    html.push_str(&render_code_navigation(exercise));
91
92    if !exercise.description.is_empty() {
93        html.push_str(&render_description(&exercise.description, &exercise.metadata.id));
94    }
95
96    if let Some(objectives) = &exercise.objectives {
97        html.push_str(&render_objectives(objectives, &exercise.metadata.id));
98    }
99
100    if let Some(discussion) = &exercise.discussion {
101        html.push_str(&render_discussion(discussion));
102    }
103
104    if let Some(starter) = &exercise.starter {
105        html.push_str(&render_starter(starter, &exercise.metadata.id));
106    }
107
108    if !exercise.hints.is_empty() {
109        html.push_str(&render_hints(&exercise.hints, config.reveal_hints, &exercise.metadata.id));
110    }
111
112    if let Some(solution) = &exercise.solution {
113        html.push_str(&render_solution(solution, config.reveal_solution, &exercise.metadata.id));
114    }
115
116    if let Some(tests) = &exercise.tests {
117        html.push_str(&render_tests(tests, &exercise.metadata.id, config));
118    }
119
120    if let Some(reflection) = &exercise.reflection {
121        html.push_str(&render_reflection(reflection, &exercise.metadata.id));
122    }
123
124    if config.enable_progress {
125        html.push_str(&render_footer(&exercise.metadata.id));
126    }
127
128    html.push_str("</article>\n");
129    Ok(html)
130}
131
132// --- UseCase Exercise Renderer ---
133
134fn render_usecase_exercise(
135    exercise: &UseCaseExercise,
136    config: &RenderConfig,
137) -> Result<String, RenderError> {
138    let mut html = String::new();
139    let id = &exercise.metadata.id;
140
141    html.push_str(&format!(
142        r#"<article class="usecase-exercise" data-exercise-id="{}" data-domain="{}" data-difficulty="{}">"#,
143        escape_html(id),
144        exercise.metadata.domain,
145        exercise.metadata.difficulty
146    ));
147    html.push('\n');
148
149    html.push_str(&render_usecase_header(exercise));
150
151    if !exercise.description.is_empty() {
152        html.push_str(&render_description(&exercise.description, id));
153    }
154
155    if let Some(objectives) = &exercise.objectives {
156        html.push_str(&render_objectives(objectives, id));
157    }
158
159    // Scenario
160    html.push_str(&render_scenario(&exercise.scenario, id));
161
162    // Prompt
163    html.push_str(&render_prompt(&exercise.prompt, id));
164
165    // Hints
166    if !exercise.hints.is_empty() {
167        html.push_str(&render_hints(&exercise.hints, config.reveal_hints, id));
168    }
169
170    // Response Area
171    html.push_str(&render_response_area(&exercise.evaluation, id));
172
173    // Evaluation Results (hidden initially)
174    html.push_str(&render_evaluation_placeholder(id));
175
176    // Context (hidden initially)
177    if let Some(context) = &exercise.context {
178        html.push_str(&render_context(context, id));
179    }
180
181    if config.enable_progress {
182        html.push_str(&render_footer(id));
183    }
184
185    html.push_str("</article>\n");
186    Ok(html)
187}
188
189// --- Shared Components ---
190
191fn render_description(description: &str, exercise_id: &str) -> String {
192    let mut html = String::new();
193    html.push_str(&format!(
194        r#"<section class="exercise-description" id="{}-description">"#,
195        exercise_id
196    ));
197    html.push('\n');
198    let parser = Parser::new(description);
199    let mut description_html = String::new();
200    html::push_html(&mut description_html, parser);
201    html.push_str(&description_html);
202    html.push_str("</section>\n");
203    html
204}
205
206fn render_objectives(objectives: &Objectives, exercise_id: &str) -> String {
207    let mut html = String::new();
208    html.push_str(&format!(
209        r#"<section class="exercise-objectives" id="{}-objectives">"#,
210        exercise_id
211    ));
212    html.push('\n');
213    html.push_str("  <h3>๐ŸŽฏ Learning Objectives</h3>\n");
214    html.push_str(r#"  <div class="objectives-grid">"#);
215    html.push('\n');
216
217    if !objectives.thinking.is_empty() {
218        html.push_str(r#"    <div class="objectives-thinking">"#);
219        html.push('\n');
220        html.push_str("      <h4>Thinking</h4>\n");
221        html.push_str("      <ul>\n");
222        for (i, obj) in objectives.thinking.iter().enumerate() {
223            let id = format!("{}-thinking-{}", exercise_id, i);
224            html.push_str(&format!(
225                r#"        <li><input type="checkbox" id="{}" class="objective-checkbox"><label for="{}">{}</label></li>"#,
226                id, id, escape_html(obj)
227            ));
228            html.push('\n');
229        }
230        html.push_str("      </ul>\n");
231        html.push_str("    </div>\n");
232    }
233
234    if !objectives.doing.is_empty() {
235        html.push_str(r#"    <div class="objectives-doing">"#);
236        html.push('\n');
237        html.push_str("      <h4>Doing</h4>\n");
238        html.push_str("      <ul>\n");
239        for (i, obj) in objectives.doing.iter().enumerate() {
240            let id = format!("{}-doing-{}", exercise_id, i);
241            html.push_str(&format!(
242                r#"        <li><input type="checkbox" id="{}" class="objective-checkbox"><label for="{}">{}</label></li>"#,
243                id, id, escape_html(obj)
244            ));
245            html.push('\n');
246        }
247        html.push_str("      </ul>\n");
248        html.push_str("    </div>\n");
249    }
250    html.push_str("  </div>\n");
251    html.push_str("</section>\n");
252    html
253}
254
255fn render_hints(hints: &[Hint], reveal: bool, exercise_id: &str) -> String {
256    let mut html = String::new();
257    html.push_str(&format!(
258        r#"<section class="exercise-hints" id="{}-hints">"#,
259        exercise_id
260    ));
261    html.push('\n');
262    html.push_str("  <h3>๐Ÿ’ก Hints</h3>\n");
263    for hint in hints {
264        let open_attr = if reveal { " open" } else { "" };
265        let title = hint
266            .title
267            .as_ref()
268            .map(|t| format!("Hint {}: {}", hint.level, t))
269            .unwrap_or_else(|| format!("Hint {}", hint.level));
270
271        html.push_str(&format!(
272            r#"  <details class="hint" data-level="{}"{}>
273    <summary>{}</summary>
274    <div class="hint-content">
275"#,
276            hint.level,
277            open_attr,
278            escape_html(&title)
279        ));
280        let parser = Parser::new(&hint.content);
281        let mut hint_html = String::new();
282        html::push_html(&mut hint_html, parser);
283        html.push_str(&hint_html);
284        html.push_str("    </div>\n");
285        html.push_str("  </details>\n");
286    }
287    html.push_str("</section>\n");
288    html
289}
290
291fn render_footer(exercise_id: &str) -> String {
292    let mut html = String::new();
293    html.push_str(r#"<footer class="exercise-footer">"#);
294    html.push('\n');
295    html.push_str(&format!(
296        r#"  <button class="btn btn-complete" data-exercise-id="{}">โœ“ Mark Complete</button>"#,
297        exercise_id
298    ));
299    html.push('\n');
300    html.push_str("</footer>\n");
301    html
302}
303
304// --- Code Exercise Specific Components ---
305
306fn render_code_header(exercise: &Exercise) -> String {
307    let mut html = String::new();
308    html.push_str(r#"<header class="exercise-header">"#);
309    html.push('\n');
310    if let Some(title) = &exercise.title {
311        html.push_str(&format!(r#"  <h2 class="exercise-title">{}</h2>"#, escape_html(title)));
312        html.push('\n');
313    }
314    html.push_str(&format!(r#"  <code class=\"exercise-id\">{}</code>"#, escape_html(&exercise.metadata.id)));
315    html.push('\n');
316    html.push_str(r#"  <div class="exercise-meta">"#);
317    html.push('\n');
318    
319    let difficulty_class = match exercise.metadata.difficulty {
320        Difficulty::Beginner => "beginner",
321        Difficulty::Intermediate => "intermediate",
322        Difficulty::Advanced => "advanced",
323    };
324    let difficulty_icon = match exercise.metadata.difficulty {
325        Difficulty::Beginner => "โญ",
326        Difficulty::Intermediate => "โญโญ",
327        Difficulty::Advanced => "โญโญโญ",
328    };
329    html.push_str(&format!(
330        r#"    <span class="badge difficulty {}">{} {}</span>"#,
331        difficulty_class, difficulty_icon, exercise.metadata.difficulty
332    ));
333    html.push('\n');
334
335    if let Some(minutes) = exercise.metadata.time_minutes {
336        let time_str = if minutes >= 60 {
337            format!("{}h {}m", minutes / 60, minutes % 60)
338        } else {
339            format!("{} min", minutes)
340        };
341        html.push_str(&format!(r#"    <span class="badge time">โฑ๏ธ {}</span>"#, time_str));
342        html.push('\n');
343    }
344
345    if !exercise.metadata.prerequisites.is_empty() {
346        let prereqs: Vec<String> = exercise
347            .metadata
348            .prerequisites
349            .iter()
350            .map(|p| format!("<a href=\"#{}\">{}</a>", escape_html(p), escape_html(p)))
351            .collect();
352        html.push_str(&format!(r#"    <span class="badge prerequisites">๐Ÿ“š Requires: {}</span>"#, prereqs.join(", ")));
353        html.push('\n');
354    }
355    html.push_str("  </div>\n");
356    html.push_str("</header>\n");
357    html
358}
359
360fn render_code_navigation(exercise: &Exercise) -> String {
361    let mut html = String::new();
362    let id = &exercise.metadata.id;
363    html.push_str(r#"<nav class="exercise-nav" aria-label="Exercise sections"><ul>"#);
364    if !exercise.description.is_empty() {
365        html.push_str(&format!(r##"<li><a href="#{}-description" data-section="description">๐Ÿ“– Overview</a></li>"##, id));
366    }
367    if exercise.objectives.is_some() {
368        html.push_str(&format!(r##"<li><a href="#{}-objectives" data-section="objectives">๐ŸŽฏ Objectives</a></li>"##, id));
369    }
370    if exercise.starter.is_some() {
371        html.push_str(&format!(r##"<li><a href="#{}-starter" data-section="starter">๐Ÿ’ป Code</a></li>"##, id));
372    }
373    if !exercise.hints.is_empty() {
374        html.push_str(&format!(r##"<li><a href="#{}-hints" data-section="hints">๐Ÿ’ก Hints</a></li>"##, id));
375    }
376    if exercise.solution.is_some() {
377        html.push_str(&format!(r##"<li><a href="#{}-solution" data-section="solution">โœ… Solution</a></li>"##, id));
378    }
379    if exercise.tests.is_some() {
380        html.push_str(&format!(r##"<li><a href="#{}-tests" data-section="tests">๐Ÿงช Tests</a></li>"##, id));
381    }
382    if exercise.reflection.is_some() {
383        html.push_str(&format!(r##"<li><a href="#{}-reflection" data-section="reflection">๐Ÿค” Reflect</a></li>"##, id));
384    }
385    html.push_str("</ul></nav>\n");
386    html
387}
388
389fn render_discussion(discussion: &[String]) -> String {
390    let mut html = String::new();
391    html.push_str(r#"<section class="exercise-discussion">"#);
392    html.push('\n');
393    html.push_str("  <h3>๐Ÿ’ฌ Discussion</h3>\n");
394    html.push_str("  <ul>\n");
395    for item in discussion {
396        html.push_str(&format!("    <li>{}</li>\n", escape_html(item)));
397    }
398    html.push_str("  </ul>\n");
399    html.push_str("</section>\n");
400    html
401}
402
403fn render_starter(starter: &StarterCode, exercise_id: &str) -> String {
404    let mut html = String::new();
405    html.push_str(&format!(r#"<section class="exercise-starter" id="{}-starter">"#, exercise_id));
406    html.push('\n');
407    html.push_str(r#"  <div class="code-header">"#);
408    html.push('\n');
409    if let Some(filename) = &starter.filename {
410        html.push_str(&format!(r#"    <span class="filename">{}</span>"#, escape_html(filename)));
411        html.push('\n');
412    }
413    html.push_str(r#"    <div class="code-actions">"#);
414    html.push('\n');
415    html.push_str(&format!(r#"      <button class="btn btn-copy" data-target="code-{}" title="Copy code">๐Ÿ“‹ Copy</button>"#, exercise_id));
416    html.push('\n');
417    html.push_str(&format!(r#"      <button class="btn btn-reset" data-target="code-{}" title="Reset to original">โ†บ Reset</button>"#, exercise_id));
418    html.push('\n');
419    html.push_str("    </div>\n");
420    html.push_str("  </div>\n");
421    html.push_str(&format!(
422        r#"  <textarea class="code-editor" id="code-{}" data-language="{}" data-original="{}" spellcheck="false"></textarea>"#,
423        exercise_id,
424        escape_html(&starter.language),
425        escape_html_attr(&starter.code)
426    ));
427    html.push('\n');
428    html.push_str("</section>\n");
429    html
430}
431
432fn render_solution(solution: &Solution, reveal: bool, exercise_id: &str) -> String {
433    let mut html = String::new();
434    html.push_str(&format!(r#"<section class="exercise-solution" id="{}-solution">"#, exercise_id));
435    html.push('\n');
436    let should_open = match solution.reveal {
437        SolutionReveal::Always => true,
438        SolutionReveal::Never => false,
439        SolutionReveal::OnDemand => reveal,
440    };
441    let open_attr = if should_open { " open" } else { "" };
442    html.push_str(&format!(r#"  <details class="solution"{}>"#, open_attr));
443    html.push('\n');
444    html.push_str(r#"    <summary><span class="solution-warning">โš ๏ธ Try the exercise first!</span><span class="solution-toggle">Show Solution</span></summary>"#);
445    html.push('\n');
446    html.push_str(r#"    <div class="solution-content">"#);
447    html.push('\n');
448    html.push_str(&format!(r#"      <pre><code class="language-{}">{}</code></pre>"#, escape_html(&solution.language), escape_html(&solution.code)));
449    html.push('\n');
450    if let Some(explanation) = &solution.explanation {
451        html.push_str(r#"      <div class="solution-explanation"><h4>Explanation</h4>"#);
452        let parser = Parser::new(explanation);
453        let mut explanation_html = String::new();
454        html::push_html(&mut explanation_html, parser);
455        html.push_str(&explanation_html);
456        html.push_str("</div>\n");
457    }
458    html.push_str("    </div>\n  </details>\n</section>\n");
459    html
460}
461
462fn render_tests(tests: &TestBlock, exercise_id: &str, config: &RenderConfig) -> String {
463    let mut html = String::new();
464    html.push_str(&format!(r#"<section class="exercise-tests" id="{}-tests" data-mode="{}">"#, exercise_id, tests.mode));
465    html.push('\n');
466    html.push_str("  <h3>๐Ÿงช Tests</h3>\n");
467    html.push_str(r#"  <div class="test-actions">"#);
468    html.push('\n');
469    if tests.mode == TestMode::Playground && config.enable_playground {
470        html.push_str(&format!(
471            r#"    <button class="btn btn-run-tests" data-exercise-id="{}" data-playground-url="{}">โ–ถ Run Tests</button>"#,
472            exercise_id, escape_html(&config.playground_url)
473        ));
474        html.push('\n');
475    } else {
476        html.push_str(r#"    <div class="local-test-info"><p>Run these tests locally with:</p><pre><code>cargo test</code></pre></div>"#);
477        html.push('\n');
478    }
479    html.push_str("  </div>\n");
480    html.push_str(&format!(r#"  <div class="test-results" id="results-{}" hidden></div>"#, exercise_id));
481    html.push('\n');
482    html.push_str(r#"  <details class="tests-code"><summary>View Test Code</summary>"#);
483    html.push('\n');
484    html.push_str(&format!(r#"    <pre><code class="language-{}">{}</code></pre>"#, escape_html(&tests.language), escape_html(&tests.code)));
485    html.push('\n');
486    html.push_str("  </details>\n</section>\n");
487    html
488}
489
490fn render_reflection(reflection: &[String], exercise_id: &str) -> String {
491    let mut html = String::new();
492    html.push_str(&format!(r#"<section class="exercise-reflection" id="{}-reflection">"#, exercise_id));
493    html.push('\n');
494    html.push_str("  <h3>๐Ÿค” Reflection</h3>\n");
495    html.push_str("  <ul>\n");
496    for item in reflection {
497        html.push_str(&format!("    <li>{}</li>\n", escape_html(item)));
498    }
499    html.push_str("  </ul>\n</section>\n");
500    html
501}
502
503// --- UseCase Exercise Specific Components ---
504
505fn render_usecase_header(exercise: &UseCaseExercise) -> String {
506    let mut html = String::new();
507    html.push_str(r#"<header class="exercise-header">"#);
508    html.push('\n');
509
510    if let Some(title) = &exercise.title {
511        html.push_str(&format!(r#"  <h2 class="exercise-title">{}</h2>"#, escape_html(title)));
512        html.push('\n');
513    }
514
515    html.push_str(r#"  <div class="exercise-meta">"#);
516    html.push('\n');
517    
518    // Domain badge
519    let domain_class = exercise.metadata.domain.to_string();
520    html.push_str(&format!(
521        r#"    <span class="badge domain {}">{}</span>"#,
522        domain_class, exercise.metadata.domain
523    ));
524    html.push('\n');
525
526    // Difficulty
527    let difficulty_class = match exercise.metadata.difficulty {
528        Difficulty::Beginner => "beginner",
529        Difficulty::Intermediate => "intermediate",
530        Difficulty::Advanced => "advanced",
531    };
532    html.push_str(&format!(
533        r#"    <span class="badge difficulty {}">{}</span>"#,
534        difficulty_class, exercise.metadata.difficulty
535    ));
536    html.push('\n');
537
538    if let Some(minutes) = exercise.metadata.time_minutes {
539        html.push_str(&format!(r#"    <span class="badge time">{} min</span>"#, minutes));
540        html.push('\n');
541    }
542    html.push_str("  </div>\n</header>\n");
543    html
544}
545
546fn render_scenario(scenario: &Scenario, _exercise_id: &str) -> String {
547    let mut html = String::new();
548    html.push_str(r#"<section class="exercise-scenario">"#);
549    html.push('\n');
550    html.push_str("  <h3>Scenario</h3>\n");
551
552    // Scenario header with organization
553    if let Some(org) = &scenario.organization {
554        html.push_str(r#"  <div class="scenario-header">"#);
555        html.push_str(&format!(r#"    <span class="organization">{}</span>"#, escape_html(org)));
556        html.push_str("  </div>\n");
557    }
558
559    // Scenario content
560    html.push_str(r#"  <div class="scenario-content">"#);
561    let parser = Parser::new(&scenario.content);
562    let mut content_html = String::new();
563    html::push_html(&mut content_html, parser);
564    html.push_str(&content_html);
565    html.push_str("  </div>\n");
566
567    // Constraints
568    if !scenario.constraints.is_empty() {
569        html.push_str(r#"  <div class="scenario-constraints">"#);
570        html.push('\n');
571        html.push_str("    <h4>Constraints</h4>\n");
572        html.push_str("    <ul>\n");
573        for c in &scenario.constraints {
574            html.push_str(&format!("      <li>{}</li>\n", escape_html(c)));
575        }
576        html.push_str("    </ul>\n");
577        html.push_str("  </div>\n");
578    }
579
580    html.push_str("</section>\n");
581    html
582}
583
584fn render_prompt(prompt: &UseCasePrompt, _exercise_id: &str) -> String {
585    let mut html = String::new();
586    html.push_str(r#"<section class="exercise-prompt">"#);
587    html.push('\n');
588    html.push_str("  <h3>Your Task</h3>\n");
589    
590    html.push_str(r#"  <div class="prompt-content">"#);
591    let parser = Parser::new(&prompt.prompt);
592    let mut prompt_html = String::new();
593    html::push_html(&mut prompt_html, parser);
594    html.push_str(&prompt_html);
595    html.push_str("  </div>\n");
596    
597    if !prompt.aspects.is_empty() {
598        html.push_str(r#"  <div class="prompt-aspects">"#);
599        html.push('\n');
600        html.push_str("    <h4>Address these aspects:</h4>\n");
601        html.push_str("    <ul>\n");
602        for aspect in &prompt.aspects {
603            html.push_str(&format!("      <li>{}</li>\n", escape_html(aspect)));
604        }
605        html.push_str("    </ul>\n");
606        html.push_str("  </div>\n");
607    }
608    
609    html.push_str("</section>\n");
610    html
611}
612
613fn render_response_area(eval: &EvaluationCriteria, exercise_id: &str) -> String {
614    let mut html = String::new();
615    html.push_str(r#"<section class="exercise-response">"#);
616    html.push('\n');
617    html.push_str("  <h3>Your Response</h3>\n");
618    
619    // Requirements display
620    let min_words = eval.min_words.unwrap_or(0);
621    let max_words = eval.max_words.unwrap_or(10000);
622    
623    let range_display = if eval.max_words.is_some() {
624        format!("{}-{} words", min_words, max_words)
625    } else if eval.min_words.is_some() {
626        format!("{}+ words", min_words)
627    } else {
628        "No word count limit".to_string()
629    };
630
631    html.push_str(r#"  <div class="response-requirements">"#);
632    html.push_str(&format!(r#"    <span class="word-count-req">{}</span>"#, range_display));
633    html.push_str("  </div>\n");
634
635    // Textarea
636    html.push_str(&format!(
637        r#"  <textarea class="response-editor" id="response-{}" placeholder="Enter your analysis here..." data-min-words="{}" data-max-words="{}"></textarea>"#,
638        exercise_id, min_words, max_words
639    ));
640    html.push('\n');
641
642    html.push_str(r#"  <div class="response-meta">"#);
643    html.push_str(r#"    <span class="current-word-count">0 words</span>"#);
644    html.push_str("  </div>\n");
645
646    html.push_str(r#"  <div class="response-actions">"#);
647    html.push_str(&format!(
648        r#"    <button class="btn-submit" data-exercise-id="{}">Submit for Evaluation</button>"#,
649        exercise_id
650    ));
651    html.push_str("  </div>\n");
652    
653    html.push_str("</section>\n");
654    html
655}
656
657fn render_evaluation_placeholder(exercise_id: &str) -> String {
658    format!(
659        r#"<section class="exercise-evaluation" id="evaluation-{}" hidden><h3>Evaluation Results</h3><div class="overall-score"></div><div class="criterion-scores"></div><div class="feedback"></div></section>"#,
660        exercise_id
661    )
662}
663
664fn render_context(context: &str, exercise_id: &str) -> String {
665    let mut html = String::new();
666    html.push_str(&format!(r#"<section class="exercise-context" id="context-{}" hidden>"#, exercise_id));
667    html.push('\n');
668    html.push_str("  <h3>Key Learning Points</h3>\n");
669    html.push_str(r#"  <div class="context-content">"#);
670    let parser = Parser::new(context);
671    let mut context_html = String::new();
672    html::push_html(&mut context_html, parser);
673    html.push_str(&context_html);
674    html.push_str("  </div>\n");
675    html.push_str("</section>\n");
676    html
677}
678
679// --- Utils ---
680
681fn escape_html(s: &str) -> String {
682    s.replace('&', "&amp;")
683        .replace('<', "&lt;")
684        .replace('>', "&gt;")
685        .replace('"', "&quot;")
686        .replace('\'', "&#x27;")
687}
688
689fn escape_html_attr(s: &str) -> String {
690    s.replace('&', "&amp;")
691        .replace('<', "&lt;")
692        .replace('>', "&gt;")
693        .replace('"', "&quot;")
694        .replace('\'', "&#x27;")
695        .replace('\n', "&#10;")
696        .replace('\r', "&#13;")
697}
698
699#[cfg(test)]
700mod tests {
701    use super::*;
702
703    #[test]
704    fn test_render_simple_code_exercise() {
705        let exercise = Exercise {
706            metadata: ExerciseMetadata {
707                id: "test-exercise".to_string(),
708                difficulty: Difficulty::Beginner,
709                time_minutes: Some(15),
710                prerequisites: vec![],
711            },
712            title: Some("Test Exercise".to_string()),
713            description: "A simple test exercise.".to_string(),
714            ..Default::default()
715        };
716
717        let parsed = ParsedExercise::Code(exercise);
718        let html = render_exercise(&parsed).unwrap();
719
720        assert!(html.contains(r#"data-exercise-id="test-exercise""#));
721        assert!(html.contains("Test Exercise"));
722        assert!(html.contains("beginner"));
723    }
724    
725    #[test]
726    fn test_render_usecase_exercise() {
727        let exercise = UseCaseExercise {
728            metadata: UseCaseMetadata {
729                id: "uc-001".to_string(),
730                difficulty: Difficulty::Intermediate,
731                domain: UseCaseDomain::Healthcare,
732                ..Default::default()
733            },
734            title: Some("HIPAA Analysis".to_string()),
735            scenario: Scenario {
736                organization: Some("Hospital".to_string()),
737                content: "Scenario text".to_string(),
738                ..Default::default()
739            },
740            prompt: UseCasePrompt {
741                prompt: "Analyze it".to_string(),
742                ..Default::default()
743            },
744            ..Default::default()
745        };
746        
747        let parsed = ParsedExercise::UseCase(exercise);
748        let html = render_exercise(&parsed).unwrap();
749        
750        assert!(html.contains("usecase-exercise"));
751        assert!(html.contains("data-domain=\"healthcare\""));
752        assert!(html.contains("HIPAA Analysis"));
753        assert!(html.contains("Hospital"));
754    }
755}