Skip to main content

html_generator/
elements.rs

1// Copyright © 2025 HTML Generator. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! HTML5 semantic element builders.
5//!
6//! This module provides type-safe builders for modern HTML5 semantic
7//! elements with built-in ARIA attribute support and accessibility
8//! validation.
9//!
10//! # Examples
11//!
12//! ```
13//! use html_generator::elements::{Article, Section, Nav, Aside, Template};
14//!
15//! let nav = Nav::new()
16//!     .aria_label("Main navigation")
17//!     .id("main-nav")
18//!     .child("<ul><li><a href=\"/\">Home</a></li></ul>")
19//!     .build();
20//! assert!(nav.contains("role=\"navigation\""));
21//! assert!(nav.contains("aria-label=\"Main navigation\""));
22//! ```
23
24use crate::seo::escape_html;
25use std::collections::HashMap;
26
27/// Builder for `<article>` elements.
28///
29/// Represents a self-contained composition in a document, such as a
30/// blog post, news article, or forum post.
31///
32/// # Examples
33///
34/// ```
35/// use html_generator::elements::{Article, SemanticElement};
36///
37/// let html = Article::new()
38///     .id("post-1")
39///     .aria_label("Latest post")
40///     .child("<h1>Title</h1>")
41///     .build();
42/// assert!(html.starts_with("<article"));
43/// assert!(html.contains(r#"id="post-1""#));
44/// ```
45#[derive(Debug, Clone, Default)]
46pub struct Article {
47    id: Option<String>,
48    class: Option<String>,
49    aria_label: Option<String>,
50    aria_labelledby: Option<String>,
51    attrs: HashMap<String, String>,
52    children: Vec<String>,
53}
54
55/// Builder for `<section>` elements.
56///
57/// Represents a standalone section of a document, typically with a
58/// heading.
59///
60/// # Examples
61///
62/// ```
63/// use html_generator::elements::{Section, SemanticElement};
64///
65/// let html = Section::new().id("intro").child("<h2>Intro</h2>").build();
66/// assert!(html.contains("<section"));
67/// assert!(html.contains(r#"id="intro""#));
68/// ```
69#[derive(Debug, Clone, Default)]
70pub struct Section {
71    id: Option<String>,
72    class: Option<String>,
73    aria_label: Option<String>,
74    aria_labelledby: Option<String>,
75    attrs: HashMap<String, String>,
76    children: Vec<String>,
77}
78
79/// Builder for `<nav>` elements.
80///
81/// Represents a navigation section containing links to other pages or
82/// sections within the page.
83///
84/// # Examples
85///
86/// ```
87/// use html_generator::elements::{Nav, SemanticElement};
88///
89/// let html = Nav::new()
90///     .aria_label("Primary")
91///     .child(r#"<a href="/">Home</a>"#)
92///     .build();
93/// assert!(html.contains("<nav"));
94/// assert!(html.contains(r#"aria-label="Primary""#));
95/// ```
96#[derive(Debug, Clone, Default)]
97pub struct Nav {
98    id: Option<String>,
99    class: Option<String>,
100    aria_label: Option<String>,
101    aria_labelledby: Option<String>,
102    attrs: HashMap<String, String>,
103    children: Vec<String>,
104}
105
106/// Builder for `<aside>` elements.
107///
108/// Represents content tangentially related to the surrounding content,
109/// such as sidebars, pull quotes, or advertising.
110///
111/// # Examples
112///
113/// ```
114/// use html_generator::elements::{Aside, SemanticElement};
115///
116/// let html = Aside::new()
117///     .class("related")
118///     .child("<p>See also</p>")
119///     .build();
120/// assert!(html.contains("<aside"));
121/// assert!(html.contains(r#"class="related""#));
122/// ```
123#[derive(Debug, Clone, Default)]
124pub struct Aside {
125    id: Option<String>,
126    class: Option<String>,
127    aria_label: Option<String>,
128    aria_labelledby: Option<String>,
129    attrs: HashMap<String, String>,
130    children: Vec<String>,
131}
132
133/// Template for composing multiple semantic elements into a page
134/// structure.
135///
136/// Provides a high-level API for building accessible HTML5 document
137/// layouts.
138///
139/// # Examples
140///
141/// ```
142/// use html_generator::elements::Template;
143///
144/// let page = Template::new()
145///     .nav("Main navigation", "<ul><li>Home</li></ul>")
146///     .main_content("<h1>Welcome</h1><p>Content here.</p>")
147///     .aside("Related", "<p>Related links</p>")
148///     .build();
149/// assert!(page.contains("<nav"));
150/// assert!(page.contains("<main"));
151/// assert!(page.contains("<aside"));
152/// ```
153#[derive(Debug, Clone, Default)]
154pub struct Template {
155    nav: Option<String>,
156    header: Option<String>,
157    main: Option<String>,
158    sections: Vec<String>,
159    aside: Option<String>,
160    footer: Option<String>,
161}
162
163// ─── Shared builder trait ──────────────────────────────────────────
164
165/// Trait implemented by all semantic element builders.
166///
167/// Each of [`Article`], [`Section`], [`Nav`], and [`Aside`] expose the
168/// same fluent setters via a shared macro implementation; this trait
169/// is the polymorphic seam used when callers want to render any of
170/// them through a single API.
171///
172/// # Examples
173///
174/// ```
175/// use html_generator::elements::{Article, SemanticElement};
176///
177/// fn render<E: SemanticElement>(e: &E) -> String {
178///     e.build()
179/// }
180///
181/// let html = render(&Article::new().id("a").child("<p>x</p>"));
182/// assert!(html.contains("<article"));
183/// ```
184pub trait SemanticElement {
185    /// Render the element to an HTML string.
186    ///
187    /// # Examples
188    ///
189    /// ```
190    /// use html_generator::elements::{Section, SemanticElement};
191    ///
192    /// let html = Section::new().child("<h2>Hi</h2>").build();
193    /// assert!(html.contains("<section"));
194    /// ```
195    fn build(&self) -> String;
196}
197
198// ─── Macro to reduce repetition ───────────────────────────────────
199
200macro_rules! impl_element_builder {
201    ($type:ident, $tag:expr, $role:expr) => {
202        impl $type {
203            /// Creates a new builder with default values.
204            ///
205            /// # Examples
206            ///
207            /// ```
208            /// use html_generator::elements::{Article, SemanticElement};
209            ///
210            /// let html = Article::new().build();
211            /// assert!(html.contains("<article"));
212            /// ```
213            #[must_use]
214            pub fn new() -> Self {
215                Self::default()
216            }
217
218            /// Sets the `id` attribute.
219            ///
220            /// # Examples
221            ///
222            /// ```
223            /// use html_generator::elements::{Article, SemanticElement};
224            ///
225            /// let html = Article::new().id("post-1").build();
226            /// assert!(html.contains(r#"id="post-1""#));
227            /// ```
228            #[must_use]
229            pub fn id(mut self, id: &str) -> Self {
230                self.id = Some(id.to_string());
231                self
232            }
233
234            /// Sets the `class` attribute.
235            ///
236            /// # Examples
237            ///
238            /// ```
239            /// use html_generator::elements::{Article, SemanticElement};
240            ///
241            /// let html = Article::new().class("post").build();
242            /// assert!(html.contains(r#"class="post""#));
243            /// ```
244            #[must_use]
245            pub fn class(mut self, class: &str) -> Self {
246                self.class = Some(class.to_string());
247                self
248            }
249
250            /// Sets the `aria-label` attribute.
251            ///
252            /// # Examples
253            ///
254            /// ```
255            /// use html_generator::elements::{Article, SemanticElement};
256            ///
257            /// let html = Article::new().aria_label("Latest post").build();
258            /// assert!(html.contains(r#"aria-label="Latest post""#));
259            /// ```
260            #[must_use]
261            pub fn aria_label(mut self, label: &str) -> Self {
262                self.aria_label = Some(label.to_string());
263                self
264            }
265
266            /// Sets the `aria-labelledby` attribute.
267            ///
268            /// # Examples
269            ///
270            /// ```
271            /// use html_generator::elements::{Article, SemanticElement};
272            ///
273            /// let html = Article::new().aria_labelledby("title-1").build();
274            /// assert!(html.contains(r#"aria-labelledby="title-1""#));
275            /// ```
276            #[must_use]
277            pub fn aria_labelledby(mut self, id: &str) -> Self {
278                self.aria_labelledby = Some(id.to_string());
279                self
280            }
281
282            /// Adds a custom attribute.
283            ///
284            /// # Examples
285            ///
286            /// ```
287            /// use html_generator::elements::{Article, SemanticElement};
288            ///
289            /// let html = Article::new().attr("data-id", "42").build();
290            /// assert!(html.contains(r#"data-id="42""#));
291            /// ```
292            #[must_use]
293            pub fn attr(mut self, key: &str, value: &str) -> Self {
294                let _ = self
295                    .attrs
296                    .insert(key.to_string(), value.to_string());
297                self
298            }
299
300            /// Appends child HTML content.
301            ///
302            /// # Examples
303            ///
304            /// ```
305            /// use html_generator::elements::{Article, SemanticElement};
306            ///
307            /// let html = Article::new().child("<p>Body</p>").build();
308            /// assert!(html.contains("<p>Body</p>"));
309            /// ```
310            #[must_use]
311            pub fn child(mut self, html: &str) -> Self {
312                self.children.push(html.to_string());
313                self
314            }
315
316            /// Appends multiple child HTML content strings.
317            ///
318            /// # Examples
319            ///
320            /// ```
321            /// use html_generator::elements::{Article, SemanticElement};
322            ///
323            /// let html = Article::new()
324            ///     .children(&["<h1>T</h1>", "<p>B</p>"])
325            ///     .build();
326            /// assert!(html.contains("<h1>T</h1><p>B</p>"));
327            /// ```
328            #[must_use]
329            pub fn children(mut self, items: &[&str]) -> Self {
330                for item in items {
331                    self.children.push((*item).to_string());
332                }
333                self
334            }
335
336            /// Renders the element to an HTML string.
337            ///
338            /// # Examples
339            ///
340            /// ```
341            /// use html_generator::elements::{Article, SemanticElement};
342            ///
343            /// let html = Article::new().id("a").build();
344            /// assert!(html.starts_with("<article"));
345            /// assert!(html.ends_with("</article>"));
346            /// ```
347            #[must_use]
348            pub fn build(&self) -> String {
349                let mut parts = Vec::new();
350                parts.push(format!("<{}", $tag));
351
352                if !$role.is_empty() {
353                    parts.push(format!(" role=\"{}\"", $role));
354                }
355
356                if let Some(ref id) = self.id {
357                    parts.push(format!(" id=\"{}\"", escape_html(id)));
358                }
359                if let Some(ref class) = self.class {
360                    parts.push(format!(
361                        " class=\"{}\"",
362                        escape_html(class)
363                    ));
364                }
365                if let Some(ref label) = self.aria_label {
366                    parts.push(format!(
367                        " aria-label=\"{}\"",
368                        escape_html(label)
369                    ));
370                }
371                if let Some(ref id) = self.aria_labelledby {
372                    parts.push(format!(
373                        " aria-labelledby=\"{}\"",
374                        escape_html(id)
375                    ));
376                }
377
378                for (key, value) in &self.attrs {
379                    parts.push(format!(
380                        " {}=\"{}\"",
381                        escape_html(key),
382                        escape_html(value)
383                    ));
384                }
385
386                parts.push(">".to_string());
387
388                for child in &self.children {
389                    parts.push(child.clone());
390                }
391
392                parts.push(format!("</{}>", $tag));
393                parts.concat()
394            }
395        }
396
397        impl SemanticElement for $type {
398            fn build(&self) -> String {
399                $type::build(self)
400            }
401        }
402    };
403}
404
405impl_element_builder!(Article, "article", "article");
406impl_element_builder!(Section, "section", "region");
407impl_element_builder!(Nav, "nav", "navigation");
408impl_element_builder!(Aside, "aside", "complementary");
409
410// ─── Template implementation ──────────────────────────────────────
411
412impl Template {
413    /// Creates a new empty template.
414    ///
415    /// # Examples
416    ///
417    /// ```
418    /// use html_generator::elements::Template;
419    ///
420    /// let html = Template::new().build();
421    /// assert!(html.is_empty());
422    /// ```
423    #[must_use]
424    pub fn new() -> Self {
425        Self::default()
426    }
427
428    /// Adds a navigation section.
429    ///
430    /// # Examples
431    ///
432    /// ```
433    /// use html_generator::elements::Template;
434    ///
435    /// let html = Template::new().nav("Primary", "<a href='/'>Home</a>").build();
436    /// assert!(html.contains("<nav"));
437    /// assert!(html.contains(r#"aria-label="Primary""#));
438    /// ```
439    #[must_use]
440    pub fn nav(mut self, label: &str, content: &str) -> Self {
441        self.nav =
442            Some(Nav::new().aria_label(label).child(content).build());
443        self
444    }
445
446    /// Adds a header section.
447    ///
448    /// # Examples
449    ///
450    /// ```
451    /// use html_generator::elements::Template;
452    ///
453    /// let html = Template::new().header("<h1>Site</h1>").build();
454    /// assert!(html.contains("<header><h1>Site</h1></header>"));
455    /// ```
456    #[must_use]
457    pub fn header(mut self, content: &str) -> Self {
458        self.header = Some(format!("<header>{content}</header>"));
459        self
460    }
461
462    /// Adds the main content area.
463    ///
464    /// # Examples
465    ///
466    /// ```
467    /// use html_generator::elements::Template;
468    ///
469    /// let html = Template::new().main_content("<p>Body</p>").build();
470    /// assert!(html.contains("<main"));
471    /// assert!(html.contains(r#"role="main""#));
472    /// ```
473    #[must_use]
474    pub fn main_content(mut self, content: &str) -> Self {
475        self.main =
476            Some(format!("<main role=\"main\">{content}</main>"));
477        self
478    }
479
480    /// Adds a section to the template.
481    ///
482    /// # Examples
483    ///
484    /// ```
485    /// use html_generator::elements::Template;
486    ///
487    /// let html = Template::new().section("Intro", "<p>Hi</p>").build();
488    /// assert!(html.contains("<section"));
489    /// assert!(html.contains(r#"aria-label="Intro""#));
490    /// ```
491    #[must_use]
492    pub fn section(mut self, label: &str, content: &str) -> Self {
493        self.sections.push(
494            Section::new().aria_label(label).child(content).build(),
495        );
496        self
497    }
498
499    /// Adds an aside section.
500    ///
501    /// # Examples
502    ///
503    /// ```
504    /// use html_generator::elements::Template;
505    ///
506    /// let html = Template::new().aside("Related", "<p>links</p>").build();
507    /// assert!(html.contains("<aside"));
508    /// ```
509    #[must_use]
510    pub fn aside(mut self, label: &str, content: &str) -> Self {
511        self.aside =
512            Some(Aside::new().aria_label(label).child(content).build());
513        self
514    }
515
516    /// Adds a footer section.
517    ///
518    /// # Examples
519    ///
520    /// ```
521    /// use html_generator::elements::Template;
522    ///
523    /// let html = Template::new().footer("&copy; 2026").build();
524    /// assert!(html.contains("<footer"));
525    /// assert!(html.contains(r#"role="contentinfo""#));
526    /// ```
527    #[must_use]
528    pub fn footer(mut self, content: &str) -> Self {
529        self.footer = Some(format!(
530            "<footer role=\"contentinfo\">{content}</footer>"
531        ));
532        self
533    }
534
535    /// Renders the full template to an HTML string.
536    ///
537    /// # Examples
538    ///
539    /// ```
540    /// use html_generator::elements::Template;
541    ///
542    /// let html = Template::new()
543    ///     .nav("N", "<ul></ul>")
544    ///     .main_content("<p>x</p>")
545    ///     .build();
546    /// assert!(html.contains("<nav"));
547    /// assert!(html.contains("<main"));
548    /// ```
549    #[must_use]
550    pub fn build(&self) -> String {
551        let mut parts = Vec::new();
552
553        if let Some(ref nav) = self.nav {
554            parts.push(nav.as_str());
555        }
556        if let Some(ref header) = self.header {
557            parts.push(header.as_str());
558        }
559        if let Some(ref main) = self.main {
560            parts.push(main.as_str());
561        }
562        for section in &self.sections {
563            parts.push(section.as_str());
564        }
565        if let Some(ref aside) = self.aside {
566            parts.push(aside.as_str());
567        }
568        if let Some(ref footer) = self.footer {
569            parts.push(footer.as_str());
570        }
571
572        parts.join("\n")
573    }
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579
580    #[test]
581    fn test_article_builder() {
582        let html = Article::new()
583            .id("post-1")
584            .class("blog-post")
585            .aria_label("Blog post about Rust")
586            .child("<h2>Learning Rust</h2>")
587            .child("<p>Rust is great.</p>")
588            .build();
589
590        assert!(html.contains("<article"));
591        assert!(html.contains("role=\"article\""));
592        assert!(html.contains("id=\"post-1\""));
593        assert!(html.contains("class=\"blog-post\""));
594        assert!(html.contains("aria-label=\"Blog post about Rust\""));
595        assert!(html.contains("<h2>Learning Rust</h2>"));
596        assert!(html.contains("</article>"));
597    }
598
599    #[test]
600    fn test_section_builder() {
601        let html = Section::new()
602            .aria_label("Introduction")
603            .child("<h2>Intro</h2>")
604            .build();
605
606        assert!(html.contains("<section"));
607        assert!(html.contains("role=\"region\""));
608        assert!(html.contains("aria-label=\"Introduction\""));
609        assert!(html.contains("</section>"));
610    }
611
612    #[test]
613    fn test_nav_builder() {
614        let html = Nav::new()
615            .id("main-nav")
616            .aria_label("Main navigation")
617            .child("<ul><li>Home</li></ul>")
618            .build();
619
620        assert!(html.contains("<nav"));
621        assert!(html.contains("role=\"navigation\""));
622        assert!(html.contains("aria-label=\"Main navigation\""));
623        assert!(html.contains("id=\"main-nav\""));
624        assert!(html.contains("</nav>"));
625    }
626
627    #[test]
628    fn test_aside_builder() {
629        let html = Aside::new()
630            .aria_label("Related links")
631            .child("<p>See also...</p>")
632            .build();
633
634        assert!(html.contains("<aside"));
635        assert!(html.contains("role=\"complementary\""));
636        assert!(html.contains("</aside>"));
637    }
638
639    #[test]
640    fn test_template_composition() {
641        let page = Template::new()
642            .nav("Site navigation", "<ul><li>Home</li></ul>")
643            .header("<h1>My Site</h1>")
644            .main_content("<p>Welcome!</p>")
645            .section("About", "<p>About us</p>")
646            .aside("Sidebar", "<p>Links</p>")
647            .footer("<p>Copyright 2025</p>")
648            .build();
649
650        assert!(page.contains("<nav"));
651        assert!(page.contains("<header>"));
652        assert!(page.contains("<main role=\"main\">"));
653        assert!(page.contains("<section"));
654        assert!(page.contains("<aside"));
655        assert!(page.contains("<footer"));
656    }
657
658    #[test]
659    fn test_escapes_attributes() {
660        let html = Nav::new()
661            .aria_label("<script>alert('xss')</script>")
662            .build();
663
664        assert!(!html.contains("<script>"));
665        assert!(html.contains("&lt;script&gt;"));
666    }
667
668    #[test]
669    fn test_custom_attrs() {
670        let html = Article::new()
671            .attr("data-post-id", "42")
672            .attr("itemscope", "")
673            .child("<p>Content</p>")
674            .build();
675
676        assert!(html.contains("data-post-id=\"42\""));
677    }
678}