use std::collections::HashMap; use std::fs::File; use std::io::Read; use std::io::{Error, ErrorKind::Other, Result}; use std::path::Path; const TXT_TEMPLATE: &str = include_str!("../templates/index.txt"); const HTML_TEMPLATE: &str = include_str!("../templates/index.html"); const GMI_TEMPLATE: &str = include_str!("../templates/index.gmi"); const RSS_TEMPLATE: &str = include_str!("../templates/feed.xml"); // Parse and Render pub enum Token { Text(String), DisplayDirective { content: String, }, ConditionalDirective { condition: String, children: Vec, }, IteratorDirective { collection: String, member_label: String, children: Vec, }, } impl std::fmt::Display for Token { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { Token::Text(label) => write!(f, "Text {label}"), Token::DisplayDirective { content } => write!(f, "DisplayDirective {content}"), Token::ConditionalDirective { condition, children, } => { writeln!(f, "ConditionalDirective {condition} [[[")?; for child in children { writeln!(f, "\t{child}")?; } write!(f, "\n]]]") } Token::IteratorDirective { collection, member_label, children, } => { writeln!(f, "{collection} in {member_label}")?; for child in children { writeln!(f, "\t{child}")?; } write!(f, "\n]]]") } } } } #[derive(Clone)] pub enum Value { String(String), Unsigned(u64), Bool(bool), Collection(Vec), Context(Context), } impl Value { fn render(&self) -> String { match self { Value::String(string) => string.to_string(), Value::Unsigned(number) => format!("{number}"), Value::Bool(bool) => format!("{bool}"), _ => String::new(), } } } pub type Context = HashMap; struct ContextGetter {} impl ContextGetter { fn get(context: &Context, path: &str) -> Option { let path_parts: Vec<&str> = path.split('.').collect(); ContextGetter::recursively_get_value(context, &path_parts) } fn recursively_get_value(context: &Context, path: &[&str]) -> Option { match context.get(path[0]) { Some(Value::Context(next)) if path.len() > 1 => { ContextGetter::recursively_get_value(next, &path[1..]) } Some(value) if path.len() == 1 => Some(value.clone()), _ => None, } } } pub struct Parsed { pub tokens: Vec, } impl Parsed { pub fn render(&self, context: &Context) -> Result { Parsed::render_tokens(&self.tokens, context) } pub fn render_tokens(tokens: &Vec, context: &Context) -> Result { let mut rendered_template: String = String::new(); for token in tokens { match token { Token::Text(contents) => rendered_template.push_str(contents), Token::DisplayDirective { content } => { let value = ContextGetter::get(context, content).ok_or_else(|| { Error::new(Other, format!("{content} is not a valid key")) })?; rendered_template.push_str(&value.render()); } Token::ConditionalDirective { condition, children, } => { let mut negator = false; let mut condition = condition.to_string(); if condition.starts_with('!') { negator = true; condition = condition[1..].to_string(); } let value = ContextGetter::get(context, &condition).ok_or_else(|| { Error::new(Other, format!("{condition} is not a valid key")) })?; match value { Value::Bool(value) => { if negator ^ value { rendered_template .push_str(&Parsed::render_tokens(children, context)?); } Ok(()) } _ => Err(Error::new( Other, format!("{condition} is not a boolean value"), )), }?; } Token::IteratorDirective { collection, member_label, children, } => { let value = ContextGetter::get(context, collection).ok_or_else(|| { Error::new(Other, format!("{collection} is not a valid key")) })?; match value { Value::Collection(collection) => { for member in collection { let mut child_context = context.clone(); child_context .insert(member_label.to_string(), Value::Context(member)); rendered_template .push_str(&Parsed::render_tokens(children, &child_context)?); } Ok(()) } _ => Err(Error::new( Other, format!("{collection} is not a collection"), )), }?; } } } Ok(rendered_template) } } pub fn parse(template: &str) -> Option { let mut tokens = Vec::new(); tokenize(template, &mut tokens).ok()?; Some(Parsed { tokens }) } fn tokenize(template: &str, tokens: &mut Vec) -> Result<()> { let mut remaining_template = template; while !remaining_template.is_empty() && remaining_template.contains("{{") { let directive_start_index = remaining_template .find("{{") .ok_or_else(|| Error::new(Other, "Was expecting at least one tag opener"))?; if directive_start_index > 0 { let text = remaining_template[..directive_start_index].to_string(); tokens.push(Token::Text(text.to_string())); } remaining_template = &remaining_template[directive_start_index..]; let directive_end_index = remaining_template .find("}}") .ok_or_else(|| Error::new(Other, "Was expecting }} after {{"))? + 2; let directive = &remaining_template[..directive_end_index]; remaining_template = &remaining_template[directive_end_index..]; let directive_type = directive.chars().nth(2).unwrap(); match directive_type { // Simple Directives '=' => { let content = directive[3..directive.len() - 2].trim(); tokens.push(Token::DisplayDirective { content: content.to_string(), }); } // Block Directives '?' | '~' => { let content = directive[3..directive.len() - 2].trim(); let mut children = Vec::new(); match directive_type { '?' => { let closing_block = remaining_template.find("{{?}}").unwrap(); let directive_block = &remaining_template[..closing_block]; remaining_template = &remaining_template[closing_block + 5..]; tokenize(directive_block, &mut children)?; tokens.push(Token::ConditionalDirective { condition: content.to_string(), children, }); } '~' => { let parts: Vec<_> = content.splitn(2, ':').collect(); let closing_block = remaining_template.find("{{~}}").unwrap(); let directive_block = &remaining_template[..closing_block]; remaining_template = &remaining_template[closing_block + 5..]; tokenize(directive_block, &mut children)?; if parts.len() == 2 { tokens.push(Token::IteratorDirective { collection: parts[0].trim().to_string(), member_label: parts[1].trim().to_string(), children, }); } } _ => unreachable!(), } } _ => unreachable!(), } } tokens.push(Token::Text(remaining_template.to_string())); Ok(()) } // File helpers. pub fn find(template_directory: &Path, filename: &str) -> Option { let template_path = template_directory.join(filename); if template_path.exists() { let mut contents = String::new(); if File::open(template_path) .ok()? .read_to_string(&mut contents) .is_ok() { return Some(contents); } } find_default(filename) } fn find_default(filename: &str) -> Option { match filename { "index.txt" => Some(TXT_TEMPLATE.to_string()), "index.html" => Some(HTML_TEMPLATE.to_string()), "index.gmi" => Some(GMI_TEMPLATE.to_string()), "index.rss" => Some(RSS_TEMPLATE.to_string()), &_ => None, } }