markdown rendering refactor and addition of code syntax highlighting

currently syntax highlighting is limited to the default syntax
definitions that syntect comes pre-loaded with (which is the same
as sublime text's out-of-the-box language support)
This commit is contained in:
Gered 2023-06-30 14:53:10 -04:00
parent 6e3eae7abd
commit 8885a7c9d8
3 changed files with 141 additions and 36 deletions

91
src/commonmark.rs Normal file
View file

@ -0,0 +1,91 @@
use pulldown_cmark::{CodeBlockKind, CowStr, Event, Parser, Tag};
use syntect::html::{ClassStyle, ClassedHTMLGenerator};
use syntect::parsing::SyntaxSet;
use syntect::util::LinesWithEndings;
#[derive(Debug, thiserror::Error)]
pub enum CommonMarkError {
#[error("Syntax highlighting error")]
SyntectError(#[from] syntect::Error),
}
struct SyntectContext {
syntax_set: SyntaxSet,
}
pub struct CommonMarkRenderer {
syntect_context: SyntectContext,
}
impl CommonMarkRenderer {
pub fn new() -> Self {
let syntax_set = SyntaxSet::load_defaults_newlines();
CommonMarkRenderer { syntect_context: SyntectContext { syntax_set } }
}
fn highlight_code(&self, code: &str, language: &str) -> Result<String, CommonMarkError> {
let syntax = self
.syntect_context
.syntax_set
.find_syntax_by_extension(language)
.unwrap_or_else(|| self.syntect_context.syntax_set.find_syntax_plain_text());
let mut html_generator = ClassedHTMLGenerator::new_with_class_style(
syntax,
&self.syntect_context.syntax_set,
ClassStyle::SpacedPrefixed { prefix: "sh-" },
);
for line in LinesWithEndings::from(code) {
html_generator.parse_html_for_line_which_includes_newline(line)?;
}
Ok(format!("<pre><code>{}</code></pre>", html_generator.finalize()))
}
fn highlight_codeblocks<'input>(
&self,
events: Parser<'input, '_>,
) -> Result<impl Iterator<Item = Event<'input>> + 'input, CommonMarkError> {
let mut modified_events = Vec::new();
let mut code_buffer = String::new();
let mut is_in_code_block = false;
for event in events {
match event {
Event::Start(Tag::CodeBlock(_)) => {
is_in_code_block = true;
code_buffer.clear();
}
Event::End(Tag::CodeBlock(kind)) => {
if is_in_code_block {
let language = if let CodeBlockKind::Fenced(language) = kind {
language.to_string()
} else {
String::new()
};
let html = self.highlight_code(&code_buffer, &language)?;
modified_events.push(Event::Html(CowStr::Boxed(html.into())));
is_in_code_block = false;
}
}
Event::Text(text) => {
if is_in_code_block {
code_buffer.push_str(&text);
} else {
modified_events.push(Event::Text(text))
}
}
event => modified_events.push(event),
}
}
Ok(modified_events.into_iter())
}
pub fn render_to_html(&self, s: &str) -> Result<String, CommonMarkError> {
let parser = Parser::new_ext(s, pulldown_cmark::Options::all());
let events = self.highlight_codeblocks(parser)?;
let mut output = String::new();
pulldown_cmark::html::push_html(&mut output, events);
Ok(output)
}
}

View file

@ -5,6 +5,7 @@ use anyhow::Context;
use std::env; use std::env;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
mod commonmark;
mod config; mod config;
mod site; mod site;
mod util; mod util;

View file

@ -7,7 +7,7 @@ use actix_web::{Either, HttpRequest, HttpResponse};
use chrono::{Datelike, TimeZone}; use chrono::{Datelike, TimeZone};
use itertools::Itertools; use itertools::Itertools;
use crate::{config, util}; use crate::{commonmark, config, util};
type UriPath = String; type UriPath = String;
type Tag = String; type Tag = String;
@ -16,25 +16,35 @@ type Tag = String;
pub enum ContentError { pub enum ContentError {
#[error("Content rendering I/O error with path {0}")] #[error("Content rendering I/O error with path {0}")]
IOError(PathBuf, #[source] std::io::Error), IOError(PathBuf, #[source] std::io::Error),
#[error("CommonMark rendering error with path {0}")]
CommonMarkError(PathBuf, #[source] commonmark::CommonMarkError),
} }
fn render_content(path: &PathBuf) -> Result<String, ContentError> { pub struct ContentRenderer {
commonmark_renderer: commonmark::CommonMarkRenderer,
}
impl ContentRenderer {
pub fn new() -> Result<Self, ContentError> {
Ok(ContentRenderer { commonmark_renderer: commonmark::CommonMarkRenderer::new() })
}
pub fn render(&self, path: &PathBuf) -> Result<String, ContentError> {
let raw_content = match std::fs::read_to_string(path) { let raw_content = match std::fs::read_to_string(path) {
Err(e) => return Err(ContentError::IOError(path.clone(), e)), Err(e) => return Err(ContentError::IOError(path.clone(), e)),
Ok(s) => s, Ok(s) => s,
}; };
match path.extension().unwrap_or_default().to_str() { match path.extension().unwrap_or_default().to_str() {
Some("md") => { Some("md") => match self.commonmark_renderer.render_to_html(&raw_content) {
let parser = pulldown_cmark::Parser::new_ext(&raw_content, pulldown_cmark::Options::all()); Err(e) => return Err(ContentError::CommonMarkError(path.clone(), e)),
let mut output = String::new(); Ok(output) => Ok(output),
// TODO: use write_html() instead because that can actually return errors instead of just panicking },
pulldown_cmark::html::push_html(&mut output, parser);
Ok(output)
}
Some("html") | Some("htm") => Ok(raw_content), Some("html") | Some("htm") => Ok(raw_content),
_ => Ok(raw_content), _ => Ok(raw_content),
} }
} }
}
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum SiteError { pub enum SiteError {
@ -110,12 +120,10 @@ pub struct Post {
pub tags: Vec<Tag>, pub tags: Vec<Tag>,
} }
impl TryFrom<config::Post> for Post { impl Post {
type Error = SiteError; pub fn try_from(value: config::Post, content_renderer: &ContentRenderer) -> Result<Self, SiteError> {
fn try_from(value: config::Post) -> Result<Self, Self::Error> {
let url = format!("/{:04}/{:02}/{:02}/{}", value.date.year(), value.date.month(), value.date.day(), value.slug); let url = format!("/{:04}/{:02}/{:02}/{}", value.date.year(), value.date.month(), value.date.day(), value.slug);
let content_html = render_content(&value.file_path)?; let content_html = content_renderer.render(&value.file_path)?;
let tags = value.tags.map_or_else(|| Vec::new(), |x| x.clone()); let tags = value.tags.map_or_else(|| Vec::new(), |x| x.clone());
Ok(Post { Ok(Post {
url, // url, //
@ -134,11 +142,9 @@ pub struct Page {
pub content_html: String, pub content_html: String,
} }
impl TryFrom<config::Page> for Page { impl Page {
type Error = SiteError; pub fn try_from(value: config::Page, content_renderer: &ContentRenderer) -> Result<Self, SiteError> {
let content_html = content_renderer.render(&value.file_path)?;
fn try_from(value: config::Page) -> Result<Self, Self::Error> {
let content_html = render_content(&value.file_path)?;
Ok(Page { Ok(Page {
url: value.url, // url: value.url, //
title: value.title, title: value.title,
@ -182,7 +188,11 @@ pub struct SiteContent {
} }
impl SiteContent { impl SiteContent {
pub fn new(pages_config: config::Pages, posts_config: config::Posts) -> Result<Self, SiteError> { pub fn new(
pages_config: config::Pages,
posts_config: config::Posts,
content_renderer: &ContentRenderer,
) -> Result<Self, SiteError> {
let mut alternate_url_mappings = AlternateUrlMappings::new(); let mut alternate_url_mappings = AlternateUrlMappings::new();
let mut post_tag_mappings = PostsByTag::new(); let mut post_tag_mappings = PostsByTag::new();
@ -190,7 +200,7 @@ impl SiteContent {
let mut pages = Vec::new(); let mut pages = Vec::new();
let mut pages_by_url = HashMap::new(); let mut pages_by_url = HashMap::new();
for (index, page_config) in pages_config.pages.iter().enumerate() { for (index, page_config) in pages_config.pages.iter().enumerate() {
let page = Page::try_from(page_config.clone())?; let page = Page::try_from(page_config.clone(), content_renderer)?;
if let Some(old_urls) = &page_config.alternate_urls { if let Some(old_urls) = &page_config.alternate_urls {
alternate_url_mappings.add_mappings(old_urls, &page.url); alternate_url_mappings.add_mappings(old_urls, &page.url);
@ -205,7 +215,7 @@ impl SiteContent {
let mut posts = Vec::new(); let mut posts = Vec::new();
let mut posts_by_url = HashMap::new(); let mut posts_by_url = HashMap::new();
for (index, post_config) in posts_config.posts.iter().sorted_by(|a, b| b.date.cmp(&a.date)).enumerate() { for (index, post_config) in posts_config.posts.iter().sorted_by(|a, b| b.date.cmp(&a.date)).enumerate() {
let post = Post::try_from(post_config.clone())?; let post = Post::try_from(post_config.clone(), content_renderer)?;
if let Some(old_urls) = &post_config.alternate_urls { if let Some(old_urls) = &post_config.alternate_urls {
alternate_url_mappings.add_mappings(old_urls, &post.url); alternate_url_mappings.add_mappings(old_urls, &post.url);
@ -262,7 +272,8 @@ impl SiteContent {
pub struct SiteService { pub struct SiteService {
pub server_config: config::Server, pub server_config: config::Server,
pub renderer: tera::Tera, pub content_renderer: ContentRenderer,
pub template_renderer: tera::Tera,
pub content: RwLock<SiteContent>, pub content: RwLock<SiteContent>,
} }
@ -272,7 +283,8 @@ impl SiteService {
pages_config: config::Pages, pages_config: config::Pages,
posts_config: config::Posts, posts_config: config::Posts,
) -> Result<Self, SiteError> { ) -> Result<Self, SiteError> {
let content = SiteContent::new(pages_config, posts_config)?; let content_renderer = ContentRenderer::new()?;
let content = SiteContent::new(pages_config, posts_config, &content_renderer)?;
let mut templates_path = PathBuf::from(&server_config.templates_path); let mut templates_path = PathBuf::from(&server_config.templates_path);
templates_path.push("**/*"); templates_path.push("**/*");
log::debug!("Using templates path: {:?}", templates_path); log::debug!("Using templates path: {:?}", templates_path);
@ -283,7 +295,8 @@ impl SiteService {
); );
Ok(SiteService { Ok(SiteService {
server_config, // server_config, //
renderer, content_renderer,
template_renderer: renderer,
content: RwLock::new(content), content: RwLock::new(content),
}) })
} }
@ -295,7 +308,7 @@ impl SiteService {
if let Some(post) = post { if let Some(post) = post {
context.insert("post", post); context.insert("post", post);
} }
HttpResponse::Ok().body(self.renderer.render("post.html", &context).unwrap()) HttpResponse::Ok().body(self.template_renderer.render("post.html", &context).unwrap())
} }
pub fn serve_posts_by_tag(&self, tag: &Tag) -> HttpResponse { pub fn serve_posts_by_tag(&self, tag: &Tag) -> HttpResponse {
@ -304,7 +317,7 @@ impl SiteService {
let mut context = tera::Context::new(); let mut context = tera::Context::new();
context.insert("tag", tag); context.insert("tag", tag);
context.insert("posts", &posts); context.insert("posts", &posts);
HttpResponse::Ok().body(self.renderer.render("tag.html", &context).unwrap()) HttpResponse::Ok().body(self.template_renderer.render("tag.html", &context).unwrap())
} }
pub fn serve_posts_archive(&self) -> HttpResponse { pub fn serve_posts_archive(&self) -> HttpResponse {
@ -312,7 +325,7 @@ impl SiteService {
let posts = content.get_posts_ordered_by_date(); let posts = content.get_posts_ordered_by_date();
let mut context = tera::Context::new(); let mut context = tera::Context::new();
context.insert("posts", &posts); context.insert("posts", &posts);
HttpResponse::Ok().body(self.renderer.render("archive.html", &context).unwrap()) HttpResponse::Ok().body(self.template_renderer.render("archive.html", &context).unwrap())
} }
pub fn serve_rss_feed(&self) -> HttpResponse { pub fn serve_rss_feed(&self) -> HttpResponse {
@ -349,14 +362,14 @@ impl SiteService {
log::debug!("Found page content at {}", req.path()); log::debug!("Found page content at {}", req.path());
let mut context = tera::Context::new(); let mut context = tera::Context::new();
context.insert("page", page); context.insert("page", page);
let rendered = self.renderer.render("page.html", &context).unwrap(); let rendered = self.template_renderer.render("page.html", &context).unwrap();
Some(Either::Left(HttpResponse::Ok().body(rendered))) Some(Either::Left(HttpResponse::Ok().body(rendered)))
} }
Some(Content::Post(post)) => { Some(Content::Post(post)) => {
log::debug!("Found post content at {}", req.path()); log::debug!("Found post content at {}", req.path());
let mut context = tera::Context::new(); let mut context = tera::Context::new();
context.insert("post", post); context.insert("post", post);
let rendered = self.renderer.render("post.html", &context).unwrap(); let rendered = self.template_renderer.render("post.html", &context).unwrap();
Some(Either::Left(HttpResponse::Ok().body(rendered))) Some(Either::Left(HttpResponse::Ok().body(rendered)))
} }
Some(Content::Redirect(url)) => { Some(Content::Redirect(url)) => {