2025-08-31 00:55:31 -05:00
|
|
|
use std::fs;
|
2025-08-30 01:11:01 -05:00
|
|
|
use std::io::Error;
|
2025-09-01 19:28:21 -05:00
|
|
|
use std::path::{Path, PathBuf};
|
2025-08-30 01:11:01 -05:00
|
|
|
use markdown::message::Message;
|
2025-08-31 00:55:31 -05:00
|
|
|
use markdown::{Constructs, Options, ParseOptions};
|
2025-08-30 01:11:01 -05:00
|
|
|
use markdown::mdast::Node;
|
|
|
|
|
use serde::Deserialize;
|
|
|
|
|
use thiserror::Error;
|
|
|
|
|
|
2025-08-31 00:55:31 -05:00
|
|
|
/// The things we might return from trying to render a file
|
|
|
|
|
#[derive(Clone, PartialEq, Debug)]
|
|
|
|
|
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)
|
2025-08-30 01:11:01 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
pub struct Frontmatter {
|
|
|
|
|
layout: Option<String>,
|
2025-08-31 00:55:31 -05:00
|
|
|
title: Option<String>,
|
2025-09-01 13:03:22 -05:00
|
|
|
published: Option<bool>
|
2025-08-30 01:11:01 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Frontmatter {
|
2025-09-01 19:28:21 -05:00
|
|
|
fn apply_layout(self, content: String, filename: PathBuf, root: &Path) -> Result<RenderOutput, RenderError> {
|
|
|
|
|
let title = self.title.unwrap_or_default();
|
2025-08-31 00:55:31 -05:00
|
|
|
let wrapped = if let Some(layout) = self.layout {
|
2025-08-30 01:11:01 -05:00
|
|
|
let absolute_layout = root.join(layout);
|
2025-09-01 19:28:21 -05:00
|
|
|
let layout = fs::read_to_string(absolute_layout.clone()).map_err(|e| RenderError::FileRead(e, absolute_layout))?;
|
2025-08-31 00:55:31 -05:00
|
|
|
layout.replace("$title", title.as_str()).replace("$content", content.as_str())
|
2025-08-30 01:11:01 -05:00
|
|
|
} else {
|
2025-08-31 00:55:31 -05:00
|
|
|
content
|
|
|
|
|
};
|
2025-09-01 13:03:22 -05:00
|
|
|
if let Some(false) = self.published {
|
2025-08-31 00:55:31 -05:00
|
|
|
Ok(RenderOutput::Hidden(filename.with_extension("html"), wrapped))
|
2025-09-01 13:03:22 -05:00
|
|
|
} else {
|
|
|
|
|
Ok(RenderOutput::Rendered(filename.with_extension("html"), wrapped))
|
2025-08-30 01:11:01 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Error, Debug)]
|
|
|
|
|
pub enum RenderError {
|
|
|
|
|
#[error("Error reading {1}: {0}")]
|
2025-09-01 19:28:21 -05:00
|
|
|
FileRead(Error, PathBuf),
|
2025-08-30 01:11:01 -05:00
|
|
|
#[error("Error parsing Markdown {1}: {0}")]
|
2025-09-01 19:28:21 -05:00
|
|
|
MarkdownParse(Message, PathBuf),
|
2025-08-30 01:11:01 -05:00
|
|
|
#[error("Error parsing frontmatter in {1}: {0}")]
|
2025-09-01 19:28:21 -05:00
|
|
|
FrontmatterParse(toml::de::Error, PathBuf)
|
2025-08-30 01:11:01 -05:00
|
|
|
}
|
|
|
|
|
|
2025-09-01 13:03:22 -05:00
|
|
|
/// Return the path for the default thing to render for the test server, which is either index.html
|
|
|
|
|
/// (if it exists) or index.md. Note that this does not guarantee that the file actually exists, it
|
|
|
|
|
/// just chooses that because those are the files which can become index.html in the output and
|
|
|
|
|
/// index.html is what a reasonable webserver will pick as the default file.
|
2025-09-01 19:28:21 -05:00
|
|
|
pub fn default_path(root: &Path) -> PathBuf {
|
|
|
|
|
if let Ok(true) = fs::exists(root.join("index.html")) {
|
2025-09-01 13:03:22 -05:00
|
|
|
"index.html".into()
|
|
|
|
|
} else {
|
|
|
|
|
"index.md".into()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-31 00:55:31 -05:00
|
|
|
/// Take a source file path (relative to the root) and the root path, and return a RenderOutput for it
|
2025-09-01 19:28:21 -05:00
|
|
|
pub fn render(source: PathBuf, root: &Path) -> Result<RenderOutput, RenderError> {
|
2025-09-01 19:15:30 -05:00
|
|
|
let extension = source.extension().map(|o| o.to_str().unwrap());
|
2025-08-31 00:55:31 -05:00
|
|
|
if skipped_path(source.clone()) {
|
2025-09-01 19:15:30 -05:00
|
|
|
// Skipped path, nothing
|
|
|
|
|
Ok(RenderOutput::NoOutput)
|
|
|
|
|
} else if root.join(source.clone()).is_dir() {
|
|
|
|
|
// Dir, which matters for producing files
|
|
|
|
|
Ok(RenderOutput::Dir(source))
|
|
|
|
|
} else if let Ok(true) = fs::exists(root.join(source.clone())) {
|
|
|
|
|
match extension {
|
|
|
|
|
// Asked for a markdown file, render it
|
|
|
|
|
// TODO this is a little silly for the server, this should properly be NoOutput but that's a big change
|
|
|
|
|
Some("md") => render_as_markdown(source.clone(), root),
|
|
|
|
|
// Not a markdown file, but it exists, return it raw
|
|
|
|
|
_ => Ok(RenderOutput::RawFile(root.join(source)))
|
|
|
|
|
}
|
|
|
|
|
} else if matches!(extension, Some("html")) &&
|
|
|
|
|
let Ok(true) = fs::exists(root.join(source.with_extension("md"))) {
|
|
|
|
|
// We asked for an html file which doesn't exist but a corresponding md file does, render it
|
|
|
|
|
render_as_markdown(source.with_extension("md"), root)
|
|
|
|
|
} else {
|
|
|
|
|
// Asked for something which doesn't exist and it's not the md -> html case, 404:
|
|
|
|
|
Ok(RenderOutput::NoOutput)
|
2025-08-31 00:55:31 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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 {
|
2025-09-01 19:28:21 -05:00
|
|
|
source.iter().any(|el| {
|
2025-08-31 00:55:31 -05:00
|
|
|
match el.to_str() {
|
|
|
|
|
Some("..") => true,
|
|
|
|
|
Some(s) if s.starts_with("_") => true,
|
|
|
|
|
_ => false
|
|
|
|
|
}
|
|
|
|
|
})
|
2025-08-30 01:11:01 -05:00
|
|
|
}
|
|
|
|
|
|
2025-08-31 00:55:31 -05:00
|
|
|
// This gets called by `render` if the source path extension is md
|
2025-09-01 19:28:21 -05:00
|
|
|
fn render_as_markdown(source: PathBuf, root: &Path) -> Result<RenderOutput, RenderError> {
|
2025-08-30 01:11:01 -05:00
|
|
|
let absolute_source = root.join(source.clone());
|
2025-09-01 19:28:21 -05:00
|
|
|
let contents = fs::read_to_string(absolute_source.clone()).map_err(|e| RenderError::FileRead(e, source.clone()))?;
|
2025-08-30 16:07:51 -05:00
|
|
|
let options = markdown_options();
|
2025-09-01 19:28:21 -05:00
|
|
|
let html = markdown::to_html_with_options(contents.as_str(), &options).map_err(|e| RenderError::MarkdownParse(e, source.clone()))?;
|
|
|
|
|
let ast = markdown::to_mdast(contents.as_str(), &options.parse).map_err(|e| RenderError::MarkdownParse(e, source.clone()))?;
|
2025-08-30 16:07:51 -05:00
|
|
|
|
|
|
|
|
if let Some(frontmatter) = find_frontmatter(ast, source.clone())? {
|
2025-08-31 00:55:31 -05:00
|
|
|
frontmatter.apply_layout(html, source, root)
|
2025-08-30 16:07:51 -05:00
|
|
|
} else {
|
2025-08-31 00:55:31 -05:00
|
|
|
Ok(RenderOutput::Rendered(source.with_extension("html"), html))
|
2025-08-30 16:07:51 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-31 00:55:31 -05:00
|
|
|
// Construct the Markdown options we'll render with
|
2025-08-30 16:07:51 -05:00
|
|
|
fn markdown_options() -> Options {
|
|
|
|
|
markdown::Options {
|
2025-08-30 01:11:01 -05:00
|
|
|
parse: ParseOptions {
|
|
|
|
|
constructs: Constructs {
|
|
|
|
|
frontmatter: true,
|
|
|
|
|
gfm_table: true,
|
|
|
|
|
..Default::default()
|
|
|
|
|
},
|
|
|
|
|
..Default::default()
|
|
|
|
|
},
|
|
|
|
|
..Default::default()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-31 00:55:31 -05:00
|
|
|
// Look for and try to parse toml frontmatter
|
2025-08-30 01:11:01 -05:00
|
|
|
fn find_frontmatter(node: Node, source: PathBuf) -> Result<Option<Frontmatter>, RenderError> {
|
|
|
|
|
if let Some(children) = node.children() {
|
2025-09-01 19:28:21 -05:00
|
|
|
for child in children.iter() {
|
2025-08-30 01:11:01 -05:00
|
|
|
if let Node::Toml(toml_str) = child {
|
2025-09-01 19:28:21 -05:00
|
|
|
let fmatter: Frontmatter = toml::from_str(toml_str.value.as_str()).map_err(|e| RenderError::FrontmatterParse(e, source))?;
|
2025-08-30 01:11:01 -05:00
|
|
|
return Ok(Some(fmatter))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(None)
|
|
|
|
|
} else { Ok(None) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
2025-08-31 00:55:31 -05:00
|
|
|
fn render_file(path: impl Into<PathBuf>) -> RenderOutput {
|
2025-09-01 19:28:21 -05:00
|
|
|
match render(path.into(), Path::new("./testdata")) {
|
2025-08-30 17:27:24 -05:00
|
|
|
Ok(ro) => ro,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
println!("{}", e);
|
|
|
|
|
panic!();
|
2025-08-30 01:11:01 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-08-30 17:27:24 -05:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_rendering_markdown() {
|
2025-08-31 00:55:31 -05:00
|
|
|
let contents = render_file("index.md");
|
|
|
|
|
assert!(matches!(contents, RenderOutput::Rendered(_, _)));
|
|
|
|
|
let RenderOutput::Rendered(filename, contents) = contents else { unreachable!() };
|
2025-08-30 17:27:24 -05:00
|
|
|
assert_eq!(filename.to_str(), Some("index.html")); // Goes to the right filename
|
|
|
|
|
assert!(contents.starts_with("<!DOCTYPE html>")); // Uses the layout
|
|
|
|
|
assert!(contents.matches("Pest Toast").next().is_some()); // Replaces in the title
|
|
|
|
|
assert!(contents.matches("<code class=\"language-rust\">").next().is_some()); // Renders the code snippet
|
|
|
|
|
assert!(contents.matches("<table>").next().is_some()); // Renders the table
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_no_layout() {
|
2025-08-31 00:55:31 -05:00
|
|
|
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());
|
2025-09-01 13:03:22 -05:00
|
|
|
|
|
|
|
|
let contents = render_file("not_hidden.md");
|
|
|
|
|
assert!(matches!(contents, RenderOutput::Rendered(_, _))); // If we don't specify, it's published by default
|
2025-08-31 00:55:31 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_raw() {
|
|
|
|
|
let contents = render_file("raw.txt");
|
2025-09-01 19:28:21 -05:00
|
|
|
assert_eq!(contents, RenderOutput::RawFile(PathBuf::from("./testdata/raw.txt")));
|
2025-08-31 00:55:31 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_dotdot() {
|
|
|
|
|
assert!(matches!(render_file("../src/main.rs"), RenderOutput::NoOutput));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_dir() {
|
|
|
|
|
assert!(matches!(render_file("dir"), RenderOutput::Dir(_)));
|
2025-08-30 17:27:24 -05:00
|
|
|
}
|
2025-09-01 19:15:30 -05:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_ask_for_html() {
|
|
|
|
|
let contents = render_file("index.html");
|
|
|
|
|
assert!(matches!(contents, RenderOutput::Rendered(_, _))) // We asked for the html file which doesn't exist but the md does
|
|
|
|
|
}
|
2025-09-01 19:32:10 -05:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_default_path() {
|
|
|
|
|
// index.html doesn't exist so we default to index.md, which will become index.html
|
|
|
|
|
assert_eq!(default_path(Path::new("./testdata")), PathBuf::from("index.md"));
|
|
|
|
|
|
|
|
|
|
// index.html exists so we default to it, rather than render something
|
|
|
|
|
assert_eq!(default_path(Path::new("./testdata/with_index")), PathBuf::from("index.html"))
|
|
|
|
|
}
|
2025-08-30 01:11:01 -05:00
|
|
|
}
|