181 lines
5.7 KiB
Rust
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: ®ex::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: ®ex::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)
|
|
}
|