1use crate::component::ComponentRegistry;
2use crate::config::Config;
3use crate::error::Error;
4use crate::parser::{Alignment, InlineNode, Node, ParsedDocument};
5use crate::theme::Theme;
6#[cfg(feature = "minify")]
7use minify_html::{minify, Cfg};
8use std::collections::HashMap;
9use syntect::highlighting::ThemeSet;
10use syntect::html::{ClassStyle, ClassedHTMLGenerator};
11use syntect::parsing::SyntaxSet;
12
13#[derive(Debug, Clone)]
15pub struct RenderOptions {
16 pub title: String,
18 pub include_default_css: bool,
20 pub minify: bool,
22 pub toc: bool,
24 pub syntax_highlight: bool,
26 pub code_copy_button: bool,
28 pub highlight_theme: String,
30}
31
32impl Default for RenderOptions {
33 fn default() -> Self {
34 Self {
35 title: "Markrust Document".to_string(),
36 include_default_css: true,
37 minify: false,
38 toc: false,
39 syntax_highlight: true,
40 code_copy_button: true,
41 highlight_theme: "InspiredGitHub".to_string(),
42 }
43 }
44}
45
46impl From<&Config> for RenderOptions {
47 fn from(config: &Config) -> Self {
48 Self {
49 title: config.renderer.title.clone(),
50 include_default_css: config.renderer.include_default_css,
51 minify: config.renderer.minify,
52 toc: config.renderer.toc,
53 syntax_highlight: config.renderer.syntax_highlight,
54 code_copy_button: config.renderer.code_copy_button,
55 highlight_theme: config.renderer.highlight_theme.clone(),
56 }
57 }
58}
59
60pub fn render(
62 doc: &ParsedDocument,
63 registry: &ComponentRegistry,
64 config: &Config,
65) -> Result<String, Error> {
66 let render_options = RenderOptions::from(config);
67 let theme = Theme::new(&config.theme.name);
68
69 let toc = if render_options.toc {
71 generate_toc(&doc.ast)
72 } else {
73 String::new()
74 };
75
76 let content = render_nodes(&doc.ast, registry, &render_options)?;
78
79 let mut css = String::new();
81 if render_options.include_default_css {
82 css.push_str(&theme.get_css());
83 }
84 css.push_str(®istry.get_all_css());
85
86 if render_options.syntax_highlight {
88 let highlight_css = get_syntax_highlight_css(&render_options.highlight_theme)?;
89 css.push_str(&highlight_css);
90 }
91
92 let title = if let Some(ref frontmatter) = doc.frontmatter {
94 frontmatter
95 .get("title")
96 .and_then(|v| v.as_str())
97 .unwrap_or(&render_options.title)
98 } else {
99 &render_options.title
100 };
101
102 let html = format!(
104 "<!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>",
105 title = title,
106 css = css,
107 toc = toc,
108 content = content,
109 scripts = get_scripts(render_options.code_copy_button)
110 );
111
112 let processed_html = process_component_directives(&html, registry)?;
114
115 let final_html = if render_options.minify {
117 minify_html(&processed_html)?
118 } else {
119 processed_html
120 };
121
122 Ok(final_html)
123}
124
125fn generate_toc(nodes: &[Node]) -> String {
127 let mut toc = String::from("<div class=\"markrust-toc\">\n <div class=\"markrust-toc-header\">Table of Contents</div>\n <ul>\n");
128
129 for node in nodes {
130 if let Node::Heading { level, content, id } = node {
131 if *level <= 3 {
132 let indent = " ".repeat(*level as usize);
133 toc.push_str(&format!(
134 "{}<li><a href=\"#{id}\">{content}</a></li>\n",
135 indent
136 ));
137 }
138 }
139 }
140
141 toc.push_str(" </ul>\n</div>");
142 toc
143}
144
145fn render_nodes(
147 nodes: &[Node],
148 registry: &ComponentRegistry,
149 options: &RenderOptions,
150) -> Result<String, Error> {
151 let mut html = String::new();
152
153 for node in nodes {
154 html.push_str(&render_node(node, registry, options)?);
155 }
156
157 Ok(html)
158}
159
160fn render_node(
162 node: &Node,
163 registry: &ComponentRegistry,
164 options: &RenderOptions,
165) -> Result<String, Error> {
166 match node {
167 Node::Heading { level, content, id } => Ok(format!(
168 "<h{level} id=\"{id}\">{content}</h{level}>\n",
169 level = level,
170 id = id,
171 content = content
172 )),
173 Node::Paragraph(inline_nodes) => {
174 let content = render_inline_nodes(inline_nodes)?;
175 Ok(format!("<p>{}</p>\n", content))
176 }
177 Node::BlockQuote(nodes) => {
178 let content = render_nodes(nodes, registry, options)?;
179 Ok(format!("<blockquote>\n{}</blockquote>\n", content))
180 }
181 Node::CodeBlock {
182 language,
183 content,
184 attributes,
185 } => render_code_block(language, content, attributes, options),
186 Node::List { ordered, items } => {
187 let tag = if *ordered { "ol" } else { "ul" };
188 let mut html = format!("<{tag}>\n");
189
190 for item in items {
191 let item_content = render_nodes(item, registry, options)?;
192 html.push_str(&format!(" <li>{}</li>\n", item_content));
193 }
194
195 html.push_str(&format!("</{tag}>\n"));
196 Ok(html)
197 }
198 Node::ThematicBreak => Ok("<hr>\n".to_string()),
199 Node::Component {
200 name,
201 attributes,
202 children,
203 } => {
204 if let Some(component) = registry.get(name) {
205 component.render(attributes, children)
206 } else {
207 Err(Error::ComponentError(format!(
208 "Component not found: {}",
209 name
210 )))
211 }
212 }
213 Node::Html(html) => Ok(format!("{}\n", html)),
214 Node::Table {
215 headers,
216 rows,
217 alignments,
218 } => table_to_html(headers, rows, alignments),
219 }
220}
221
222fn render_inline_nodes(nodes: &[InlineNode]) -> Result<String, Error> {
224 let mut html = String::new();
225
226 for node in nodes {
227 html.push_str(&render_inline_node(node)?);
228 }
229
230 Ok(html)
231}
232
233fn render_inline_node(node: &InlineNode) -> Result<String, Error> {
235 match node {
236 InlineNode::Text(text) => Ok(text.clone()),
237 InlineNode::Emphasis(nodes) => {
238 let content = render_inline_nodes(nodes)?;
239 Ok(format!("<em>{}</em>", content))
240 }
241 InlineNode::Strong(nodes) => {
242 let content = render_inline_nodes(nodes)?;
243 Ok(format!("<strong>{}</strong>", content))
244 }
245 InlineNode::Strikethrough(nodes) => {
246 let content = render_inline_nodes(nodes)?;
247 Ok(format!("<del>{}</del>", content))
248 }
249 InlineNode::Link { text, url, title } => {
250 let content = render_inline_nodes(text)?;
251 let title_attr = if let Some(title) = title {
252 format!(" title=\"{}\"", title)
253 } else {
254 String::new()
255 };
256 Ok(format!(
257 "<a href=\"{}\"{title_attr}>{}</a>",
258 url,
259 content,
260 title_attr = title_attr
261 ))
262 }
263 InlineNode::Image { alt, url, title } => {
264 let title_attr = if let Some(title) = title {
265 format!(" title=\"{}\"", title)
266 } else {
267 String::new()
268 };
269 Ok(format!(
270 "<img src=\"{}\" alt=\"{}\"{title_attr}>",
271 url,
272 alt,
273 title_attr = title_attr
274 ))
275 }
276 InlineNode::Code(code) => Ok(format!("<code>{}</code>", code)),
277 InlineNode::LineBreak => Ok("<br>".to_string()),
278 InlineNode::Html(html) => Ok(html.clone()),
279 }
280}
281
282fn render_code_block(
284 language: &Option<String>,
285 content: &str,
286 _attributes: &HashMap<String, String>,
287 options: &RenderOptions,
288) -> Result<String, Error> {
289 let lang_class = if let Some(lang) = language {
290 format!(" class=\"language-{}\"", lang)
291 } else {
292 String::new()
293 };
294
295 let code_content = if options.syntax_highlight && language.is_some() {
296 highlight_code(
297 content,
298 language.as_ref().unwrap(),
299 &options.highlight_theme,
300 )?
301 } else {
302 content.to_string()
303 };
304
305 let copy_button = if options.code_copy_button {
306 "<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>"
307 } else {
308 ""
309 };
310
311 Ok(format!(
312 "<div class=\"markrust-code-block\">\n <pre{lang_class}><code>{code_content}</code></pre>\n {copy_button}\n</div>\n",
313 lang_class = lang_class,
314 code_content = code_content,
315 copy_button = copy_button
316 ))
317}
318
319fn table_to_html(
321 headers: &[Vec<InlineNode>],
322 rows: &[Vec<Vec<InlineNode>>],
323 alignments: &[Alignment],
324) -> Result<String, Error> {
325 let mut html = String::from("<table class=\"markrust-table\">\n");
326
327 if !headers.is_empty() {
329 html.push_str(" <thead>\n <tr>\n");
330
331 for (i, header) in headers.iter().enumerate() {
332 let align_class =
333 get_alignment_class(alignments.get(i).copied().unwrap_or(Alignment::None));
334 let content = render_inline_nodes(header)?;
335 html.push_str(&format!(
336 " <th{align_class}>{content}</th>\n",
337 align_class = align_class,
338 content = content
339 ));
340 }
341
342 html.push_str(" </tr>\n </thead>\n");
343 }
344
345 if !rows.is_empty() {
347 html.push_str(" <tbody>\n");
348
349 for row in rows {
350 html.push_str(" <tr>\n");
351
352 for (i, cell) in row.iter().enumerate() {
353 let align_class =
354 get_alignment_class(alignments.get(i).copied().unwrap_or(Alignment::None));
355 let content = render_inline_nodes(cell)?;
356 html.push_str(&format!(
357 " <td{align_class}>{content}</td>\n",
358 align_class = align_class,
359 content = content
360 ));
361 }
362
363 html.push_str(" </tr>\n");
364 }
365
366 html.push_str(" </tbody>\n");
367 }
368
369 html.push_str("</table>\n");
370 Ok(html)
371}
372
373fn get_alignment_class(alignment: Alignment) -> String {
375 match alignment {
376 Alignment::None => String::new(),
377 Alignment::Left => " class=\"align-left\"".to_string(),
378 Alignment::Center => " class=\"align-center\"".to_string(),
379 Alignment::Right => " class=\"align-right\"".to_string(),
380 }
381}
382
383fn highlight_code(code: &str, language: &str, theme_name: &str) -> Result<String, Error> {
385 let syntax_set = SyntaxSet::load_defaults_newlines();
386 let theme_set = ThemeSet::load_defaults();
387
388 let syntax = syntax_set
389 .find_syntax_by_token(language)
390 .or_else(|| syntax_set.find_syntax_by_extension(language))
391 .unwrap_or_else(|| syntax_set.find_syntax_plain_text());
392
393 let _theme = theme_set
394 .themes
395 .get(theme_name)
396 .ok_or_else(|| Error::RenderError(format!("Theme not found: {}", theme_name)))?;
397
398 let mut html_generator =
399 ClassedHTMLGenerator::new_with_class_style(syntax, &syntax_set, ClassStyle::Spaced);
400
401 for line in code.lines() {
402 let newline = '\n';
404 let line_with_newline = format!("{}{}", line, newline);
405 html_generator
406 .parse_html_for_line_which_includes_newline(&line_with_newline)
407 .map_err(|e| Error::RenderError(format!("Failed to highlight code: {}", e)))?;
408 }
409
410 Ok(html_generator.finalize())
411}
412
413fn get_syntax_highlight_css(_theme_name: &str) -> Result<String, Error> {
415 Ok("
417/* Syntax highlighting styles */
418.hljs-keyword { color: #0000ff; font-weight: bold; }
419.hljs-string { color: #a31515; }
420.hljs-comment { color: #008000; }
421.hljs-function { color: #795e26; }
422.hljs-number { color: #098658; }
423 "
424 .to_string())
425}
426
427fn get_scripts(include_copy_button: bool) -> String {
429 if include_copy_button {
430 "<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()
431 } else {
432 String::new()
433 }
434}
435
436#[cfg(feature = "minify")]
437fn minify_html_impl(html: &str) -> Result<String, Error> {
438 let mut cfg = Cfg::new();
439 cfg.do_not_minify_doctype = true;
440 cfg.ensure_spec_compliant_unquoted_attribute_values = true;
441 cfg.keep_closing_tags = true;
442
443 let bytes = html.as_bytes();
444 let minified = minify(bytes, &cfg);
445
446 Ok(String::from_utf8_lossy(&minified).to_string())
447}
448
449#[cfg(not(feature = "minify"))]
450fn minify_html_impl(html: &str) -> Result<String, Error> {
451 Ok(html.to_string())
452}
453
454fn minify_html(html: &str) -> Result<String, Error> {
456 minify_html_impl(html)
457}
458
459fn process_component_directives(html: &str, registry: &ComponentRegistry) -> Result<String, Error> {
461 use regex::Regex;
462 use std::collections::HashMap;
463
464 let component_start_regex =
466 Regex::new(r"<!-- component_start:([a-zA-Z0-9_-]+):(.*?) -->").unwrap();
467 let component_end_regex = Regex::new(r"<!-- component_end -->").unwrap();
468 let nested_start_regex =
469 Regex::new(r"<!-- nested_component_start:([a-zA-Z0-9_-]+):(.*?) -->").unwrap();
470 let nested_end_regex = Regex::new(r"<!-- nested_component_end -->").unwrap();
471
472 let mut result = html.to_string();
473
474 let mut start_positions = Vec::new();
476 let mut component_data = Vec::new();
477
478 for cap in nested_start_regex.captures_iter(html) {
479 let start_match = cap.get(0).unwrap();
480 let component_name = cap[1].to_string();
481 let attributes_str = cap[2].to_string();
482
483 let mut attributes = HashMap::new();
485 for attr_pair in attributes_str.split_whitespace() {
486 if let Some(equals_pos) = attr_pair.find('=') {
487 let key = attr_pair[..equals_pos].trim();
488 let mut value = attr_pair[equals_pos + 1..].trim();
489
490 if (value.starts_with('"') && value.ends_with('"'))
492 || (value.starts_with('\'') && value.ends_with('\''))
493 {
494 value = &value[1..value.len() - 1];
495 }
496
497 attributes.insert(key.to_string(), value.to_string());
498 }
499 }
500
501 start_positions.push(start_match.start());
502 component_data.push((component_name, attributes, start_match.end()));
503 }
504
505 let mut replacements = Vec::new();
507
508 for (i, &start_pos) in start_positions.iter().enumerate() {
509 let (component_name, attributes, content_start) = &component_data[i];
510
511 if let Some(end_match) = nested_end_regex.find_at(&result, *content_start) {
512 let _content = &result[*content_start..end_match.start()];
513
514 if let Some(component) = registry.get(component_name) {
516 if let Ok(rendered) = component.render(attributes, &Vec::new()) {
517 replacements.push((start_pos, end_match.end(), rendered));
518 }
519 }
520 }
521 }
522
523 replacements.sort_by(|a, b| b.0.cmp(&a.0));
525 for (start, end, replacement) in replacements {
526 result.replace_range(start..end, &replacement);
527 }
528
529 let mut start_positions = Vec::new();
531 let mut component_data = Vec::new();
532
533 for cap in component_start_regex.captures_iter(&result) {
534 let start_match = cap.get(0).unwrap();
535 let component_name = cap[1].to_string();
536 let attributes_str = cap[2].to_string();
537
538 let mut attributes = HashMap::new();
540 for attr_pair in attributes_str.split_whitespace() {
541 if let Some(equals_pos) = attr_pair.find('=') {
542 let key = attr_pair[..equals_pos].trim();
543 let mut value = attr_pair[equals_pos + 1..].trim();
544
545 if (value.starts_with('"') && value.ends_with('"'))
547 || (value.starts_with('\'') && value.ends_with('\''))
548 {
549 value = &value[1..value.len() - 1];
550 }
551
552 attributes.insert(key.to_string(), value.to_string());
553 }
554 }
555
556 start_positions.push(start_match.start());
557 component_data.push((component_name, attributes, start_match.end()));
558 }
559
560 let mut replacements = Vec::new();
562
563 for (i, &start_pos) in start_positions.iter().enumerate() {
564 let (component_name, attributes, content_start) = &component_data[i];
565
566 if let Some(end_match) = component_end_regex.find_at(&result, *content_start) {
567 let _content = &result[*content_start..end_match.start()];
568
569 if let Some(component) = registry.get(component_name) {
571 if let Ok(rendered) = component.render(attributes, &Vec::new()) {
572 replacements.push((start_pos, end_match.end(), rendered));
573 }
574 }
575 }
576 }
577
578 replacements.sort_by(|a, b| b.0.cmp(&a.0));
580 for (start, end, replacement) in replacements {
581 result.replace_range(start..end, &replacement);
582 }
583
584 Ok(result)
585}