use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum BlockType {
Heading(u8),
Paragraph,
List,
CodeBlock,
Blockquote,
HorizontalRule,
Table,
Image,
Custom(String),
}
#[derive(Debug, Clone)]
pub struct BlockElement {
pub block_type: BlockType,
pub content: String,
pub attributes: HashMap<String, String>,
pub children: Vec<BlockElement>,
}
impl BlockElement {
pub fn new(block_type: BlockType, content: impl Into<String>) -> Self {
Self {
block_type,
content: content.into(),
attributes: HashMap::new(),
children: Vec::new(),
}
}
pub fn with_attr(mut self, key: &str, value: &str) -> Self {
self.attributes.insert(key.to_string(), value.to_string());
self
}
pub fn child(mut self, child: BlockElement) -> Self {
self.children.push(child);
self
}
}
pub struct BlockRenderer {
renderers: HashMap<BlockType, Box<dyn Fn(&BlockElement) -> String>>,
}
impl BlockRenderer {
pub fn new() -> Self {
Self {
renderers: HashMap::new(),
}
}
pub fn register<F>(&mut self, block_type: BlockType, renderer: F)
where
F: Fn(&BlockElement) -> String + 'static,
{
self.renderers.insert(block_type, Box::new(renderer));
}
pub fn render(&self, block: &BlockElement) -> String {
if let Some(renderer) = self.renderers.get(&block.block_type) {
renderer(block)
} else {
self.default_render(block)
}
}
fn default_render(&self, block: &BlockElement) -> String {
match &block.block_type {
BlockType::Heading(level) => {
format!("<h{level}>{}</h{level}>", block.content)
}
BlockType::Paragraph => {
format!("<p>{}</p>", block.content)
}
BlockType::CodeBlock => {
let lang = block
.attributes
.get("language")
.map(|s| s.as_str())
.unwrap_or("");
format!(
"<pre><code class=\"language-{lang}\">{}</code></pre>",
block.content
)
}
BlockType::Blockquote => {
format!("<blockquote>{}</blockquote>", block.content)
}
BlockType::HorizontalRule => "<hr />".to_string(),
BlockType::List => {
format!("<ul>{}</ul>", block.content)
}
BlockType::Table => {
format!("<table>{}</table>", block.content)
}
BlockType::Image => {
let src = block
.attributes
.get("src")
.map(|s| s.as_str())
.unwrap_or("");
let alt = block
.attributes
.get("alt")
.map(|s| s.as_str())
.unwrap_or("");
format!("<img src=\"{src}\" alt=\"{alt}\" />")
}
BlockType::Custom(name) => {
format!("<div class=\"custom-{name}\">{}</div>", block.content)
}
}
}
pub fn parse_markdown(&self, input: &str) -> Vec<BlockElement> {
let mut blocks = Vec::new();
let mut current_paragraph = String::new();
for line in input.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
if !current_paragraph.is_empty() {
blocks.push(BlockElement::new(
BlockType::Paragraph,
current_paragraph.clone(),
));
current_paragraph.clear();
}
continue;
}
if trimmed.starts_with('#') {
let level = trimmed.chars().take_while(|&c| c == '#').count() as u8;
if level >= 1 && level <= 6 {
let content = trimmed[level as usize..].trim();
blocks.push(BlockElement::new(BlockType::Heading(level), content));
continue;
}
}
if trimmed.starts_with("---")
|| trimmed.starts_with("***")
|| trimmed.starts_with("___")
{
blocks.push(BlockElement::new(BlockType::HorizontalRule, ""));
continue;
}
if trimmed.starts_with("```") {
let lang = trimmed.strip_prefix("```").unwrap_or("").trim();
blocks
.push(BlockElement::new(BlockType::CodeBlock, "").with_attr("language", lang));
continue;
}
if let Some(content) = trimmed.strip_prefix('>') {
blocks.push(BlockElement::new(BlockType::Blockquote, content.trim()));
continue;
}
if !current_paragraph.is_empty() {
current_paragraph.push(' ');
}
current_paragraph.push_str(trimmed);
}
if !current_paragraph.is_empty() {
blocks.push(BlockElement::new(BlockType::Paragraph, current_paragraph));
}
blocks
}
}
impl Default for BlockRenderer {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_paragraph_render() {
let renderer = BlockRenderer::new();
let block = BlockElement::new(BlockType::Paragraph, "Hello");
assert_eq!(renderer.render(&block), "<p>Hello</p>");
}
#[test]
fn test_heading_render() {
let renderer = BlockRenderer::new();
let h1 = BlockElement::new(BlockType::Heading(1), "Title");
assert_eq!(renderer.render(&h1), "<h1>Title</h1>");
}
#[test]
fn test_parse_markdown() {
let renderer = BlockRenderer::new();
let blocks = renderer.parse_markdown("# Title\n\nPara\n\n---");
assert_eq!(blocks.len(), 3);
assert!(matches!(blocks[0].block_type, BlockType::Heading(1)));
assert!(matches!(blocks[1].block_type, BlockType::Paragraph));
assert!(matches!(blocks[2].block_type, BlockType::HorizontalRule));
}
}