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("© 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("<script>"));
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}