rust parser working on local build. fix docker build
This commit is contained in:
18
markdown_backend/Cargo.toml
Normal file
18
markdown_backend/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "markdown_backend"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_yaml = "0.9"
|
||||
pulldown-cmark = "0.9"
|
||||
gray_matter = "0.2.8"
|
||||
ammonia = "3.1"
|
||||
slug = "0.1"
|
||||
notify = "6.1"
|
||||
syntect = { version = "5.1", features = ["default"] }
|
||||
regex = "1.10"
|
||||
clap = { version = "4.4", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
69
markdown_backend/src/main.rs
Normal file
69
markdown_backend/src/main.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
mod markdown;
|
||||
use markdown::{get_all_posts, get_post_by_slug, get_posts_by_tag, watch_posts};
|
||||
use serde_json;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "Markdown Backend")]
|
||||
#[command(about = "A CLI for managing markdown blog posts", long_about = None)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// List all posts
|
||||
List,
|
||||
/// Show a post by slug
|
||||
Show {
|
||||
slug: String,
|
||||
},
|
||||
/// List posts by tag
|
||||
Tags {
|
||||
tag: String,
|
||||
},
|
||||
/// Watch for changes in the posts directory
|
||||
Watch,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
match &cli.command {
|
||||
Commands::List => {
|
||||
let posts = get_all_posts().unwrap_or_else(|e| {
|
||||
eprintln!("{}", e);
|
||||
std::process::exit(1);
|
||||
});
|
||||
println!("{}", serde_json::to_string(&posts).unwrap());
|
||||
}
|
||||
Commands::Show { slug } => {
|
||||
match get_post_by_slug(slug) {
|
||||
Ok(post) => {
|
||||
println!("{}", serde_json::to_string(&post).unwrap());
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("{}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
Commands::Tags { tag } => {
|
||||
let posts = get_posts_by_tag(tag).unwrap_or_else(|e| {
|
||||
eprintln!("{}", e);
|
||||
std::process::exit(1);
|
||||
});
|
||||
println!("{}", serde_json::to_string(&posts).unwrap());
|
||||
}
|
||||
Commands::Watch => {
|
||||
println!("Watching for changes in posts directory. Press Ctrl+C to exit.");
|
||||
let _ = watch_posts(|| {
|
||||
println!("Posts directory changed!");
|
||||
});
|
||||
// Keep the main thread alive
|
||||
loop {
|
||||
std::thread::sleep(std::time::Duration::from_secs(60));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
181
markdown_backend/src/markdown.rs
Normal file
181
markdown_backend/src/markdown.rs
Normal file
@@ -0,0 +1,181 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user