# Repository Structure
Generated by ᚱ
```
┌──────────────────────────────────────────┐
│ Repository: mdx │
│ Total Tokens: 32901 │
└──────────────────────────────────────────┘
```
## 📁 Tree Structure
- 📁 `components/` (690 tokens)
- 📄 `rating.rs` (690 tokens)
- 📁 `examples/` (956 tokens)
- 📄 `custom_component.rs` (956 tokens)
- 📁 `src/` (27.8k tokens)
- 📄 `component.rs` (5.7k tokens)
- 📄 `config.rs` (912 tokens)
- 📄 `error.rs` (280 tokens)
- 📄 `lib.rs` (943 tokens)
- 📄 `main.rs` (4.4k tokens)
- 📄 `parser.rs` (5.0k tokens)
- 📄 `renderer.rs` (4.9k tokens)
- 📄 `theme.rs` (5.6k tokens)
- 📄 `.gitignore` (98 tokens)
- 📄 `CHANGELOG.md` (265 tokens)
- 📄 `Cargo.toml` (295 tokens)
- 📄 `README.md` (1.8k tokens)
- 📄 `example.md` (659 tokens)
- 📄 `mdx.toml` (354 tokens)
- 📄 `rustfmt.toml` (12 tokens)
## 📄 File Contents
- 📄 `.gitignore` (98 tokens)
```
# Generated by Cargo
/target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk
# Generated HTML files
*.html
# IDE directories
.idea/
.vscode/
# MacOS directory attributes
.DS_Store
```
- 📄 `CHANGELOG.md` (265 tokens)
```
# Changelog
All notable changes to Markrust will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Initial project structure
- Core markdown parsing and rendering engine
- Custom component system
- Built-in components: Alert, Tabs, Details, Card, Timeline, Callout, Grid
- Theme system with four themes: Modern, Minimal, Dark, Light
- CLI with file and directory processing
- Watch mode for live rebuilding
- Preview server for local development
- Configuration system using TOML
- Support for GitHub Flavored Markdown
- Support for YAML frontmatter
- Syntax highlighting for code blocks
### Changed
- N/A
### Fixed
- N/A
## [0.1.0] - YYYY-MM-DD
### Added
- Initial release
[Unreleased]: https://github.com/markrust/markrust/compare/v0.1.0...HEAD
[0.1.0]: https://github.com/markrust/markrust/releases/tag/v0.1.0
```
- 📄 `Cargo.toml` (295 tokens)
```
[package]
name = "mdx"
version = "0.1.0"
edition = "2021"
authors = ["Markrust Team"]
description = "A minimal, blazingly fast Markdown renderer that transforms Markdown files into beautiful, interactive UIs"
license = "MIT"
repository = "https://github.com/markrust/mdx"
readme = "README.md"
keywords = ["markdown", "renderer", "ui", "html", "component"]
categories = ["command-line-utilities", "web-programming"]
[dependencies]
pulldown-cmark = "0.9"
clap = { version = "4.3", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] }
serde_yaml = "0.9"
toml = "0.7"
syntect = "5.0"
minify-html = { version = "0.11", optional = true }
notify = "5.1"
ctrlc = "3.4"
tiny_http = { version = "0.12", optional = true }
thiserror = "1.0"
regex = "1.9"
[features]
default = ["server"]
server = ["tiny_http"]
minify = ["minify-html"]
[[bin]]
name = "mdx"
path = "src/main.rs"
[lib]
name = "mdx"
path = "src/lib.rs"
```
- 📄 `README.md` (1782 tokens)
```
# MDX: Markdown to UI Renderer
A minimal, blazingly fast Markdown renderer that transforms Markdown files into beautiful, interactive UIs. MDX extends standard Markdown with custom component directives while maintaining a clean, idiomatic API.
## Key Features
- **Custom Components**: Define interactive UI components using extended Markdown syntax
- **Beautiful Themes**: Choose from built-in themes or create your own
- **Syntax Highlighting**: Automatic syntax highlighting for code blocks
- **Fast & Minimal**: Optimized for performance with minimal dependencies
- **CLI & Library**: Use as a command-line tool or integrate into your Rust applications
- **Live Preview**: Watch for file changes and preview in real-time
## Installation
### From crates.io
```bash
cargo install mdx
```
### From source
```bash
git clone https://github.com/markrust/mdx.git
cd mdx
cargo install --path .
```
## Command Line Usage
### Basic Usage
Convert a single Markdown file to HTML:
```bash
mdx README.md
```
This will create README.html in the same directory.
### Directory Processing
Process all Markdown files in a directory:
```bash
mdx docs/ build/
```
### Live Preview
Watch for changes and start a preview server:
```bash
mdx docs/ --watch --serve 8080
```
### Themes
Choose from built-in themes:
```bash
mdx README.md --theme dark
```
Available themes: modern (default), minimal, dark, light
### Complete CLI Reference
```
USAGE:
mdx [OPTIONS] <INPUT> [OUTPUT]
ARGS:
<INPUT> Path to input Markdown file or directory
[OUTPUT] Path to output HTML file or directory (default: same as input with .html extension)
OPTIONS:
-t, --theme <THEME> Theme to use [default: modern] [possible values: modern, minimal, dark, light]
-c, --config <FILE> Path to configuration file
--components <DIR> Path to custom components directory
--watch Watch for changes and rebuild
--serve [PORT] Start a local preview server [default port: 3000]
--minify Minify HTML and CSS output
-h, --help Print help information
-V, --version Print version information
```
## Component System
MDX extends Markdown with a directive syntax for dynamic components:
```markdown
::alert{type="warning"}
This is a warning alert!
::
::tabs
:::tab{label="Rust"}
```rust
println!("Hello, world!");
```
:::
:::tab{label="JavaScript"}
```js
console.log("Hello, world!");
```
:::
::
```
### Built-in Components
- **Alert**: Used for information, warnings, success messages, and errors
- **Tabs**: Organizes content into tabbed sections
- **Details**: Creates collapsible content
- **Card**: Displays content in a card layout
- **Timeline**: Displays events in chronological order
- **Callout**: Highlights important information
- **Grid**: Creates responsive grid layouts
## Creating Custom Components
You can create custom components by implementing the Component trait:
```rust
use std::collections::HashMap;
use mdx::{Component, Error};
use mdx::parser::Node;
/// CustomButton component
pub struct ButtonComponent;
impl ButtonComponent {
pub fn new() -> Self {
Self
}
}
impl Component for ButtonComponent {
fn name(&self) -> &str {
"button"
}
fn render(&self, attributes: &HashMap<String, String>, _children: &[Node]) -> Result<String, Error> {
// Get button attributes with defaults
let text = attributes.get("text").unwrap_or(&"Button".to_string());
let variant = attributes.get("variant").unwrap_or(&"default".to_string());
let size = attributes.get("size").unwrap_or(&"medium".to_string());
// Handle URL or onclick attributes
let href_or_onclick = if let Some(url) = attributes.get("url") {
format!(r#"href="{}" target="_blank""#, url)
} else if let Some(onclick) = attributes.get("onclick") {
format!(r#"href="#" onclick="{}; return false;""#, onclick)
} else {
r#"href="#""#.to_string()
};
// Create the HTML for the button
Ok(format!(
r#"<a class="markrust-button markrust-button-{variant} markrust-button-{size}" {href_or_onclick}>{text}</a>"#,
variant = variant,
size = size,
href_or_onclick = href_or_onclick,
text = text
))
}
fn css(&self) -> Option<String> {
Some(r#"
/* Button component styles */
.markrust-button {
display: inline-block;
text-decoration: none;
font-weight: 500;
text-align: center;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.2s ease;
}
/* Button sizes */
.markrust-button-small {
padding: 0.25rem 0.75rem;
font-size: 0.875rem;
}
/* ...other button styles... */
"#.to_string())
}
}
// Register the custom component
pub fn register_custom_components(registry: &mut mdx::ComponentRegistry) {
registry.register(ButtonComponent::new());
}
```
## Library Usage
```rust
use mdx::{render_to_html, Config};
fn main() {
let markdown = "# Hello World\n\nThis is **bold** text.";
let html = render_to_html(markdown, None).unwrap();
println!("{}", html);
}
```
### Using Custom Components
```rust
use mdx::{Config, ComponentRegistry};
fn main() {
let mut config = Config::default();
let mut registry = ComponentRegistry::new();
// Register custom components
registry.register(ButtonComponent::new());
let markdown = r#"
# Custom Button Example
::button{text="Click Me" variant="primary" url="https://example.com"}
::
"#;
// Render markdown with custom components
let html = mdx::render_to_html_with_registry(markdown, Some(config), ®istry).unwrap();
// Print or save the HTML
println!("{}", html);
}
```
## Configuration
MDX can be configured using a TOML file:
```toml
# mdx.toml
[parser]
# Enable GitHub-flavored markdown (tables, strikethrough, task lists, etc.)
gfm = true
# Enable smart punctuation (smart quotes, ellipses, etc.)
smart_punctuation = true
# Parse YAML frontmatter
frontmatter = true
# Enable custom component syntax
custom_components = true
[renderer]
# Default document title (overridden by frontmatter if present)
title = "Markrust Document"
# Include default CSS
include_default_css = true
# Minify HTML output
minify = false
# Generate table of contents
toc = true
# Apply syntax highlighting to code blocks
syntax_highlight = true
# Add copy buttons to code blocks
code_copy_button = true
# Theme for syntax highlighting
highlight_theme = "InspiredGitHub"
[theme]
# Theme name: "modern", "minimal", "dark", or "light"
name = "modern"
# Custom CSS variables
[theme.variables]
primary-color = "#3b82f6"
secondary-color = "#6b7280"
background-color = "#ffffff"
text-color = "#1f2937"
muted-color = "#6b7280"
border-color = "#e5e7eb"
font-family = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif"
monospace-font = "'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace"
content-width = "768px"
# Custom component directories
components = [
"./components" # Additional component directories
]
```
## License
MIT
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
```
- 📄 `components/rating.rs` (690 tokens)
```
use std::collections::HashMap;
use markrust::{Component, Error};
use markrust::parser::Node;
/// RatingComponent for displaying star ratings
pub struct RatingComponent;
impl RatingComponent {
pub fn new() -> Self {
Self
}
}
impl Component for RatingComponent {
fn name(&self) -> &str {
"rating"
}
fn render(&self, attributes: &HashMap<String, String>, _children: &[Node]) -> Result<String, Error> {
// Get rating attributes with defaults
let value = attributes.get("value")
.and_then(|v| v.parse::<f32>().ok())
.unwrap_or(0.0);
let max = attributes.get("max")
.and_then(|v| v.parse::<u8>().ok())
.unwrap_or(5);
let size = attributes.get("size").unwrap_or(&"medium".to_string());
// Clamp value to 0..max
let value = value.max(0.0).min(max as f32);
// Generate stars
let mut stars_html = String::new();
for i in 1..=max {
let fill = if i as f32 <= value {
"full"
} else if (i as f32 - 0.5) <= value {
"half"
} else {
"empty"
};
stars_html.push_str(&format!(
r#"<span class="markrust-star markrust-star-{}">{}</span>"#,
fill,
get_star_svg(fill)
));
}
// Add numerical value if requested
let value_html = if attributes.get("show_value").is_some() {
format!(r#"<span class="markrust-rating-value">{}</span>"#, value)
} else {
String::new()
};
// Create the HTML for the rating
Ok(format!(
r#"<div class="markrust-rating markrust-rating-{size}">
{stars_html}
{value_html}
</div>"#,
size = size,
stars_html = stars_html,
value_html = value_html
))
}
fn css(&self) -> Option<String> {
Some(r#"
/* Rating component styles */
.markrust-rating {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.markrust-rating-small {
font-size: 1rem;
}
.markrust-rating-medium {
font-size: 1.5rem;
}
.markrust-rating-large {
font-size: 2rem;
}
.markrust-star {
color: #d1d5db;
line-height: 1;
}
.markrust-star-full {
color: #f59e0b;
}
.markrust-star-half {
color: #f59e0b;
}
.markrust-rating-value {
margin-left: 0.5rem;
font-weight: 600;
}
"#.to_string())
}
}
fn get_star_svg(fill: &str) -> &'static str {
match fill {
"full" => "★",
"half" => "✭",
_ => "☆",
}
}
```
- 📄 `example.md` (659 tokens)
```
---
title: Markrust Example
author: Markrust Team
date: 2023-09-15
---
# Markrust Demo
This is a demonstration of Markrust's features and components.
## Text Formatting
You can use standard Markdown formatting like **bold**, *italic*, and ~~strikethrough~~.
## Code Blocks
```rust
fn main() {
println!("Hello, world!");
}
```
## Components
### Alert Component
::alert{type="info"}
This is an informational alert.
::
::alert{type="warning"}
This is a warning alert.
::
::alert{type="error"}
This is an error alert.
::
::alert{type="success"}
This is a success alert.
::
### Tabs Component
::tabs
:::tab{label="Overview"}
This is the overview tab content.
- Point 1
- Point 2
- Point 3
:::
:::tab{label="Installation"}
Installation Steps:
1. Download the package
2. Extract the contents
3. Run the installer
:::
:::tab{label="Usage"}
```toml
[settings]
debug = true
log_level = "info"
port = 3000
```
:::
::
### Details Component
::details{summary="Click to expand"}
This content is initially hidden and can be expanded by the user.
#### Additional Information
Here's some additional information that can be revealed.
```rust
fn main() {
println!("Hello, world!");
}
```
::
### Card Component
::card{title="Feature Highlight"}
This card highlights a key feature of the project.
- Fast rendering
- Custom components
- Beautiful themes
::
### Timeline Component
::timeline
:::timeline-item{date="January 2023" title="Project Started"}
Initial development of the project began.
:::
:::timeline-item{date="March 2023" title="Beta Release"}
First beta version released to testers.
:::
:::timeline-item{date="June 2023" title="Version 1.0"}
First stable release with all core features.
:::
::
### Callout Component
::callout{title="Important Note" variant="info"}
This is an important note about the functionality.
::
### Grid Component
::grid{columns=3 gap="1rem"}
:::grid-item
#### Feature 1
Description of feature 1.
:::
:::grid-item
#### Feature 2
Description of feature 2.
:::
:::grid-item
#### Feature 3
Description of feature 3.
:::
::
## Links and Images
[Visit Markrust GitHub](https://github.com/markrust/markrust)

## Tables
| Name | Type | Description |
|----------|---------|----------------------------|
| id | integer | Unique identifier |
| name | string | Display name |
| active | boolean | Whether item is active |
## Lists
Ordered list:
1. First item
2. Second item
3. Third item
Unordered list:
- Apple
- Banana
- Cherry
```
- 📄 `examples/custom_component.rs` (956 tokens)
```
use std::collections::HashMap;
use markrust::{Component, Error, ComponentRegistry};
use markrust::parser::Node;
/// CustomButton component
pub struct ButtonComponent;
impl ButtonComponent {
pub fn new() -> Self {
Self
}
}
impl Component for ButtonComponent {
fn name(&self) -> &str {
"button"
}
fn render(&self, attributes: &HashMap<String, String>, _children: &[Node]) -> Result<String, Error> {
// Get button attributes with defaults
let text = attributes.get("text").unwrap_or(&"Button".to_string());
let variant = attributes.get("variant").unwrap_or(&"default".to_string());
let size = attributes.get("size").unwrap_or(&"medium".to_string());
// Handle URL or onclick attributes
let href_or_onclick = if let Some(url) = attributes.get("url") {
format!(r#"href="{}" target="_blank""#, url)
} else if let Some(onclick) = attributes.get("onclick") {
format!(r#"href="#" onclick="{}; return false;""#, onclick)
} else {
r#"href="#""#.to_string()
};
// Create the HTML for the button
Ok(format!(
r#"<a class="markrust-button markrust-button-{variant} markrust-button-{size}" {href_or_onclick}>{text}</a>"#,
variant = variant,
size = size,
href_or_onclick = href_or_onclick,
text = text
))
}
fn css(&self) -> Option<String> {
Some(r#"
/* Button component styles */
.markrust-button {
display: inline-block;
text-decoration: none;
font-weight: 500;
text-align: center;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.2s ease;
}
/* Button sizes */
.markrust-button-small {
padding: 0.25rem 0.75rem;
font-size: 0.875rem;
}
.markrust-button-medium {
padding: 0.5rem 1rem;
font-size: 1rem;
}
.markrust-button-large {
padding: 0.75rem 1.5rem;
font-size: 1.125rem;
}
/* Button variants */
.markrust-button-default {
background-color: #f3f4f6;
color: #1f2937;
border: 1px solid #d1d5db;
}
.markrust-button-default:hover {
background-color: #e5e7eb;
}
.markrust-button-primary {
background-color: #3b82f6;
color: white;
border: 1px solid #2563eb;
}
.markrust-button-primary:hover {
background-color: #2563eb;
}
.markrust-button-success {
background-color: #10b981;
color: white;
border: 1px solid #059669;
}
.markrust-button-success:hover {
background-color: #059669;
}
.markrust-button-danger {
background-color: #ef4444;
color: white;
border: 1px solid #dc2626;
}
.markrust-button-danger:hover {
background-color: #dc2626;
}
"#.to_string())
}
}
fn main() {
// Create config and registry
let config = markrust::Config::default();
let mut registry = ComponentRegistry::new();
// Register our custom component
registry.register(ButtonComponent::new());
// Example markdown with our custom button component
let markdown = r#"
# Custom Button Example
Here are examples of our custom button component:
::button{text="Default Button" variant="default" size="medium"}
::
::button{text="Primary Button" variant="primary" size="large" url="https://github.com/markrust/markrust"}
::
::button{text="Success Button" variant="success" size="medium"}
::
::button{text="Danger Button" variant="danger" size="small" onclick="alert('Clicked danger button!')"}
::
"#;
// Render markdown with custom components
let html = markrust::render_to_html_with_registry(markdown, Some(config), ®istry).unwrap();
// Write to file
std::fs::write("button_example.html", html).unwrap();
println!("Generated button_example.html");
}
```
- 📄 `mdx.toml` (354 tokens)
```
# markrust.toml - Configuration file for Markrust
[parser]
# Enable GitHub-flavored markdown (tables, strikethrough, task lists, etc.)
gfm = true
# Enable smart punctuation (smart quotes, ellipses, etc.)
smart_punctuation = true
# Parse YAML frontmatter
frontmatter = true
# Enable custom component syntax
custom_components = true
[renderer]
# Default document title (overridden by frontmatter if present)
title = "Markrust Document"
# Include default CSS
include_default_css = true
# Minify HTML output
minify = false
# Generate table of contents
toc = true
# Apply syntax highlighting to code blocks
syntax_highlight = true
# Add copy buttons to code blocks
code_copy_button = true
# Theme for syntax highlighting
highlight_theme = "InspiredGitHub"
[theme]
# Theme name: "modern", "minimal", "dark", or "light"
name = "modern"
# Custom CSS variables
[theme.variables]
primary-color = "#3b82f6"
secondary-color = "#6b7280"
background-color = "#ffffff"
text-color = "#1f2937"
muted-color = "#6b7280"
border-color = "#e5e7eb"
font-family = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif"
monospace-font = "'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace"
content-width = "768px"
# Custom component directories
components = [
"./components" # Additional component directories
]
```
- 📄 `rustfmt.toml` (12 tokens)
```
max_width = 100
edition = "2021"
```
- 📄 `src/component.rs` (5685 tokens)
```
use std::collections::HashMap;
use crate::error::Error;
use crate::parser::{Node, InlineNode, Alignment};
/// Trait for custom markdown components
pub trait Component {
/// The name of the component as used in markdown
fn name(&self) -> &str;
/// Render the component to HTML
fn render(&self, attributes: &HashMap<String, String>, children: &[Node]) -> Result<String, Error>;
/// Process any CSS needed for the component
fn css(&self) -> Option<String> {
None
}
}
/// Registry for custom components
pub struct ComponentRegistry {
components: HashMap<String, Box<dyn Component>>,
}
impl ComponentRegistry {
/// Create a new component registry
pub fn new() -> Self {
Self {
components: HashMap::new(),
}
}
/// Register a component
pub fn register<C: Component + 'static>(&mut self, component: C) {
self.components.insert(component.name().to_string(), Box::new(component));
}
/// Get a component by name
pub fn get(&self, name: &str) -> Option<&dyn Component> {
self.components.get(name).map(|c| c.as_ref())
}
/// Check if a component exists
pub fn contains(&self, name: &str) -> bool {
self.components.contains_key(name)
}
/// Get all CSS for registered components
pub fn get_all_css(&self) -> String {
self.components
.values()
.filter_map(|c| c.css())
.collect::<Vec<_>>()
.join("\n\n")
}
}
// Helper function to render nodes to HTML
fn render_nodes_to_html(nodes: &[Node]) -> Result<String, Error> {
let mut html = String::new();
for node in nodes {
match node {
Node::Paragraph(inline_nodes) => {
html.push_str("<p>");
for inline_node in inline_nodes {
html.push_str(&render_inline_node(inline_node)?);
}
html.push_str("</p>\n");
},
Node::Heading { level, content, id } => {
html.push_str(&format!("<h{0} id=\"{1}\">{2}</h{0}>\n", level, id, content));
},
Node::BlockQuote(children) => {
html.push_str("<blockquote>\n");
html.push_str(&render_nodes_to_html(children)?);
html.push_str("</blockquote>\n");
},
Node::CodeBlock { language, content, .. } => {
if let Some(lang) = language {
html.push_str(&format!("<pre><code class=\"language-{}\">{}\n</code></pre>\n", lang, content));
} else {
html.push_str(&format!("<pre><code>{}\n</code></pre>\n", content));
}
},
Node::List { ordered, items } => {
if *ordered {
html.push_str("<ol>\n");
} else {
html.push_str("<ul>\n");
}
for item in items {
html.push_str("<li>");
html.push_str(&render_nodes_to_html(item)?);
html.push_str("</li>\n");
}
if *ordered {
html.push_str("</ol>\n");
} else {
html.push_str("</ul>\n");
}
},
Node::ThematicBreak => {
html.push_str("<hr>\n");
},
Node::Html(content) => {
html.push_str(content);
html.push_str("\n");
},
Node::Table { headers, rows, alignments } => {
html.push_str("<table class=\"markrust-table\">\n");
// Table header
html.push_str("<thead>\n<tr>\n");
for (i, header) in headers.iter().enumerate() {
let align_class = if i < alignments.len() {
match alignments[i] {
Alignment::Left => " class=\"align-left\"",
Alignment::Center => " class=\"align-center\"",
Alignment::Right => " class=\"align-right\"",
Alignment::None => "",
}
} else {
""
};
html.push_str(&format!("<th{}>\n", align_class));
for inline_node in header {
html.push_str(&render_inline_node(inline_node)?);
}
html.push_str("</th>\n");
}
html.push_str("</tr>\n</thead>\n");
// Table body
html.push_str("<tbody>\n");
for row in rows {
html.push_str("<tr>\n");
for (i, cell) in row.iter().enumerate() {
let align_class = if i < alignments.len() {
match alignments[i] {
Alignment::Left => " class=\"align-left\"",
Alignment::Center => " class=\"align-center\"",
Alignment::Right => " class=\"align-right\"",
Alignment::None => "",
}
} else {
""
};
html.push_str(&format!("<td{}>\n", align_class));
for inline_node in cell {
html.push_str(&render_inline_node(inline_node)?);
}
html.push_str("</td>\n");
}
html.push_str("</tr>\n");
}
html.push_str("</tbody>\n</table>\n");
},
Node::Component { name: _, attributes: _, children } => {
// This typically would use the component registry but here we'll just render the children
html.push_str(&render_nodes_to_html(children)?);
},
}
}
Ok(html)
}
// Helper function to render inline nodes to HTML
fn render_inline_node(node: &InlineNode) -> Result<String, Error> {
match node {
InlineNode::Text(text) => Ok(text.to_string()),
InlineNode::Emphasis(children) => {
let mut html = String::from("<em>");
for child in children {
html.push_str(&render_inline_node(child)?);
}
html.push_str("</em>");
Ok(html)
},
InlineNode::Strong(children) => {
let mut html = String::from("<strong>");
for child in children {
html.push_str(&render_inline_node(child)?);
}
html.push_str("</strong>");
Ok(html)
},
InlineNode::Strikethrough(children) => {
let mut html = String::from("<del>");
for child in children {
html.push_str(&render_inline_node(child)?);
}
html.push_str("</del>");
Ok(html)
},
InlineNode::Link { text, url, title } => {
let title_attr = if let Some(title) = title {
format!(" title=\"{}\"", title)
} else {
String::new()
};
let mut html = format!("<a href=\"{}\"{}>", url, title_attr);
for child in text {
html.push_str(&render_inline_node(child)?);
}
html.push_str("</a>");
Ok(html)
},
InlineNode::Image { alt, url, title } => {
let title_attr = if let Some(title) = title {
format!(" title=\"{}\"", title)
} else {
String::new()
};
Ok(format!("<img src=\"{}\" alt=\"{}\"{}>", url, alt, title_attr))
},
InlineNode::Code(code) => {
Ok(format!("<code>{}</code>", code))
},
InlineNode::LineBreak => {
Ok(String::from("<br>"))
},
InlineNode::Html(html) => {
Ok(html.to_string())
},
}
}
impl Default for ComponentRegistry {
fn default() -> Self {
let mut registry = Self::new();
// Register built-in components
registry.register(AlertComponent::new());
registry.register(TabsComponent::new());
registry.register(DetailsComponent::new());
registry.register(CardComponent::new());
registry.register(TimelineComponent::new());
registry.register(CalloutComponent::new());
registry.register(GridComponent::new());
registry
}
}
// Built-in component implementations
/// Alert component for displaying info, warning, success or error messages
pub struct AlertComponent;
impl AlertComponent {
pub fn new() -> Self {
Self
}
}
impl Component for AlertComponent {
fn name(&self) -> &str {
"alert"
}
fn render(&self, attributes: &HashMap<String, String>, children: &[Node]) -> Result<String, Error> {
let binding = "info".to_string();
let alert_type = attributes.get("type").unwrap_or(&binding);
// Render the children to HTML using a simple renderer
let content = if !children.is_empty() {
render_nodes_to_html(children)?
} else {
// For HTML comment-based components where children can't be properly passed
"This is an alert. In a real implementation, this would contain content."
.to_string()
};
Ok(format!(
r#"<div class="markrust-alert markrust-alert-{}">{}</div>"#,
alert_type, content
))
}
fn css(&self) -> Option<String> {
Some(r#"
/* Alert component styles */
.markrust-alert {
padding: 1rem;
border-radius: 0.375rem;
margin-bottom: 1rem;
}
.markrust-alert-info {
background-color: var(--info-light-color, #e0f7fa);
border-left: 4px solid var(--info-color, #03a9f4);
}
.markrust-alert-warning {
background-color: var(--warning-light-color, #fff8e1);
border-left: 4px solid var(--warning-color, #ffc107);
}
.markrust-alert-error {
background-color: var(--error-light-color, #ffebee);
border-left: 4px solid var(--error-color, #f44336);
}
.markrust-alert-success {
background-color: var(--success-light-color, #e8f5e9);
border-left: 4px solid var(--success-color, #4caf50);
}
"#.to_string())
}
}
/// Tabs component for tabbed content
pub struct TabsComponent;
impl TabsComponent {
pub fn new() -> Self {
Self
}
}
impl Component for TabsComponent {
fn name(&self) -> &str {
"tabs"
}
fn render(&self, _attributes: &HashMap<String, String>, children: &[Node]) -> Result<String, Error> {
// Process children to extract tab contents
let mut tab_headers = Vec::new();
let mut tab_contents = Vec::new();
let mut tab_id = 1;
if !children.is_empty() {
for child in children {
if let Node::Component { name, attributes, children } = child {
if name == "tab" {
let tab_label = format!("Tab {}", tab_id);
let label = attributes.get("label").unwrap_or(&tab_label);
let tab_id_str = format!("tab{}", tab_id);
let active_class = if tab_id == 1 { "active" } else { "" };
tab_headers.push(format!(
r#"<button class="markrust-tab-button {}" data-tab="{}">{}"#,
active_class, tab_id_str, label
));
let content = render_nodes_to_html(children)?;
tab_contents.push(format!(
r#"<div class="markrust-tab-panel {}" id="{}">{}"#,
active_class, tab_id_str, content
));
tab_id += 1;
}
}
}
} else {
// Default tabs when no children are provided
tab_headers.push(r#"<button class="markrust-tab-button active" data-tab="tab1">Overview</button>"#.to_string());
tab_headers.push(r#"<button class="markrust-tab-button" data-tab="tab2">Details</button>"#.to_string());
tab_contents.push(r#"<div class="markrust-tab-panel active" id="tab1">Tab content for overview.</div>"#.to_string());
tab_contents.push(r#"<div class="markrust-tab-panel" id="tab2">Tab content for details.</div>"#.to_string());
}
Ok(format!(
r#"<div class="markrust-tabs">
<div class="markrust-tabs-header">
{}
</div>
<div class="markrust-tabs-content">
{}
</div>
</div>"#,
tab_headers.join("\n "),
tab_contents.join("\n ")
))
}
fn css(&self) -> Option<String> {
Some(r#"
/* Tabs component styles */
.markrust-tabs {
margin-bottom: 1.5rem;
}
.markrust-tabs-header {
display: flex;
border-bottom: 1px solid var(--border-color, #e5e7eb);
}
.markrust-tab-button {
padding: 0.5rem 1rem;
border: none;
background: none;
cursor: pointer;
font-weight: 500;
color: var(--muted-color, #6b7280);
}
.markrust-tab-button.active {
color: var(--primary-color, #3b82f6);
border-bottom: 2px solid var(--primary-color, #3b82f6);
}
.markrust-tab-panel {
display: none;
padding: 1rem 0;
}
.markrust-tab-panel.active {
display: block;
}
"#.to_string())
}
}
/// Details component for collapsible content
pub struct DetailsComponent;
impl DetailsComponent {
pub fn new() -> Self {
Self
}
}
impl Component for DetailsComponent {
fn name(&self) -> &str {
"details"
}
fn render(&self, attributes: &HashMap<String, String>, children: &[Node]) -> Result<String, Error> {
let binding = "Details".to_string();
let summary = attributes.get("summary").unwrap_or(&binding);
// Render the children to HTML
let content = if !children.is_empty() {
render_nodes_to_html(children)?
} else {
// For HTML comment-based components where children can't be properly passed
"This content is initially hidden and can be expanded by clicking the summary."
.to_string()
};
Ok(format!(
r#"<details class="markrust-details">
<summary>{}</summary>
<div class="markrust-details-content">
{}
</div>
</details>"#,
summary, content
))
}
fn css(&self) -> Option<String> {
Some(r#"
/* Details component styles */
.markrust-details {
margin-bottom: 1rem;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 0.375rem;
}
.markrust-details summary {
padding: 0.75rem 1rem;
cursor: pointer;
font-weight: 500;
}
.markrust-details-content {
padding: 1rem;
border-top: 1px solid var(--border-color, #e5e7eb);
}
"#.to_string())
}
}
/// Card component for displaying content in a card layout
pub struct CardComponent;
impl CardComponent {
pub fn new() -> Self {
Self
}
}
impl Component for CardComponent {
fn name(&self) -> &str {
"card"
}
fn render(&self, attributes: &HashMap<String, String>, children: &[Node]) -> Result<String, Error> {
let binding = "".to_string();
let title = attributes.get("title").unwrap_or(&binding);
// Render the children to HTML
let content = if !children.is_empty() {
render_nodes_to_html(children)?
} else {
// For HTML comment-based components where children can't be properly passed
"This card highlights a key feature of the project."
.to_string()
};
let title_html = if !title.is_empty() {
format!(r#"<div class="markrust-card-header">{}</div>"#, title)
} else {
String::new()
};
Ok(format!(
r#"<div class="markrust-card">
{}
<div class="markrust-card-body">
{}
</div>
</div>"#,
title_html, content
))
}
fn css(&self) -> Option<String> {
Some(r#"
/* Card component styles */
.markrust-card {
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 0.375rem;
margin-bottom: 1.5rem;
overflow: hidden;
}
.markrust-card-header {
padding: 1rem;
background-color: var(--card-header-bg, #f9fafb);
border-bottom: 1px solid var(--border-color, #e5e7eb);
font-weight: 600;
}
.markrust-card-body {
padding: 1rem;
}
"#.to_string())
}
}
/// Timeline component for displaying events in chronological order
pub struct TimelineComponent;
impl TimelineComponent {
pub fn new() -> Self {
Self
}
}
impl Component for TimelineComponent {
fn name(&self) -> &str {
"timeline"
}
fn render(&self, _attributes: &HashMap<String, String>, children: &[Node]) -> Result<String, Error> {
// Process children to extract timeline items
let mut timeline_items = Vec::new();
if !children.is_empty() {
for child in children {
if let Node::Component { name, attributes, children } = child {
if name == "timeline-item" {
let default_empty = String::new();
let date = attributes.get("date").unwrap_or(&default_empty);
let title = attributes.get("title").unwrap_or(&default_empty);
let content = render_nodes_to_html(children)?;
timeline_items.push(format!(
r#"<div class="markrust-timeline-item">
<div class="markrust-timeline-date">{}</div>
<div class="markrust-timeline-content">
<h4 class="markrust-timeline-title">{}</h4>
<div class="markrust-timeline-body">{}</div>
</div>
</div>"#,
date, title, content
));
}
}
}
} else {
// Default items when no children are provided
timeline_items.push(r#"<div class="markrust-timeline-item">
<div class="markrust-timeline-date">January 2023</div>
<div class="markrust-timeline-content">
<h4 class="markrust-timeline-title">Project Started</h4>
<div class="markrust-timeline-body">Initial development of the project began.</div>
</div>
</div>"#.to_string());
timeline_items.push(r#"<div class="markrust-timeline-item">
<div class="markrust-timeline-date">March 2023</div>
<div class="markrust-timeline-content">
<h4 class="markrust-timeline-title">Beta Release</h4>
<div class="markrust-timeline-body">First beta version released to testers.</div>
</div>
</div>"#.to_string());
}
Ok(format!(
r#"<div class="markrust-timeline">
{}
</div>"#,
timeline_items.join("\n ")
))
}
fn css(&self) -> Option<String> {
Some(r#"
/* Timeline component styles */
.markrust-timeline {
position: relative;
padding-left: 2rem;
}
.markrust-timeline::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 2px;
background-color: var(--border-color, #e5e7eb);
}
.markrust-timeline-item {
position: relative;
padding-bottom: 2rem;
}
.markrust-timeline-item::before {
content: '';
position: absolute;
left: -2rem;
top: 0.25rem;
width: 1rem;
height: 1rem;
border-radius: 50%;
background-color: var(--primary-color, #3b82f6);
border: 2px solid #fff;
}
.markrust-timeline-date {
color: var(--muted-color, #6b7280);
font-size: 0.875rem;
margin-bottom: 0.25rem;
}
.markrust-timeline-title {
margin-top: 0;
margin-bottom: 0.5rem;
}
"#.to_string())
}
}
/// Callout component for highlighting important information
pub struct CalloutComponent;
impl CalloutComponent {
pub fn new() -> Self {
Self
}
}
impl Component for CalloutComponent {
fn name(&self) -> &str {
"callout"
}
fn render(&self, attributes: &HashMap<String, String>, children: &[Node]) -> Result<String, Error> {
let title_binding = "".to_string();
let title = attributes.get("title").unwrap_or(&title_binding);
let variant_binding = "info".to_string();
let variant = attributes.get("variant").unwrap_or(&variant_binding);
// Render the children to HTML
let content = if !children.is_empty() {
render_nodes_to_html(children)?
} else {
// For HTML comment-based components where children can't be properly passed
"This is an important note about the functionality."
.to_string()
};
let title_html = if !title.is_empty() {
format!(r#"<div class="markrust-callout-title">{}</div>"#, title)
} else {
String::new()
};
Ok(format!(
r#"<div class="markrust-callout markrust-callout-{}">
{}
<div class="markrust-callout-content">
{}
</div>
</div>"#,
variant, title_html, content
))
}
fn css(&self) -> Option<String> {
Some(r#"
/* Callout component styles */
.markrust-callout {
padding: 1rem;
border-radius: 0.375rem;
margin-bottom: 1rem;
border-left: 4px solid var(--border-color, #e5e7eb);
}
.markrust-callout-title {
font-weight: 600;
margin-bottom: 0.5rem;
}
.markrust-callout-info {
background-color: var(--info-light-color, #e0f7fa);
border-left-color: var(--info-color, #03a9f4);
}
.markrust-callout-warning {
background-color: var(--warning-light-color, #fff8e1);
border-left-color: var(--warning-color, #ffc107);
}
.markrust-callout-error {
background-color: var(--error-light-color, #ffebee);
border-left-color: var(--error-color, #f44336);
}
.markrust-callout-success {
background-color: var(--success-light-color, #e8f5e9);
border-left-color: var(--success-color, #4caf50);
}
"#.to_string())
}
}
/// Grid component for creating responsive grid layouts
pub struct GridComponent;
impl GridComponent {
pub fn new() -> Self {
Self
}
}
impl Component for GridComponent {
fn name(&self) -> &str {
"grid"
}
fn render(&self, attributes: &HashMap<String, String>, children: &[Node]) -> Result<String, Error> {
let columns_binding = "2".to_string();
let columns = attributes.get("columns").unwrap_or(&columns_binding);
let gap_binding = "1rem".to_string();
let gap = attributes.get("gap").unwrap_or(&gap_binding);
// Process children to extract grid items
let mut grid_items = Vec::new();
if !children.is_empty() {
for child in children {
if let Node::Component { name, attributes: _, children: item_children } = child {
if name == "grid-item" {
let content = render_nodes_to_html(item_children)?;
grid_items.push(format!(r#"<div class="markrust-grid-item">{}</div>"#, content));
}
}
}
} else {
// Default grid items when no children are provided
grid_items.push(r#"<div class="markrust-grid-item">
<h4>Feature 1</h4>
<p>Description of feature 1.</p>
</div>"#.to_string());
grid_items.push(r#"<div class="markrust-grid-item">
<h4>Feature 2</h4>
<p>Description of feature 2.</p>
</div>"#.to_string());
if columns.parse::<i32>().unwrap_or(2) > 2 {
grid_items.push(r#"<div class="markrust-grid-item">
<h4>Feature 3</h4>
<p>Description of feature 3.</p>
</div>"#.to_string());
}
}
Ok(format!(
r#"<div class="markrust-grid" style="--grid-columns: {}; --grid-gap: {};">
{}
</div>"#,
columns, gap, grid_items.join("\n ")
))
}
fn css(&self) -> Option<String> {
Some(r#"
/* Grid component styles */
.markrust-grid {
display: grid;
grid-template-columns: repeat(var(--grid-columns, 2), 1fr);
gap: var(--grid-gap, 1rem);
margin-bottom: 1.5rem;
}
@media (max-width: 768px) {
.markrust-grid {
grid-template-columns: 1fr;
}
}
.markrust-grid-item {
min-width: 0;
}
"#.to_string())
}
}
```
- 📄 `src/config.rs` (912 tokens)
```
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use std::fs;
use crate::error::Error;
use crate::parser::ParseOptions;
use crate::renderer::RenderOptions;
use crate::theme::ThemeOptions;
/// Configuration for Markrust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
/// Parser configuration
#[serde(default)]
pub parser: ParserConfig,
/// Renderer configuration
#[serde(default)]
pub renderer: RendererConfig,
/// Theme configuration
#[serde(default)]
pub theme: ThemeConfig,
/// Custom component directories
#[serde(default)]
pub components: Vec<String>,
}
/// Parser configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParserConfig {
/// Enable GitHub-flavored markdown
#[serde(default = "default_true")]
pub gfm: bool,
/// Enable smart punctuation
#[serde(default = "default_true")]
pub smart_punctuation: bool,
/// Parse YAML frontmatter
#[serde(default = "default_true")]
pub frontmatter: bool,
/// Enable custom component syntax
#[serde(default = "default_true")]
pub custom_components: bool,
}
/// Renderer configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RendererConfig {
/// Default document title
#[serde(default = "default_title")]
pub title: String,
/// Include default CSS
#[serde(default = "default_true")]
pub include_default_css: bool,
/// Minify HTML output
#[serde(default)]
pub minify: bool,
/// Generate table of contents
#[serde(default)]
pub toc: bool,
/// Apply syntax highlighting to code blocks
#[serde(default = "default_true")]
pub syntax_highlight: bool,
/// Add copy buttons to code blocks
#[serde(default = "default_true")]
pub code_copy_button: bool,
/// Theme for syntax highlighting
#[serde(default = "default_highlight_theme")]
pub highlight_theme: String,
}
/// Theme configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThemeConfig {
/// Theme name
#[serde(default = "default_theme")]
pub name: String,
/// Theme variables
#[serde(default)]
pub variables: HashMap<String, String>,
}
impl Default for Config {
fn default() -> Self {
Self {
parser: ParserConfig::default(),
renderer: RendererConfig::default(),
theme: ThemeConfig::default(),
components: Vec::new(),
}
}
}
impl Default for ParserConfig {
fn default() -> Self {
Self {
gfm: true,
smart_punctuation: true,
frontmatter: true,
custom_components: true,
}
}
}
impl Default for RendererConfig {
fn default() -> Self {
Self {
title: default_title(),
include_default_css: true,
minify: false,
toc: false,
syntax_highlight: true,
code_copy_button: true,
highlight_theme: default_highlight_theme(),
}
}
}
impl Default for ThemeConfig {
fn default() -> Self {
Self {
name: default_theme(),
variables: HashMap::new(),
}
}
}
impl Config {
/// Load configuration from a TOML file
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
let content = fs::read_to_string(path)?;
let config: Config = toml::from_str(&content)?;
Ok(config)
}
}
// Conversion from Config to component options
impl From<&Config> for ParseOptions {
fn from(config: &Config) -> Self {
ParseOptions {
gfm: config.parser.gfm,
smart_punctuation: config.parser.smart_punctuation,
frontmatter: config.parser.frontmatter,
custom_components: config.parser.custom_components,
}
}
}
fn default_true() -> bool {
true
}
fn default_title() -> String {
"Markrust Document".to_string()
}
fn default_theme() -> String {
"modern".to_string()
}
fn default_highlight_theme() -> String {
"InspiredGitHub".to_string()
}
```
- 📄 `src/error.rs` (280 tokens)
```
use thiserror::Error as ThisError;
use std::io;
/// Error type for Markrust
#[derive(ThisError, Debug)]
pub enum Error {
/// IO error
#[error("IO error: {0}")]
IoError(#[from] io::Error),
/// Parse error
#[error("Parse error: {0}")]
ParseError(String),
/// Component error
#[error("Component error: {0}")]
ComponentError(String),
/// Render error
#[error("Render error: {0}")]
RenderError(String),
/// Configuration error
#[error("Configuration error: {0}")]
ConfigError(String),
/// YAML parsing error
#[error("YAML parsing error: {0}")]
YamlError(#[from] serde_yaml::Error),
/// TOML parsing error
#[error("TOML parsing error: {0}")]
TomlError(#[from] toml::de::Error),
/// Notify error
#[error("Watch error: {0}")]
NotifyError(#[from] notify::Error),
/// HTML minification error
#[cfg(feature = "minify")]
#[error("HTML minification error: {0}")]
MinifyError(#[from] minify_html::Error),
}
```
- 📄 `src/lib.rs` (943 tokens)
```
//! MDX: A minimal, elegant Markdown to UI renderer with custom components
pub mod parser;
pub mod component;
pub mod renderer;
pub mod theme;
pub mod error;
pub mod config;
pub use parser::{parse, ParseOptions, ParsedDocument, Node, InlineNode};
pub use component::{Component, ComponentRegistry};
pub use renderer::{render, RenderOptions};
pub use theme::{Theme, ThemeOptions};
pub use error::Error;
pub use config::Config;
/// Renders a Markdown string to HTML
pub fn render_to_html(markdown: &str, config: Option<Config>) -> Result<String, error::Error> {
let config = config.unwrap_or_default();
let parse_options = ParseOptions::from(&config);
let registry = ComponentRegistry::default();
// Preprocess the markdown to handle custom component syntax
let preprocessed_markdown = preprocess_components(markdown);
let ast = parser::parse(&preprocessed_markdown, &parse_options)?;
renderer::render(&ast, ®istry, &config)
}
/// Renders a Markdown string to HTML with custom component registry
pub fn render_to_html_with_registry(
markdown: &str,
config: Option<Config>,
registry: &ComponentRegistry
) -> Result<String, error::Error> {
let config = config.unwrap_or_default();
let parse_options = ParseOptions::from(&config);
// Preprocess the markdown to handle custom component syntax
let preprocessed_markdown = preprocess_components(markdown);
let ast = parser::parse(&preprocessed_markdown, &parse_options)?;
renderer::render(&ast, registry, &config)
}
/// Preprocesses markdown content to transform custom component syntax to HTML
fn preprocess_components(markdown: &str) -> String {
use regex::Regex;
// Handle components with :: syntax
let component_regex = Regex::new(r"(?m)^::([a-zA-Z0-9_-]+)(\{([^}]*)\})?\s*$").unwrap();
let component_end_regex = Regex::new(r"(?m)^::\s*$").unwrap();
// Handle nested components with ::: syntax
let nested_component_regex = Regex::new(r"(?m)^:::([a-zA-Z0-9_-]+)(\{([^}]*)\})?\s*$").unwrap();
let nested_component_end_regex = Regex::new(r"(?m)^:::\s*$").unwrap();
// First, replace all component starts with HTML comments with metadata
let mut result = component_regex.replace_all(markdown, |caps: ®ex::Captures| {
let component_name = &caps[1];
let attributes = caps.get(3).map_or("", |m| m.as_str());
format!("<!-- component_start:{}:{} -->\n", component_name, attributes)
}).to_string();
// Replace all component ends with HTML comments
result = component_end_regex.replace_all(&result, |_: ®ex::Captures| {
"<!-- component_end -->\n".to_string()
}).to_string();
// Handle nested components
result = nested_component_regex.replace_all(&result, |caps: ®ex::Captures| {
let component_name = &caps[1];
let attributes = caps.get(3).map_or("", |m| m.as_str());
format!("<!-- nested_component_start:{}:{} -->\n", component_name, attributes)
}).to_string();
// Replace all nested component ends with HTML comments
result = nested_component_end_regex.replace_all(&result, |_: ®ex::Captures| {
"<!-- nested_component_end -->\n".to_string()
}).to_string();
result
}
/// Renders a Markdown file to HTML
pub fn render_file(path: &str, config: Option<Config>) -> Result<String, error::Error> {
use std::fs;
let markdown = fs::read_to_string(path).map_err(|e| error::Error::IoError(e))?;
render_to_html(&markdown, config)
}
/// Renders a Markdown file to an HTML file
pub fn render_file_to_file(input: &str, output: &str, config: Option<Config>) -> Result<(), error::Error> {
use std::fs;
let html = render_file(input, config)?;
fs::write(output, html).map_err(|e| error::Error::IoError(e))?;
Ok(())
}
```
- 📄 `src/main.rs` (4410 tokens)
```
use clap::{Parser, ValueEnum};
use mdx::{Config, Error, render_file_to_file};
use std::path::{Path, PathBuf};
use std::time::Duration;
use std::sync::mpsc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::fs;
/// MDX: A minimal, elegant Markdown to UI renderer with custom components
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Cli {
/// Path to input Markdown file or directory
input: String,
/// Path to output HTML file or directory (defaults to same as input with .html extension)
output: Option<String>,
/// Theme to use
#[arg(short, long, value_enum, default_value_t = ThemeArg::Modern)]
theme: ThemeArg,
/// Path to configuration file
#[arg(short, long)]
config: Option<PathBuf>,
/// Path to custom components directory
#[arg(long)]
components: Option<PathBuf>,
/// Watch for changes and rebuild
#[arg(long)]
watch: bool,
/// Start a local preview server
#[arg(long)]
serve: Option<Option<u16>>,
/// Minify HTML and CSS output
#[arg(long)]
minify: bool,
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum ThemeArg {
Modern,
Minimal,
Dark,
Light,
}
fn main() -> Result<(), Error> {
let cli = Cli::parse();
// Load configuration
let mut config = match &cli.config {
Some(path) => Config::from_file(path)?,
None => Config::default(),
};
// Override config with CLI args
config.theme.name = match cli.theme {
ThemeArg::Modern => "modern".to_string(),
ThemeArg::Minimal => "minimal".to_string(),
ThemeArg::Dark => "dark".to_string(),
ThemeArg::Light => "light".to_string(),
};
config.renderer.minify = cli.minify;
// Add custom components directory if specified
if let Some(components_dir) = &cli.components {
config.components.push(components_dir.to_string_lossy().to_string());
}
// Get the input path
let input_path = PathBuf::from(&cli.input);
// If input is a directory, process all markdown files
if input_path.is_dir() {
let output_path = match &cli.output {
Some(output) => PathBuf::from(output),
None => input_path.clone(),
};
if !output_path.exists() {
fs::create_dir_all(&output_path)?;
}
process_directory(&input_path, &output_path, &config)?;
// Watch for changes if requested
if cli.watch {
watch_directory(&input_path, &output_path, &config)?;
}
} else {
// Process a single file
let output_path = match &cli.output {
Some(output) => PathBuf::from(output),
None => {
let mut output = input_path.clone();
output.set_extension("html");
output
}
};
// Create parent directory if it doesn't exist
if let Some(parent) = output_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent)?;
}
}
render_file_to_file(
input_path.to_str().unwrap(),
output_path.to_str().unwrap(),
Some(config.clone()),
)?;
println!("Rendered {} to {}", input_path.display(), output_path.display());
// Watch for changes if requested
if cli.watch {
watch_file(&input_path, &output_path, &config)?;
}
}
// Start server if requested
if let Some(port_option) = cli.serve {
let port = port_option.unwrap_or(3000);
start_server(cli.output.unwrap_or_else(|| cli.input.clone()), port)?;
}
Ok(())
}
// Process all markdown files in a directory
fn process_directory(input_dir: &Path, output_dir: &Path, config: &Config) -> Result<(), Error> {
let entries = fs::read_dir(input_dir)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
let relative_path = path.strip_prefix(input_dir).unwrap();
let new_output_dir = output_dir.join(relative_path);
if !new_output_dir.exists() {
fs::create_dir_all(&new_output_dir)?;
}
process_directory(&path, &new_output_dir, config)?;
} else if is_markdown_file(&path) {
let relative_path = path.strip_prefix(input_dir).unwrap();
let mut output_path = output_dir.join(relative_path);
output_path.set_extension("html");
// Create parent directory if it doesn't exist
if let Some(parent) = output_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent)?;
}
}
render_file_to_file(
path.to_str().unwrap(),
output_path.to_str().unwrap(),
Some(config.clone()),
)?;
println!("Rendered {} to {}", path.display(), output_path.display());
}
}
Ok(())
}
// Check if a file is a markdown file
fn is_markdown_file(path: &Path) -> bool {
if let Some(ext) = path.extension() {
let ext = ext.to_string_lossy().to_lowercase();
ext == "md" || ext == "markdown"
} else {
false
}
}
// Watch a directory for changes
fn watch_directory(input_dir: &Path, output_dir: &Path, config: &Config) -> Result<(), Error> {
#[cfg(feature = "server")]
{
use notify::{Watcher, RecursiveMode, RecommendedWatcher};
println!("Watching directory {} for changes...", input_dir.display());
let (tx, rx) = mpsc::channel();
let running = Arc::new(AtomicBool::new(true));
let r = running.clone();
ctrlc::set_handler(move || {
r.store(false, Ordering::SeqCst);
}).expect("Error setting Ctrl-C handler");
let mut watcher = RecommendedWatcher::new(tx, notify::Config::default())?;
watcher.watch(input_dir, RecursiveMode::Recursive)?;
while running.load(Ordering::SeqCst) {
match rx.recv_timeout(Duration::from_secs(1)) {
Ok(event) => {
// Process the event
if let Ok(event) = event {
for path in event.paths {
if path.is_file() && is_markdown_file(&path) {
let relative_path = path.strip_prefix(input_dir).unwrap();
let mut output_path = output_dir.join(relative_path);
output_path.set_extension("html");
// Create parent directory if it doesn't exist
if let Some(parent) = output_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent)?;
}
}
match render_file_to_file(
path.to_str().unwrap(),
output_path.to_str().unwrap(),
Some(config.clone()),
) {
Ok(_) => println!("Rendered {} to {}", path.display(), output_path.display()),
Err(e) => eprintln!("Error rendering {}: {}", path.display(), e),
}
}
}
}
},
Err(mpsc::RecvTimeoutError::Timeout) => {
// No events received, continue
},
Err(e) => {
eprintln!("Watch error: {:?}", e);
break;
}
}
}
}
#[cfg(not(feature = "server"))]
{
eprintln!("Watch feature is not enabled. Please build with --features server");
}
Ok(())
}
// Watch a file for changes
fn watch_file(input_file: &Path, output_file: &Path, config: &Config) -> Result<(), Error> {
#[cfg(feature = "server")]
{
use notify::{Watcher, RecursiveMode, RecommendedWatcher};
println!("Watching file {} for changes...", input_file.display());
let (tx, rx) = mpsc::channel();
let running = Arc::new(AtomicBool::new(true));
let r = running.clone();
ctrlc::set_handler(move || {
r.store(false, Ordering::SeqCst);
}).expect("Error setting Ctrl-C handler");
let mut watcher = RecommendedWatcher::new(tx, notify::Config::default())?;
watcher.watch(input_file, RecursiveMode::Recursive)?;
while running.load(Ordering::SeqCst) {
match rx.recv_timeout(Duration::from_secs(1)) {
Ok(event) => {
// Process the event
if let Ok(event) = event {
for path in event.paths {
if path == *input_file {
match render_file_to_file(
input_file.to_str().unwrap(),
output_file.to_str().unwrap(),
Some(config.clone()),
) {
Ok(_) => println!("Rendered {} to {}", input_file.display(), output_file.display()),
Err(e) => eprintln!("Error rendering {}: {}", input_file.display(), e),
}
}
}
}
},
Err(mpsc::RecvTimeoutError::Timeout) => {
// No events received, continue
},
Err(e) => {
eprintln!("Watch error: {:?}", e);
break;
}
}
}
}
#[cfg(not(feature = "server"))]
{
eprintln!("Watch feature is not enabled. Please build with --features server");
}
Ok(())
}
// Start a preview server
fn start_server(dir: String, port: u16) -> Result<(), Error> {
#[cfg(feature = "server")]
{
use tiny_http::{Server, Response, Method, StatusCode};
use std::thread;
let server_dir = PathBuf::from(&dir);
if !server_dir.exists() {
return Err(Error::IoError(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Directory not found: {}", dir),
)));
}
let server = Server::http(format!("0.0.0.0:{}", port))
.map_err(|e| Error::IoError(std::io::Error::new(std::io::ErrorKind::Other, e)))?;
println!("Server started at http://localhost:{}", port);
println!("Press Ctrl+C to stop the server");
let running = Arc::new(AtomicBool::new(true));
let r = running.clone();
ctrlc::set_handler(move || {
r.store(false, Ordering::SeqCst);
println!("Stopping server...");
}).expect("Error setting Ctrl-C handler");
while running.load(Ordering::SeqCst) {
if let Ok(Some(request)) = server.try_recv() {
let method = request.method().clone();
let url = request.url().to_string();
// Only handle GET requests
if method != Method::Get {
let response = Response::from_string("Method not allowed")
.with_status_code(StatusCode(405));
let _ = request.respond(response);
continue;
}
// Remove query parameters
let url_path = url.split('?').next().unwrap_or("");
// Determine file path
let mut file_path = server_dir.clone();
if url_path == "/" {
file_path.push("index.html");
} else {
let decoded_path = url_decode(url_path);
let path = Path::new(&decoded_path);
// Remove leading slash and add to file path
let path_without_slash = path.strip_prefix("/").unwrap_or(path);
file_path.push(path_without_slash);
}
// Handle directory requests
if file_path.is_dir() {
let index_path = file_path.join("index.html");
if index_path.exists() {
file_path = index_path;
} else {
// Generate directory listing
let listing = generate_directory_listing(&file_path, url_path);
let response = Response::from_string(&listing)
.with_header(tiny_http::Header {
field: "Content-Type".parse().unwrap(),
value: "text/html; charset=utf-8".parse().unwrap(),
});
let _ = request.respond(response);
continue;
}
}
// If path doesn't have an extension and isn't a real file, try adding .html
if !file_path.exists() && file_path.extension().is_none() {
let html_path = file_path.with_extension("html");
if html_path.exists() {
file_path = html_path;
}
}
// Serve the file if it exists
if file_path.exists() {
let content_type = get_content_type(&file_path);
match fs::read(&file_path) {
Ok(content) => {
let response = Response::from_data(content)
.with_header(tiny_http::Header {
field: "Content-Type".parse().unwrap(),
value: content_type.parse().unwrap(),
});
let _ = request.respond(response);
}
Err(e) => {
eprintln!("Error reading file {}: {}", file_path.display(), e);
let response = Response::from_string(format!("Error: {}", e))
.with_status_code(StatusCode(500));
let _ = request.respond(response);
}
}
} else {
// File not found
let response = Response::from_string("404 Not Found")
.with_status_code(StatusCode(404));
let _ = request.respond(response);
}
} else {
// Sleep a bit to prevent CPU hogging
thread::sleep(Duration::from_millis(100));
}
}
}
#[cfg(not(feature = "server"))]
{
eprintln!("Server feature is not enabled. Please build with --features server");
}
Ok(())
}
// URL decode a string
fn url_decode(input: &str) -> String {
let mut result = String::with_capacity(input.len());
let mut i = 0;
let bytes = input.as_bytes();
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let (Some(h), Some(l)) = (from_hex(bytes[i + 1]), from_hex(bytes[i + 2])) {
result.push((h << 4 | l) as char);
i += 3;
} else {
result.push('%');
i += 1;
}
} else if bytes[i] == b'+' {
result.push(' ');
i += 1;
} else {
result.push(bytes[i] as char);
i += 1;
}
}
result
}
// Convert a hex character to a value
fn from_hex(c: u8) -> Option<u8> {
match c {
b'0'..=b'9' => Some(c - b'0'),
b'A'..=b'F' => Some(c - b'A' + 10),
b'a'..=b'f' => Some(c - b'a' + 10),
_ => None,
}
}
// Get content type based on file extension
fn get_content_type(path: &Path) -> &'static str {
if let Some(extension) = path.extension() {
let ext = extension.to_string_lossy().to_lowercase();
match ext.as_str() {
"html" | "htm" => "text/html; charset=utf-8",
"css" => "text/css; charset=utf-8",
"js" => "application/javascript; charset=utf-8",
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"gif" => "image/gif",
"svg" => "image/svg+xml",
"ico" => "image/x-icon",
"json" => "application/json; charset=utf-8",
"pdf" => "application/pdf",
"xml" => "application/xml; charset=utf-8",
"md" | "markdown" => "text/markdown; charset=utf-8",
"txt" => "text/plain; charset=utf-8",
_ => "application/octet-stream",
}
} else {
"application/octet-stream"
}
}
// Generate HTML directory listing
fn generate_directory_listing(dir: &Path, url_path: &str) -> String {
let mut html = String::from(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Directory Listing</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
line-height: 1.6;
color: #333;
max-width: 900px;
margin: 0 auto;
padding: 1rem;
}
h1 {
border-bottom: 1px solid #eee;
padding-bottom: 0.5rem;
}
.listing {
list-style: none;
padding: 0;
}
.listing li {
padding: 0.5rem;
border-bottom: 1px solid #f4f4f4;
}
.listing li:hover {
background-color: #f8f9fa;
}
.listing a {
display: block;
text-decoration: none;
color: #0366d6;
}
.listing a:hover {
text-decoration: underline;
}
.folder:before {
content: "📁 ";
}
.file:before {
content: "📄 ";
}
</style>
</head>
<body>
<h1>Directory Listing: "#,
);
html.push_str(url_path);
html.push_str("</h1>\n <ul class=\"listing\">\n");
// Add parent directory link if not at root
if url_path != "/" {
let parent_path = Path::new(url_path)
.parent()
.and_then(|p| p.to_str())
.unwrap_or("/");
html.push_str(&format!(
" <li><a href=\"{}\" class=\"folder\">..</a></li>\n",
parent_path
));
}
// Add directory entries
if let Ok(entries) = fs::read_dir(dir) {
let mut dirs = Vec::new();
let mut files = Vec::new();
for entry in entries {
if let Ok(entry) = entry {
let path = entry.path();
let file_name = entry
.file_name()
.to_string_lossy()
.to_string();
// Skip hidden files
if file_name.starts_with('.') {
continue;
}
let mut url = format!(
"{}{}{}",
url_path,
if url_path.ends_with('/') { "" } else { "/" },
file_name
);
if path.is_dir() {
url.push('/');
dirs.push((url, file_name, true));
} else {
files.push((url, file_name, false));
}
}
}
// Sort directories and files
dirs.sort_by(|a, b| a.1.to_lowercase().cmp(&b.1.to_lowercase()));
files.sort_by(|a, b| a.1.to_lowercase().cmp(&b.1.to_lowercase()));
// List directories first
for (url, name, _) in dirs {
html.push_str(&format!(
" <li><a href=\"{}\" class=\"folder\">{}</a></li>\n",
url, name
));
}
// Then list files
for (url, name, _) in files {
html.push_str(&format!(
" <li><a href=\"{}\" class=\"file\">{}</a></li>\n",
url, name
));
}
}
html.push_str(
r#" </ul>
<p><em>Generated by Markrust</em></p>
</body>
</html>"#,
);
html
}
```
- 📄 `src/parser.rs` (4971 tokens)
```
use pulldown_cmark::{Parser as CmarkParser, Options as CmarkOptions, Event, Tag, CodeBlockKind};
use std::collections::HashMap;
use crate::error::Error;
use crate::config::Config;
use serde_yaml;
/// Options for Markdown parsing
#[derive(Debug, Clone)]
pub struct ParseOptions {
/// Enable GitHub-flavored markdown
pub gfm: bool,
/// Enable smart punctuation
pub smart_punctuation: bool,
/// Parse YAML frontmatter
pub frontmatter: bool,
/// Enable custom component syntax
pub custom_components: bool,
}
impl Default for ParseOptions {
fn default() -> Self {
Self {
gfm: true,
smart_punctuation: true,
frontmatter: true,
custom_components: true,
}
}
}
/// Represents a parsed Markdown document
#[derive(Debug)]
pub struct ParsedDocument {
/// The AST (Abstract Syntax Tree) of the markdown document
pub ast: Vec<Node>,
/// Frontmatter metadata if present
pub frontmatter: Option<HashMap<String, serde_yaml::Value>>,
}
/// Represents a node in the Markdown AST
#[derive(Debug, Clone)]
pub enum Node {
/// A heading with level (1-6) and content
Heading {
level: u8,
content: String,
id: String,
},
/// A paragraph of text
Paragraph(Vec<InlineNode>),
/// A blockquote
BlockQuote(Vec<Node>),
/// A code block with optional language
CodeBlock {
language: Option<String>,
content: String,
attributes: HashMap<String, String>,
},
/// A list (ordered or unordered)
List {
ordered: bool,
items: Vec<Vec<Node>>,
},
/// A thematic break (horizontal rule)
ThematicBreak,
/// A custom component
Component {
name: String,
attributes: HashMap<String, String>,
children: Vec<Node>,
},
/// Raw HTML
Html(String),
/// Table
Table {
headers: Vec<Vec<InlineNode>>,
rows: Vec<Vec<Vec<InlineNode>>>,
alignments: Vec<Alignment>,
},
}
impl Node {
/// Get the name of a component node
pub fn name(&self) -> &str {
match self {
Node::Component { name, .. } => name,
_ => "",
}
}
/// Get the attributes of a component node
pub fn attributes(&self) -> HashMap<String, String> {
match self {
Node::Component { attributes, .. } => attributes.clone(),
_ => HashMap::new(),
}
}
/// Get the children of a component node
pub fn children(&self) -> Vec<Node> {
match self {
Node::Component { children, .. } => children.clone(),
_ => Vec::new(),
}
}
}
/// Represents an inline node in the Markdown AST
#[derive(Debug, Clone)]
pub enum InlineNode {
/// Plain text
Text(String),
/// Emphasized text
Emphasis(Vec<InlineNode>),
/// Strongly emphasized text
Strong(Vec<InlineNode>),
/// Strikethrough text
Strikethrough(Vec<InlineNode>),
/// Link
Link {
text: Vec<InlineNode>,
url: String,
title: Option<String>,
},
/// Image
Image {
alt: String,
url: String,
title: Option<String>,
},
/// Inline code
Code(String),
/// Line break
LineBreak,
/// HTML entity
Html(String),
}
/// Table column alignment
#[derive(Debug, Clone, Copy)]
pub enum Alignment {
/// No specific alignment
None,
/// Left aligned
Left,
/// Center aligned
Center,
/// Right aligned
Right,
}
/// Parse a Markdown string into an AST
pub fn parse(markdown: &str, options: &ParseOptions) -> Result<ParsedDocument, Error> {
let mut frontmatter = None;
let mut content = markdown.to_string();
// Process frontmatter if enabled
if options.frontmatter && content.starts_with("---") {
if let Some((yaml, rest)) = extract_frontmatter(&content) {
frontmatter = parse_yaml_frontmatter(yaml)?;
content = rest.to_string();
}
}
// Configure pulldown-cmark parser options
let mut cmark_options = CmarkOptions::empty();
if options.gfm {
cmark_options.insert(CmarkOptions::ENABLE_TABLES);
cmark_options.insert(CmarkOptions::ENABLE_STRIKETHROUGH);
cmark_options.insert(CmarkOptions::ENABLE_TASKLISTS);
}
if options.smart_punctuation {
cmark_options.insert(CmarkOptions::ENABLE_SMART_PUNCTUATION);
}
// Parse Markdown content
let parser = CmarkParser::new_ext(&content, cmark_options);
let ast = process_events(parser, options)?;
Ok(ParsedDocument { ast, frontmatter })
}
// Extract YAML frontmatter from Markdown content
fn extract_frontmatter(content: &str) -> Option<(&str, &str)> {
let rest = content.strip_prefix("---")?;
let end_index = rest.find("\n---")?;
let yaml = &rest[..end_index];
let content_start = end_index + 5; // Skip over the ending "---\n"
if content_start < rest.len() {
Some((yaml, &rest[content_start..]))
} else {
Some((yaml, ""))
}
}
// Parse YAML frontmatter into a HashMap
fn parse_yaml_frontmatter(yaml: &str) -> Result<Option<HashMap<String, serde_yaml::Value>>, Error> {
let frontmatter: HashMap<String, serde_yaml::Value> = serde_yaml::from_str(yaml)?;
if frontmatter.is_empty() {
Ok(None)
} else {
Ok(Some(frontmatter))
}
}
// Process pulldown-cmark parser events into our AST
fn process_events<'a, I>(events: I, options: &ParseOptions) -> Result<Vec<Node>, Error>
where
I: Iterator<Item = Event<'a>>,
{
let mut nodes = Vec::new();
let mut current_node: Option<Node> = None;
let mut current_inline_nodes: Vec<InlineNode> = Vec::new();
let mut list_stack: Vec<(bool, Vec<Vec<Node>>)> = Vec::new();
let mut block_quote_stack: Vec<Vec<Node>> = Vec::new();
let mut link_stack: Vec<(String, Option<String>, Vec<InlineNode>)> = Vec::new();
let mut component_stack: Vec<(String, HashMap<String, String>, Vec<Node>)> = Vec::new();
let mut table_headers: Vec<Vec<InlineNode>> = Vec::new();
let mut table_alignments: Vec<Alignment> = Vec::new();
let mut table_rows: Vec<Vec<Vec<InlineNode>>> = Vec::new();
let mut in_table_head = false;
let mut in_table_row = false;
let mut current_table_row: Vec<Vec<InlineNode>> = Vec::new();
let mut current_table_cell: Vec<InlineNode> = Vec::new();
let mut in_emphasis = false;
let mut in_strong = false;
let mut in_strikethrough = false;
use pulldown_cmark::{Event, Tag};
let mut events = events.peekable();
while let Some(event) = events.next() {
match event {
Event::Start(Tag::Paragraph) => {
current_inline_nodes = Vec::new();
},
Event::End(Tag::Paragraph) => {
if !current_inline_nodes.is_empty() {
let node = Node::Paragraph(current_inline_nodes.clone());
current_inline_nodes.clear();
if !block_quote_stack.is_empty() {
let last_idx = block_quote_stack.len() - 1;
block_quote_stack[last_idx].push(node);
} else if !list_stack.is_empty() {
let last_list_idx = list_stack.len() - 1;
if let Some(last_item) = list_stack[last_list_idx].1.last_mut() {
last_item.push(node);
}
} else if !component_stack.is_empty() {
let last_idx = component_stack.len() - 1;
component_stack[last_idx].2.push(node);
} else {
nodes.push(node);
}
}
},
Event::Start(Tag::Heading(level, _, _)) => {
current_inline_nodes = Vec::new();
current_node = Some(Node::Heading {
level: level as u8,
content: String::new(),
id: String::new(),
});
},
Event::End(Tag::Heading(..)) => {
if let Some(Node::Heading { level, .. }) = current_node {
// Convert inline nodes to string for the heading content
let mut content = String::new();
for node in ¤t_inline_nodes {
match node {
InlineNode::Text(text) => content.push_str(text),
InlineNode::Code(code) => content.push_str(code),
_ => {} // Simplified handling
}
}
// Generate a slug ID from the heading content
let id = content.to_lowercase()
.replace(|c: char| !c.is_alphanumeric(), "-")
.replace("--", "-")
.trim_matches('-')
.to_string();
let heading = Node::Heading {
level,
content,
id,
};
if !block_quote_stack.is_empty() {
let last_idx = block_quote_stack.len() - 1;
block_quote_stack[last_idx].push(heading);
} else if !component_stack.is_empty() {
let last_idx = component_stack.len() - 1;
component_stack[last_idx].2.push(heading);
} else {
nodes.push(heading);
}
current_node = None;
current_inline_nodes.clear();
}
},
Event::Start(Tag::BlockQuote) => {
block_quote_stack.push(Vec::new());
},
Event::End(Tag::BlockQuote) => {
if let Some(quote_nodes) = block_quote_stack.pop() {
let node = Node::BlockQuote(quote_nodes);
if !block_quote_stack.is_empty() {
let last_idx = block_quote_stack.len() - 1;
block_quote_stack[last_idx].push(node);
} else if !component_stack.is_empty() {
let last_idx = component_stack.len() - 1;
component_stack[last_idx].2.push(node);
} else {
nodes.push(node);
}
}
},
Event::Start(Tag::CodeBlock(kind)) => {
let mut language = None;
let mut attributes = HashMap::new();
match kind {
CodeBlockKind::Fenced(lang) => {
let lang_str = lang.to_string();
if !lang_str.is_empty() {
language = Some(lang_str);
}
},
_ => {}
}
current_node = Some(Node::CodeBlock {
language,
content: String::new(),
attributes,
});
},
Event::End(Tag::CodeBlock(_)) => {
if let Some(node) = current_node.take() {
if !block_quote_stack.is_empty() {
let last_idx = block_quote_stack.len() - 1;
block_quote_stack[last_idx].push(node);
} else if !component_stack.is_empty() {
let last_idx = component_stack.len() - 1;
component_stack[last_idx].2.push(node);
} else {
nodes.push(node);
}
}
},
Event::Start(Tag::List(first_item_number)) => {
list_stack.push((first_item_number.is_some(), Vec::new()));
},
Event::End(Tag::List(_)) => {
if let Some((ordered, items)) = list_stack.pop() {
let node = Node::List {
ordered,
items,
};
if !block_quote_stack.is_empty() {
let last_idx = block_quote_stack.len() - 1;
block_quote_stack[last_idx].push(node);
} else if !list_stack.is_empty() {
let last_list_idx = list_stack.len() - 1;
if let Some(last_item) = list_stack[last_list_idx].1.last_mut() {
last_item.push(node);
}
} else if !component_stack.is_empty() {
let last_idx = component_stack.len() - 1;
component_stack[last_idx].2.push(node);
} else {
nodes.push(node);
}
}
},
Event::Start(Tag::Item) => {
if !list_stack.is_empty() {
let last_idx = list_stack.len() - 1;
list_stack[last_idx].1.push(Vec::new());
}
},
Event::End(Tag::Item) => {
// Handled in the list processing
},
Event::Text(text) => {
if let Some(Node::CodeBlock { ref mut content, .. }) = current_node {
content.push_str(&text);
} else {
current_inline_nodes.push(InlineNode::Text(text.to_string()));
}
},
Event::Code(code) => {
current_inline_nodes.push(InlineNode::Code(code.to_string()));
},
Event::Html(html) => {
let html_str = html.to_string();
// Check for custom component syntax if enabled
if options.custom_components && html_str.trim().starts_with("::") {
if html_str.trim().starts_with(":::") {
// Nested component (like tab inside tabs)
if let Some(component_name) = parse_component_start(&html_str) {
let attributes = extract_component_attributes(&html_str).unwrap_or_default();
if !component_stack.is_empty() {
let child_component = (component_name, attributes, Vec::new());
let last_idx = component_stack.len() - 1;
component_stack[last_idx].2.push(Node::Component {
name: child_component.0.clone(),
attributes: child_component.1.clone(),
children: Vec::new(),
});
component_stack.push(child_component);
}
}
} else if let Some(component_name) = parse_component_start(&html_str) {
let attributes = extract_component_attributes(&html_str).unwrap_or_default();
component_stack.push((component_name, attributes, Vec::new()));
} else if html_str.trim() == "::" || html_str.trim() == ":::" {
// End of component
if let Some((name, attributes, children)) = component_stack.pop() {
let node = Node::Component {
name,
attributes,
children,
};
if !component_stack.is_empty() {
let last_idx = component_stack.len() - 1;
// Check if the last child of the parent component is already this component
if let Some(Node::Component { name: child_name, attributes: child_attrs, children: child_children })
= component_stack[last_idx].2.last_mut() {
if child_name == &node.name() && *child_attrs == node.attributes() {
// This is already a placeholder for this component - update its children
*child_children = node.children();
continue;
}
}
component_stack[last_idx].2.push(node);
} else if !block_quote_stack.is_empty() {
let last_idx = block_quote_stack.len() - 1;
block_quote_stack[last_idx].push(node);
} else {
nodes.push(node);
}
}
} else {
nodes.push(Node::Html(html_str));
}
} else {
nodes.push(Node::Html(html_str));
}
},
Event::Start(Tag::Emphasis) => {
in_emphasis = true;
let mut emphasis_nodes = Vec::new();
// Extract emphasized content
while let Some(next_event) = events.next() {
match next_event {
Event::Text(text) => {
emphasis_nodes.push(InlineNode::Text(text.to_string()));
},
Event::End(Tag::Emphasis) => {
in_emphasis = false;
break;
},
_ => {} // Simplify other events
}
}
current_inline_nodes.push(InlineNode::Emphasis(emphasis_nodes));
},
Event::Start(Tag::Strong) => {
in_strong = true;
let mut strong_nodes = Vec::new();
// Extract strong content
while let Some(next_event) = events.next() {
match next_event {
Event::Text(text) => {
strong_nodes.push(InlineNode::Text(text.to_string()));
},
Event::End(Tag::Strong) => {
in_strong = false;
break;
},
_ => {} // Simplify other events
}
}
current_inline_nodes.push(InlineNode::Strong(strong_nodes));
},
Event::Start(Tag::Strikethrough) => {
in_strikethrough = true;
let mut strikethrough_nodes = Vec::new();
// Extract strikethrough content
while let Some(next_event) = events.next() {
match next_event {
Event::Text(text) => {
strikethrough_nodes.push(InlineNode::Text(text.to_string()));
},
Event::End(Tag::Strikethrough) => {
in_strikethrough = false;
break;
},
_ => {} // Simplify other events
}
}
current_inline_nodes.push(InlineNode::Strikethrough(strikethrough_nodes));
},
Event::Start(Tag::Link(link_type, url, title)) => {
let url_str = url.to_string();
let title_opt = if title.is_empty() { None } else { Some(title.to_string()) };
link_stack.push((url_str, title_opt, Vec::new()));
},
Event::End(Tag::Link(_, _, _)) => {
if let Some((url, title, text)) = link_stack.pop() {
current_inline_nodes.push(InlineNode::Link {
url,
title,
text,
});
}
},
Event::Start(Tag::Image(link_type, url, title)) => {
let url_str = url.to_string();
let title_opt = if title.is_empty() { None } else { Some(title.to_string()) };
// Collect alt text from next text event
if let Some(Event::Text(alt)) = events.next() {
current_inline_nodes.push(InlineNode::Image {
url: url_str,
title: title_opt,
alt: alt.to_string(),
});
} else {
current_inline_nodes.push(InlineNode::Image {
url: url_str,
title: title_opt,
alt: String::new(),
});
}
// Skip the end tag
events.next();
},
Event::SoftBreak | Event::HardBreak => {
current_inline_nodes.push(InlineNode::LineBreak);
},
Event::Start(Tag::Table(alignments)) => {
table_headers = Vec::new();
table_rows = Vec::new();
table_alignments = alignments.iter().map(|a| match a {
pulldown_cmark::Alignment::None => Alignment::None,
pulldown_cmark::Alignment::Left => Alignment::Left,
pulldown_cmark::Alignment::Center => Alignment::Center,
pulldown_cmark::Alignment::Right => Alignment::Right,
}).collect();
},
Event::End(Tag::Table(_)) => {
let node = Node::Table {
headers: table_headers.clone(),
rows: table_rows.clone(),
alignments: table_alignments.clone(),
};
if !block_quote_stack.is_empty() {
let last_idx = block_quote_stack.len() - 1;
block_quote_stack[last_idx].push(node);
} else if !component_stack.is_empty() {
let last_idx = component_stack.len() - 1;
component_stack[last_idx].2.push(node);
} else {
nodes.push(node);
}
table_headers.clear();
table_rows.clear();
table_alignments.clear();
},
Event::Start(Tag::TableHead) => {
in_table_head = true;
},
Event::End(Tag::TableHead) => {
in_table_head = false;
},
Event::Start(Tag::TableRow) => {
in_table_row = true;
current_table_row = Vec::new();
},
Event::End(Tag::TableRow) => {
in_table_row = false;
if !current_table_row.is_empty() {
if in_table_head {
table_headers = current_table_row.clone();
} else {
table_rows.push(current_table_row.clone());
}
current_table_row.clear();
}
},
Event::Start(Tag::TableCell) => {
current_table_cell = Vec::new();
},
Event::End(Tag::TableCell) => {
if in_table_row {
current_table_row.push(current_table_cell.clone());
current_table_cell.clear();
}
},
Event::Rule => {
nodes.push(Node::ThematicBreak);
},
Event::FootnoteReference(_) => {
// Not implemented in this example
},
Event::TaskListMarker(_) => {
// Not implemented in this example
},
// Handle any other events that we haven't explicitly handled
_ => {
// For simplicity, we'll just ignore other events
},
}
}
Ok(nodes)
}
// Helper function to parse component syntax
fn parse_component_start(html: &str) -> Option<String> {
let html = html.trim();
if !html.starts_with("::") {
return None;
}
let content = if html.starts_with(":::") {
html.trim_start_matches(":::")
} else {
html.trim_start_matches("::")
};
let name_end = content.find('{').unwrap_or(content.len());
let name = content[..name_end].trim();
if name.is_empty() {
None
} else {
Some(name.to_string())
}
}
// Helper function to extract component attributes
fn extract_component_attributes(html: &str) -> Option<HashMap<String, String>> {
let html = html.trim();
if let Some(start) = html.find('{') {
if let Some(end) = html.find('}') {
let attrs_str = &html[start + 1..end];
let mut attributes = HashMap::new();
for attr_pair in attrs_str.split_whitespace() {
if let Some(equals_pos) = attr_pair.find('=') {
let name = attr_pair[..equals_pos].trim();
let value_with_quotes = attr_pair[equals_pos + 1..].trim();
let value = value_with_quotes
.trim_start_matches('"')
.trim_start_matches('\'')
.trim_end_matches('"')
.trim_end_matches('\'');
attributes.insert(name.to_string(), value.to_string());
}
}
return Some(attributes);
}
}
None
}
```
- 📄 `src/renderer.rs` (4946 tokens)
```
use crate::error::Error;
use crate::parser::{Node, InlineNode, ParsedDocument, Alignment};
use crate::component::ComponentRegistry;
use crate::theme::Theme;
use crate::config::Config;
use syntect::html::{ClassedHTMLGenerator, ClassStyle};
use syntect::parsing::SyntaxSet;
use syntect::highlighting::{ThemeSet, Theme as SyntectTheme};
#[cfg(feature = "minify")]
use minify_html::{minify, Cfg};
use std::collections::HashMap;
/// 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)
}
```
- 📄 `src/theme.rs` (5643 tokens)
```
use std::collections::HashMap;
use std::path::Path;
use crate::error::Error;
use crate::config::Config;
/// Theme options
#[derive(Debug, Clone)]
pub struct ThemeOptions {
/// The name of the theme
pub name: String,
/// Custom CSS variables
pub variables: HashMap<String, String>,
}
impl Default for ThemeOptions {
fn default() -> Self {
Self {
name: "modern".to_string(),
variables: HashMap::new(),
}
}
}
/// Theme for styling rendered Markdown
pub struct Theme {
name: String,
variables: HashMap<String, String>,
}
impl Theme {
/// Create a new theme with the given name
pub fn new(name: &str) -> Self {
let mut theme = Self {
name: name.to_string(),
variables: HashMap::new(),
};
// Set default variables
theme.set_default_variables();
theme
}
/// Set default CSS variables
fn set_default_variables(&mut self) {
let defaults = match self.name.as_str() {
"modern" => get_modern_theme_variables(),
"minimal" => get_minimal_theme_variables(),
"dark" => get_dark_theme_variables(),
"light" => get_light_theme_variables(),
_ => get_modern_theme_variables(), // Default to modern
};
for (key, value) in defaults {
self.variables.insert(key.to_string(), value.to_string());
}
}
/// Get the theme's CSS
pub fn get_css(&self) -> String {
let mut css = String::from(":root {\n");
// Add CSS variables
for (key, value) in &self.variables {
css.push_str(&format!(" --{}: {};\n", key, value));
}
css.push_str("}\n\n");
// Add theme CSS based on name
match self.name.as_str() {
"modern" => css.push_str(MODERN_THEME_CSS),
"minimal" => css.push_str(MINIMAL_THEME_CSS),
"dark" => css.push_str(DARK_THEME_CSS),
"light" => css.push_str(LIGHT_THEME_CSS),
_ => css.push_str(MODERN_THEME_CSS), // Default to modern
}
css
}
/// Set a CSS variable
pub fn set_variable(&mut self, key: &str, value: &str) {
self.variables.insert(key.to_string(), value.to_string());
}
/// Get a CSS variable
pub fn get_variable(&self, key: &str) -> Option<&String> {
self.variables.get(key)
}
}
// Default CSS variables for the Modern theme
fn get_modern_theme_variables() -> Vec<(&'static str, &'static str)> {
vec![
("primary-color", "#3b82f6"),
("secondary-color", "#6b7280"),
("background-color", "#ffffff"),
("text-color", "#1f2937"),
("muted-color", "#6b7280"),
("border-color", "#e5e7eb"),
("font-family", "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif"),
("monospace-font", "'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace"),
("content-width", "768px"),
("info-color", "#3b82f6"),
("info-light-color", "#eff6ff"),
("warning-color", "#f59e0b"),
("warning-light-color", "#fffbeb"),
("error-color", "#ef4444"),
("error-light-color", "#fee2e2"),
("success-color", "#10b981"),
("success-light-color", "#ecfdf5"),
]
}
// Default CSS variables for the Minimal theme
fn get_minimal_theme_variables() -> Vec<(&'static str, &'static str)> {
vec![
("primary-color", "#000000"),
("secondary-color", "#4b5563"),
("background-color", "#ffffff"),
("text-color", "#000000"),
("muted-color", "#4b5563"),
("border-color", "#e5e7eb"),
("font-family", "Georgia, serif"),
("monospace-font", "'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace"),
("content-width", "700px"),
("info-color", "#000000"),
("info-light-color", "#f3f4f6"),
("warning-color", "#000000"),
("warning-light-color", "#f3f4f6"),
("error-color", "#000000"),
("error-light-color", "#f3f4f6"),
("success-color", "#000000"),
("success-light-color", "#f3f4f6"),
]
}
// Default CSS variables for the Dark theme
fn get_dark_theme_variables() -> Vec<(&'static str, &'static str)> {
vec![
("primary-color", "#3b82f6"),
("secondary-color", "#9ca3af"),
("background-color", "#111827"),
("text-color", "#f9fafb"),
("muted-color", "#9ca3af"),
("border-color", "#374151"),
("font-family", "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif"),
("monospace-font", "'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace"),
("content-width", "768px"),
("info-color", "#3b82f6"),
("info-light-color", "#1e3a8a"),
("warning-color", "#f59e0b"),
("warning-light-color", "#78350f"),
("error-color", "#ef4444"),
("error-light-color", "#7f1d1d"),
("success-color", "#10b981"),
("success-light-color", "#064e3b"),
]
}
// Default CSS variables for the Light theme
fn get_light_theme_variables() -> Vec<(&'static str, &'static str)> {
vec![
("primary-color", "#0284c7"),
("secondary-color", "#64748b"),
("background-color", "#f8fafc"),
("text-color", "#334155"),
("muted-color", "#64748b"),
("border-color", "#e2e8f0"),
("font-family", "system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif"),
("monospace-font", "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace"),
("content-width", "768px"),
("info-color", "#0284c7"),
("info-light-color", "#e0f2fe"),
("warning-color", "#ea580c"),
("warning-light-color", "#fff7ed"),
("error-color", "#dc2626"),
("error-light-color", "#fee2e2"),
("success-color", "#16a34a"),
("success-light-color", "#f0fdf4"),
]
}
// CSS for the Modern theme
const MODERN_THEME_CSS: &str = r#"
/* Modern theme */
body {
font-family: var(--font-family);
color: var(--text-color);
background-color: var(--background-color);
line-height: 1.6;
margin: 0;
padding: 0;
}
.markrust-container {
max-width: var(--content-width);
margin: 0 auto;
padding: 2rem 1rem;
}
.markrust-content {
margin-top: 2rem;
}
h1, h2, h3, h4, h5, h6 {
margin-top: 2rem;
margin-bottom: 1rem;
font-weight: 600;
line-height: 1.25;
}
h1 {
font-size: 2.25rem;
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.5rem;
}
h2 {
font-size: 1.8rem;
}
h3 {
font-size: 1.5rem;
}
h4 {
font-size: 1.25rem;
}
h5 {
font-size: 1rem;
}
h6 {
font-size: 0.875rem;
}
a {
color: var(--primary-color);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
ul, ol {
margin-top: 0;
margin-bottom: 1rem;
padding-left: 2rem;
}
li {
margin-bottom: 0.25rem;
}
blockquote {
margin: 1rem 0;
padding: 0.5rem 1rem;
border-left: 4px solid var(--primary-color);
background-color: rgba(0, 0, 0, 0.05);
}
hr {
border: 0;
height: 1px;
background-color: var(--border-color);
margin: 2rem 0;
}
code {
font-family: var(--monospace-font);
font-size: 0.9em;
background-color: rgba(0, 0, 0, 0.05);
padding: 0.2em 0.4em;
border-radius: 3px;
}
pre {
margin: 1rem 0;
padding: 1rem;
overflow-x: auto;
background-color: rgba(0, 0, 0, 0.05);
border-radius: 0.375rem;
}
pre code {
background: none;
padding: 0;
font-size: 0.9em;
color: inherit;
}
/* Code block with copy button */
.markrust-code-block {
position: relative;
}
.markrust-copy-button {
position: absolute;
top: 0.5rem;
right: 0.5rem;
padding: 0.25rem;
background-color: rgba(255, 255, 255, 0.2);
border: none;
border-radius: 0.25rem;
color: var(--text-color);
cursor: pointer;
opacity: 0.6;
transition: opacity 0.2s;
}
.markrust-copy-button:hover {
opacity: 1;
}
/* Table styles */
.markrust-table {
border-collapse: collapse;
width: 100%;
margin: 1rem 0;
}
.markrust-table th,
.markrust-table td {
border: 1px solid var(--border-color);
padding: 0.5rem 0.75rem;
text-align: left;
}
.markrust-table th {
background-color: rgba(0, 0, 0, 0.05);
font-weight: 600;
}
.markrust-table .align-center {
text-align: center;
}
.markrust-table .align-right {
text-align: right;
}
/* Table of contents */
.markrust-toc {
background-color: rgba(0, 0, 0, 0.03);
border-radius: 0.375rem;
padding: 1rem;
margin-bottom: 2rem;
}
.markrust-toc-header {
font-weight: 600;
margin-bottom: 0.5rem;
}
.markrust-toc ul {
list-style-type: none;
padding-left: 0;
margin-bottom: 0;
}
.markrust-toc ul ul {
padding-left: 1.5rem;
}
.markrust-toc li {
margin-bottom: 0.25rem;
}
.markrust-toc a {
text-decoration: none;
color: var(--text-color);
}
.markrust-toc a:hover {
color: var(--primary-color);
text-decoration: underline;
}
"#;
// CSS for the Minimal theme
const MINIMAL_THEME_CSS: &str = r#"
/* Minimal theme */
body {
font-family: var(--font-family);
color: var(--text-color);
background-color: var(--background-color);
line-height: 1.7;
margin: 0;
padding: 0;
}
.markrust-container {
max-width: var(--content-width);
margin: 0 auto;
padding: 2rem 1rem;
}
.markrust-content {
margin-top: 2rem;
}
h1, h2, h3, h4, h5, h6 {
margin-top: 2.5rem;
margin-bottom: 1rem;
font-weight: 400;
line-height: 1.25;
letter-spacing: -0.02em;
}
h1 {
font-size: 2.5rem;
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.5rem;
}
h2 {
font-size: 2rem;
}
h3 {
font-size: 1.75rem;
}
h4 {
font-size: 1.5rem;
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
a {
color: var(--text-color);
text-decoration: underline;
}
a:hover {
color: var(--primary-color);
}
p {
margin-top: 0;
margin-bottom: 1.5rem;
}
ul, ol {
margin-top: 0;
margin-bottom: 1.5rem;
padding-left: 2rem;
}
li {
margin-bottom: 0.5rem;
}
blockquote {
margin: 1.5rem 0;
padding: 0 0 0 1.5rem;
border-left: 1px solid var(--text-color);
font-style: italic;
}
hr {
border: 0;
height: 1px;
background-color: var(--border-color);
margin: 3rem 0;
}
code {
font-family: var(--monospace-font);
font-size: 0.85em;
background-color: transparent;
padding: 0;
border-radius: 0;
}
pre {
margin: 1.5rem 0;
padding: 1rem;
overflow-x: auto;
background-color: rgb(245, 245, 245);
border-radius: 0;
}
pre code {
background: none;
padding: 0;
font-size: 0.85em;
color: inherit;
}
/* Table styles */
.markrust-table {
border-collapse: collapse;
width: 100%;
margin: 1.5rem 0;
}
.markrust-table th,
.markrust-table td {
border: 1px solid var(--border-color);
padding: 0.75rem 1rem;
text-align: left;
}
.markrust-table th {
font-weight: 400;
border-bottom: 2px solid var(--text-color);
}
/* Table of contents */
.markrust-toc {
border: 1px solid var(--border-color);
padding: 1.5rem;
margin-bottom: 2.5rem;
}
.markrust-toc-header {
font-weight: 400;
margin-bottom: 1rem;
font-size: 1.25rem;
}
.markrust-toc ul {
list-style-type: none;
padding-left: 0;
margin-bottom: 0;
}
.markrust-toc ul ul {
padding-left: 1.5rem;
}
.markrust-toc li {
margin-bottom: 0.5rem;
}
.markrust-toc a {
text-decoration: none;
color: var(--text-color);
}
.markrust-toc a:hover {
text-decoration: underline;
}
"#;
// CSS for the Dark theme
const DARK_THEME_CSS: &str = r#"
/* Dark theme */
body {
font-family: var(--font-family);
color: var(--text-color);
background-color: var(--background-color);
line-height: 1.6;
margin: 0;
padding: 0;
}
.markrust-container {
max-width: var(--content-width);
margin: 0 auto;
padding: 2rem 1rem;
}
.markrust-content {
margin-top: 2rem;
}
h1, h2, h3, h4, h5, h6 {
margin-top: 2rem;
margin-bottom: 1rem;
font-weight: 600;
line-height: 1.25;
color: var(--primary-color);
}
h1 {
font-size: 2.25rem;
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.5rem;
}
h2 {
font-size: 1.8rem;
}
h3 {
font-size: 1.5rem;
}
h4 {
font-size: 1.25rem;
}
h5 {
font-size: 1rem;
}
h6 {
font-size: 0.875rem;
}
a {
color: var(--primary-color);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
ul, ol {
margin-top: 0;
margin-bottom: 1rem;
padding-left: 2rem;
}
li {
margin-bottom: 0.25rem;
}
blockquote {
margin: 1rem 0;
padding: 0.5rem 1rem;
border-left: 4px solid var(--primary-color);
background-color: rgba(255, 255, 255, 0.05);
}
hr {
border: 0;
height: 1px;
background-color: var(--border-color);
margin: 2rem 0;
}
code {
font-family: var(--monospace-font);
font-size: 0.9em;
background-color: rgba(255, 255, 255, 0.1);
padding: 0.2em 0.4em;
border-radius: 3px;
}
pre {
margin: 1rem 0;
padding: 1rem;
overflow-x: auto;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 0.375rem;
}
pre code {
background: none;
padding: 0;
font-size: 0.9em;
color: inherit;
}
/* Code block with copy button */
.markrust-code-block {
position: relative;
}
.markrust-copy-button {
position: absolute;
top: 0.5rem;
right: 0.5rem;
padding: 0.25rem;
background-color: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 0.25rem;
color: var(--text-color);
cursor: pointer;
opacity: 0.6;
transition: opacity 0.2s;
}
.markrust-copy-button:hover {
opacity: 1;
}
/* Table styles */
.markrust-table {
border-collapse: collapse;
width: 100%;
margin: 1rem 0;
}
.markrust-table th,
.markrust-table td {
border: 1px solid var(--border-color);
padding: 0.5rem 0.75rem;
text-align: left;
}
.markrust-table th {
background-color: rgba(255, 255, 255, 0.05);
font-weight: 600;
}
.markrust-table .align-center {
text-align: center;
}
.markrust-table .align-right {
text-align: right;
}
/* Table of contents */
.markrust-toc {
background-color: rgba(255, 255, 255, 0.05);
border-radius: 0.375rem;
padding: 1rem;
margin-bottom: 2rem;
}
.markrust-toc-header {
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--primary-color);
}
.markrust-toc ul {
list-style-type: none;
padding-left: 0;
margin-bottom: 0;
}
.markrust-toc ul ul {
padding-left: 1.5rem;
}
.markrust-toc li {
margin-bottom: 0.25rem;
}
.markrust-toc a {
text-decoration: none;
color: var(--text-color);
}
.markrust-toc a:hover {
color: var(--primary-color);
text-decoration: underline;
}
"#;
// CSS for the Light theme
const LIGHT_THEME_CSS: &str = r#"
/* Light theme */
body {
font-family: var(--font-family);
color: var(--text-color);
background-color: var(--background-color);
line-height: 1.6;
margin: 0;
padding: 0;
}
.markrust-container {
max-width: var(--content-width);
margin: 0 auto;
padding: 2rem 1rem;
}
.markrust-content {
margin-top: 2rem;
}
h1, h2, h3, h4, h5, h6 {
margin-top: 2rem;
margin-bottom: 1rem;
font-weight: 600;
line-height: 1.25;
color: var(--primary-color);
}
h1 {
font-size: 2.25rem;
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.5rem;
}
h2 {
font-size: 1.8rem;
}
h3 {
font-size: 1.5rem;
}
h4 {
font-size: 1.25rem;
}
h5 {
font-size: 1rem;
}
h6 {
font-size: 0.875rem;
}
a {
color: var(--primary-color);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
ul, ol {
margin-top: 0;
margin-bottom: 1rem;
padding-left: 2rem;
}
li {
margin-bottom: 0.25rem;
}
blockquote {
margin: 1rem 0;
padding: 0.5rem 1rem;
border-left: 4px solid var(--primary-color);
background-color: var(--info-light-color);
}
hr {
border: 0;
height: 1px;
background-color: var(--border-color);
margin: 2rem 0;
}
code {
font-family: var(--monospace-font);
font-size: 0.9em;
background-color: rgba(0, 0, 0, 0.05);
padding: 0.2em 0.4em;
border-radius: 3px;
}
pre {
margin: 1rem 0;
padding: 1rem;
overflow-x: auto;
background-color: rgba(0, 0, 0, 0.05);
border-radius: 0.375rem;
}
pre code {
background: none;
padding: 0;
font-size: 0.9em;
color: inherit;
}
/* Code block with copy button */
.markrust-code-block {
position: relative;
}
.markrust-copy-button {
position: absolute;
top: 0.5rem;
right: 0.5rem;
padding: 0.25rem;
background-color: rgba(255, 255, 255, 0.7);
border: none;
border-radius: 0.25rem;
color: var(--text-color);
cursor: pointer;
opacity: 0.6;
transition: opacity 0.2s;
}
.markrust-copy-button:hover {
opacity: 1;
}
/* Table styles */
.markrust-table {
border-collapse: collapse;
width: 100%;
margin: 1rem 0;
}
.markrust-table th,
.markrust-table td {
border: 1px solid var(--border-color);
padding: 0.5rem 0.75rem;
text-align: left;
}
.markrust-table th {
background-color: rgba(0, 0, 0, 0.03);
font-weight: 600;
}
.markrust-table .align-center {
text-align: center;
}
.markrust-table .align-right {
text-align: right;
}
/* Table of contents */
.markrust-toc {
background-color: rgba(0, 0, 0, 0.02);
border-radius: 0.375rem;
padding: 1rem;
margin-bottom: 2rem;
}
.markrust-toc-header {
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--primary-color);
}
.markrust-toc ul {
list-style-type: none;
padding-left: 0;
margin-bottom: 0;
}
.markrust-toc ul ul {
padding-left: 1.5rem;
}
.markrust-toc li {
margin-bottom: 0.25rem;
}
.markrust-toc a {
text-decoration: none;
color: var(--text-color);
}
.markrust-toc a:hover {
color: var(--primary-color);
text-decoration: underline;
}
"#;
```