Tests and more renderer logic

This commit is contained in:
2025-08-31 00:55:31 -05:00
parent 0aca4c36cb
commit 4ffd647377
10 changed files with 130 additions and 25 deletions
+97 -23
View File
@@ -1,33 +1,48 @@
use std::{default, fs}; use std::fs;
use std::io::Error; use std::io::Error;
use std::path::PathBuf; use std::path::PathBuf;
use eframe::egui::TextBuffer;
use markdown::message::Message; use markdown::message::Message;
use markdown::{CompileOptions, Constructs, Options, ParseOptions}; use markdown::{Constructs, Options, ParseOptions};
use markdown::mdast::Node; use markdown::mdast::Node;
use serde::Deserialize; use serde::Deserialize;
use thiserror::Error; use thiserror::Error;
pub struct RenderOutput { /// The things we might return from trying to render a file
pub filename: PathBuf, #[derive(Clone, PartialEq, Debug)]
pub contents: String pub enum RenderOutput {
/// A string which had been rendered from some source file or script
Rendered(PathBuf, String),
/// Hidden output should be returned by the test server but not rendered to a file
Hidden(PathBuf, String),
/// The raw contents of the path
RawFile(PathBuf),
/// No contents; this file should not be output / test server should return 404
NoOutput,
/// This is a directory; the test server won't return anything but we need to mkdir it
Dir(PathBuf)
} }
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct Frontmatter { pub struct Frontmatter {
layout: Option<String>, layout: Option<String>,
title: Option<String> title: Option<String>,
published: bool
} }
impl Frontmatter { impl Frontmatter {
fn apply_layout(self, content: String, root: PathBuf) -> Result<String, RenderError> { fn apply_layout(self, content: String, filename: PathBuf, root: PathBuf) -> Result<RenderOutput, RenderError> {
let title = self.title.unwrap_or(String::new()); let title = self.title.unwrap_or(String::new());
if let Some(layout) = self.layout { let wrapped = if let Some(layout) = self.layout {
let absolute_layout = root.join(layout); let absolute_layout = root.join(layout);
let layout = fs::read_to_string(absolute_layout.clone()).map_err(|e| RenderError::FileReadError(e, absolute_layout))?; let layout = fs::read_to_string(absolute_layout.clone()).map_err(|e| RenderError::FileReadError(e, absolute_layout))?;
Ok(layout.replace("$title", title.as_str()).replace("$content", content.as_str())) layout.replace("$title", title.as_str()).replace("$content", content.as_str())
} else { } else {
Ok(content) content
};
if self.published {
Ok(RenderOutput::Rendered(filename.with_extension("html"), wrapped))
} else {
Ok(RenderOutput::Hidden(filename.with_extension("html"), wrapped))
} }
} }
} }
@@ -42,16 +57,34 @@ pub enum RenderError {
FrontmatterParseError(toml::de::Error, PathBuf) FrontmatterParseError(toml::de::Error, PathBuf)
} }
/// Take a source file path (relative to the root) and the root path, and return a RenderOutput for it
pub fn render(source: PathBuf, root: PathBuf) -> Result<RenderOutput, RenderError> { pub fn render(source: PathBuf, root: PathBuf) -> Result<RenderOutput, RenderError> {
let contents = match source.extension().map(|o| o.to_str().unwrap()) { if skipped_path(source.clone()) {
return Ok(RenderOutput::NoOutput)
}
if root.join(source.clone()).is_dir() {
return Ok(RenderOutput::Dir(source))
}
match source.extension().map(|o| o.to_str().unwrap()) {
Some("md") => render_as_markdown(source.clone(), root), Some("md") => render_as_markdown(source.clone(), root),
_ => todo!() _ => Ok(RenderOutput::RawFile(source))
}?; }
let filename = source.with_extension("html");
Ok(RenderOutput { filename, contents })
} }
fn render_as_markdown(source: PathBuf, root: PathBuf) -> Result<String, RenderError> { // If any element of the path starts with an underscore, we want to skip rendering it.
// In addition, if a cheeky person has put .. in the path, just skip it (which will trigger a 404 from the dev server)
fn skipped_path(source: PathBuf) -> bool {
source.into_iter().any(|el| {
match el.to_str() {
Some("..") => true,
Some(s) if s.starts_with("_") => true,
_ => false
}
})
}
// This gets called by `render` if the source path extension is md
fn render_as_markdown(source: PathBuf, root: PathBuf) -> Result<RenderOutput, RenderError> {
let absolute_source = root.join(source.clone()); let absolute_source = root.join(source.clone());
let contents = fs::read_to_string(absolute_source.clone()).map_err(|e| RenderError::FileReadError(e, source.clone()))?; let contents = fs::read_to_string(absolute_source.clone()).map_err(|e| RenderError::FileReadError(e, source.clone()))?;
let options = markdown_options(); let options = markdown_options();
@@ -59,13 +92,13 @@ fn render_as_markdown(source: PathBuf, root: PathBuf) -> Result<String, RenderEr
let ast = markdown::to_mdast(contents.as_str(), &options.parse).map_err(|e| RenderError::MarkdownParseError(e, source.clone()))?; let ast = markdown::to_mdast(contents.as_str(), &options.parse).map_err(|e| RenderError::MarkdownParseError(e, source.clone()))?;
if let Some(frontmatter) = find_frontmatter(ast, source.clone())? { if let Some(frontmatter) = find_frontmatter(ast, source.clone())? {
frontmatter.apply_layout(html, root) frontmatter.apply_layout(html, source, root)
} else { } else {
Ok(html) Ok(RenderOutput::Rendered(source.with_extension("html"), html))
} }
} }
// Construct the // Construct the Markdown options we'll render with
fn markdown_options() -> Options { fn markdown_options() -> Options {
markdown::Options { markdown::Options {
parse: ParseOptions { parse: ParseOptions {
@@ -80,6 +113,7 @@ fn markdown_options() -> Options {
} }
} }
// Look for and try to parse toml frontmatter
fn find_frontmatter(node: Node, source: PathBuf) -> Result<Option<Frontmatter>, RenderError> { fn find_frontmatter(node: Node, source: PathBuf) -> Result<Option<Frontmatter>, RenderError> {
if let Some(children) = node.children() { if let Some(children) = node.children() {
for child in children.into_iter() { for child in children.into_iter() {
@@ -96,8 +130,8 @@ fn find_frontmatter(node: Node, source: PathBuf) -> Result<Option<Frontmatter>,
mod tests { mod tests {
use super::*; use super::*;
fn render_index() -> RenderOutput { fn render_file(path: impl Into<PathBuf>) -> RenderOutput {
match render("index.md".into(), "./testdata".into()) { match render(path.into(), "./testdata".into()) {
Ok(ro) => ro, Ok(ro) => ro,
Err(e) => { Err(e) => {
println!("{}", e); println!("{}", e);
@@ -108,7 +142,9 @@ mod tests {
#[test] #[test]
fn test_rendering_markdown() { fn test_rendering_markdown() {
let RenderOutput { contents, filename } = render_index(); let contents = render_file("index.md");
assert!(matches!(contents, RenderOutput::Rendered(_, _)));
let RenderOutput::Rendered(filename, contents) = contents else { unreachable!() };
assert_eq!(filename.to_str(), Some("index.html")); // Goes to the right filename assert_eq!(filename.to_str(), Some("index.html")); // Goes to the right filename
assert!(contents.starts_with("<!DOCTYPE html>")); // Uses the layout assert!(contents.starts_with("<!DOCTYPE html>")); // Uses the layout
assert!(contents.matches("Pest Toast").next().is_some()); // Replaces in the title assert!(contents.matches("Pest Toast").next().is_some()); // Replaces in the title
@@ -118,5 +154,43 @@ mod tests {
#[test] #[test]
fn test_no_layout() { fn test_no_layout() {
let contents = render_file("nolayout.md");
assert!(matches!(contents, RenderOutput::Rendered(_, _))); // Normal output
let RenderOutput::Rendered(_, contents) = contents else { unreachable!() };
assert!(contents.starts_with("<p>This file has no layout")) // Doesn't use any layout, just the contents
}
#[test]
fn test_no_output() {
let contents = render_file("_skipped.md");
assert!(matches!(contents, RenderOutput::NoOutput)); // This begins with an underscore so it's skipped
let contents = render_file("_layouts/post.html");
assert!(matches!(contents, RenderOutput::NoOutput)); // This is in a dir that begins with an underscore
}
#[test]
fn test_hidden() {
let contents = render_file("hidden.md");
assert!(matches!(contents, RenderOutput::Hidden(_, _))); // The dev server should return it but it shouldn't produce a file
let RenderOutput::Hidden(_, contents) = contents else { unreachable!() };
assert!(contents.starts_with("<!DOCTYPE html>")); // Uses the layout
assert!(contents.matches("This file should render to hidden").next().is_some());
}
#[test]
fn test_raw() {
let contents = render_file("raw.txt");
assert_eq!(contents, RenderOutput::RawFile(PathBuf::from("raw.txt")));
}
#[test]
fn test_dotdot() {
assert!(matches!(render_file("../src/main.rs"), RenderOutput::NoOutput));
}
#[test]
fn test_dir() {
assert!(matches!(render_file("dir"), RenderOutput::Dir(_)));
} }
} }
+9
View File
@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<title>$title</title>
</head>
<body>
$content
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
+++
layout = "_layouts/default.html"
title = "Page Title"
published = false
+++
Page Title
===
+1 -1
View File
@@ -1,7 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<title>blog - $title</title> <title>$title</title>
</head> </head>
<body> <body>
$content $content
+1
View File
@@ -0,0 +1 @@
This file should be skipped entirely because it begins with an underscore
View File
+6
View File
@@ -0,0 +1,6 @@
+++
layout = "_layouts/post.html"
published = false
+++
This file should render to hidden because it's unpublished
+2 -1
View File
@@ -1,6 +1,7 @@
+++ +++
layout = "layouts/post.html" layout = "_layouts/post.html"
title = "Pest Toast" title = "Pest Toast"
published = true
+++ +++
Test Test
=== ===
+5
View File
@@ -0,0 +1,5 @@
+++
published = true
+++
This file has no layout, so it'll be rendered but nothing around it.
+1
View File
@@ -0,0 +1 @@
This should be echoed back raw because of the extension