Skip to main content

typstify_generator/
template.rs

1//! HTML template system for page generation.
2//!
3//! Provides a lightweight template system using string interpolation rather than
4//! heavy template engines like Tera or Handlebars.
5
6use std::collections::HashMap;
7
8use thiserror::Error;
9use typstify_core::utils::html_escape;
10
11/// Template rendering errors.
12#[derive(Debug, Error)]
13pub enum TemplateError {
14    /// Missing required variable.
15    #[error("missing required variable: {0}")]
16    MissingVariable(String),
17
18    /// Template not found.
19    #[error("template not found: {0}")]
20    NotFound(String),
21
22    /// Invalid template syntax.
23    #[error("invalid template syntax: {0}")]
24    InvalidSyntax(String),
25}
26
27/// Result type for template operations.
28pub type Result<T> = std::result::Result<T, TemplateError>;
29
30/// Template context with variables for interpolation.
31#[derive(Debug, Clone, Default)]
32pub struct TemplateContext {
33    variables: HashMap<String, String>,
34}
35
36impl TemplateContext {
37    /// Create a new empty context.
38    #[must_use]
39    pub fn new() -> Self {
40        Self::default()
41    }
42
43    /// Insert a variable into the context.
44    pub fn insert(&mut self, key: impl Into<String>, value: impl Into<String>) {
45        self.variables.insert(key.into(), value.into());
46    }
47
48    /// Create context with initial variables.
49    pub fn with_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
50        self.insert(key, value);
51        self
52    }
53
54    /// Get a variable value.
55    #[must_use]
56    pub fn get(&self, key: &str) -> Option<&str> {
57        self.variables.get(key).map(String::as_str)
58    }
59
60    /// Check if a variable exists.
61    #[must_use]
62    pub fn contains(&self, key: &str) -> bool {
63        self.variables.contains_key(key)
64    }
65}
66
67/// A simple template that supports variable interpolation.
68///
69/// Variables are specified as `{{ variable_name }}` in the template string.
70#[derive(Debug, Clone)]
71pub struct Template {
72    name: String,
73    content: String,
74}
75
76impl Template {
77    /// Create a new template with the given name and content.
78    #[must_use]
79    pub fn new(name: impl Into<String>, content: impl Into<String>) -> Self {
80        Self {
81            name: name.into(),
82            content: content.into(),
83        }
84    }
85
86    /// Get the template name.
87    #[must_use]
88    pub fn name(&self) -> &str {
89        &self.name
90    }
91
92    /// Render the template with the given context.
93    ///
94    /// Replaces all `{{ variable }}` placeholders with values from context.
95    /// Values are HTML-escaped by default. Use `{{ variable|raw }}` to skip escaping.
96    pub fn render(&self, context: &TemplateContext) -> Result<String> {
97        let mut result = self.content.clone();
98        let mut pos = 0;
99
100        while let Some(start) = result[pos..].find("{{") {
101            let start = pos + start;
102            let end = result[start..]
103                .find("}}")
104                .ok_or_else(|| TemplateError::InvalidSyntax("unclosed {{ delimiter".to_string()))?;
105            let end = start + end + 2;
106
107            let var_name = result[start + 2..end - 2].trim();
108
109            // Check for optional variable syntax: {{ variable? }}
110            let (var_name, optional) = if let Some(stripped) = var_name.strip_suffix('?') {
111                (stripped, true)
112            } else {
113                (var_name, false)
114            };
115
116            // Check for raw (unescaped) syntax: {{ variable|raw }}
117            let (var_name, raw) = if let Some(stripped) = var_name.strip_suffix("|raw") {
118                (stripped.trim(), true)
119            } else {
120                (var_name, false)
121            };
122
123            let value = match context.get(var_name) {
124                Some(v) => v.to_string(),
125                None if optional => String::new(),
126                None => return Err(TemplateError::MissingVariable(var_name.to_string())),
127            };
128
129            let value = if raw { value } else { html_escape(&value) };
130
131            result.replace_range(start..end, &value);
132            pos = start + value.len();
133        }
134
135        Ok(result)
136    }
137}
138
139/// Registry of templates.
140#[derive(Debug, Clone, Default)]
141pub struct TemplateRegistry {
142    templates: HashMap<String, Template>,
143}
144
145impl TemplateRegistry {
146    /// Create a new registry with default templates.
147    #[must_use]
148    pub fn new() -> Self {
149        let mut registry = Self::default();
150        registry.register_defaults();
151        registry
152    }
153
154    /// Register default built-in templates.
155    fn register_defaults(&mut self) {
156        self.register(Template::new("base", DEFAULT_BASE_TEMPLATE));
157        self.register(Template::new("page", DEFAULT_PAGE_TEMPLATE));
158        self.register(Template::new("post", DEFAULT_POST_TEMPLATE));
159        self.register(Template::new("short", DEFAULT_SHORT_TEMPLATE));
160        self.register(Template::new("list", DEFAULT_LIST_TEMPLATE));
161        self.register(Template::new("taxonomy", DEFAULT_TAXONOMY_TEMPLATE));
162        self.register(Template::new("redirect", DEFAULT_REDIRECT_TEMPLATE));
163        self.register(Template::new("tags_index", DEFAULT_TAGS_INDEX_TEMPLATE));
164        self.register(Template::new(
165            "categories_index",
166            DEFAULT_CATEGORIES_INDEX_TEMPLATE,
167        ));
168        self.register(Template::new("archives", DEFAULT_ARCHIVES_TEMPLATE));
169        self.register(Template::new("section", DEFAULT_SECTION_TEMPLATE));
170        self.register(Template::new("shorts", DEFAULT_SHORTS_SECTION_TEMPLATE));
171    }
172
173    /// Register a template.
174    pub fn register(&mut self, template: Template) {
175        self.templates.insert(template.name.clone(), template);
176    }
177
178    /// Get a template by name.
179    #[must_use]
180    pub fn get(&self, name: &str) -> Option<&Template> {
181        self.templates.get(name)
182    }
183
184    /// Render a named template with the given context.
185    pub fn render(&self, name: &str, context: &TemplateContext) -> Result<String> {
186        let template = self
187            .get(name)
188            .ok_or_else(|| TemplateError::NotFound(name.to_string()))?;
189        template.render(context)
190    }
191}
192
193/// Default base HTML template.
194/// Uses external CSS and JS files for better caching and smaller HTML files.
195pub const DEFAULT_BASE_TEMPLATE: &str = r##"<!DOCTYPE html>
196<html lang="{{ lang }}" class="scroll-smooth">
197<head>
198    <meta charset="UTF-8">
199    <meta name="viewport" content="width=device-width, initial-scale=1.0">
200    <title>{{ title }}{{ site_title_suffix? }}</title>
201    <meta name="description" content="{{ description? }}">
202    <meta name="author" content="{{ author? }}">
203    <link rel="canonical" href="{{ canonical_url }}">
204    {{ hreflang|raw? }}
205    <link rel="preconnect" href="https://fonts.googleapis.com">
206    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
207    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
208    <link rel="stylesheet" href="{{ base_path }}/assets/style.css">
209    {{ custom_css|raw? }}
210    <script>
211        // Inline critical JS to prevent FOUC (Flash of Unstyled Content)
212        (function() {
213            const saved = localStorage.getItem('theme');
214            const theme = saved || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
215            document.documentElement.setAttribute('data-theme', theme);
216        })();
217    </script>
218</head>
219<body>
220    <header>
221        <div class="container">
222            <nav>
223                <a href="{{ nav_home_url }}" class="site-title">{{ site_title }}</a>
224                <div class="nav-links">
225                    {{ section_nav|raw? }}
226                    <a href="{{ nav_archives_url }}">Archives</a>
227                    <a href="{{ nav_tags_url }}">Tags</a>
228                    <a href="{{ nav_about_url }}">About</a>
229                    <div class="nav-actions">
230                        <div class="search-wrapper" id="searchWrapper">
231                            <input type="text" class="search-input" id="searchInput" placeholder="Search..." autocomplete="off">
232                            <button class="search-btn" id="searchBtn" aria-label="Search" type="button">
233                                <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
234                                    <path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
235                                </svg>
236                            </button>
237                            <div class="search-results" id="searchResults"></div>
238                        </div>
239                        {{ lang_switcher|raw? }}
240                        <button class="theme-toggle" aria-label="Toggle theme" type="button">
241                            <svg class="icon-sun" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
242                                <path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
243                            </svg>
244                            <svg class="icon-moon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
245                                <path stroke-linecap="round" stroke-linejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
246                            </svg>
247                        </button>
248                    </div>
249                </div>
250            </nav>
251        </div>
252    </header>
253    <main>
254        <div class="container">
255            {{ content|raw }}
256        </div>
257    </main>
258    <footer>
259        <div class="container">
260            <p>&copy; {{ year }} {{ site_title }}. Built with <a href="https://github.com/longcipher/typstify">Typstify</a>.</p>
261        </div>
262    </footer>
263    <script src="{{ base_path }}/assets/main.js" defer></script>
264    {{ custom_js|raw? }}
265</body>
266</html>"##;
267
268/// Default page template (for standalone pages).
269pub const DEFAULT_PAGE_TEMPLATE: &str = r#"<article class="page">
270    <h1>{{ title }}</h1>
271    <div class="content">
272        {{ content|raw }}
273    </div>
274</article>"#;
275
276/// Default post template (for blog posts with metadata).
277pub const DEFAULT_POST_TEMPLATE: &str = r#"<article class="post">
278    <header>
279        <h1>{{ title }}</h1>
280        <time datetime="{{ date_iso }}">{{ date_formatted }}</time>
281        {{ tags_html|raw? }}
282    </header>
283    <div class="content">
284        {{ content|raw }}
285    </div>
286</article>"#;
287
288/// Default list template (for index pages).
289pub const DEFAULT_LIST_TEMPLATE: &str = r#"<section class="post-list">
290    <h1>{{ title }}</h1>
291    <ul>
292        {{ items|raw }}
293    </ul>
294    <div class="pagination">{{ pagination|raw? }}</div>
295</section>"#;
296
297/// Default taxonomy term template (for tag/category pages).
298pub const DEFAULT_TAXONOMY_TEMPLATE: &str = r#"<section class="taxonomy post-list">
299    <h1>{{ taxonomy_name }}: <span>{{ term }}</span></h1>
300    <ul>
301        {{ items|raw }}
302    </ul>
303    <div class="pagination">{{ pagination|raw? }}</div>
304</section>"#;
305
306/// Default redirect template for URL aliases.
307pub const DEFAULT_REDIRECT_TEMPLATE: &str = r#"<!DOCTYPE html>
308<html>
309<head>
310    <meta charset="UTF-8">
311    <meta http-equiv="refresh" content="0; url={{ redirect_url }}">
312    <link rel="canonical" href="{{ redirect_url }}">
313    <title>Redirecting...</title>
314</head>
315<body>
316    <p>Redirecting to <a href="{{ redirect_url }}">{{ redirect_url }}</a></p>
317</body>
318</html>"#;
319
320/// Default tags index template (lists all tags with counts).
321pub const DEFAULT_TAGS_INDEX_TEMPLATE: &str = r#"<section class="taxonomy-index">
322    <h1>Tags</h1>
323    <div class="tags-cloud">
324        {{ items|raw }}
325    </div>
326</section>"#;
327
328/// Default categories index template (lists all categories with counts).
329pub const DEFAULT_CATEGORIES_INDEX_TEMPLATE: &str = r#"<section class="taxonomy-index">
330    <h1>Categories</h1>
331    <ul class="categories-list">
332        {{ items|raw }}
333    </ul>
334</section>"#;
335
336/// Default archives template (lists all posts grouped by year).
337pub const DEFAULT_ARCHIVES_TEMPLATE: &str = r#"<section class="archives">
338    <h1>Archives</h1>
339    {{ items|raw }}
340</section>"#;
341
342/// Default section template (lists all posts in a section).
343pub const DEFAULT_SECTION_TEMPLATE: &str = r#"<section class="section-list post-list">
344    <h1>{{ title }}</h1>
345    <p class="section-description">{{ description? }}</p>
346    <ul>
347        {{ items|raw }}
348    </ul>
349    <div class="pagination">{{ pagination|raw? }}</div>
350</section>"#;
351
352/// Default short template (minimalist layout).
353pub const DEFAULT_SHORT_TEMPLATE: &str = r#"<div class="short-item">
354    <time class="short-date" datetime="{{ date_iso }}">{{ date_formatted }}</time>
355    <div class="short-content">
356        {{ content|raw }}
357    </div>
358</div>"#;
359
360/// Default shorts section template (minimalist layout).
361pub const DEFAULT_SHORTS_SECTION_TEMPLATE: &str = r#"<section class="shorts-section">
362    <h1>{{ title }}</h1>
363    <p class="section-description">{{ description? }}</p>
364    <div class="short-list">
365        {{ items|raw }}
366    </div>
367    <div class="pagination">{{ pagination|raw? }}</div>
368</section>"#;
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    #[test]
375    fn test_template_simple_render() {
376        let template = Template::new("test", "Hello, {{ name }}!");
377        let mut ctx = TemplateContext::new();
378        ctx.insert("name", "World");
379
380        let result = template.render(&ctx).unwrap();
381        assert_eq!(result, "Hello, World!");
382    }
383
384    #[test]
385    fn test_template_multiple_variables() {
386        let template = Template::new(
387            "test",
388            "{{ greeting }}, {{ name }}! Welcome to {{ place }}.",
389        );
390        let ctx = TemplateContext::new()
391            .with_var("greeting", "Hello")
392            .with_var("name", "User")
393            .with_var("place", "Typstify");
394
395        let result = template.render(&ctx).unwrap();
396        assert_eq!(result, "Hello, User! Welcome to Typstify.");
397    }
398
399    #[test]
400    fn test_template_optional_variable() {
401        let template = Template::new("test", "Hello{{ suffix? }}!");
402        let ctx = TemplateContext::new();
403
404        let result = template.render(&ctx).unwrap();
405        assert_eq!(result, "Hello!");
406
407        let ctx = TemplateContext::new().with_var("suffix", ", World");
408        let result = template.render(&ctx).unwrap();
409        assert_eq!(result, "Hello, World!");
410    }
411
412    #[test]
413    fn test_template_missing_required_variable() {
414        let template = Template::new("test", "Hello, {{ name }}!");
415        let ctx = TemplateContext::new();
416
417        let result = template.render(&ctx);
418        assert!(matches!(result, Err(TemplateError::MissingVariable(_))));
419    }
420
421    #[test]
422    fn test_template_registry() {
423        let registry = TemplateRegistry::new();
424
425        assert!(registry.get("base").is_some());
426        assert!(registry.get("page").is_some());
427        assert!(registry.get("post").is_some());
428        assert!(registry.get("list").is_some());
429        assert!(registry.get("nonexistent").is_none());
430    }
431
432    #[test]
433    fn test_render_base_template() {
434        let registry = TemplateRegistry::new();
435        let ctx = TemplateContext::new()
436            .with_var("lang", "en")
437            .with_var("title", "My Page")
438            .with_var("base_path", "")
439            .with_var("canonical_url", "https://example.com/my-page")
440            .with_var("content", "<p>Hello!</p>")
441            .with_var("site_title", "My Site")
442            .with_var("year", "2026")
443            // Navigation URLs
444            .with_var("nav_home_url", "/")
445            .with_var("section_nav", r#"<a href="/posts">Posts</a>"#)
446            .with_var("nav_archives_url", "/archives")
447            .with_var("nav_tags_url", "/tags")
448            .with_var("nav_about_url", "/about");
449
450        let result = registry.render("base", &ctx).unwrap();
451        assert!(result.contains("<!DOCTYPE html>"));
452        assert!(result.contains("<title>My Page</title>"));
453        assert!(result.contains("<p>Hello!</p>"));
454    }
455
456    #[test]
457    fn test_auto_escaping() {
458        let template = Template::new("test", "Hello, {{ name }}!");
459        let ctx = TemplateContext::new().with_var("name", "<b>bold</b>");
460
461        let result = template.render(&ctx).unwrap();
462        assert_eq!(result, "Hello, &lt;b&gt;bold&lt;/b&gt;!");
463        assert!(!result.contains("<b>"));
464    }
465
466    #[test]
467    fn test_raw_suffix() {
468        let template = Template::new("test", "{{ content|raw }}");
469        let ctx = TemplateContext::new().with_var("content", "<p>Hello</p>");
470
471        let result = template.render(&ctx).unwrap();
472        assert_eq!(result, "<p>Hello</p>");
473    }
474
475    #[test]
476    fn test_raw_with_optional() {
477        let template = Template::new("test", "{{ content|raw? }}");
478        let ctx = TemplateContext::new();
479
480        let result = template.render(&ctx).unwrap();
481        assert_eq!(result, "");
482
483        let ctx = TemplateContext::new().with_var("content", "<b>bold</b>");
484        let result = template.render(&ctx).unwrap();
485        assert_eq!(result, "<b>bold</b>");
486    }
487
488    #[test]
489    fn test_html_escape_in_attribute() {
490        let template = Template::new("test", r#"<meta name="description" content="{{ desc }}">"#);
491        let ctx = TemplateContext::new().with_var("desc", "A \"quoted\" description");
492
493        let result = template.render(&ctx).unwrap();
494        assert!(result.contains("A &quot;quoted&quot; description"));
495    }
496}