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:
parent
6e3eae7abd
commit
8885a7c9d8
91
src/commonmark.rs
Normal file
91
src/commonmark.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
85
src/site.rs
85
src/site.rs
|
@ -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,23 +16,33 @@ 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 {
|
||||||
let raw_content = match std::fs::read_to_string(path) {
|
commonmark_renderer: commonmark::CommonMarkRenderer,
|
||||||
Err(e) => return Err(ContentError::IOError(path.clone(), e)),
|
}
|
||||||
Ok(s) => s,
|
|
||||||
};
|
impl ContentRenderer {
|
||||||
match path.extension().unwrap_or_default().to_str() {
|
pub fn new() -> Result<Self, ContentError> {
|
||||||
Some("md") => {
|
Ok(ContentRenderer { commonmark_renderer: commonmark::CommonMarkRenderer::new() })
|
||||||
let parser = pulldown_cmark::Parser::new_ext(&raw_content, pulldown_cmark::Options::all());
|
}
|
||||||
let mut output = String::new();
|
|
||||||
// TODO: use write_html() instead because that can actually return errors instead of just panicking
|
pub fn render(&self, path: &PathBuf) -> Result<String, ContentError> {
|
||||||
pulldown_cmark::html::push_html(&mut output, parser);
|
let raw_content = match std::fs::read_to_string(path) {
|
||||||
Ok(output)
|
Err(e) => return Err(ContentError::IOError(path.clone(), e)),
|
||||||
|
Ok(s) => s,
|
||||||
|
};
|
||||||
|
match path.extension().unwrap_or_default().to_str() {
|
||||||
|
Some("md") => match self.commonmark_renderer.render_to_html(&raw_content) {
|
||||||
|
Err(e) => return Err(ContentError::CommonMarkError(path.clone(), e)),
|
||||||
|
Ok(output) => Ok(output),
|
||||||
|
},
|
||||||
|
Some("html") | Some("htm") => Ok(raw_content),
|
||||||
|
_ => Ok(raw_content),
|
||||||
}
|
}
|
||||||
Some("html") | Some("htm") => Ok(raw_content),
|
|
||||||
_ => Ok(raw_content),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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)) => {
|
||||||
|
|
Loading…
Reference in a new issue