use crate::component::ComponentRegistry;
use crate::config::Config;
use crate::error::Error;
use crate::parser::{Alignment, InlineNode, Node, ParsedDocument};
use crate::theme::Theme;
#[cfg(feature = "minify")]
use minify_html::{minify, Cfg};
use std::collections::HashMap;
use syntect::highlighting::ThemeSet;
use syntect::html::{ClassStyle, ClassedHTMLGenerator};
use syntect::parsing::SyntaxSet;
/// Options for HTML rendering
#[derive(Debug, Clone)]
pub struct RenderOptions {
/// Title for the HTML document
pub title: String,
/// Whether to include default CSS
pub include_default_css: bool,
/// Whether to minify HTML output
pub minify: bool,
/// Whether to generate a table of contents
pub toc: bool,
/// Whether to apply syntax highlighting to code blocks
pub syntax_highlight: bool,
/// Whether to add copy buttons to code blocks
pub code_copy_button: bool,
/// Theme for syntax highlighting
pub highlight_theme: String,
}
impl Default for RenderOptions {
fn default() -> Self {
Self {
title: "Markrust Document".to_string(),
include_default_css: true,
minify: false,
toc: false,
syntax_highlight: true,
code_copy_button: true,
highlight_theme: "InspiredGitHub".to_string(),
}
}
}
impl From<&Config> for RenderOptions {
fn from(config: &Config) -> Self {
Self {
title: config.renderer.title.clone(),
include_default_css: config.renderer.include_default_css,
minify: config.renderer.minify,
toc: config.renderer.toc,
syntax_highlight: config.renderer.syntax_highlight,
code_copy_button: config.renderer.code_copy_button,
highlight_theme: config.renderer.highlight_theme.clone(),
}
}
}
/// Render an AST to HTML
pub fn render(
doc: &ParsedDocument,
registry: &ComponentRegistry,
config: &Config,
) -> Result<String, Error> {
let render_options = RenderOptions::from(config);
let theme = Theme::new(&config.theme.name);
// Generate table of contents if requested
let toc = if render_options.toc {
generate_toc(&doc.ast)
} else {
String::new()
};
// Render the document body
let content = render_nodes(&doc.ast, registry, &render_options)?;
// Combine CSS from theme and components
let mut css = String::new();
if render_options.include_default_css {
css.push_str(&theme.get_css());
}
css.push_str(®istry.get_all_css());
// Add syntax highlighting CSS if enabled
if render_options.syntax_highlight {
let highlight_css = get_syntax_highlight_css(&render_options.highlight_theme)?;
css.push_str(&highlight_css);
}
// Get title from frontmatter or use default
let title = if let Some(ref frontmatter) = doc.frontmatter {
frontmatter
.get("title")
.and_then(|v| v.as_str())
.unwrap_or(&render_options.title)
} else {
&render_options.title
};
// Generate the final HTML document
let html = format!(
"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>{title}</title>\n <style>\n{css}\n </style>\n</head>\n<body>\n <div class=\"markrust-container\">\n {toc}\n <div class=\"markrust-content\">\n{content}\n </div>\n </div>\n {scripts}\n</body>\n</html>",
title = title,
css = css,
toc = toc,
content = content,
scripts = get_scripts(render_options.code_copy_button)
);
// Process component directives in HTML
let processed_html = process_component_directives(&html, registry)?;
// Minify HTML if requested
let final_html = if render_options.minify {
minify_html(&processed_html)?
} else {
processed_html
};
Ok(final_html)
}
// Generate a table of contents from the AST
fn generate_toc(nodes: &[Node]) -> String {
let mut toc = String::from("<div class=\"markrust-toc\">\n <div class=\"markrust-toc-header\">Table of Contents</div>\n <ul>\n");
for node in nodes {
if let Node::Heading { level, content, id } = node {
if *level <= 3 {
let indent = " ".repeat(*level as usize);
toc.push_str(&format!(
"{}<li><a href=\"#{id}\">{content}</a></li>\n",
indent
));
}
}
}
toc.push_str(" </ul>\n</div>");
toc
}
// Render AST nodes to HTML
fn render_nodes(
nodes: &[Node],
registry: &ComponentRegistry,
options: &RenderOptions,
) -> Result<String, Error> {
let mut html = String::new();
for node in nodes {
html.push_str(&render_node(node, registry, options)?);
}
Ok(html)
}
// Render a single AST node to HTML
fn render_node(
node: &Node,
registry: &ComponentRegistry,
options: &RenderOptions,
) -> Result<String, Error> {
match node {
Node::Heading { level, content, id } => Ok(format!(
"<h{level} id=\"{id}\">{content}</h{level}>\n",
level = level,
id = id,
content = content
)),
Node::Paragraph(inline_nodes) => {
let content = render_inline_nodes(inline_nodes)?;
Ok(format!("<p>{}</p>\n", content))
}
Node::BlockQuote(nodes) => {
let content = render_nodes(nodes, registry, options)?;
Ok(format!("<blockquote>\n{}</blockquote>\n", content))
}
Node::CodeBlock {
language,
content,
attributes,
} => render_code_block(language, content, attributes, options),
Node::List { ordered, items } => {
let tag = if *ordered { "ol" } else { "ul" };
let mut html = format!("<{tag}>\n");
for item in items {
let item_content = render_nodes(item, registry, options)?;
html.push_str(&format!(" <li>{}</li>\n", item_content));
}
html.push_str(&format!("</{tag}>\n"));
Ok(html)
}
Node::ThematicBreak => Ok("<hr>\n".to_string()),
Node::Component {
name,
attributes,
children,
} => {
if let Some(component) = registry.get(name) {
component.render(attributes, children)
} else {
Err(Error::ComponentError(format!(
"Component not found: {}",
name
)))
}
}
Node::Html(html) => Ok(format!("{}\n", html)),
Node::Table {
headers,
rows,
alignments,
} => table_to_html(headers, rows, alignments),
}
}
// Render inline nodes to HTML
fn render_inline_nodes(nodes: &[InlineNode]) -> Result<String, Error> {
let mut html = String::new();
for node in nodes {
html.push_str(&render_inline_node(node)?);
}
Ok(html)
}
// Render a single inline node to HTML
fn render_inline_node(node: &InlineNode) -> Result<String, Error> {
match node {
InlineNode::Text(text) => Ok(text.clone()),
InlineNode::Emphasis(nodes) => {
let content = render_inline_nodes(nodes)?;
Ok(format!("<em>{}</em>", content))
}
InlineNode::Strong(nodes) => {
let content = render_inline_nodes(nodes)?;
Ok(format!("<strong>{}</strong>", content))
}
InlineNode::Strikethrough(nodes) => {
let content = render_inline_nodes(nodes)?;
Ok(format!("<del>{}</del>", content))
}
InlineNode::Link { text, url, title } => {
let content = render_inline_nodes(text)?;
let title_attr = if let Some(title) = title {
format!(" title=\"{}\"", title)
} else {
String::new()
};
Ok(format!(
"<a href=\"{}\"{title_attr}>{}</a>",
url,
content,
title_attr = title_attr
))
}
InlineNode::Image { alt, url, title } => {
let title_attr = if let Some(title) = title {
format!(" title=\"{}\"", title)
} else {
String::new()
};
Ok(format!(
"<img src=\"{}\" alt=\"{}\"{title_attr}>",
url,
alt,
title_attr = title_attr
))
}
InlineNode::Code(code) => Ok(format!("<code>{}</code>", code)),
InlineNode::LineBreak => Ok("<br>".to_string()),
InlineNode::Html(html) => Ok(html.clone()),
}
}
// Render a code block with optional syntax highlighting
fn render_code_block(
language: &Option<String>,
content: &str,
_attributes: &HashMap<String, String>,
options: &RenderOptions,
) -> Result<String, Error> {
let lang_class = if let Some(lang) = language {
format!(" class=\"language-{}\"", lang)
} else {
String::new()
};
let code_content = if options.syntax_highlight && language.is_some() {
highlight_code(
content,
language.as_ref().unwrap(),
&options.highlight_theme,
)?
} else {
content.to_string()
};
let copy_button = if options.code_copy_button {
"<button class=\"markrust-copy-button\" data-clipboard-target=\"#code-block \">\n <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <rect x=\"9\" y=\"9\" width=\"13\" height=\"13\" rx=\"2\" ry=\"2\"></rect>\n <path d=\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\"></path>\n </svg>\n </button>"
} else {
""
};
Ok(format!(
"<div class=\"markrust-code-block\">\n <pre{lang_class}><code>{code_content}</code></pre>\n {copy_button}\n</div>\n",
lang_class = lang_class,
code_content = code_content,
copy_button = copy_button
))
}
// Render a table to HTML
fn table_to_html(
headers: &[Vec<InlineNode>],
rows: &[Vec<Vec<InlineNode>>],
alignments: &[Alignment],
) -> Result<String, Error> {
let mut html = String::from("<table class=\"markrust-table\">\n");
// Render table header
if !headers.is_empty() {
html.push_str(" <thead>\n <tr>\n");
for (i, header) in headers.iter().enumerate() {
let align_class =
get_alignment_class(alignments.get(i).copied().unwrap_or(Alignment::None));
let content = render_inline_nodes(header)?;
html.push_str(&format!(
" <th{align_class}>{content}</th>\n",
align_class = align_class,
content = content
));
}
html.push_str(" </tr>\n </thead>\n");
}
// Render table body
if !rows.is_empty() {
html.push_str(" <tbody>\n");
for row in rows {
html.push_str(" <tr>\n");
for (i, cell) in row.iter().enumerate() {
let align_class =
get_alignment_class(alignments.get(i).copied().unwrap_or(Alignment::None));
let content = render_inline_nodes(cell)?;
html.push_str(&format!(
" <td{align_class}>{content}</td>\n",
align_class = align_class,
content = content
));
}
html.push_str(" </tr>\n");
}
html.push_str(" </tbody>\n");
}
html.push_str("</table>\n");
Ok(html)
}
// Get the alignment class for a table cell
fn get_alignment_class(alignment: Alignment) -> String {
match alignment {
Alignment::None => String::new(),
Alignment::Left => " class=\"align-left\"".to_string(),
Alignment::Center => " class=\"align-center\"".to_string(),
Alignment::Right => " class=\"align-right\"".to_string(),
}
}
// Highlight code using syntect
fn highlight_code(code: &str, language: &str, theme_name: &str) -> Result<String, Error> {
let syntax_set = SyntaxSet::load_defaults_newlines();
let theme_set = ThemeSet::load_defaults();
let syntax = syntax_set
.find_syntax_by_token(language)
.or_else(|| syntax_set.find_syntax_by_extension(language))
.unwrap_or_else(|| syntax_set.find_syntax_plain_text());
let _theme = theme_set
.themes
.get(theme_name)
.ok_or_else(|| Error::RenderError(format!("Theme not found: {}", theme_name)))?;
let mut html_generator =
ClassedHTMLGenerator::new_with_class_style(syntax, &syntax_set, ClassStyle::Spaced);
for line in code.lines() {
// Use a newer method that doesn't cause errors
let newline = '\n';
let line_with_newline = format!("{}{}", line, newline);
html_generator
.parse_html_for_line_which_includes_newline(&line_with_newline)
.map_err(|e| Error::RenderError(format!("Failed to highlight code: {}", e)))?;
}
Ok(html_generator.finalize())
}
// Get syntax highlighting CSS
fn get_syntax_highlight_css(_theme_name: &str) -> Result<String, Error> {
// In a real implementation, this would generate CSS based on the selected theme
Ok("
/* Syntax highlighting styles */
.hljs-keyword { color: #0000ff; font-weight: bold; }
.hljs-string { color: #a31515; }
.hljs-comment { color: #008000; }
.hljs-function { color: #795e26; }
.hljs-number { color: #098658; }
"
.to_string())
}
// Get scripts for the HTML document
fn get_scripts(include_copy_button: bool) -> String {
if include_copy_button {
"<script>\n // Code copy button functionality\n document.addEventListener(\"DOMContentLoaded\", function() {\n const copyButtons = document.querySelectorAll(\".markrust-copy-button\");\n \n copyButtons.forEach(button => {\n button.addEventListener(\"click\", function() {\n const codeBlock = this.previousElementSibling.querySelector(\"code\");\n const textToCopy = codeBlock.innerText;\n \n navigator.clipboard.writeText(textToCopy).then(() => {\n // Show copied feedback\n const originalLabel = this.getAttribute(\"aria-label\");\n this.setAttribute(\"aria-label\", \"Copied!\");\n \n setTimeout(() => {\n this.setAttribute(\"aria-label\", originalLabel);\n }, 2000);\n });\n });\n });\n \n // Tab functionality\n const tabButtons = document.querySelectorAll(\".markrust-tab-button\");\n \n tabButtons.forEach(button => {\n button.addEventListener(\"click\", function() {\n const tabs = this.closest(\".markrust-tabs\");\n const tabId = this.getAttribute(\"data-tab\");\n \n // Deactivate all tabs\n tabs.querySelectorAll(\".markrust-tab-button\").forEach(btn => btn.classList.remove(\"active\"));\n tabs.querySelectorAll(\".markrust-tab-panel\").forEach(panel => panel.classList.remove(\"active\"));\n \n // Activate selected tab\n this.classList.add(\"active\");\n tabs.querySelector(\"#\" + tabId).classList.add(\"active\");\n });\n });\n });\n </script>".to_string()
} else {
String::new()
}
}
#[cfg(feature = "minify")]
fn minify_html_impl(html: &str) -> Result<String, Error> {
let mut cfg = Cfg::new();
cfg.do_not_minify_doctype = true;
cfg.ensure_spec_compliant_unquoted_attribute_values = true;
cfg.keep_closing_tags = true;
let bytes = html.as_bytes();
let minified = minify(bytes, &cfg);
Ok(String::from_utf8_lossy(&minified).to_string())
}
#[cfg(not(feature = "minify"))]
fn minify_html_impl(html: &str) -> Result<String, Error> {
Ok(html.to_string())
}
// Minify HTML
fn minify_html(html: &str) -> Result<String, Error> {
minify_html_impl(html)
}
// Process component directives in HTML
fn process_component_directives(html: &str, registry: &ComponentRegistry) -> Result<String, Error> {
use regex::Regex;
use std::collections::HashMap;
// Define regex patterns for component directives
let component_start_regex =
Regex::new(r"<!-- component_start:([a-zA-Z0-9_-]+):(.*?) -->").unwrap();
let component_end_regex = Regex::new(r"<!-- component_end -->").unwrap();
let nested_start_regex =
Regex::new(r"<!-- nested_component_start:([a-zA-Z0-9_-]+):(.*?) -->").unwrap();
let nested_end_regex = Regex::new(r"<!-- nested_component_end -->").unwrap();
let mut result = html.to_string();
// First, process nested components
let mut start_positions = Vec::new();
let mut component_data = Vec::new();
for cap in nested_start_regex.captures_iter(html) {
let start_match = cap.get(0).unwrap();
let component_name = cap[1].to_string();
let attributes_str = cap[2].to_string();
// Parse attributes
let mut attributes = HashMap::new();
for attr_pair in attributes_str.split_whitespace() {
if let Some(equals_pos) = attr_pair.find('=') {
let key = attr_pair[..equals_pos].trim();
let mut value = attr_pair[equals_pos + 1..].trim();
// Remove quotes from value if present
if (value.starts_with('"') && value.ends_with('"'))
|| (value.starts_with('\'') && value.ends_with('\''))
{
value = &value[1..value.len() - 1];
}
attributes.insert(key.to_string(), value.to_string());
}
}
start_positions.push(start_match.start());
component_data.push((component_name, attributes, start_match.end()));
}
// Find matching end tags and process components
let mut replacements = Vec::new();
for (i, &start_pos) in start_positions.iter().enumerate() {
let (component_name, attributes, content_start) = &component_data[i];
if let Some(end_match) = nested_end_regex.find_at(&result, *content_start) {
let _content = &result[*content_start..end_match.start()];
// If the component exists in the registry, render it
if let Some(component) = registry.get(component_name) {
if let Ok(rendered) = component.render(attributes, &Vec::new()) {
replacements.push((start_pos, end_match.end(), rendered));
}
}
}
}
// Apply replacements in reverse order to preserve positions
replacements.sort_by(|a, b| b.0.cmp(&a.0));
for (start, end, replacement) in replacements {
result.replace_range(start..end, &replacement);
}
// Then, process top-level components
let mut start_positions = Vec::new();
let mut component_data = Vec::new();
for cap in component_start_regex.captures_iter(&result) {
let start_match = cap.get(0).unwrap();
let component_name = cap[1].to_string();
let attributes_str = cap[2].to_string();
// Parse attributes
let mut attributes = HashMap::new();
for attr_pair in attributes_str.split_whitespace() {
if let Some(equals_pos) = attr_pair.find('=') {
let key = attr_pair[..equals_pos].trim();
let mut value = attr_pair[equals_pos + 1..].trim();
// Remove quotes from value if present
if (value.starts_with('"') && value.ends_with('"'))
|| (value.starts_with('\'') && value.ends_with('\''))
{
value = &value[1..value.len() - 1];
}
attributes.insert(key.to_string(), value.to_string());
}
}
start_positions.push(start_match.start());
component_data.push((component_name, attributes, start_match.end()));
}
// Find matching end tags and process components
let mut replacements = Vec::new();
for (i, &start_pos) in start_positions.iter().enumerate() {
let (component_name, attributes, content_start) = &component_data[i];
if let Some(end_match) = component_end_regex.find_at(&result, *content_start) {
let _content = &result[*content_start..end_match.start()];
// If the component exists in the registry, render it
if let Some(component) = registry.get(component_name) {
if let Ok(rendered) = component.render(attributes, &Vec::new()) {
replacements.push((start_pos, end_match.end(), rendered));
}
}
}
}
// Apply replacements in reverse order to preserve positions
replacements.sort_by(|a, b| b.0.cmp(&a.0));
for (start, end, replacement) in replacements {
result.replace_range(start..end, &replacement);
}
Ok(result)
}