523 lines
16 KiB
Rust
523 lines
16 KiB
Rust
//! Template service for handling localized template rendering
|
|
|
|
#![allow(dead_code)]
|
|
|
|
use crate::template::{
|
|
RenderedTemplate, Result, TemplateConfig, TemplateEngine, TemplateError, TemplateLoader,
|
|
TemplatePageConfig,
|
|
};
|
|
use std::collections::HashMap;
|
|
use std::sync::{Arc, RwLock};
|
|
use tracing::{debug, info, warn};
|
|
|
|
/// Template service that manages template loading and rendering
|
|
#[derive(Debug)]
|
|
pub struct TemplateService {
|
|
/// Template engine
|
|
engine: Arc<RwLock<TemplateEngine>>,
|
|
/// Template loader
|
|
loader: Arc<RwLock<TemplateLoader>>,
|
|
/// Template configuration
|
|
config: TemplateConfig,
|
|
/// Cache for rendered templates
|
|
render_cache: Arc<RwLock<HashMap<String, RenderedTemplate>>>,
|
|
/// Cache enabled flag
|
|
cache_enabled: bool,
|
|
}
|
|
|
|
impl TemplateService {
|
|
/// Create a new template service
|
|
pub fn new(template_dir: impl Into<String>, content_dir: impl Into<String>) -> Result<Self> {
|
|
let template_dir = template_dir.into();
|
|
let content_dir = content_dir.into();
|
|
|
|
let config = TemplateConfig::new(&template_dir, &content_dir);
|
|
let mut engine = TemplateEngine::new(&template_dir)?;
|
|
|
|
// Add default filters
|
|
engine.add_default_filters();
|
|
|
|
let loader = TemplateLoader::new(config.clone());
|
|
|
|
Ok(Self {
|
|
engine: Arc::new(RwLock::new(engine)),
|
|
loader: Arc::new(RwLock::new(loader)),
|
|
config,
|
|
render_cache: Arc::new(RwLock::new(HashMap::new())),
|
|
cache_enabled: true,
|
|
})
|
|
}
|
|
|
|
/// Create template service with custom configuration
|
|
pub fn with_config(config: TemplateConfig) -> Result<Self> {
|
|
let mut engine = TemplateEngine::new(&config.template_dir)?;
|
|
engine.add_default_filters();
|
|
|
|
let loader = TemplateLoader::new(config.clone());
|
|
|
|
Ok(Self {
|
|
engine: Arc::new(RwLock::new(engine)),
|
|
loader: Arc::new(RwLock::new(loader)),
|
|
config,
|
|
render_cache: Arc::new(RwLock::new(HashMap::new())),
|
|
cache_enabled: true,
|
|
})
|
|
}
|
|
|
|
/// Set available languages
|
|
pub fn with_languages(self, languages: Vec<String>) -> Self {
|
|
if let Ok(mut loader) = self.loader.write() {
|
|
*loader = std::mem::take(&mut *loader).with_languages(languages);
|
|
}
|
|
self
|
|
}
|
|
|
|
/// Set default language
|
|
pub fn with_default_language(self, lang: impl Into<String>) -> Self {
|
|
if let Ok(mut loader) = self.loader.write() {
|
|
*loader = std::mem::take(&mut *loader).with_default_lang(lang);
|
|
}
|
|
self
|
|
}
|
|
|
|
/// Enable or disable caching
|
|
pub fn with_cache(mut self, enabled: bool) -> Self {
|
|
self.cache_enabled = enabled;
|
|
if let Ok(mut loader) = self.loader.write() {
|
|
*loader = std::mem::take(&mut *loader).with_cache(enabled);
|
|
}
|
|
self
|
|
}
|
|
|
|
/// Render a template page
|
|
///
|
|
/// For URL `/page:content-name` with language `lang`, this will:
|
|
/// 1. Load the template configuration from `{lang}_content-name.tpl.toml`
|
|
/// 2. Render the template using the Tera engine
|
|
/// 3. Return the rendered content
|
|
pub async fn render_page(&self, content_name: &str, lang: &str) -> Result<RenderedTemplate> {
|
|
let cache_key = format!("{}_{}", lang, content_name);
|
|
|
|
// Check render cache first
|
|
if self.cache_enabled {
|
|
if let Ok(cache) = self.render_cache.read() {
|
|
if let Some(cached) = cache.get(&cache_key) {
|
|
debug!("Serving cached template for {}", cache_key);
|
|
return Ok(cached.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load template configuration
|
|
let config = {
|
|
let mut loader = self.loader.write().map_err(|_| {
|
|
TemplateError::RenderError("Failed to acquire loader lock".to_string())
|
|
})?;
|
|
loader.load_page_config_with_fallback(content_name, lang)?
|
|
};
|
|
|
|
// Render template
|
|
let source_path = {
|
|
let loader = self.loader.read().map_err(|_| {
|
|
TemplateError::RenderError("Failed to acquire loader lock".to_string())
|
|
})?;
|
|
loader.get_template_file_path(content_name, lang)
|
|
};
|
|
|
|
let rendered = {
|
|
let engine = self.engine.read().map_err(|_| {
|
|
TemplateError::RenderError("Failed to acquire engine lock".to_string())
|
|
})?;
|
|
engine.render_template(&config, &source_path)?
|
|
};
|
|
|
|
// Cache the result
|
|
if self.cache_enabled {
|
|
if let Ok(mut cache) = self.render_cache.write() {
|
|
cache.insert(cache_key, rendered.clone());
|
|
}
|
|
}
|
|
|
|
info!(
|
|
"Rendered template '{}' for content '{}' in language '{}'",
|
|
config.template_name, content_name, lang
|
|
);
|
|
|
|
Ok(rendered)
|
|
}
|
|
|
|
/// Check if a template page exists
|
|
pub fn page_exists(&self, content_name: &str, lang: &str) -> bool {
|
|
let loader = match self.loader.read() {
|
|
Ok(loader) => loader,
|
|
Err(_) => return false,
|
|
};
|
|
|
|
loader.exists(content_name, lang)
|
|
}
|
|
|
|
/// Get all available content for a specific language
|
|
pub async fn get_available_content(&self, lang: &str) -> Result<Vec<String>> {
|
|
let loader = self
|
|
.loader
|
|
.read()
|
|
.map_err(|_| TemplateError::RenderError("Failed to acquire loader lock".to_string()))?;
|
|
|
|
loader.get_available_content(lang)
|
|
}
|
|
|
|
/// Get available languages
|
|
pub fn get_available_languages(&self) -> Vec<String> {
|
|
let loader = match self.loader.read() {
|
|
Ok(loader) => loader,
|
|
Err(_) => return vec!["en".to_string()],
|
|
};
|
|
|
|
loader.get_available_languages().to_vec()
|
|
}
|
|
|
|
/// Get default language
|
|
pub fn get_default_language(&self) -> String {
|
|
let loader = match self.loader.read() {
|
|
Ok(loader) => loader,
|
|
Err(_) => return "en".to_string(),
|
|
};
|
|
|
|
loader.get_default_language().to_string()
|
|
}
|
|
|
|
/// Parse page URL to extract content name
|
|
///
|
|
/// Converts `/page:content-name` to `content-name`
|
|
pub fn parse_page_url(&self, url_path: &str) -> Option<String> {
|
|
let loader = self.loader.read().ok()?;
|
|
loader.parse_page_url(url_path)
|
|
}
|
|
|
|
/// Generate page URL from content name
|
|
///
|
|
/// Converts `content-name` to `/page:content-name`
|
|
pub fn generate_page_url(&self, content_name: &str) -> String {
|
|
let loader = match self.loader.read() {
|
|
Ok(loader) => loader,
|
|
Err(_) => return format!("/page:{}", content_name),
|
|
};
|
|
|
|
loader.generate_page_url(content_name)
|
|
}
|
|
|
|
/// Clear all caches
|
|
pub async fn clear_cache(&self) -> Result<()> {
|
|
// Clear render cache
|
|
if let Ok(mut cache) = self.render_cache.write() {
|
|
cache.clear();
|
|
}
|
|
|
|
// Clear loader cache
|
|
if let Ok(mut loader) = self.loader.write() {
|
|
loader.clear_cache();
|
|
}
|
|
|
|
info!("Template caches cleared");
|
|
Ok(())
|
|
}
|
|
|
|
/// Reload templates from disk
|
|
pub async fn reload_templates(&self) -> Result<()> {
|
|
// Reload engine templates
|
|
if let Ok(mut engine) = self.engine.write() {
|
|
engine.reload_templates()?;
|
|
}
|
|
|
|
// Clear all caches
|
|
self.clear_cache().await?;
|
|
|
|
info!("Templates reloaded from disk");
|
|
Ok(())
|
|
}
|
|
|
|
/// Get template engine statistics
|
|
pub fn get_engine_stats(&self) -> HashMap<String, serde_json::Value> {
|
|
let mut stats = HashMap::new();
|
|
|
|
if let Ok(engine) = self.engine.read() {
|
|
let template_names = engine.get_template_names();
|
|
stats.insert("template_count".to_string(), template_names.len().into());
|
|
stats.insert("template_names".to_string(), template_names.into());
|
|
}
|
|
|
|
if let Ok(cache) = self.render_cache.read() {
|
|
stats.insert("render_cache_size".to_string(), cache.len().into());
|
|
}
|
|
|
|
if let Ok(loader) = self.loader.read() {
|
|
let (cache_size, cache_enabled) = loader.get_cache_stats();
|
|
stats.insert("loader_cache_size".to_string(), cache_size.into());
|
|
stats.insert("loader_cache_enabled".to_string(), cache_enabled.into());
|
|
}
|
|
|
|
stats.insert("cache_enabled".to_string(), self.cache_enabled.into());
|
|
|
|
stats
|
|
}
|
|
|
|
/// Render a template with custom context (for testing or special cases)
|
|
pub async fn render_with_context(
|
|
&self,
|
|
template_name: &str,
|
|
context: HashMap<String, serde_json::Value>,
|
|
) -> Result<String> {
|
|
let engine = self
|
|
.engine
|
|
.read()
|
|
.map_err(|_| TemplateError::RenderError("Failed to acquire engine lock".to_string()))?;
|
|
|
|
let tera_context = TemplateEngine::create_context(&context);
|
|
engine.render_with_context(template_name, &tera_context)
|
|
}
|
|
|
|
/// Add a custom filter to the template engine
|
|
pub fn add_filter<F>(&self, name: &str, filter: F) -> Result<()>
|
|
where
|
|
F: tera::Filter + 'static,
|
|
{
|
|
let mut engine = self
|
|
.engine
|
|
.write()
|
|
.map_err(|_| TemplateError::RenderError("Failed to acquire engine lock".to_string()))?;
|
|
|
|
engine.add_filter(name, filter);
|
|
Ok(())
|
|
}
|
|
|
|
/// Add a custom function to the template engine
|
|
pub fn add_function<F>(&self, name: &str, func: F) -> Result<()>
|
|
where
|
|
F: tera::Function + 'static,
|
|
{
|
|
let mut engine = self
|
|
.engine
|
|
.write()
|
|
.map_err(|_| TemplateError::RenderError("Failed to acquire engine lock".to_string()))?;
|
|
|
|
engine.add_function(name, func);
|
|
Ok(())
|
|
}
|
|
|
|
/// Preload templates for a specific language
|
|
pub async fn preload_language(&self, lang: &str) -> Result<usize> {
|
|
let content_names = self.get_available_content(lang).await?;
|
|
let mut loaded_count = 0;
|
|
|
|
for content_name in content_names {
|
|
match self.render_page(&content_name, lang).await {
|
|
Ok(_) => {
|
|
loaded_count += 1;
|
|
debug!("Preloaded template: {}_{}", lang, content_name);
|
|
}
|
|
Err(e) => {
|
|
warn!(
|
|
"Failed to preload template {}_{}: {}",
|
|
lang, content_name, e
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
info!(
|
|
"Preloaded {} templates for language '{}'",
|
|
loaded_count, lang
|
|
);
|
|
Ok(loaded_count)
|
|
}
|
|
|
|
/// Get template configuration for a specific page
|
|
pub async fn get_page_config(
|
|
&self,
|
|
content_name: &str,
|
|
lang: &str,
|
|
) -> Result<TemplatePageConfig> {
|
|
let mut loader = self
|
|
.loader
|
|
.write()
|
|
.map_err(|_| TemplateError::RenderError("Failed to acquire loader lock".to_string()))?;
|
|
|
|
loader.load_page_config_with_fallback(content_name, lang)
|
|
}
|
|
|
|
/// Check if template engine has a specific template
|
|
pub fn has_template(&self, template_name: &str) -> bool {
|
|
let engine = match self.engine.read() {
|
|
Ok(engine) => engine,
|
|
Err(_) => return false,
|
|
};
|
|
|
|
engine.template_exists(template_name)
|
|
}
|
|
}
|
|
|
|
impl Clone for TemplateService {
|
|
fn clone(&self) -> Self {
|
|
Self {
|
|
engine: Arc::clone(&self.engine),
|
|
loader: Arc::clone(&self.loader),
|
|
config: self.config.clone(),
|
|
render_cache: Arc::clone(&self.render_cache),
|
|
cache_enabled: self.cache_enabled,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::fs;
|
|
use tempfile::TempDir;
|
|
|
|
fn create_test_template_file(
|
|
dir: &std::path::Path,
|
|
filename: &str,
|
|
content: &str,
|
|
) -> std::io::Result<()> {
|
|
let file_path = dir.join(filename);
|
|
fs::write(file_path, content)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_template_service_creation() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let template_dir = temp_dir.path().join("templates");
|
|
let content_dir = temp_dir.path().join("content");
|
|
|
|
fs::create_dir_all(&template_dir).unwrap();
|
|
fs::create_dir_all(&content_dir).unwrap();
|
|
|
|
// Create a test template
|
|
create_test_template_file(&template_dir, "test.html", "<h1>{{title}}</h1>").unwrap();
|
|
|
|
let service = TemplateService::new(
|
|
template_dir.to_str().unwrap(),
|
|
content_dir.to_str().unwrap(),
|
|
);
|
|
|
|
assert!(service.is_ok());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_render_page() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let template_dir = temp_dir.path().join("templates");
|
|
let content_dir = temp_dir.path().join("content");
|
|
|
|
fs::create_dir_all(&template_dir).unwrap();
|
|
fs::create_dir_all(&content_dir).unwrap();
|
|
|
|
// Create template
|
|
create_test_template_file(
|
|
&template_dir,
|
|
"blog-post.html",
|
|
"<h1>{{title}}</h1><p>{{content}}</p>",
|
|
)
|
|
.unwrap();
|
|
|
|
// Create template config
|
|
let config_content = r#"
|
|
template_name = "blog-post"
|
|
|
|
[values]
|
|
title = "Test Blog Post"
|
|
content = "This is test content"
|
|
"#;
|
|
|
|
create_test_template_file(&content_dir, "en_my-blog.tpl.toml", config_content).unwrap();
|
|
|
|
let service = TemplateService::new(
|
|
template_dir.to_str().unwrap(),
|
|
content_dir.to_str().unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
let result = service.render_page("my-blog", "en").await.unwrap();
|
|
assert!(result.content.contains("Test Blog Post"));
|
|
assert!(result.content.contains("This is test content"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_language_fallback() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let template_dir = temp_dir.path().join("templates");
|
|
let content_dir = temp_dir.path().join("content");
|
|
|
|
fs::create_dir_all(&template_dir).unwrap();
|
|
fs::create_dir_all(&content_dir).unwrap();
|
|
|
|
// Create template
|
|
create_test_template_file(&template_dir, "blog-post.html", "<h1>{{title}}</h1>").unwrap();
|
|
|
|
// Create only English config
|
|
let config_content = r#"
|
|
template_name = "blog-post"
|
|
|
|
[values]
|
|
title = "English Title"
|
|
"#;
|
|
|
|
create_test_template_file(&content_dir, "en_my-blog.tpl.toml", config_content).unwrap();
|
|
|
|
let service = TemplateService::new(
|
|
template_dir.to_str().unwrap(),
|
|
content_dir.to_str().unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
// Try to render in French, should fallback to English
|
|
let result = service.render_page("my-blog", "fr").await.unwrap();
|
|
assert!(result.content.contains("English Title"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_parse_page_url() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let template_dir = temp_dir.path().join("templates");
|
|
let content_dir = temp_dir.path().join("content");
|
|
|
|
fs::create_dir_all(&template_dir).unwrap();
|
|
fs::create_dir_all(&content_dir).unwrap();
|
|
|
|
let service = TemplateService::new(
|
|
template_dir.to_str().unwrap(),
|
|
content_dir.to_str().unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
service.parse_page_url("/page:my-blog"),
|
|
Some("my-blog".to_string())
|
|
);
|
|
assert_eq!(
|
|
service.parse_page_url("/page:about-us"),
|
|
Some("about-us".to_string())
|
|
);
|
|
assert_eq!(service.parse_page_url("/other-path"), None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_generate_page_url() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let template_dir = temp_dir.path().join("templates");
|
|
let content_dir = temp_dir.path().join("content");
|
|
|
|
fs::create_dir_all(&template_dir).unwrap();
|
|
fs::create_dir_all(&content_dir).unwrap();
|
|
|
|
let service = TemplateService::new(
|
|
template_dir.to_str().unwrap(),
|
|
content_dir.to_str().unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(service.generate_page_url("my-blog"), "/page:my-blog");
|
|
assert_eq!(service.generate_page_url("about-us"), "/page:about-us");
|
|
}
|
|
}
|