Files
markdownblog/markdown_backend/src/markdown.rs
2025-06-24 10:23:34 +02:00

181 lines
5.7 KiB
Rust

// src/markdown.rs
use std::fs;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::Deserialize;
use pulldown_cmark::{Parser, Options, html};
use gray_matter::engine::YAML;
use gray_matter::Matter;
use ammonia::clean;
use slug::slugify;
use notify::{RecursiveMode, RecommendedWatcher, Watcher, Config};
use std::sync::mpsc::channel;
use std::time::Duration;
use syntect::highlighting::{ThemeSet, Style};
use syntect::parsing::SyntaxSet;
use syntect::html::{highlighted_html_for_string, IncludeBackground};
#[derive(Debug, Deserialize, Clone, serde::Serialize)]
pub struct PostFrontmatter {
pub title: String,
pub date: String,
pub tags: Option<Vec<String>>,
pub summary: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Post {
pub slug: String,
pub title: String,
pub date: String,
pub tags: Vec<String>,
pub summary: Option<String>,
pub content: String,
pub created_at: String,
pub author: String,
}
fn get_posts_directory() -> PathBuf {
let candidates = [
"./posts",
"../posts",
"/posts",
"/docker"
];
for candidate in candidates.iter() {
let path = PathBuf::from(candidate);
if path.exists() && path.is_dir() {
return path;
}
}
// Fallback: default to ./posts
PathBuf::from("./posts")
}
fn get_file_creation_date(path: &Path) -> std::io::Result<DateTime<Utc>> {
let metadata = fs::metadata(path)?;
let created = metadata.created()?;
Ok(DateTime::<Utc>::from(created))
}
fn process_anchor_links(content: &str) -> String {
// Replace [text](#anchor) with slugified anchor
let re = regex::Regex::new(r"\[([^\]]+)\]\(#([^)]+)\)").unwrap();
re.replace_all(content, |caps: &regex::Captures| {
let link_text = &caps[1];
let anchor = &caps[2];
let slugified = slugify(anchor);
format!("[{}](#{})", link_text, slugified)
}).to_string()
}
fn highlight_code_blocks(html: &str) -> String {
let ss = SyntaxSet::load_defaults_newlines();
let ts = ThemeSet::load_defaults();
let theme = &ts.themes["base16-ocean.dark"];
// Simple code block detection and highlighting
// In a real implementation, you'd want to parse the HTML and handle code blocks properly
let re = regex::Regex::new(r#"<pre><code class="language-([^"]+)">([^<]+)</code></pre>"#).unwrap();
re.replace_all(html, |caps: &regex::Captures| {
let lang = &caps[1];
let code = &caps[2];
if let Some(syntax) = ss.find_syntax_by_token(lang) {
match highlighted_html_for_string(code, &ss, syntax, theme) {
Ok(highlighted) => highlighted,
Err(_) => caps[0].to_string(),
}
} else {
caps[0].to_string()
}
}).to_string()
}
pub fn get_post_by_slug(slug: &str) -> Result<Post, Box<dyn std::error::Error>> {
let posts_dir = get_posts_directory();
let file_path = posts_dir.join(format!("{}.md", slug));
let file_content = fs::read_to_string(&file_path)?;
let matter = Matter::<YAML>::new();
let result = matter.parse(&file_content);
let front: PostFrontmatter = if let Some(data) = result.data {
match data.deserialize() {
Ok(front) => front,
Err(e) => {
eprintln!("Failed to deserialize frontmatter for post {}: {}", slug, e);
return Err("Failed to deserialize frontmatter".into());
}
}
} else {
eprintln!("No frontmatter found for post: {}", slug);
return Err("No frontmatter found".into());
};
let created_at = get_file_creation_date(&file_path)?;
let processed_markdown = process_anchor_links(&result.content);
let mut html_output = String::new();
let parser = Parser::new_ext(&processed_markdown, Options::all());
html::push_html(&mut html_output, parser);
let highlighted_html = highlight_code_blocks(&html_output);
let sanitized_html = clean(&highlighted_html);
Ok(Post {
slug: slug.to_string(),
title: front.title,
date: front.date,
tags: front.tags.unwrap_or_default(),
summary: front.summary,
content: sanitized_html,
created_at: created_at.to_rfc3339(),
author: std::env::var("BLOG_OWNER").unwrap_or_else(|_| "Anonymous".to_string()),
})
}
pub fn get_all_posts() -> Result<Vec<Post>, Box<dyn std::error::Error>> {
let posts_dir = get_posts_directory();
let mut posts = Vec::new();
for entry in fs::read_dir(posts_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().map(|e| e == "md").unwrap_or(false) {
let file_stem = path.file_stem().unwrap().to_string_lossy();
if let Ok(post) = get_post_by_slug(&file_stem) {
posts.push(post);
}
}
}
posts.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(posts)
}
pub fn get_posts_by_tag(tag: &str) -> Result<Vec<Post>, Box<dyn std::error::Error>> {
let all_posts = get_all_posts()?;
Ok(all_posts.into_iter().filter(|p| p.tags.contains(&tag.to_string())).collect())
}
pub fn watch_posts<F: Fn() + Send + 'static>(on_change: F) -> notify::Result<RecommendedWatcher> {
let (tx, rx) = channel();
let mut watcher = RecommendedWatcher::new(tx, Config::default())?;
watcher.watch(get_posts_directory().as_path(), RecursiveMode::Recursive)?;
std::thread::spawn(move || {
loop {
match rx.recv() {
Ok(_event) => {
on_change();
},
Err(e) => {
eprintln!("watch error: {:?}", e);
break;
}
}
}
});
Ok(watcher)
}