Skip to main content

jugar_probar/web/
html_builder.rs

1//! Type-Safe HTML Generation (Zero-JavaScript Policy)
2//!
3//! Generates valid HTML programmatically with accessibility attributes.
4
5use crate::result::{ProbarError, ProbarResult};
6use serde::{Deserialize, Serialize};
7
8/// Generated HTML output
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct GeneratedHtml {
11    /// Document title
12    pub title: String,
13    /// Body content (inner HTML)
14    pub body_content: String,
15    /// Full HTML document
16    pub content: String,
17    /// Elements in the document
18    pub elements: Vec<Element>,
19}
20
21/// HTML element types
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub enum Element {
24    /// Canvas element for WASM rendering
25    Canvas {
26        /// Element ID
27        id: String,
28        /// Width in pixels
29        width: u32,
30        /// Height in pixels
31        height: u32,
32        /// ARIA role
33        role: String,
34        /// ARIA label
35        aria_label: String,
36    },
37    /// Div container
38    Div {
39        /// Element ID
40        id: String,
41        /// CSS classes
42        classes: Vec<String>,
43        /// Inner content
44        content: String,
45    },
46    /// Button element
47    Button {
48        /// Element ID
49        id: String,
50        /// Button text
51        text: String,
52        /// ARIA label
53        aria_label: String,
54    },
55    /// Input element
56    Input {
57        /// Element ID
58        id: String,
59        /// Input type
60        input_type: String,
61        /// Placeholder text
62        placeholder: String,
63        /// ARIA label
64        aria_label: String,
65    },
66}
67
68impl Element {
69    /// Render element to HTML string
70    #[must_use]
71    pub fn render(&self) -> String {
72        match self {
73            Element::Canvas {
74                id,
75                width,
76                height,
77                role,
78                aria_label,
79            } => {
80                format!(
81                    r#"<canvas id="{id}" width="{width}" height="{height}" role="{role}" aria-label="{aria_label}" tabindex="0"></canvas>"#
82                )
83            }
84            Element::Div {
85                id,
86                classes,
87                content,
88            } => {
89                let class_attr = if classes.is_empty() {
90                    String::new()
91                } else {
92                    format!(r#" class="{}""#, classes.join(" "))
93                };
94                format!(r#"<div id="{id}"{class_attr}>{content}</div>"#)
95            }
96            Element::Button {
97                id,
98                text,
99                aria_label,
100            } => {
101                format!(r#"<button id="{id}" aria-label="{aria_label}">{text}</button>"#)
102            }
103            Element::Input {
104                id,
105                input_type,
106                placeholder,
107                aria_label,
108            } => {
109                format!(
110                    r#"<input id="{id}" type="{input_type}" placeholder="{placeholder}" aria-label="{aria_label}">"#
111                )
112            }
113        }
114    }
115}
116
117/// Internal HTML document structure
118#[derive(Debug, Clone, Default)]
119pub struct HtmlDocument {
120    /// Document title
121    pub title: String,
122    /// Document language
123    pub lang: String,
124    /// Character encoding
125    pub charset: String,
126    /// Viewport configuration
127    pub viewport: String,
128    /// Body elements
129    pub elements: Vec<Element>,
130}
131
132/// Type-safe HTML builder
133#[derive(Debug, Clone, Default)]
134pub struct HtmlBuilder {
135    document: HtmlDocument,
136}
137
138impl HtmlBuilder {
139    /// Create a new HTML builder
140    #[must_use]
141    pub fn new() -> Self {
142        Self {
143            document: HtmlDocument {
144                title: String::new(),
145                lang: "en".to_string(),
146                charset: "UTF-8".to_string(),
147                viewport: "width=device-width, initial-scale=1.0".to_string(),
148                elements: Vec::new(),
149            },
150        }
151    }
152
153    /// Set document title (required)
154    #[must_use]
155    pub fn title(mut self, title: &str) -> Self {
156        self.document.title = title.to_string();
157        self
158    }
159
160    /// Set document language
161    #[must_use]
162    pub fn lang(mut self, lang: &str) -> Self {
163        self.document.lang = lang.to_string();
164        self
165    }
166
167    /// Add a canvas element for WASM rendering
168    #[must_use]
169    pub fn canvas(mut self, id: &str, width: u32, height: u32) -> Self {
170        self.document.elements.push(Element::Canvas {
171            id: id.to_string(),
172            width,
173            height,
174            role: "application".to_string(),
175            aria_label: "Application canvas".to_string(),
176        });
177        self
178    }
179
180    /// Add a canvas element with custom accessibility attributes
181    #[must_use]
182    pub fn canvas_with_a11y(
183        mut self,
184        id: &str,
185        width: u32,
186        height: u32,
187        role: &str,
188        aria_label: &str,
189    ) -> Self {
190        self.document.elements.push(Element::Canvas {
191            id: id.to_string(),
192            width,
193            height,
194            role: role.to_string(),
195            aria_label: aria_label.to_string(),
196        });
197        self
198    }
199
200    /// Add a div container
201    #[must_use]
202    pub fn div(mut self, id: &str, classes: &[&str], content: &str) -> Self {
203        self.document.elements.push(Element::Div {
204            id: id.to_string(),
205            classes: classes.iter().map(|s| (*s).to_string()).collect(),
206            content: content.to_string(),
207        });
208        self
209    }
210
211    /// Add a button element
212    #[must_use]
213    pub fn button(mut self, id: &str, text: &str, aria_label: &str) -> Self {
214        self.document.elements.push(Element::Button {
215            id: id.to_string(),
216            text: text.to_string(),
217            aria_label: aria_label.to_string(),
218        });
219        self
220    }
221
222    /// Add an input element
223    #[must_use]
224    pub fn input(
225        mut self,
226        id: &str,
227        input_type: &str,
228        placeholder: &str,
229        aria_label: &str,
230    ) -> Self {
231        self.document.elements.push(Element::Input {
232            id: id.to_string(),
233            input_type: input_type.to_string(),
234            placeholder: placeholder.to_string(),
235            aria_label: aria_label.to_string(),
236        });
237        self
238    }
239
240    /// Add a raw element
241    #[must_use]
242    pub fn element(mut self, element: Element) -> Self {
243        self.document.elements.push(element);
244        self
245    }
246
247    /// Build and validate HTML document
248    ///
249    /// # Errors
250    ///
251    /// Returns error if title is empty
252    pub fn build(self) -> ProbarResult<GeneratedHtml> {
253        // Validation: title is required
254        if self.document.title.is_empty() {
255            return Err(ProbarError::HtmlGeneration(
256                "Document title is required".to_string(),
257            ));
258        }
259
260        // Generate body content
261        let body_content = self
262            .document
263            .elements
264            .iter()
265            .map(Element::render)
266            .collect::<Vec<_>>()
267            .join("\n    ");
268
269        // Generate full HTML
270        let content = format!(
271            r#"<!DOCTYPE html>
272<html lang="{lang}">
273<head>
274    <meta charset="{charset}">
275    <meta name="viewport" content="{viewport}">
276    <title>{title}</title>
277</head>
278<body>
279    {body}
280</body>
281</html>"#,
282            lang = self.document.lang,
283            charset = self.document.charset,
284            viewport = self.document.viewport,
285            title = self.document.title,
286            body = body_content,
287        );
288
289        Ok(GeneratedHtml {
290            title: self.document.title,
291            body_content,
292            content,
293            elements: self.document.elements,
294        })
295    }
296}
297
298#[cfg(test)]
299#[allow(clippy::unwrap_used, clippy::expect_used)]
300mod tests {
301    use super::*;
302
303    // =========================================================================
304    // H₀-HTML-01: HtmlBuilder creation and defaults
305    // =========================================================================
306
307    #[test]
308    fn h0_html_01_builder_new() {
309        let builder = HtmlBuilder::new();
310        assert_eq!(builder.document.lang, "en");
311        assert_eq!(builder.document.charset, "UTF-8");
312    }
313
314    #[test]
315    fn h0_html_02_builder_title() {
316        let builder = HtmlBuilder::new().title("My App");
317        assert_eq!(builder.document.title, "My App");
318    }
319
320    #[test]
321    fn h0_html_03_builder_lang() {
322        let builder = HtmlBuilder::new().lang("es");
323        assert_eq!(builder.document.lang, "es");
324    }
325
326    // =========================================================================
327    // H₀-HTML-04: Canvas element generation
328    // =========================================================================
329
330    #[test]
331    fn h0_html_04_canvas_element() {
332        let html = HtmlBuilder::new()
333            .title("Test")
334            .canvas("game", 800, 600)
335            .build()
336            .unwrap();
337
338        assert!(html.content.contains(r#"id="game""#));
339        assert!(html.content.contains(r#"width="800""#));
340        assert!(html.content.contains(r#"height="600""#));
341        assert!(html.content.contains(r#"role="application""#));
342        assert!(html.content.contains(r#"aria-label="Application canvas""#));
343        assert!(html.content.contains(r#"tabindex="0""#));
344    }
345
346    #[test]
347    fn h0_html_05_canvas_custom_a11y() {
348        let html = HtmlBuilder::new()
349            .title("Test")
350            .canvas_with_a11y("calc", 400, 300, "img", "Calculator display")
351            .build()
352            .unwrap();
353
354        assert!(html.content.contains(r#"role="img""#));
355        assert!(html.content.contains(r#"aria-label="Calculator display""#));
356    }
357
358    // =========================================================================
359    // H₀-HTML-06: Other element types
360    // =========================================================================
361
362    #[test]
363    fn h0_html_06_div_element() {
364        let html = HtmlBuilder::new()
365            .title("Test")
366            .div("container", &["main", "flex"], "Hello")
367            .build()
368            .unwrap();
369
370        assert!(html
371            .content
372            .contains(r#"<div id="container" class="main flex">Hello</div>"#));
373    }
374
375    #[test]
376    fn h0_html_07_button_element() {
377        let html = HtmlBuilder::new()
378            .title("Test")
379            .button("submit", "Submit", "Submit form")
380            .build()
381            .unwrap();
382
383        assert!(html
384            .content
385            .contains(r#"<button id="submit" aria-label="Submit form">Submit</button>"#));
386    }
387
388    #[test]
389    fn h0_html_08_input_element() {
390        let html = HtmlBuilder::new()
391            .title("Test")
392            .input("email", "email", "Enter email", "Email address")
393            .build()
394            .unwrap();
395
396        assert!(html.content.contains(r#"<input id="email""#));
397        assert!(html.content.contains(r#"type="email""#));
398        assert!(html.content.contains(r#"placeholder="Enter email""#));
399    }
400
401    // =========================================================================
402    // H₀-HTML-09: Validation
403    // =========================================================================
404
405    #[test]
406    fn h0_html_09_empty_title_fails() {
407        let result = HtmlBuilder::new().build();
408        assert!(result.is_err());
409        assert!(result
410            .unwrap_err()
411            .to_string()
412            .contains("title is required"));
413    }
414
415    #[test]
416    fn h0_html_10_valid_html_structure() {
417        let html = HtmlBuilder::new()
418            .title("Test App")
419            .canvas("app", 100, 100)
420            .build()
421            .unwrap();
422
423        assert!(html.content.starts_with("<!DOCTYPE html>"));
424        assert!(html.content.contains("<html lang=\"en\">"));
425        assert!(html.content.contains("<head>"));
426        assert!(html.content.contains("</head>"));
427        assert!(html.content.contains("<body>"));
428        assert!(html.content.contains("</body>"));
429        assert!(html.content.contains("</html>"));
430    }
431
432    // =========================================================================
433    // H₀-HTML-11: Element rendering
434    // =========================================================================
435
436    #[test]
437    fn h0_html_11_element_render_canvas() {
438        let elem = Element::Canvas {
439            id: "c".to_string(),
440            width: 100,
441            height: 100,
442            role: "img".to_string(),
443            aria_label: "Test".to_string(),
444        };
445
446        let rendered = elem.render();
447        assert!(rendered.contains("<canvas"));
448        assert!(rendered.contains("</canvas>"));
449    }
450
451    #[test]
452    fn h0_html_12_element_render_div_no_classes() {
453        let elem = Element::Div {
454            id: "d".to_string(),
455            classes: vec![],
456            content: "Test".to_string(),
457        };
458
459        let rendered = elem.render();
460        assert_eq!(rendered, r#"<div id="d">Test</div>"#);
461    }
462
463    #[test]
464    fn h0_html_13_generated_html_fields() {
465        let html = HtmlBuilder::new()
466            .title("My Title")
467            .canvas("c", 10, 10)
468            .build()
469            .unwrap();
470
471        assert_eq!(html.title, "My Title");
472        assert!(!html.body_content.is_empty());
473        assert!(!html.elements.is_empty());
474    }
475}