Rust library to generate static websites
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(markdown): Support for custom components (#17)

* feat(markdown): Support for custom components

* fix: remove example

* remove handles

* fix: use different structs

* fix: don't run components if there's none

* feat: options

* fix: no idea what I'm doing

* fix: import

* fix: refactor

* fix: move tests

* fix: move tests

* fix: enum for blockquotes

* fix: link types

* feat: add everything else

* feat: example

* fix: some cleanup

* fix: simplify heading example

* fix: comments

authored by

Erika and committed by
GitHub
2ef8b941 810c0729

+2304 -43
+9
Cargo.lock
··· 2513 2513 ] 2514 2514 2515 2515 [[package]] 2516 + name = "maudit-example-markdown-components" 2517 + version = "0.1.0" 2518 + dependencies = [ 2519 + "maud", 2520 + "maudit", 2521 + "serde", 2522 + ] 2523 + 2524 + [[package]] 2516 2525 name = "maudit-macros" 2517 2526 version = "0.2.0" 2518 2527 dependencies = [
+1 -1
benchmarks/md-benchmark/src/lib.rs
··· 7 7 pub fn build_website(markdown_count: u32) { 8 8 let _ = coronate( 9 9 routes![page::Article], 10 - content_sources!["articles" => glob_markdown::<UntypedMarkdownContent>(&format!("content/{}/*.md", markdown_count))], 10 + content_sources!["articles" => glob_markdown::<UntypedMarkdownContent>(&format!("content/{}/*.md", markdown_count), None)], 11 11 BuildOptions::default(), 12 12 ); 13 13 }
+14 -5
crates/maudit/src/content.rs
··· 6 6 use rustc_hash::FxHashMap; 7 7 8 8 mod highlight; 9 - mod markdown; 9 + pub mod markdown; 10 10 mod slugger; 11 11 12 12 use crate::page::RouteParams; 13 - pub use markdown::*; 13 + pub use markdown::{ 14 + components::{ 15 + BlockQuoteKind, BlockquoteComponent, CodeComponent, EmphasisComponent, HardBreakComponent, 16 + HeadingComponent, HorizontalRuleComponent, ImageComponent, LinkComponent, LinkType, 17 + ListComponent, ListItemComponent, ListType, MarkdownComponents, ParagraphComponent, 18 + StrikethroughComponent, StrongComponent, TableAlignment, TableCellComponent, 19 + TableComponent, TableHeadComponent, TableRowComponent, TaskListMarkerComponent, 20 + }, 21 + *, 22 + }; 14 23 15 24 /// Helps implement a struct as a Markdown content entry. 16 25 /// ··· 29 38 /// coronate( 30 39 /// routes![], 31 40 /// content_sources![ 32 - /// "articles" => glob_markdown::<ArticleContent>("content/articles/*.md") 41 + /// "articles" => glob_markdown::<ArticleContent>("content/articles/*.md", None) 33 42 /// ], 34 43 /// BuildOptions::default(), 35 44 /// ) ··· 90 99 /// coronate( 91 100 /// routes![], 92 101 /// content_sources![ 93 - /// "articles" => glob_markdown::<ArticleContent>("content/articles/*.md") 102 + /// "articles" => glob_markdown::<ArticleContent>("content/articles/*.md", None) 94 103 /// ], 95 104 /// BuildOptions::default(), 96 105 /// ) ··· 296 305 /// # } 297 306 /// 298 307 /// pub fn content_sources() -> ContentSources { 299 - /// content_sources!["docs" => glob_markdown::<ArticleContent>("content/docs/*.md")] 308 + /// content_sources!["docs" => glob_markdown::<ArticleContent>("content/docs/*.md", None)] 300 309 /// } 301 310 pub struct ContentSources(pub Vec<Box<dyn ContentSourceInternal>>); 302 311
+1
crates/maudit/src/content/highlight.rs
··· 76 76 pub fn highlight(&self, content: &str) -> Result<String, Error> { 77 77 let ss = get_syntax_set(); 78 78 let ts = get_theme_set(); 79 + 79 80 let syntax = ss 80 81 .find_syntax_by_name(&self.meta.language) 81 82 .or_else(|| ss.find_syntax_by_extension(&self.meta.language))
+533 -27
crates/maudit/src/content/markdown.rs
··· 1 + use std::sync::Arc; 2 + 1 3 use glob::glob as glob_fs; 2 4 use log::warn; 3 5 use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd}; 4 6 use serde::de::DeserializeOwned; 7 + 8 + pub mod components; 9 + 10 + use components::{LinkType, ListType, MarkdownComponents, TableAlignment}; 5 11 6 12 use super::{highlight::CodeBlock, slugger, ContentEntry}; 7 13 ··· 103 109 /// coronate( 104 110 /// routes![], 105 111 /// content_sources![ 106 - /// "articles" => glob_markdown::<UntypedMarkdownContent>("content/spooky/*.md") 112 + /// "articles" => glob_markdown::<UntypedMarkdownContent>("content/spooky/*.md", None) 107 113 /// ], 108 114 /// BuildOptions::default(), 109 115 /// ) ··· 127 133 } 128 134 } 129 135 136 + #[derive(Default)] 137 + pub struct MarkdownOptions { 138 + pub components: MarkdownComponents, 139 + } 140 + 141 + impl MarkdownOptions { 142 + pub fn new() -> Self { 143 + Self::default() 144 + } 145 + 146 + pub fn with_components(components: MarkdownComponents) -> Self { 147 + Self { components } 148 + } 149 + } 150 + 130 151 /// Glob for Markdown files and return a vector of [`ContentEntry`]s. 131 152 /// 132 153 /// Typically used by [`content_sources!`](crate::content_sources) to define a Markdown content source in [`coronate()`](crate::coronate). ··· 146 167 /// coronate( 147 168 /// routes![], 148 169 /// content_sources![ 149 - /// "articles" => glob_markdown::<ArticleContent>("content/articles/*.md") 170 + /// "articles" => glob_markdown::<ArticleContent>("content/articles/*.md", None) 150 171 /// ], 151 172 /// BuildOptions::default(), 152 173 /// ) 153 174 /// } 154 175 /// ``` 155 - pub fn glob_markdown<T>(pattern: &str) -> Vec<ContentEntry<T>> 176 + pub fn glob_markdown<T>(pattern: &str, options: Option<MarkdownOptions>) -> Vec<ContentEntry<T>> 156 177 where 157 178 T: DeserializeOwned + MarkdownContent + InternalMarkdownContent + Send + Sync + 'static, 158 179 { 159 180 let mut entries = vec![]; 181 + let options = options.map(Arc::new); 160 182 161 183 for entry in glob_fs(pattern).unwrap() { 162 184 let entry = entry.unwrap(); ··· 223 245 parsed 224 246 }); 225 247 248 + // Perhaps not ideal, but I don't know better. We're at the "get it working" stage - erika, 2025-08-24 249 + // Ideally, we'd at least avoid the allocation here whenever `options` is None, not sure how to do that ergonomically 250 + let opts = options.clone(); 251 + 226 252 entries.push(ContentEntry::new_lazy( 227 253 id, 228 - Some(Box::new(render_markdown)), 254 + Some(Box::new(move |content: &str| { 255 + render_markdown(content, opts.as_deref()) 256 + })), 229 257 Some(content), 230 258 data_loader, 231 259 Some(entry), ··· 279 307 heading_refs 280 308 } 281 309 282 - /// Render Markdown content to HTML. 310 + /// Render Markdown content to HTML with optional custom components. 283 311 /// 284 312 /// ## Example 285 313 /// ```rs 286 - /// use maudit::content::render_markdown; 314 + /// use maudit::content::{render_markdown, MarkdownOptions, MarkdownComponents}; 315 + /// use maudit::content::components::HeadingComponent; 316 + /// 317 + /// // Without components 287 318 /// let markdown = r#"# Hello, world!"#; 288 - /// let html = render_markdown(markdown); 319 + /// let html = render_markdown(markdown, None); 320 + /// 321 + /// // With components 322 + /// struct MyCustomHeading; 323 + /// impl HeadingComponent for MyCustomHeading { 324 + /// fn render_start(&self, level: u8, id: Option<&str>, classes: &[&str]) -> String { 325 + /// let id_attr = id.map(|i| format!(" id=\"{}\"", i)).unwrap_or_default(); 326 + /// let class_attr = if classes.is_empty() { 327 + /// String::new() 328 + /// } else { 329 + /// format!(" class=\"{}\"", classes.join(" ")) 330 + /// }; 331 + /// format!("<h{level}{id_attr}{class_attr}><span class=\"icon\">§</span>") 332 + /// } 333 + /// 334 + /// fn render_end(&self, level: u8) -> String { 335 + /// format!("</h{level}>") 336 + /// } 337 + /// } 338 + /// 339 + /// let options = MarkdownOptions { 340 + /// components: MarkdownComponents::new().heading(MyCustomHeading), 341 + /// }; 342 + /// let html = render_markdown(markdown, Some(&options)); 289 343 /// ``` 290 - pub fn render_markdown(content: &str) -> String { 344 + pub fn render_markdown(content: &str, options: Option<&MarkdownOptions>) -> String { 291 345 let mut slugger = slugger::Slugger::new(); 292 346 let mut html_output = String::new(); 293 - let mut options = Options::empty(); 294 - options.insert(Options::ENABLE_YAML_STYLE_METADATA_BLOCKS); 347 + let parser_options = Options::ENABLE_YAML_STYLE_METADATA_BLOCKS 348 + | Options::ENABLE_STRIKETHROUGH 349 + | Options::ENABLE_TASKLISTS 350 + | Options::ENABLE_TABLES 351 + | Options::ENABLE_GFM 352 + | Options::ENABLE_MATH 353 + | Options::ENABLE_FOOTNOTES; 295 354 296 355 let mut code_block = None; 297 356 let mut code_block_content = String::new(); 298 357 let mut in_frontmatter = false; 299 358 let mut events = Vec::new(); 300 - for (event, _) in Parser::new_ext(content, options).into_offset_iter() { 359 + 360 + // Do a first pass to collect body events 361 + for (event, _) in Parser::new_ext(content, parser_options).into_offset_iter() { 301 362 match event { 302 363 Event::Start(Tag::MetadataBlock(_)) => { 303 364 in_frontmatter = true; ··· 314 375 } 315 376 } 316 377 } 378 + 379 + // TODO: Handle this differently so it's compatible with the component system - erika, 2025-08-24 317 380 Event::Start(Tag::CodeBlock(ref kind)) => { 318 381 if let CodeBlockKind::Fenced(ref fence) = kind { 319 382 let (block, begin) = CodeBlock::new(fence); ··· 326 389 let html = code_block.highlight(&code_block_content); 327 390 events.push(Event::Html(html.unwrap().into())); 328 391 } 329 - 330 392 code_block = None; 331 393 code_block_content.clear(); 332 394 events.push(Event::Html("</code></pre>\n".into())); 333 395 } 396 + 334 397 _ => { 335 398 events.push(event); 336 399 } 337 400 } 338 401 } 339 402 340 - let headings = find_headings(&events); 403 + // Second pass: transform events with custom components only if needed 404 + let final_events = match options { 405 + Some(options) if options.components.has_any_components() => { 406 + transform_events_with_components(&events, &options.components, &mut slugger) 407 + } 408 + _ => { 409 + // No options, no components, or empty components - use events as-is 410 + events 411 + } 412 + }; 341 413 342 - for heading in &headings { 343 - let heading_content = get_text_from_events(&events[heading.start..heading.end]); 344 - let slug: String = slugger.slugify(&heading_content); 414 + pulldown_cmark::html::push_html(&mut html_output, final_events.into_iter()); 415 + html_output 416 + } 345 417 346 - events[heading.start] = Event::Html( 347 - format!( 348 - "<h{} id=\"{}\" class=\"{}\">", 349 - heading.level, 350 - heading.id.clone().unwrap_or(slug), 351 - heading.classes.join(" ") 352 - ) 353 - .into(), 354 - ); 418 + fn transform_events_with_components<'a>( 419 + events: &'a [Event], 420 + components: &MarkdownComponents, 421 + slugger: &mut slugger::Slugger, 422 + ) -> Vec<Event<'a>> { 423 + let mut transformed = Vec::new(); 424 + let mut i = 0; 425 + 426 + while i < events.len() { 427 + let event = &events[i]; 428 + 429 + match event { 430 + // Headings 431 + Event::Start(Tag::Heading { 432 + level, id, classes, .. 433 + }) => { 434 + let heading_content = if let Some(end_index) = find_matching_heading_end(events, i) 435 + { 436 + get_text_from_events(&events[i + 1..end_index]) 437 + } else { 438 + String::new() 439 + }; 440 + let slug = slugger.slugify(&heading_content); 441 + let heading_id = id.as_ref().map(|s| s.as_ref()).unwrap_or(&slug); 442 + let classes_vec: Vec<&str> = classes.iter().map(|c| c.as_ref()).collect(); 443 + 444 + if let Some(component) = &components.heading { 445 + let custom_html = 446 + component.render_start(*level as u8, Some(heading_id), &classes_vec); 447 + transformed.push(Event::Html(custom_html.into())); 448 + } else { 449 + // Default behavior 450 + transformed.push(Event::Html( 451 + format!( 452 + "<h{} id=\"{}\" class=\"{}\">", 453 + level, 454 + heading_id, 455 + classes_vec.join(" ") 456 + ) 457 + .into(), 458 + )); 459 + } 460 + } 461 + Event::End(TagEnd::Heading(level)) => { 462 + if let Some(component) = &components.heading { 463 + let custom_html = component.render_end(*level as u8); 464 + transformed.push(Event::Html(custom_html.into())); 465 + } else { 466 + transformed.push(event.clone()); 467 + } 468 + } 469 + 470 + // Paragraphs 471 + Event::Start(Tag::Paragraph) => { 472 + if let Some(component) = &components.paragraph { 473 + let custom_html = component.render_start(); 474 + transformed.push(Event::Html(custom_html.into())); 475 + } else { 476 + transformed.push(event.clone()); 477 + } 478 + } 479 + Event::End(TagEnd::Paragraph) => { 480 + if let Some(component) = &components.paragraph { 481 + let custom_html = component.render_end(); 482 + transformed.push(Event::Html(custom_html.into())); 483 + } else { 484 + transformed.push(event.clone()); 485 + } 486 + } 487 + 488 + // Links 489 + Event::Start(Tag::Link { 490 + link_type, 491 + dest_url, 492 + title, 493 + .. 494 + }) => { 495 + if let Some(component) = &components.link { 496 + let link_type_converted: LinkType = link_type.into(); 497 + let title_str = if title.is_empty() { 498 + None 499 + } else { 500 + Some(title.as_ref()) 501 + }; 502 + let custom_html = 503 + component.render_start(dest_url.as_ref(), title_str, link_type_converted); 504 + transformed.push(Event::Html(custom_html.into())); 505 + } else { 506 + transformed.push(event.clone()); 507 + } 508 + } 509 + Event::End(TagEnd::Link) => { 510 + if let Some(component) = &components.link { 511 + let custom_html = component.render_end(); 512 + transformed.push(Event::Html(custom_html.into())); 513 + } else { 514 + transformed.push(event.clone()); 515 + } 516 + } 517 + 518 + // Images 519 + Event::Start(Tag::Image { 520 + dest_url, title, .. 521 + }) => { 522 + if let Some(component) = &components.image { 523 + // For images, we need to get the alt text from content between start and end 524 + let alt_text = if let Some(end_index) = find_matching_image_end(events, i) { 525 + get_text_from_events(&events[i + 1..end_index]) 526 + } else { 527 + String::new() 528 + }; 529 + let title_str = if title.is_empty() { 530 + None 531 + } else { 532 + Some(title.as_ref()) 533 + }; 534 + let custom_html = component.render(dest_url.as_ref(), &alt_text, title_str); 535 + transformed.push(Event::Html(custom_html.into())); 536 + // Skip to the end tag 537 + if let Some(end_index) = find_matching_image_end(events, i) { 538 + i = end_index; 539 + } 540 + } else { 541 + transformed.push(event.clone()); 542 + } 543 + } 544 + Event::End(TagEnd::Image) => { 545 + // Only add this if we didn't handle it above with custom component 546 + if components.image.is_none() { 547 + transformed.push(event.clone()); 548 + } 549 + } 550 + 551 + // Bold (strong) 552 + Event::Start(Tag::Strong) => { 553 + if let Some(component) = &components.strong { 554 + let custom_html = component.render_start(); 555 + transformed.push(Event::Html(custom_html.into())); 556 + } else { 557 + transformed.push(event.clone()); 558 + } 559 + } 560 + Event::End(TagEnd::Strong) => { 561 + if let Some(component) = &components.strong { 562 + let custom_html = component.render_end(); 563 + transformed.push(Event::Html(custom_html.into())); 564 + } else { 565 + transformed.push(event.clone()); 566 + } 567 + } 568 + 569 + // Italic (emphasis) 570 + Event::Start(Tag::Emphasis) => { 571 + if let Some(component) = &components.emphasis { 572 + let custom_html = component.render_start(); 573 + transformed.push(Event::Html(custom_html.into())); 574 + } else { 575 + transformed.push(event.clone()); 576 + } 577 + } 578 + Event::End(TagEnd::Emphasis) => { 579 + if let Some(component) = &components.emphasis { 580 + let custom_html = component.render_end(); 581 + transformed.push(Event::Html(custom_html.into())); 582 + } else { 583 + transformed.push(event.clone()); 584 + } 585 + } 586 + 587 + // Inline Code, i.e `something` 588 + Event::Code(code) => { 589 + if let Some(component) = &components.code { 590 + let custom_html = component.render(code.as_ref()); 591 + transformed.push(Event::Html(custom_html.into())); 592 + } else { 593 + transformed.push(event.clone()); 594 + } 595 + } 596 + 597 + // Blockquotes, i.e. > quote 598 + Event::Start(Tag::BlockQuote(kind)) => { 599 + if let Some(component) = &components.blockquote { 600 + let kind_converted = kind.as_ref().map(|k| k.into()); 601 + let custom_html = component.render_start(kind_converted); 602 + transformed.push(Event::Html(custom_html.into())); 603 + } else { 604 + transformed.push(event.clone()); 605 + } 606 + } 607 + Event::End(TagEnd::BlockQuote(kind)) => { 608 + if let Some(component) = &components.blockquote { 609 + let kind_converted = kind.as_ref().map(|k| k.into()); 610 + let custom_html = component.render_end(kind_converted); 611 + transformed.push(Event::Html(custom_html.into())); 612 + } else { 613 + transformed.push(event.clone()); 614 + } 615 + } 616 + 617 + // Hard Breaks, i.e. double spaces at the end of a line 618 + Event::HardBreak => { 619 + if let Some(component) = &components.hard_break { 620 + let custom_html = component.render(); 621 + transformed.push(Event::Html(custom_html.into())); 622 + } else { 623 + transformed.push(event.clone()); 624 + } 625 + } 626 + 627 + // Horizontal Rules, i.e. --- -> <hr /> 628 + Event::Rule => { 629 + if let Some(component) = &components.horizontal_rule { 630 + let custom_html = component.render(); 631 + transformed.push(Event::Html(custom_html.into())); 632 + } else { 633 + transformed.push(event.clone()); 634 + } 635 + } 636 + 637 + // Lists, i.e. - item 638 + Event::Start(Tag::List(first_number)) => { 639 + if let Some(component) = &components.list { 640 + let list_type = if first_number.is_some() { 641 + ListType::Ordered 642 + } else { 643 + ListType::Unordered 644 + }; 645 + let custom_html = component.render_start(list_type, *first_number); 646 + transformed.push(Event::Html(custom_html.into())); 647 + } else { 648 + transformed.push(event.clone()); 649 + } 650 + } 651 + Event::End(TagEnd::List(ordered)) => { 652 + if let Some(component) = &components.list { 653 + let list_type = if *ordered { 654 + ListType::Ordered 655 + } else { 656 + ListType::Unordered 657 + }; 658 + let custom_html = component.render_end(list_type); 659 + transformed.push(Event::Html(custom_html.into())); 660 + } else { 661 + transformed.push(event.clone()); 662 + } 663 + } 664 + 665 + // List Items, i.e. individual - item 666 + Event::Start(Tag::Item) => { 667 + if let Some(component) = &components.list_item { 668 + let custom_html = component.render_start(); 669 + transformed.push(Event::Html(custom_html.into())); 670 + } else { 671 + transformed.push(event.clone()); 672 + } 673 + } 674 + Event::End(TagEnd::Item) => { 675 + if let Some(component) = &components.list_item { 676 + let custom_html = component.render_end(); 677 + transformed.push(Event::Html(custom_html.into())); 678 + } else { 679 + transformed.push(event.clone()); 680 + } 681 + } 682 + 683 + // (GFM) Strikethrough, i.e. ~~strikethrough~~ 684 + Event::Start(Tag::Strikethrough) => { 685 + if let Some(component) = &components.strikethrough { 686 + let custom_html = component.render_start(); 687 + transformed.push(Event::Html(custom_html.into())); 688 + } else { 689 + transformed.push(event.clone()); 690 + } 691 + } 692 + Event::End(TagEnd::Strikethrough) => { 693 + if let Some(component) = &components.strikethrough { 694 + let custom_html = component.render_end(); 695 + transformed.push(Event::Html(custom_html.into())); 696 + } else { 697 + transformed.push(event.clone()); 698 + } 699 + } 700 + 701 + // (GFM) Task List Markers, i.e. - [ ] item 702 + Event::TaskListMarker(checked) => { 703 + if let Some(component) = &components.task_list_marker { 704 + let custom_html = component.render(*checked); 705 + transformed.push(Event::Html(custom_html.into())); 706 + } else { 707 + transformed.push(event.clone()); 708 + } 709 + } 710 + 711 + // (GFM) Tables, i.e. | Header | Header | 712 + // |--------|--------| 713 + // | Cell | Cell | 714 + // |--------|--------| 715 + Event::Start(Tag::Table(alignments)) => { 716 + if let Some(component) = &components.table { 717 + let alignment_vec: Vec<TableAlignment> = alignments 718 + .iter() 719 + .map(|a| match a { 720 + pulldown_cmark::Alignment::Left => TableAlignment::Left, 721 + pulldown_cmark::Alignment::Center => TableAlignment::Center, 722 + pulldown_cmark::Alignment::Right => TableAlignment::Right, 723 + pulldown_cmark::Alignment::None => TableAlignment::Left, 724 + }) 725 + .collect(); 726 + let custom_html = component.render_start(&alignment_vec); 727 + transformed.push(Event::Html(custom_html.into())); 728 + } else { 729 + transformed.push(event.clone()); 730 + } 731 + } 732 + Event::End(TagEnd::Table) => { 733 + if let Some(component) = &components.table { 734 + let custom_html = component.render_end(); 735 + transformed.push(Event::Html(custom_html.into())); 736 + } else { 737 + transformed.push(event.clone()); 738 + } 739 + } 740 + 741 + // (GFM) Table Heads, i.e. | Header | Header | 742 + Event::Start(Tag::TableHead) => { 743 + if let Some(component) = &components.table_head { 744 + let custom_html = component.render_start(); 745 + transformed.push(Event::Html(custom_html.into())); 746 + } else { 747 + transformed.push(event.clone()); 748 + } 749 + } 750 + Event::End(TagEnd::TableHead) => { 751 + if let Some(component) = &components.table_head { 752 + let custom_html = component.render_end(); 753 + transformed.push(Event::Html(custom_html.into())); 754 + } else { 755 + transformed.push(event.clone()); 756 + } 757 + } 758 + 759 + // (GFM) Table Rows, i.e. | Cell | Cell | 760 + Event::Start(Tag::TableRow) => { 761 + if let Some(component) = &components.table_row { 762 + let custom_html = component.render_start(); 763 + transformed.push(Event::Html(custom_html.into())); 764 + } else { 765 + transformed.push(event.clone()); 766 + } 767 + } 768 + Event::End(TagEnd::TableRow) => { 769 + if let Some(component) = &components.table_row { 770 + let custom_html = component.render_end(); 771 + transformed.push(Event::Html(custom_html.into())); 772 + } else { 773 + transformed.push(event.clone()); 774 + } 775 + } 776 + 777 + // (GFM) Table Cells, i.e. individual | Cell | 778 + Event::Start(Tag::TableCell) => { 779 + if let Some(component) = &components.table_cell { 780 + // For now, assume it's not a header and no specific alignment 781 + // TODO: Track context to determine if we're in a table head and column alignment 782 + let custom_html = component.render_start(false, None); 783 + transformed.push(Event::Html(custom_html.into())); 784 + } else { 785 + transformed.push(event.clone()); 786 + } 787 + } 788 + Event::End(TagEnd::TableCell) => { 789 + if let Some(component) = &components.table_cell { 790 + // TODO: Track context to determine if we're in a table head 791 + let custom_html = component.render_end(false); 792 + transformed.push(Event::Html(custom_html.into())); 793 + } else { 794 + transformed.push(event.clone()); 795 + } 796 + } 797 + 798 + // All other events pass through unchanged 799 + _ => { 800 + transformed.push(event.clone()); 801 + } 802 + } 803 + i += 1; 355 804 } 356 805 357 - pulldown_cmark::html::push_html(&mut html_output, events.into_iter()); 806 + transformed 807 + } 808 + 809 + fn find_matching_heading_end(events: &[Event], start_index: usize) -> Option<usize> { 810 + for (i, event) in events.iter().enumerate().skip(start_index + 1) { 811 + if matches!(event, Event::End(TagEnd::Heading(_))) { 812 + return Some(i); 813 + } 814 + } 815 + None 816 + } 817 + 818 + fn find_matching_image_end(events: &[Event], start_index: usize) -> Option<usize> { 819 + for (i, event) in events.iter().enumerate().skip(start_index + 1) { 820 + if matches!(event, Event::End(TagEnd::Image)) { 821 + return Some(i); 822 + } 823 + } 824 + None 825 + } 358 826 359 - html_output 827 + #[cfg(test)] 828 + mod tests { 829 + use super::*; 830 + 831 + #[test] 832 + fn test_basic_markdown_rendering() { 833 + let markdown = r#"# Hello, world! 834 + 835 + This is a **bold** text. 836 + 837 + ## Subheading 838 + 839 + More content here."#; 840 + 841 + let html = render_markdown(markdown, None); 842 + 843 + // Test basic markdown rendering 844 + assert!(html.contains("<h1")); 845 + assert!(html.contains("<h2")); 846 + assert!(html.contains("</h1>")); 847 + assert!(html.contains("</h2>")); 848 + assert!(html.contains("<strong>bold</strong>")); 849 + assert!(html.contains("Hello, world!")); 850 + assert!(html.contains("Subheading")); 851 + } 852 + 853 + #[test] 854 + fn test_rendering_with_empty_components() { 855 + let options = MarkdownOptions { 856 + components: MarkdownComponents::new(), 857 + }; 858 + let markdown = r#"# Hello, world!"#; 859 + 860 + let html = render_markdown(markdown, Some(&options)); 861 + let default_html = render_markdown(markdown, None); 862 + 863 + // Should be the same as default rendering when no custom components are provided 864 + assert_eq!(html, default_html); 865 + } 360 866 }
+932
crates/maudit/src/content/markdown/components.rs
··· 1 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 2 + pub enum BlockQuoteKind { 3 + Note, 4 + Tip, 5 + Important, 6 + Warning, 7 + Caution, 8 + } 9 + 10 + impl From<pulldown_cmark::BlockQuoteKind> for BlockQuoteKind { 11 + fn from(kind: pulldown_cmark::BlockQuoteKind) -> Self { 12 + match kind { 13 + pulldown_cmark::BlockQuoteKind::Note => BlockQuoteKind::Note, 14 + pulldown_cmark::BlockQuoteKind::Tip => BlockQuoteKind::Tip, 15 + pulldown_cmark::BlockQuoteKind::Important => BlockQuoteKind::Important, 16 + pulldown_cmark::BlockQuoteKind::Warning => BlockQuoteKind::Warning, 17 + pulldown_cmark::BlockQuoteKind::Caution => BlockQuoteKind::Caution, 18 + } 19 + } 20 + } 21 + 22 + impl From<&pulldown_cmark::BlockQuoteKind> for BlockQuoteKind { 23 + fn from(kind: &pulldown_cmark::BlockQuoteKind) -> Self { 24 + (*kind).into() 25 + } 26 + } 27 + 28 + impl BlockQuoteKind { 29 + pub fn as_str(&self) -> &'static str { 30 + match self { 31 + BlockQuoteKind::Note => "note", 32 + BlockQuoteKind::Tip => "tip", 33 + BlockQuoteKind::Important => "important", 34 + BlockQuoteKind::Warning => "warning", 35 + BlockQuoteKind::Caution => "caution", 36 + } 37 + } 38 + } 39 + 40 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 41 + pub enum LinkType { 42 + Inline, 43 + Reference, 44 + ReferenceUnknown, 45 + Collapsed, 46 + CollapsedUnknown, 47 + Shortcut, 48 + ShortcutUnknown, 49 + Autolink, 50 + Email, 51 + } 52 + 53 + impl From<pulldown_cmark::LinkType> for LinkType { 54 + fn from(link_type: pulldown_cmark::LinkType) -> Self { 55 + match link_type { 56 + pulldown_cmark::LinkType::Inline => LinkType::Inline, 57 + pulldown_cmark::LinkType::Reference => LinkType::Reference, 58 + pulldown_cmark::LinkType::ReferenceUnknown => LinkType::ReferenceUnknown, 59 + pulldown_cmark::LinkType::Collapsed => LinkType::Collapsed, 60 + pulldown_cmark::LinkType::CollapsedUnknown => LinkType::CollapsedUnknown, 61 + pulldown_cmark::LinkType::Shortcut => LinkType::Shortcut, 62 + pulldown_cmark::LinkType::ShortcutUnknown => LinkType::ShortcutUnknown, 63 + pulldown_cmark::LinkType::Autolink => LinkType::Autolink, 64 + pulldown_cmark::LinkType::Email => LinkType::Email, 65 + } 66 + } 67 + } 68 + 69 + impl From<&pulldown_cmark::LinkType> for LinkType { 70 + fn from(link_type: &pulldown_cmark::LinkType) -> Self { 71 + (*link_type).into() 72 + } 73 + } 74 + 75 + impl LinkType { 76 + pub fn as_str(&self) -> &'static str { 77 + match self { 78 + LinkType::Inline => "inline", 79 + LinkType::Reference => "reference", 80 + LinkType::ReferenceUnknown => "reference_unknown", 81 + LinkType::Collapsed => "collapsed", 82 + LinkType::CollapsedUnknown => "collapsed_unknown", 83 + LinkType::Shortcut => "shortcut", 84 + LinkType::ShortcutUnknown => "shortcut_unknown", 85 + LinkType::Autolink => "autolink", 86 + LinkType::Email => "email", 87 + } 88 + } 89 + } 90 + 91 + pub trait HeadingComponent { 92 + fn render_start(&self, level: u8, id: Option<&str>, classes: &[&str]) -> String { 93 + let class_attr = if !classes.is_empty() { 94 + format!(" class=\"{}\"", classes.join(" ")) 95 + } else { 96 + String::new() 97 + }; 98 + 99 + let id_attr = id 100 + .as_ref() 101 + .map(|i| format!(" id=\"{}\"", i)) 102 + .unwrap_or_default(); 103 + 104 + format!("<h{}{}{}>", level, id_attr, class_attr) 105 + } 106 + 107 + fn render_end(&self, level: u8) -> String { 108 + format!("</h{}>", level) 109 + } 110 + } 111 + 112 + pub trait ParagraphComponent { 113 + fn render_start(&self) -> String { 114 + "<p>".to_string() 115 + } 116 + 117 + fn render_end(&self) -> String { 118 + "</p>".to_string() 119 + } 120 + } 121 + 122 + pub trait LinkComponent { 123 + fn render_start(&self, url: &str, title: Option<&str>, link_type: LinkType) -> String; 124 + 125 + fn render_end(&self) -> String { 126 + "</a>".to_string() 127 + } 128 + } 129 + 130 + pub trait ImageComponent { 131 + fn render(&self, url: &str, alt: &str, title: Option<&str>) -> String; 132 + } 133 + 134 + pub trait StrongComponent { 135 + fn render_start(&self) -> String { 136 + "<strong>".to_string() 137 + } 138 + 139 + fn render_end(&self) -> String { 140 + "</strong>".to_string() 141 + } 142 + } 143 + 144 + pub trait EmphasisComponent { 145 + fn render_start(&self) -> String { 146 + "<em>".to_string() 147 + } 148 + 149 + fn render_end(&self) -> String { 150 + "</em>".to_string() 151 + } 152 + } 153 + 154 + pub trait CodeComponent { 155 + fn render(&self, code: &str) -> String; 156 + } 157 + 158 + pub trait BlockquoteComponent { 159 + fn render_start(&self, kind: Option<BlockQuoteKind>) -> String { 160 + match kind { 161 + Some(k) => format!("<blockquote data-kind=\"{}\">", k.as_str()), 162 + None => "<blockquote>".to_string(), 163 + } 164 + } 165 + 166 + fn render_end(&self, _kind: Option<BlockQuoteKind>) -> String { 167 + "</blockquote>".to_string() 168 + } 169 + } 170 + 171 + pub trait HardBreakComponent { 172 + fn render(&self) -> String { 173 + "<br />".to_string() 174 + } 175 + } 176 + 177 + pub trait HorizontalRuleComponent { 178 + fn render(&self) -> String { 179 + "<hr />".to_string() 180 + } 181 + } 182 + 183 + pub trait ListComponent { 184 + fn render_start(&self, list_type: ListType, start_number: Option<u64>) -> String { 185 + match list_type { 186 + ListType::Ordered => { 187 + if let Some(start) = start_number { 188 + if start != 1 { 189 + format!("<ol start=\"{}\">", start) 190 + } else { 191 + "<ol>".to_string() 192 + } 193 + } else { 194 + "<ol>".to_string() 195 + } 196 + } 197 + ListType::Unordered => "<ul>".to_string(), 198 + } 199 + } 200 + 201 + fn render_end(&self, list_type: ListType) -> String { 202 + match list_type { 203 + ListType::Ordered => "</ol>".to_string(), 204 + ListType::Unordered => "</ul>".to_string(), 205 + } 206 + } 207 + } 208 + 209 + pub trait ListItemComponent { 210 + fn render_start(&self) -> String { 211 + "<li>".to_string() 212 + } 213 + 214 + fn render_end(&self) -> String { 215 + "</li>".to_string() 216 + } 217 + } 218 + 219 + pub trait StrikethroughComponent { 220 + fn render_start(&self) -> String { 221 + "<del>".to_string() 222 + } 223 + 224 + fn render_end(&self) -> String { 225 + "</del>".to_string() 226 + } 227 + } 228 + 229 + pub trait TaskListMarkerComponent { 230 + fn render(&self, checked: bool) -> String { 231 + if checked { 232 + "<input type=\"checkbox\" checked disabled />".to_string() 233 + } else { 234 + "<input type=\"checkbox\" disabled />".to_string() 235 + } 236 + } 237 + } 238 + 239 + pub trait TableComponent { 240 + fn render_start(&self, _column_alignments: &[TableAlignment]) -> String { 241 + "<table>".to_string() 242 + } 243 + 244 + fn render_end(&self) -> String { 245 + "</table>".to_string() 246 + } 247 + } 248 + 249 + pub trait TableHeadComponent { 250 + fn render_start(&self) -> String { 251 + "<thead>".to_string() 252 + } 253 + 254 + fn render_end(&self) -> String { 255 + "</thead>".to_string() 256 + } 257 + } 258 + 259 + pub trait TableRowComponent { 260 + fn render_start(&self) -> String { 261 + "<tr>".to_string() 262 + } 263 + 264 + fn render_end(&self) -> String { 265 + "</tr>".to_string() 266 + } 267 + } 268 + 269 + pub trait TableCellComponent { 270 + fn render_start(&self, is_header: bool, alignment: Option<TableAlignment>) -> String { 271 + let tag = if is_header { "th" } else { "td" }; 272 + match alignment { 273 + Some(TableAlignment::Left) => format!("<{} style=\"text-align: left\">", tag), 274 + Some(TableAlignment::Center) => format!("<{} style=\"text-align: center\">", tag), 275 + Some(TableAlignment::Right) => format!("<{} style=\"text-align: right\">", tag), 276 + None => format!("<{}>", tag), 277 + } 278 + } 279 + 280 + fn render_end(&self, is_header: bool) -> String { 281 + if is_header { 282 + "</th>".to_string() 283 + } else { 284 + "</td>".to_string() 285 + } 286 + } 287 + } 288 + 289 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 290 + pub enum ListType { 291 + Ordered, 292 + Unordered, 293 + } 294 + 295 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 296 + pub enum TableAlignment { 297 + Left, 298 + Center, 299 + Right, 300 + } 301 + 302 + #[derive(Default)] 303 + pub struct MarkdownComponents { 304 + pub heading: Option<Box<dyn HeadingComponent + Send + Sync>>, 305 + pub paragraph: Option<Box<dyn ParagraphComponent + Send + Sync>>, 306 + pub link: Option<Box<dyn LinkComponent + Send + Sync>>, 307 + pub image: Option<Box<dyn ImageComponent + Send + Sync>>, 308 + pub strong: Option<Box<dyn StrongComponent + Send + Sync>>, 309 + pub emphasis: Option<Box<dyn EmphasisComponent + Send + Sync>>, 310 + pub code: Option<Box<dyn CodeComponent + Send + Sync>>, 311 + pub blockquote: Option<Box<dyn BlockquoteComponent + Send + Sync>>, 312 + pub hard_break: Option<Box<dyn HardBreakComponent + Send + Sync>>, 313 + pub horizontal_rule: Option<Box<dyn HorizontalRuleComponent + Send + Sync>>, 314 + pub list: Option<Box<dyn ListComponent + Send + Sync>>, 315 + pub list_item: Option<Box<dyn ListItemComponent + Send + Sync>>, 316 + pub strikethrough: Option<Box<dyn StrikethroughComponent + Send + Sync>>, 317 + pub task_list_marker: Option<Box<dyn TaskListMarkerComponent + Send + Sync>>, 318 + pub table: Option<Box<dyn TableComponent + Send + Sync>>, 319 + pub table_head: Option<Box<dyn TableHeadComponent + Send + Sync>>, 320 + pub table_row: Option<Box<dyn TableRowComponent + Send + Sync>>, 321 + pub table_cell: Option<Box<dyn TableCellComponent + Send + Sync>>, 322 + } 323 + 324 + impl MarkdownComponents { 325 + pub fn new() -> Self { 326 + Self::default() 327 + } 328 + 329 + pub fn has_any_components(&self) -> bool { 330 + self.heading.is_some() 331 + || self.paragraph.is_some() 332 + || self.link.is_some() 333 + || self.image.is_some() 334 + || self.strong.is_some() 335 + || self.emphasis.is_some() 336 + || self.code.is_some() 337 + || self.blockquote.is_some() 338 + || self.hard_break.is_some() 339 + || self.horizontal_rule.is_some() 340 + || self.list.is_some() 341 + || self.list_item.is_some() 342 + || self.strikethrough.is_some() 343 + || self.task_list_marker.is_some() 344 + || self.table.is_some() 345 + || self.table_head.is_some() 346 + || self.table_row.is_some() 347 + || self.table_cell.is_some() 348 + } 349 + 350 + /// Set a custom heading component 351 + pub fn heading<C: HeadingComponent + Send + Sync + 'static>(mut self, component: C) -> Self { 352 + self.heading = Some(Box::new(component)); 353 + self 354 + } 355 + 356 + /// Set a custom paragraph component 357 + pub fn paragraph<C: ParagraphComponent + Send + Sync + 'static>( 358 + mut self, 359 + component: C, 360 + ) -> Self { 361 + self.paragraph = Some(Box::new(component)); 362 + self 363 + } 364 + 365 + /// Set a custom link component 366 + pub fn link<C: LinkComponent + Send + Sync + 'static>(mut self, component: C) -> Self { 367 + self.link = Some(Box::new(component)); 368 + self 369 + } 370 + 371 + /// Set a custom image component 372 + pub fn image<C: ImageComponent + Send + Sync + 'static>(mut self, component: C) -> Self { 373 + self.image = Some(Box::new(component)); 374 + self 375 + } 376 + 377 + /// Set a custom strong component 378 + pub fn strong<C: StrongComponent + Send + Sync + 'static>(mut self, component: C) -> Self { 379 + self.strong = Some(Box::new(component)); 380 + self 381 + } 382 + 383 + /// Set a custom emphasis component 384 + pub fn emphasis<C: EmphasisComponent + Send + Sync + 'static>(mut self, component: C) -> Self { 385 + self.emphasis = Some(Box::new(component)); 386 + self 387 + } 388 + 389 + /// Set a custom code component 390 + pub fn code<C: CodeComponent + Send + Sync + 'static>(mut self, component: C) -> Self { 391 + self.code = Some(Box::new(component)); 392 + self 393 + } 394 + 395 + /// Set a custom blockquote component 396 + pub fn blockquote<C: BlockquoteComponent + Send + Sync + 'static>( 397 + mut self, 398 + component: C, 399 + ) -> Self { 400 + self.blockquote = Some(Box::new(component)); 401 + self 402 + } 403 + 404 + /// Set a custom hard break component 405 + pub fn hard_break<C: HardBreakComponent + Send + Sync + 'static>( 406 + mut self, 407 + component: C, 408 + ) -> Self { 409 + self.hard_break = Some(Box::new(component)); 410 + self 411 + } 412 + 413 + /// Set a custom horizontal rule component 414 + pub fn horizontal_rule<C: HorizontalRuleComponent + Send + Sync + 'static>( 415 + mut self, 416 + component: C, 417 + ) -> Self { 418 + self.horizontal_rule = Some(Box::new(component)); 419 + self 420 + } 421 + 422 + /// Set a custom list component 423 + pub fn list<C: ListComponent + Send + Sync + 'static>(mut self, component: C) -> Self { 424 + self.list = Some(Box::new(component)); 425 + self 426 + } 427 + 428 + /// Set a custom list item component 429 + pub fn list_item<C: ListItemComponent + Send + Sync + 'static>(mut self, component: C) -> Self { 430 + self.list_item = Some(Box::new(component)); 431 + self 432 + } 433 + 434 + /// Set a custom strikethrough component 435 + pub fn strikethrough<C: StrikethroughComponent + Send + Sync + 'static>( 436 + mut self, 437 + component: C, 438 + ) -> Self { 439 + self.strikethrough = Some(Box::new(component)); 440 + self 441 + } 442 + 443 + /// Set a custom task list marker component 444 + pub fn task_list_marker<C: TaskListMarkerComponent + Send + Sync + 'static>( 445 + mut self, 446 + component: C, 447 + ) -> Self { 448 + self.task_list_marker = Some(Box::new(component)); 449 + self 450 + } 451 + 452 + /// Set a custom table component 453 + pub fn table<C: TableComponent + Send + Sync + 'static>(mut self, component: C) -> Self { 454 + self.table = Some(Box::new(component)); 455 + self 456 + } 457 + 458 + /// Set a custom table head component 459 + pub fn table_head<C: TableHeadComponent + Send + Sync + 'static>( 460 + mut self, 461 + component: C, 462 + ) -> Self { 463 + self.table_head = Some(Box::new(component)); 464 + self 465 + } 466 + 467 + /// Set a custom table row component 468 + pub fn table_row<C: TableRowComponent + Send + Sync + 'static>(mut self, component: C) -> Self { 469 + self.table_row = Some(Box::new(component)); 470 + self 471 + } 472 + 473 + /// Set a custom table cell component 474 + pub fn table_cell<C: TableCellComponent + Send + Sync + 'static>( 475 + mut self, 476 + component: C, 477 + ) -> Self { 478 + self.table_cell = Some(Box::new(component)); 479 + self 480 + } 481 + } 482 + 483 + #[cfg(test)] 484 + mod tests { 485 + use super::*; 486 + use crate::content::{render_markdown, MarkdownOptions}; 487 + 488 + struct TestCustomHeading; 489 + 490 + impl HeadingComponent for TestCustomHeading { 491 + fn render_start(&self, level: u8, id: Option<&str>, classes: &[&str]) -> String { 492 + let id_attr = id.map(|i| format!(" id=\"{}\"", i)).unwrap_or_default(); 493 + let class_attr = if classes.is_empty() { 494 + String::new() 495 + } else { 496 + format!(" class=\"{}\"", classes.join(" ")) 497 + }; 498 + format!("<h{}{}{}>🎯", level, id_attr, class_attr) 499 + } 500 + 501 + fn render_end(&self, level: u8) -> String { 502 + format!("</h{}>", level) 503 + } 504 + } 505 + 506 + struct TestCustomParagraph; 507 + 508 + impl ParagraphComponent for TestCustomParagraph { 509 + fn render_start(&self) -> String { 510 + "<p class=\"custom-paragraph\">".to_string() 511 + } 512 + 513 + fn render_end(&self) -> String { 514 + "</p><!-- end custom paragraph -->".to_string() 515 + } 516 + } 517 + 518 + struct TestCustomLink; 519 + 520 + impl LinkComponent for TestCustomLink { 521 + fn render_start(&self, url: &str, title: Option<&str>, _link_type: LinkType) -> String { 522 + let title_attr = title 523 + .map(|t| format!(" title=\"{}\"", t)) 524 + .unwrap_or_default(); 525 + format!("<a href=\"{}\" class=\"custom-link\"{}>🔗", url, title_attr) 526 + } 527 + 528 + fn render_end(&self) -> String { 529 + "</a>".to_string() 530 + } 531 + } 532 + 533 + struct TestCustomImage; 534 + 535 + impl ImageComponent for TestCustomImage { 536 + fn render(&self, url: &str, alt: &str, title: Option<&str>) -> String { 537 + let title_attr = title 538 + .map(|t| format!(" title=\"{}\"", t)) 539 + .unwrap_or_default(); 540 + format!( 541 + "<img src=\"{}\" alt=\"{}\" class=\"custom-image\"{} />📸", 542 + url, alt, title_attr 543 + ) 544 + } 545 + } 546 + 547 + struct TestCustomStrong; 548 + 549 + impl StrongComponent for TestCustomStrong { 550 + fn render_start(&self) -> String { 551 + "<strong class=\"custom-strong\">💪".to_string() 552 + } 553 + 554 + fn render_end(&self) -> String { 555 + "</strong>".to_string() 556 + } 557 + } 558 + 559 + struct TestCustomEmphasis; 560 + 561 + impl EmphasisComponent for TestCustomEmphasis { 562 + fn render_start(&self) -> String { 563 + "<em class=\"custom-emphasis\">✨".to_string() 564 + } 565 + 566 + fn render_end(&self) -> String { 567 + "</em>".to_string() 568 + } 569 + } 570 + 571 + struct TestCustomCode; 572 + 573 + impl CodeComponent for TestCustomCode { 574 + fn render(&self, code: &str) -> String { 575 + format!("<code class=\"custom-code\">💻{}</code>", code) 576 + } 577 + } 578 + 579 + struct TestCustomBlockquote; 580 + 581 + impl BlockquoteComponent for TestCustomBlockquote { 582 + fn render_start(&self, kind: Option<BlockQuoteKind>) -> String { 583 + match kind { 584 + Some(k) => format!( 585 + "<blockquote class=\"custom-blockquote {}\" data-kind=\"{}\">📝", 586 + k.as_str(), 587 + k.as_str() 588 + ), 589 + None => "<blockquote class=\"custom-blockquote\">📝".to_string(), 590 + } 591 + } 592 + 593 + fn render_end(&self, _kind: Option<BlockQuoteKind>) -> String { 594 + "</blockquote>".to_string() 595 + } 596 + } 597 + 598 + #[test] 599 + fn test_components_builder_pattern() { 600 + let components = MarkdownComponents::new().heading(TestCustomHeading); 601 + 602 + assert!(components.heading.is_some()); 603 + assert!(components.paragraph.is_none()); 604 + assert!(components.link.is_none()); 605 + } 606 + 607 + #[test] 608 + fn test_has_any_components() { 609 + let empty_components = MarkdownComponents::new(); 610 + assert!(!empty_components.has_any_components()); 611 + 612 + let with_heading = MarkdownComponents::new().heading(TestCustomHeading); 613 + assert!(with_heading.has_any_components()); 614 + } 615 + 616 + #[test] 617 + fn test_custom_heading_component() { 618 + let options = MarkdownOptions { 619 + components: MarkdownComponents::new().heading(TestCustomHeading), 620 + }; 621 + 622 + let html = render_markdown("# Hello, world!", Some(&options)); 623 + assert!(html.contains("🎯Hello, world!")); 624 + } 625 + 626 + #[test] 627 + fn test_custom_paragraph_component() { 628 + let options = MarkdownOptions { 629 + components: MarkdownComponents::new().paragraph(TestCustomParagraph), 630 + }; 631 + 632 + let content = render_markdown("This is a paragraph.", Some(&options)); 633 + assert!(content.contains( 634 + "<p class=\"custom-paragraph\">This is a paragraph.</p><!-- end custom paragraph -->" 635 + )); 636 + } 637 + 638 + #[test] 639 + fn test_custom_link_component() { 640 + let options = MarkdownOptions { 641 + components: MarkdownComponents::new().link(TestCustomLink), 642 + }; 643 + 644 + let content = render_markdown("[Example](https://example.com)", Some(&options)); 645 + assert!( 646 + content.contains("<a href=\"https://example.com\" class=\"custom-link\">🔗Example</a>") 647 + ); 648 + } 649 + 650 + #[test] 651 + fn test_custom_image_component() { 652 + let options = MarkdownOptions { 653 + components: MarkdownComponents::new().image(TestCustomImage), 654 + }; 655 + 656 + let content = render_markdown("![Alt text](image.jpg)", Some(&options)); 657 + assert!( 658 + content.contains("<img src=\"image.jpg\" alt=\"Alt text\" class=\"custom-image\" />📸") 659 + ); 660 + } 661 + 662 + #[test] 663 + fn test_custom_strong_component() { 664 + let options = MarkdownOptions { 665 + components: MarkdownComponents::new().strong(TestCustomStrong), 666 + }; 667 + 668 + let content = render_markdown("**Bold text**", Some(&options)); 669 + assert!(content.contains("<strong class=\"custom-strong\">💪Bold text</strong>")); 670 + } 671 + 672 + #[test] 673 + fn test_custom_emphasis_component() { 674 + let options = MarkdownOptions { 675 + components: MarkdownComponents::new().emphasis(TestCustomEmphasis), 676 + }; 677 + 678 + let content = render_markdown("*Italic text*", Some(&options)); 679 + assert!(content.contains("<em class=\"custom-emphasis\">✨Italic text</em>")); 680 + } 681 + 682 + #[test] 683 + fn test_custom_code_component() { 684 + let options = MarkdownOptions { 685 + components: MarkdownComponents::new().code(TestCustomCode), 686 + }; 687 + 688 + let content = render_markdown("`console.log('hello')`", Some(&options)); 689 + assert!(content.contains("<code class=\"custom-code\">💻console.log('hello')</code>")); 690 + } 691 + 692 + #[test] 693 + fn test_custom_blockquote_component() { 694 + let options = MarkdownOptions { 695 + components: MarkdownComponents::new().blockquote(TestCustomBlockquote), 696 + }; 697 + 698 + let content = render_markdown("> This is a quote", Some(&options)); 699 + assert!(content.contains("<blockquote class=\"custom-blockquote\">📝")); 700 + assert!(content.contains("</blockquote>")); 701 + assert!(content.contains("This is a quote")); 702 + } 703 + 704 + #[test] 705 + fn test_multiple_custom_components() { 706 + let options = MarkdownOptions { 707 + components: MarkdownComponents::new() 708 + .heading(TestCustomHeading) 709 + .paragraph(TestCustomParagraph) 710 + .link(TestCustomLink) 711 + .strong(TestCustomStrong), 712 + }; 713 + 714 + let content = render_markdown( 715 + "# Title\n\nThis is a **bold** [link](https://example.com).", 716 + Some(&options), 717 + ); 718 + 719 + assert!(content.contains("🎯Title")); 720 + assert!(content.contains("<p class=\"custom-paragraph\">")); 721 + assert!(content.contains("<strong class=\"custom-strong\">💪bold</strong>")); 722 + assert!( 723 + content.contains("<a href=\"https://example.com\" class=\"custom-link\">🔗link</a>") 724 + ); 725 + } 726 + 727 + #[test] 728 + fn test_nested_components() { 729 + let options = MarkdownOptions { 730 + components: MarkdownComponents::new() 731 + .blockquote(TestCustomBlockquote) 732 + .strong(TestCustomStrong) 733 + .emphasis(TestCustomEmphasis) 734 + .code(TestCustomCode), 735 + }; 736 + 737 + let content = render_markdown( 738 + "> This is a **bold** and *italic* with `code`", 739 + Some(&options), 740 + ); 741 + assert!(content.contains("<blockquote class=\"custom-blockquote\">📝")); 742 + assert!(content.contains("<strong class=\"custom-strong\">💪bold</strong>")); 743 + assert!(content.contains("<em class=\"custom-emphasis\">✨italic</em>")); 744 + assert!(content.contains("<code class=\"custom-code\">💻code</code>")); 745 + assert!(content.contains("</blockquote>")); 746 + } 747 + 748 + struct TestHardBreak; 749 + impl HardBreakComponent for TestHardBreak { 750 + fn render(&self) -> String { 751 + "<br class=\"custom-break\" />".to_string() 752 + } 753 + } 754 + 755 + struct TestHorizontalRule; 756 + impl HorizontalRuleComponent for TestHorizontalRule { 757 + fn render(&self) -> String { 758 + "<hr class=\"custom-rule\" />".to_string() 759 + } 760 + } 761 + 762 + struct TestList; 763 + impl ListComponent for TestList { 764 + fn render_start(&self, list_type: ListType, start_number: Option<u64>) -> String { 765 + match list_type { 766 + ListType::Ordered => format!( 767 + "<ol class=\"custom-list\" start=\"{}\">", 768 + start_number.unwrap_or(1) 769 + ), 770 + ListType::Unordered => "<ul class=\"custom-list\">".to_string(), 771 + } 772 + } 773 + fn render_end(&self, list_type: ListType) -> String { 774 + match list_type { 775 + ListType::Ordered => "</ol>".to_string(), 776 + ListType::Unordered => "</ul>".to_string(), 777 + } 778 + } 779 + } 780 + 781 + struct TestListItem; 782 + impl ListItemComponent for TestListItem { 783 + fn render_start(&self) -> String { 784 + "<li class=\"custom-item\">".to_string() 785 + } 786 + fn render_end(&self) -> String { 787 + "</li>".to_string() 788 + } 789 + } 790 + 791 + struct TestStrikethrough; 792 + impl StrikethroughComponent for TestStrikethrough { 793 + fn render_start(&self) -> String { 794 + "<del class=\"custom-strike\">".to_string() 795 + } 796 + fn render_end(&self) -> String { 797 + "</del>".to_string() 798 + } 799 + } 800 + 801 + struct TestTaskListMarker; 802 + impl TaskListMarkerComponent for TestTaskListMarker { 803 + fn render(&self, checked: bool) -> String { 804 + if checked { 805 + "<input type=\"checkbox\" checked class=\"custom-task\" />" 806 + } else { 807 + "<input type=\"checkbox\" class=\"custom-task\" />" 808 + } 809 + .to_string() 810 + } 811 + } 812 + 813 + struct TestTable; 814 + impl TableComponent for TestTable { 815 + fn render_start(&self, _alignments: &[TableAlignment]) -> String { 816 + "<table class=\"custom-table\">".to_string() 817 + } 818 + fn render_end(&self) -> String { 819 + "</table>".to_string() 820 + } 821 + } 822 + 823 + struct TestTableHead; 824 + impl TableHeadComponent for TestTableHead { 825 + fn render_start(&self) -> String { 826 + "<thead class=\"custom-thead\">".to_string() 827 + } 828 + fn render_end(&self) -> String { 829 + "</thead>".to_string() 830 + } 831 + } 832 + 833 + struct TestTableRow; 834 + impl TableRowComponent for TestTableRow { 835 + fn render_start(&self) -> String { 836 + "<tr class=\"custom-row\">".to_string() 837 + } 838 + fn render_end(&self) -> String { 839 + "</tr>".to_string() 840 + } 841 + } 842 + 843 + struct TestTableCell; 844 + impl TableCellComponent for TestTableCell { 845 + fn render_start(&self, is_header: bool, alignment: Option<TableAlignment>) -> String { 846 + let tag = if is_header { "th" } else { "td" }; 847 + let align = match alignment { 848 + Some(TableAlignment::Left) => " style=\"text-align: left\"", 849 + Some(TableAlignment::Center) => " style=\"text-align: center\"", 850 + Some(TableAlignment::Right) => " style=\"text-align: right\"", 851 + None => "", 852 + }; 853 + format!("<{} class=\"custom-cell\"{}>", tag, align) 854 + } 855 + fn render_end(&self, is_header: bool) -> String { 856 + if is_header { 857 + "</th>".to_string() 858 + } else { 859 + "</td>".to_string() 860 + } 861 + } 862 + } 863 + 864 + #[test] 865 + fn test_hard_break_component() { 866 + let options = MarkdownOptions { 867 + components: MarkdownComponents::new().hard_break(TestHardBreak), 868 + }; 869 + let content = render_markdown("Line 1 \nLine 2", Some(&options)); 870 + assert!(content.contains("<br class=\"custom-break\" />")); 871 + } 872 + 873 + #[test] 874 + fn test_horizontal_rule_component() { 875 + let options = MarkdownOptions { 876 + components: MarkdownComponents::new().horizontal_rule(TestHorizontalRule), 877 + }; 878 + let content = render_markdown("---", Some(&options)); 879 + assert!(content.contains("<hr class=\"custom-rule\" />")); 880 + } 881 + 882 + #[test] 883 + fn test_list_components() { 884 + let options = MarkdownOptions { 885 + components: MarkdownComponents::new() 886 + .list(TestList) 887 + .list_item(TestListItem), 888 + }; 889 + let content = render_markdown("1. First\n2. Second\n\n- Bullet\n- Point", Some(&options)); 890 + assert!(content.contains("<ol class=\"custom-list\" start=\"1\">")); 891 + assert!(content.contains("<ul class=\"custom-list\">")); 892 + assert!(content.contains("<li class=\"custom-item\">")); 893 + } 894 + 895 + #[test] 896 + fn test_strikethrough_component() { 897 + let options = MarkdownOptions { 898 + components: MarkdownComponents::new().strikethrough(TestStrikethrough), 899 + }; 900 + let content = render_markdown("~~strikethrough~~", Some(&options)); 901 + assert!(content.contains("<del class=\"custom-strike\">")); 902 + } 903 + 904 + #[test] 905 + fn test_task_list_component() { 906 + let options = MarkdownOptions { 907 + components: MarkdownComponents::new().task_list_marker(TestTaskListMarker), 908 + }; 909 + let content = render_markdown("- [x] Done\n- [ ] Todo", Some(&options)); 910 + assert!(content.contains("<input type=\"checkbox\" checked class=\"custom-task\" />")); 911 + assert!(content.contains("<input type=\"checkbox\" class=\"custom-task\" />")); 912 + } 913 + 914 + #[test] 915 + fn test_table_components() { 916 + let options = MarkdownOptions { 917 + components: MarkdownComponents::new() 918 + .table(TestTable) 919 + .table_head(TestTableHead) 920 + .table_row(TestTableRow) 921 + .table_cell(TestTableCell), 922 + }; 923 + let content = render_markdown( 924 + "| Header | Header |\n|--------|--------|\n| Cell | Cell |", 925 + Some(&options), 926 + ); 927 + assert!(content.contains("<table class=\"custom-table\">")); 928 + assert!(content.contains("<thead class=\"custom-thead\">")); 929 + assert!(content.contains("<tr class=\"custom-row\">")); 930 + assert!(content.contains("<td class=\"custom-cell\">")); 931 + } 932 + }
+3 -3
crates/maudit/src/lib.rs
··· 123 123 /// coronate( 124 124 /// routes![], 125 125 /// content_sources![ 126 - /// "articles" => glob_markdown::<ArticleContent>("content/articles/*.md") 126 + /// "articles" => glob_markdown::<ArticleContent>("content/articles/*.md", None) 127 127 /// ], 128 128 /// BuildOptions::default(), 129 129 /// ) ··· 141 141 /// # } 142 142 /// 143 143 /// content_sources![ 144 - /// "articles" => glob_markdown::<ArticleContent>("content/articles/*.md") 144 + /// "articles" => glob_markdown::<ArticleContent>("content/articles/*.md", None) 145 145 /// ]; 146 146 /// ``` 147 147 /// expands to ··· 154 154 /// # } 155 155 /// 156 156 /// maudit::content::ContentSources(vec![ 157 - /// Box::new(maudit::content::ContentSource::new("articles", Box::new(move || glob_markdown::<ArticleContent>("content/articles/*.md")))) 157 + /// Box::new(maudit::content::ContentSource::new("articles", Box::new(move || glob_markdown::<ArticleContent>("content/articles/*.md", None)))) 158 158 /// ]); 159 159 #[macro_export] 160 160 macro_rules! content_sources {
+1 -1
crates/oubli/src/lib.rs
··· 60 60 stringify!($ident), 61 61 Box::new({ 62 62 let glob = $glob.to_string(); 63 - move || maudit::content::glob_markdown::<oubli::archetypes::blog::BlogEntryContent>(&glob) 63 + move || maudit::content::glob_markdown::<oubli::archetypes::blog::BlogEntryContent>(&glob, None) 64 64 }), 65 65 ); 66 66 // Generate the pages
+1 -1
examples/blog/src/main.rs
··· 16 16 coronate( 17 17 routes![pages::Index, pages::Article], 18 18 content_sources![ 19 - "articles" => glob_markdown::<ArticleContent>("content/articles/*.md") 19 + "articles" => glob_markdown::<ArticleContent>("content/articles/*.md", None) 20 20 ], 21 21 BuildOptions::default(), 22 22 )
+13
examples/markdown-components/Cargo.toml
··· 1 + [package] 2 + name = "maudit-example-markdown-components" 3 + version = "0.1.0" 4 + edition = "2021" 5 + publish = false 6 + 7 + [package.metadata.maudit] 8 + intended_version = "0.2.0" 9 + 10 + [dependencies] 11 + maudit = { workspace = true } 12 + maud = "0.26.0" 13 + serde = { workspace = true }
+123
examples/markdown-components/content/showcase.md
··· 1 + # Main Heading 2 + 3 + Welcome to the **Maudit Custom Components** showcase! This example demonstrates _all_ the available markdown components with custom styling. 4 + 5 + ## Text Formatting 6 + 7 + Here's some **bold text** and _italic text_ and `inline code`. You can also use ~~strikethrough text~~ for corrections. 8 + 9 + Let's also test a hard break here: 10 + This line comes after a hard break. 11 + 12 + ## Links and Images 13 + 14 + Here are different types of links: 15 + 16 + - [Internal link](/about) 17 + - [External link](https://github.com/maudit-org/maudit) 18 + - [Link with title](https://example.com "Example Website") 19 + 20 + ![Example image](https://placehold.co/600x300 "A placeholder image") 21 + 22 + ## Lists 23 + 24 + ### Unordered List 25 + 26 + - First item 27 + - Second item with **bold text** 28 + - Third item with _italic text_ 29 + - Fourth item with `inline code` 30 + 31 + ### Ordered List 32 + 33 + 1. First numbered item 34 + 2. Second numbered item 35 + 3. Third numbered item with [an external link](https://example.com) 36 + 37 + ### Task List 38 + 39 + - [x] Completed task 40 + - [ ] Incomplete task 41 + - [x] Another completed task 42 + - [ ] Another incomplete task 43 + 44 + ## Blockquotes 45 + 46 + Here's a regular blockquote: 47 + 48 + > This is a regular blockquote with some text that spans multiple lines and contains **bold** and _italic_ text. 49 + 50 + And here are GitHub-style blockquotes: 51 + 52 + > [!NOTE] 53 + > This is a note blockquote with important information. 54 + 55 + > [!TIP] 56 + > This is a tip blockquote with helpful advice. 57 + 58 + > [!IMPORTANT] 59 + > This is an important blockquote that you should pay attention to. 60 + 61 + > [!WARNING] 62 + > This is a warning blockquote about potential issues. 63 + 64 + > [!CAUTION] 65 + > This is a caution blockquote about dangerous operations. 66 + 67 + ## Code Blocks 68 + 69 + Here's some inline `code` and here's a code block: 70 + 71 + ```rs 72 + fn main() { 73 + println!("Hello, world!"); 74 + let numbers = vec![1, 2, 3, 4, 5]; 75 + for number in numbers { 76 + println!("Number: {}", number); 77 + } 78 + } 79 + ``` 80 + 81 + In Maudit 0.3.0, code blocks do not unfortunately allow for custom components. 82 + 83 + ## Tables 84 + 85 + | Header 1 | Header 2 | Header 3 | 86 + | ------------ | :------------------------------: | --------------: | 87 + | Left aligned | Center aligned | Right aligned | 88 + | Data 1 | Data 2 | Data 3 | 89 + | More data | **Bold data** | _Italic data_ | 90 + | `Code data` | [Link data](https://example.com) | ~~Strike data~~ | 91 + 92 + --- 93 + 94 + ## Horizontal Rule 95 + 96 + The line above is a horizontal rule that separates sections. 97 + 98 + ## Nested Elements 99 + 100 + Here's a complex example with nested formatting: 101 + 102 + 1. **First item** with _italic_ and `code` 103 + 2. Second item with [an external link](https://example.com) and ~~strikethrough~~ 104 + - Nested unordered item 105 + - Another nested item with **bold** text 106 + 3. Third item with an image: ![Small image](https://placehold.co/100x100) 107 + 108 + ### Complex Blockquote 109 + 110 + > This blockquote contains a list with: 111 + > 112 + > - **Bold text** 113 + > - _Italic text_ 114 + > - `Inline code` 115 + > - [A link](https://example.com) 116 + 117 + ## All Together 118 + 119 + This section combines **all** the _components_ in `one` ~~paragraph~~ to show how they work together with [links](https://example.com) and hard breaks: 120 + New line after break! 121 + 122 + > [!TIP] 123 + > You can combine all these components to create rich, interactive markdown content with custom styling!
+285
examples/markdown-components/src/components.rs
··· 1 + use maudit::content::markdown::components::*; 2 + 3 + // Custom heading component that adds icons and anchor links 4 + pub struct CustomHeading; 5 + 6 + impl HeadingComponent for CustomHeading { 7 + fn render_start(&self, level: u8, id: Option<&str>, classes: &[&str]) -> String { 8 + let id_attr = id.map(|i| format!(" id=\"{}\"", i)).unwrap_or_default(); 9 + let class_attr = if classes.is_empty() { 10 + String::new() 11 + } else { 12 + format!(" class=\"{}\"", classes.join(" ")) 13 + }; 14 + let icon = match level { 15 + 1 => "🎯", 16 + 2 => "📌", 17 + 3 => "⭐", 18 + 4 => "🔹", 19 + 5 => "🔸", 20 + 6 => "💎", 21 + _ => "🔷", 22 + }; 23 + format!("<h{level}{id_attr}{class_attr}>{icon} ") 24 + } 25 + 26 + fn render_end(&self, level: u8) -> String { 27 + format!("</h{level}>") 28 + } 29 + } 30 + 31 + // Custom paragraph with fancy styling 32 + pub struct CustomParagraph; 33 + 34 + impl ParagraphComponent for CustomParagraph { 35 + fn render_start(&self) -> String { 36 + "<p class=\"prose\">".to_string() 37 + } 38 + 39 + fn render_end(&self) -> String { 40 + "</p>".to_string() 41 + } 42 + } 43 + 44 + // Custom link with external link detection 45 + pub struct CustomLink; 46 + 47 + impl LinkComponent for CustomLink { 48 + fn render_start(&self, url: &str, title: Option<&str>, _link_type: LinkType) -> String { 49 + let title_attr = title 50 + .map(|t| format!(" title=\"{}\"", t)) 51 + .unwrap_or_default(); 52 + 53 + let class = if url.starts_with("http") { 54 + "external-link" 55 + } else { 56 + "internal-link" 57 + }; 58 + 59 + format!("<a href=\"{}\" class=\"{}\"{}>", url, class, title_attr) 60 + } 61 + 62 + fn render_end(&self) -> String { 63 + "</a>".to_string() 64 + } 65 + } 66 + 67 + // Custom image with figure wrapper 68 + pub struct CustomImage; 69 + 70 + impl ImageComponent for CustomImage { 71 + fn render(&self, url: &str, alt: &str, title: Option<&str>) -> String { 72 + let title_attr = title 73 + .map(|t| format!(" title=\"{}\"", t)) 74 + .unwrap_or_default(); 75 + 76 + format!( 77 + "<figure class=\"image-wrapper\"><img src=\"{}\" alt=\"{}\" class=\"responsive-image\"{} /><figcaption>{}</figcaption></figure>", 78 + url, alt, title_attr, alt 79 + ) 80 + } 81 + } 82 + 83 + // Custom strong with gradient text 84 + pub struct CustomStrong; 85 + 86 + impl StrongComponent for CustomStrong { 87 + fn render_start(&self) -> String { 88 + "<strong class=\"gradient-text\">".to_string() 89 + } 90 + 91 + fn render_end(&self) -> String { 92 + "</strong>".to_string() 93 + } 94 + } 95 + 96 + // Custom emphasis with italic styling 97 + pub struct CustomEmphasis; 98 + 99 + impl EmphasisComponent for CustomEmphasis { 100 + fn render_start(&self) -> String { 101 + "<em class=\"emphasis-text\">".to_string() 102 + } 103 + 104 + fn render_end(&self) -> String { 105 + "</em>".to_string() 106 + } 107 + } 108 + 109 + // Custom inline code with syntax highlighting 110 + pub struct CustomCode; 111 + 112 + impl CodeComponent for CustomCode { 113 + fn render(&self, code: &str) -> String { 114 + format!("<code class=\"inline-code\">{}</code>", code) 115 + } 116 + } 117 + 118 + // Custom blockquote with different styles per type 119 + pub struct CustomBlockquote; 120 + 121 + impl BlockquoteComponent for CustomBlockquote { 122 + fn render_start(&self, kind: Option<BlockQuoteKind>) -> String { 123 + match kind { 124 + Some(BlockQuoteKind::Note) => "<blockquote class=\"blockquote-note\"><div class=\"blockquote-icon\">ℹ️</div><div class=\"blockquote-content\">".to_string(), 125 + Some(BlockQuoteKind::Tip) => "<blockquote class=\"blockquote-tip\"><div class=\"blockquote-icon\">💡</div><div class=\"blockquote-content\">".to_string(), 126 + Some(BlockQuoteKind::Warning) => "<blockquote class=\"blockquote-warning\"><div class=\"blockquote-icon\">⚠️</div><div class=\"blockquote-content\">".to_string(), 127 + Some(BlockQuoteKind::Important) => "<blockquote class=\"blockquote-important\"><div class=\"blockquote-icon\">❗</div><div class=\"blockquote-content\">".to_string(), 128 + Some(BlockQuoteKind::Caution) => "<blockquote class=\"blockquote-caution\"><div class=\"blockquote-icon\">🚨</div><div class=\"blockquote-content\">".to_string(), 129 + None => "<blockquote class=\"blockquote-default blockquote-content\">".to_string(), 130 + } 131 + } 132 + 133 + fn render_end(&self, kind: Option<BlockQuoteKind>) -> String { 134 + if kind.is_some() { 135 + "</div></blockquote>".to_string() 136 + } else { 137 + "</blockquote>".to_string() 138 + } 139 + } 140 + } 141 + 142 + // Custom hard break 143 + pub struct CustomHardBreak; 144 + 145 + impl HardBreakComponent for CustomHardBreak { 146 + fn render(&self) -> String { 147 + "<br class=\"hard-break\" />".to_string() 148 + } 149 + } 150 + 151 + // Custom horizontal rule 152 + pub struct CustomHorizontalRule; 153 + 154 + impl HorizontalRuleComponent for CustomHorizontalRule { 155 + fn render(&self) -> String { 156 + "<hr class=\"custom-hr\" />".to_string() 157 + } 158 + } 159 + 160 + // Custom list with different styling 161 + pub struct CustomList; 162 + 163 + impl ListComponent for CustomList { 164 + fn render_start(&self, list_type: ListType, start_number: Option<u64>) -> String { 165 + match list_type { 166 + ListType::Ordered => { 167 + let start_attr = start_number 168 + .map(|n| format!(" start=\"{}\"", n)) 169 + .unwrap_or_default(); 170 + format!("<ol class=\"custom-list\" style=\"list-style-type: decimal; list-style-position: inside;\"{}>", start_attr) 171 + } 172 + ListType::Unordered => { 173 + "<ul class=\"custom-list\" style=\"list-style-type: disc; list-style-position: inside;\">".to_string() 174 + } 175 + } 176 + } 177 + 178 + fn render_end(&self, list_type: ListType) -> String { 179 + match list_type { 180 + ListType::Ordered => "</ol>".to_string(), 181 + ListType::Unordered => "</ul>".to_string(), 182 + } 183 + } 184 + } 185 + 186 + // Custom list item 187 + pub struct CustomListItem; 188 + 189 + impl ListItemComponent for CustomListItem { 190 + fn render_start(&self) -> String { 191 + "<li>".to_string() 192 + } 193 + 194 + fn render_end(&self) -> String { 195 + "</li>".to_string() 196 + } 197 + } 198 + 199 + // Custom strikethrough 200 + pub struct CustomStrikethrough; 201 + 202 + impl StrikethroughComponent for CustomStrikethrough { 203 + fn render_start(&self) -> String { 204 + "<del class=\"strikethrough\">".to_string() 205 + } 206 + 207 + fn render_end(&self) -> String { 208 + "</del>".to_string() 209 + } 210 + } 211 + 212 + // Custom task list marker 213 + pub struct CustomTaskListMarker; 214 + 215 + impl TaskListMarkerComponent for CustomTaskListMarker { 216 + fn render(&self, checked: bool) -> String { 217 + if checked { 218 + "<input type=\"checkbox\" checked disabled class=\"task-checkbox\" />".to_string() 219 + } else { 220 + "<input type=\"checkbox\" disabled class=\"task-checkbox\" />".to_string() 221 + } 222 + } 223 + } 224 + 225 + // Custom table 226 + pub struct CustomTable; 227 + 228 + impl TableComponent for CustomTable { 229 + fn render_start(&self, _alignments: &[TableAlignment]) -> String { 230 + "<table class=\"custom-table\">".to_string() 231 + } 232 + 233 + fn render_end(&self) -> String { 234 + "</table>".to_string() 235 + } 236 + } 237 + 238 + // Custom table head 239 + pub struct CustomTableHead; 240 + 241 + impl TableHeadComponent for CustomTableHead { 242 + fn render_start(&self) -> String { 243 + "<thead class=\"table-header\">".to_string() 244 + } 245 + 246 + fn render_end(&self) -> String { 247 + "</thead>".to_string() 248 + } 249 + } 250 + 251 + // Custom table row 252 + pub struct CustomTableRow; 253 + 254 + impl TableRowComponent for CustomTableRow { 255 + fn render_start(&self) -> String { 256 + "<tr class=\"table-row\">".to_string() 257 + } 258 + 259 + fn render_end(&self) -> String { 260 + "</tr>".to_string() 261 + } 262 + } 263 + 264 + // Custom table cell 265 + pub struct CustomTableCell; 266 + 267 + impl TableCellComponent for CustomTableCell { 268 + fn render_start(&self, is_header: bool, alignment: Option<TableAlignment>) -> String { 269 + let tag = if is_header { "th" } else { "td" }; 270 + let mut class = "table-cell".to_string(); 271 + 272 + match alignment { 273 + Some(TableAlignment::Center) => class.push_str(" center"), 274 + Some(TableAlignment::Right) => class.push_str(" right"), 275 + _ => {} 276 + }; 277 + 278 + format!("<{} class=\"{}\">", tag, class) 279 + } 280 + 281 + fn render_end(&self, is_header: bool) -> String { 282 + let tag = if is_header { "th" } else { "td" }; 283 + format!("</{}>", tag) 284 + } 285 + }
+40
examples/markdown-components/src/main.rs
··· 1 + use maudit::content::{glob_markdown, MarkdownComponents, MarkdownOptions}; 2 + use maudit::{content_sources, coronate, routes, BuildOptions, BuildOutput}; 3 + 4 + mod components; 5 + mod pages; 6 + 7 + use components::*; 8 + use pages::{ComponentExample, IndexPage}; 9 + 10 + fn main() -> Result<BuildOutput, Box<dyn std::error::Error>> { 11 + coronate( 12 + routes![IndexPage], 13 + content_sources![ 14 + "examples" => glob_markdown::<ComponentExample>("content/*.md", Some( 15 + MarkdownOptions::with_components( 16 + MarkdownComponents::new() 17 + .heading(CustomHeading) 18 + .paragraph(CustomParagraph) 19 + .link(CustomLink) 20 + .image(CustomImage) 21 + .strong(CustomStrong) 22 + .emphasis(CustomEmphasis) 23 + .code(CustomCode) 24 + .blockquote(CustomBlockquote) 25 + .hard_break(CustomHardBreak) 26 + .horizontal_rule(CustomHorizontalRule) 27 + .list(CustomList) 28 + .list_item(CustomListItem) 29 + .strikethrough(CustomStrikethrough) 30 + .task_list_marker(CustomTaskListMarker) 31 + .table(CustomTable) 32 + .table_head(CustomTableHead) 33 + .table_row(CustomTableRow) 34 + .table_cell(CustomTableCell) 35 + ) 36 + )) 37 + ], 38 + BuildOptions::default(), 39 + ) 40 + }
+39
examples/markdown-components/src/pages.rs
··· 1 + use maud::{html, Markup, PreEscaped, DOCTYPE}; 2 + use maudit::content::markdown_entry; 3 + use maudit::page::prelude::*; 4 + 5 + #[markdown_entry] 6 + pub struct ComponentExample {} 7 + 8 + #[route("/")] 9 + pub struct IndexPage; 10 + 11 + impl Page<RouteParams, Markup> for IndexPage { 12 + fn render(&self, ctx: &mut RouteContext) -> Markup { 13 + let examples = ctx.content.get_source::<ComponentExample>("examples"); 14 + let example = examples.get_entry("showcase"); 15 + 16 + // The content is already rendered with the custom components 17 + // when it was loaded via glob_markdown with options 18 + let content_html = example.render(); 19 + 20 + html! { 21 + (DOCTYPE) 22 + html lang="en" { 23 + head { 24 + meta charset="utf-8"; 25 + meta name="viewport" content="width=device-width, initial-scale=1"; 26 + title { "Custom Markdown Components Showcase" } 27 + style { 28 + (PreEscaped(include_str!("./style.css"))) 29 + } 30 + } 31 + body { 32 + div class="container" { 33 + (PreEscaped(content_html)) 34 + } 35 + } 36 + } 37 + } 38 + } 39 + }
+304
examples/markdown-components/src/style.css
··· 1 + /* Reset and base styles */ 2 + * { 3 + box-sizing: border-box; 4 + } 5 + body { 6 + margin: 0; 7 + padding: 0; 8 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 9 + "Helvetica Neue", Arial, sans-serif; 10 + line-height: 1.6; 11 + color: #333; 12 + min-height: 100vh; 13 + } 14 + 15 + /* Layout */ 16 + .container { 17 + max-width: 1024px; 18 + margin: 0 auto; 19 + padding: 48px 24px; 20 + } 21 + 22 + .header { 23 + text-align: center; 24 + margin-bottom: 48px; 25 + } 26 + 27 + .header h1 { 28 + font-size: 2.5rem; 29 + font-weight: bold; 30 + color: #1a1a1a; 31 + margin: 0 0 16px 0; 32 + } 33 + 34 + .header p { 35 + font-size: 1.25rem; 36 + color: #666; 37 + margin: 0; 38 + } 39 + 40 + .footer { 41 + margin-top: 48px; 42 + text-align: center; 43 + color: #888; 44 + } 45 + 46 + /* Component styles */ 47 + .prose { 48 + font-size: 1.125rem; 49 + color: #374151; 50 + line-height: 1.75; 51 + } 52 + 53 + /* Heading styles */ 54 + .anchor-link { 55 + opacity: 0; 56 + transition: opacity 0.2s; 57 + text-decoration: none; 58 + color: #ba1f33; 59 + } 60 + h1:hover .anchor-link, 61 + h2:hover .anchor-link, 62 + h3:hover .anchor-link, 63 + h4:hover .anchor-link, 64 + h5:hover .anchor-link, 65 + h6:hover .anchor-link { 66 + opacity: 1; 67 + } 68 + 69 + /* Link styles */ 70 + .external-link { 71 + color: #ba1f33; 72 + text-decoration: none; 73 + } 74 + .external-link:hover { 75 + text-decoration: underline; 76 + } 77 + .external-link::after { 78 + content: "↗"; 79 + color: #ba1f33; 80 + } 81 + 82 + .internal-link { 83 + color: #ba1f33; 84 + text-decoration: none; 85 + } 86 + .internal-link:hover { 87 + text-decoration: underline; 88 + } 89 + 90 + /* Image styles */ 91 + .image-wrapper { 92 + margin: 24px 0; 93 + text-align: center; 94 + } 95 + .responsive-image { 96 + max-width: 100%; 97 + height: auto; 98 + border-radius: 8px; 99 + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); 100 + } 101 + .image-wrapper figcaption { 102 + margin-top: 8px; 103 + font-style: italic; 104 + color: #666; 105 + font-size: 0.9rem; 106 + } 107 + 108 + /* Strong and emphasis */ 109 + .gradient-text { 110 + font-weight: bold; 111 + background: linear-gradient(to right, #ba1f33, #fa3252); 112 + -webkit-background-clip: text; 113 + -webkit-text-fill-color: transparent; 114 + background-clip: text; 115 + } 116 + 117 + .emphasis-text { 118 + font-style: italic; 119 + color: #ba1f33; 120 + } 121 + 122 + /* Code styles */ 123 + .inline-code { 124 + background-color: #f3f4f6; 125 + color: #dc2626; 126 + padding: 2px 4px; 127 + border-radius: 4px; 128 + font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, 129 + "Courier New", monospace; 130 + font-size: 0.875rem; 131 + } 132 + 133 + /* Code blocks */ 134 + pre { 135 + background-color: #1a202c; 136 + color: #e2e8f0; 137 + padding: 16px; 138 + border-radius: 8px; 139 + overflow-x: auto; 140 + margin: 16px 0; 141 + border: 1px solid #2d3748; 142 + } 143 + 144 + pre code { 145 + background: none; 146 + color: inherit; 147 + padding: 0; 148 + border-radius: 0; 149 + font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, 150 + "Courier New", monospace; 151 + font-size: 0.875rem; 152 + line-height: 1.5; 153 + } 154 + 155 + /* Blockquote styles */ 156 + .blockquote-note, 157 + .blockquote-tip, 158 + .blockquote-warning, 159 + .blockquote-important, 160 + .blockquote-caution { 161 + padding: 16px; 162 + margin: 16px 0; 163 + border-radius: 8px; 164 + display: flex; 165 + align-items: center; 166 + gap: 12px; 167 + } 168 + 169 + .blockquote-note { 170 + border-left: 4px solid #3b82f6; 171 + background-color: #eff6ff; 172 + } 173 + .blockquote-tip { 174 + border-left: 4px solid #10b981; 175 + background-color: #f0fdf4; 176 + } 177 + .blockquote-warning { 178 + border-left: 4px solid #f59e0b; 179 + background-color: #fffbeb; 180 + } 181 + .blockquote-important { 182 + border-left: 4px solid #8b5cf6; 183 + background-color: #faf5ff; 184 + } 185 + .blockquote-caution { 186 + border-left: 4px solid #ef4444; 187 + background-color: #fef2f2; 188 + } 189 + .blockquote-default { 190 + border-left: 4px solid #9ca3af; 191 + background-color: #f9fafb; 192 + padding: 16px; 193 + margin: 16px 0; 194 + font-style: italic; 195 + border-radius: 8px; 196 + } 197 + 198 + .blockquote-icon { 199 + font-size: 1.2em; 200 + line-height: 1; 201 + flex-shrink: 0; 202 + display: flex; 203 + align-items: center; 204 + justify-content: center; 205 + } 206 + 207 + .blockquote-content { 208 + flex: 1; 209 + margin: 0; 210 + } 211 + 212 + .blockquote-content p { 213 + margin: 0; 214 + } 215 + 216 + /* List styles */ 217 + .custom-list { 218 + margin: 16px 0; 219 + padding-left: 16px; 220 + } 221 + .custom-list li { 222 + margin: 8px 0; 223 + color: #374151; 224 + transition: color 0.2s; 225 + } 226 + .custom-list li:hover { 227 + color: #1f2937; 228 + } 229 + 230 + /* Strikethrough */ 231 + .strikethrough { 232 + text-decoration: line-through; 233 + color: #6b7280; 234 + opacity: 0.75; 235 + } 236 + 237 + /* Task list */ 238 + .task-checkbox { 239 + margin-right: 8px; 240 + } 241 + .task-checkbox[checked] { 242 + accent-color: #ba1f33; 243 + } 244 + 245 + /* Table styles */ 246 + .custom-table { 247 + width: 100%; 248 + border-collapse: separate; 249 + border-spacing: 0; 250 + border: 1px solid #d1d5db; 251 + border-radius: 8px; 252 + overflow: hidden; 253 + margin: 24px 0; 254 + } 255 + 256 + .table-header { 257 + background-color: #f9fafb; 258 + } 259 + 260 + .table-header th { 261 + padding: 12px 24px; 262 + text-align: left; 263 + font-size: 0.75rem; 264 + font-weight: 500; 265 + color: #6b7280; 266 + text-transform: uppercase; 267 + letter-spacing: 0.05em; 268 + } 269 + 270 + .table-row { 271 + transition: background-color 0.2s; 272 + } 273 + .table-row:hover { 274 + background-color: #f9fafb; 275 + } 276 + 277 + .table-cell { 278 + padding: 12px 24px; 279 + white-space: nowrap; 280 + font-size: 0.875rem; 281 + color: #1f2937; 282 + } 283 + 284 + .table-cell.center { 285 + text-align: center; 286 + } 287 + .table-cell.right { 288 + text-align: right; 289 + } 290 + 291 + /* Horizontal rule */ 292 + .custom-hr { 293 + margin: 32px 0; 294 + border: none; 295 + height: 2px; 296 + background: linear-gradient(to right, #ba1f33, #fa3252); 297 + } 298 + 299 + /* Hard break */ 300 + .hard-break { 301 + display: block; 302 + content: ""; 303 + margin: 8px 0; 304 + }
+1 -1
website/content/docs/index.md
··· 4 4 5 5 Welcome to the Maudit documentation! Maudit (pronounced /mo.di/, meaning _cursed_ in French) is a static site generator. 6 6 7 - [Static site generators](https://en.wikipedia.org/wiki/Static_site_generator) are tools that take a collection of files and convert them into a website, once in a build step. This is in contrast to dynamic websites, which are generated on-the-fly by a server. Other similar tools include [Jekyll](https://jekyllrb.com), [Hugo](https://gohugo.io), [Astro](https://astro.build), [Eleventy](https://www.11ty.dev), [Zola](https://www.getzola.org) and [many more](https://jamstack.org/generators/). 7 + [Static site generators](https://en.wikipedia.org/wiki/Static_site_generator) are tools that take a collection of files and convert them into a website, once in a build step. This is in contrast to dynamic websites, which are generated on-the-fly by a server. Other similar tools to Maudit include [Jekyll](https://jekyllrb.com), [Hugo](https://gohugo.io), [Astro](https://astro.build), [Eleventy](https://www.11ty.dev), [Zola](https://www.getzola.org) and [many more](https://jamstack.org/generators/). 8 8 9 9 Maudit aims to be a simple and easy-to-use static site generator, [nothing more](/docs/philosophy/#maudit-is-about-making-static-websites). We hope that you'll find it useful for your projects, and we're excited to see what you'll create with it! 10 10
+1 -1
website/content/docs/quick-start.md
··· 59 59 ```rs 60 60 impl Page for HelloWorld { 61 61 fn render(&self, ctx: &mut RouteContext) -> RenderResult { 62 - RenderResult::Text("Hello, world!".to_string()) 62 + "Hello, world!".into() 63 63 } 64 64 } 65 65 ```
+1 -1
website/content/docs/styling.md
··· 62 62 63 63 Tailwind can then be configured normally, through native CSS in Tailwind 4.0, or through a `tailwind.config.js` file in earlier versions. 64 64 65 - **Caution:** Tailwind CSS is a JavaScript-based tool, which means that Maudit needs to spawn a separate Node.js process to run it. This comes with a significant performance overhead and in most projects using it, Tailwind will account for more than 99% of the build time, even when using Tailwind 4.0. 65 + **Caution:** Tailwind CSS is a JavaScript-based tool, which means that Maudit needs to spawn a separate Node.js process to run it. This comes with a significant performance overhead and in most projects using it, Tailwind will account for more than 99% of the build time, even when using Tailwind 4.0+.
+1 -1
website/src/content.rs
··· 37 37 } 38 38 39 39 pub fn content_sources() -> ContentSources { 40 - content_sources!["docs" => glob_markdown::<DocsContent>("content/docs/*.md"), "news" => glob_markdown::<NewsContent>("content/news/*.md")] 40 + content_sources!["docs" => glob_markdown::<DocsContent>("content/docs/*.md", None), "news" => glob_markdown::<NewsContent>("content/news/*.md", None)] 41 41 }
+1 -1
website/src/pages/news.rs
··· 25 25 .or_insert_with(Vec::new) 26 26 .push(article); 27 27 } else { 28 - // Handle articles without dates 28 + // articles without dates 29 29 articles_by_year 30 30 .entry("Unknown".to_string()) 31 31 .or_insert_with(Vec::new)