Files
log/src/markdown.rs
2025-06-14 15:56:25 +09:00

173 lines
6.5 KiB
Rust

use anyhow::Result;
use pulldown_cmark::{html, Options, Parser, CodeBlockKind};
use syntect::parsing::SyntaxSet;
use syntect::highlighting::ThemeSet;
use syntect::html::{styled_line_to_highlighted_html, IncludeBackground};
use gray_matter::Matter;
use gray_matter::engine::YAML;
use serde_json::Value;
pub struct MarkdownProcessor {
highlight_code: bool,
highlight_theme: String,
syntax_set: SyntaxSet,
theme_set: ThemeSet,
}
impl MarkdownProcessor {
pub fn new(highlight_code: bool, highlight_theme: Option<String>) -> Self {
Self {
highlight_code,
highlight_theme: highlight_theme.unwrap_or_else(|| "Monokai".to_string()),
syntax_set: SyntaxSet::load_defaults_newlines(),
theme_set: ThemeSet::load_defaults(),
}
}
pub fn parse_frontmatter(&self, content: &str) -> Result<(serde_json::Map<String, Value>, String)> {
let matter = Matter::<YAML>::new();
let result = matter.parse(content);
let frontmatter = result.data
.and_then(|pod| pod.as_hashmap().ok())
.map(|map| {
let mut json_map = serde_json::Map::new();
for (k, v) in map {
// Keys in hashmap are already strings
let value = self.pod_to_json_value(v);
json_map.insert(k, value);
}
json_map
})
.unwrap_or_default();
Ok((frontmatter, result.content))
}
fn pod_to_json_value(&self, pod: gray_matter::Pod) -> Value {
match pod {
gray_matter::Pod::Null => Value::Null,
gray_matter::Pod::Boolean(b) => Value::Bool(b),
gray_matter::Pod::Integer(i) => Value::Number(serde_json::Number::from(i)),
gray_matter::Pod::Float(f) => serde_json::Number::from_f64(f)
.map(Value::Number)
.unwrap_or(Value::Null),
gray_matter::Pod::String(s) => Value::String(s),
gray_matter::Pod::Array(arr) => {
Value::Array(arr.into_iter().map(|p| self.pod_to_json_value(p)).collect())
}
gray_matter::Pod::Hash(map) => {
let mut json_map = serde_json::Map::new();
for (k, v) in map {
json_map.insert(k, self.pod_to_json_value(v));
}
Value::Object(json_map)
}
}
}
pub fn render(&self, content: &str) -> Result<String> {
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_FOOTNOTES);
options.insert(Options::ENABLE_TASKLISTS);
if self.highlight_code {
self.render_with_syntax_highlighting(content, options)
} else {
let parser = Parser::new_ext(content, options);
let mut html_output = String::new();
html::push_html(&mut html_output, parser);
Ok(html_output)
}
}
fn render_with_syntax_highlighting(&self, content: &str, options: Options) -> Result<String> {
let parser = Parser::new_ext(content, options);
let mut html_output = String::new();
let mut code_block = None;
// Force use dark theme for better visibility on dark background
let theme = self.theme_set.themes.get("base16-monokai.dark")
.or_else(|| self.theme_set.themes.get("base16-ocean.dark"))
.or_else(|| self.theme_set.themes.get("Solarized (dark)"))
.or_else(|| self.theme_set.themes.get(&self.highlight_theme))
.unwrap_or_else(|| self.theme_set.themes.values().next().unwrap());
let mut events = Vec::new();
for event in parser {
match event {
pulldown_cmark::Event::Start(pulldown_cmark::Tag::CodeBlock(kind)) => {
if let CodeBlockKind::Fenced(lang_info) = &kind {
code_block = Some((String::new(), lang_info.to_string()));
}
}
pulldown_cmark::Event::Text(text) => {
if let Some((ref mut code, _)) = code_block {
code.push_str(&text);
} else {
events.push(pulldown_cmark::Event::Text(text));
}
}
pulldown_cmark::Event::End(pulldown_cmark::TagEnd::CodeBlock) => {
if let Some((code, lang_info)) = code_block.take() {
let highlighted = self.highlight_code_block(&code, &lang_info, theme);
events.push(pulldown_cmark::Event::Html(highlighted.into()));
}
}
_ => events.push(event),
}
}
html::push_html(&mut html_output, events.into_iter());
Ok(html_output)
}
fn highlight_code_block(&self, code: &str, lang_info: &str, theme: &syntect::highlighting::Theme) -> String {
// Parse language and filename from lang_info (e.g., "sh:/path/to/file" or "rust:main.rs")
let (lang, filename) = if lang_info.contains(':') {
let parts: Vec<&str> = lang_info.splitn(2, ':').collect();
(parts[0], Some(parts[1]))
} else {
(lang_info, None)
};
// Map short language names to full names
let lang = match lang {
"rs" => "rust",
"js" => "javascript",
"ts" => "typescript",
"sh" => "bash",
"yml" => "yaml",
"md" => "markdown",
"py" => "python",
_ => lang,
};
let syntax = self.syntax_set
.find_syntax_by_token(lang)
.unwrap_or_else(|| self.syntax_set.find_syntax_plain_text());
let mut highlighter = syntect::easy::HighlightLines::new(syntax, theme);
// Create pre tag with optional filename attribute
let pre_tag = if let Some(filename) = filename {
format!("<pre data-filename=\"{}\">", filename)
} else {
"<pre>".to_string()
};
let mut output = format!("{}<code>", pre_tag);
for line in code.lines() {
let ranges = highlighter.highlight_line(line, &self.syntax_set).unwrap();
let html_line = styled_line_to_highlighted_html(&ranges[..], IncludeBackground::No).unwrap();
output.push_str(&html_line);
output.push('\n');
}
output.push_str("</code></pre>");
output
}
}