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 }}">
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="stylesheet" href="{{ base_path }}/assets/style.css">
206    {{ custom_css|raw? }}
207    <script>
208        (function() {
209            var saved = null;
210            try { saved = localStorage.getItem('theme'); } catch(e) {}
211            var theme = saved || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
212            document.documentElement.setAttribute('data-theme', theme);
213        })();
214    </script>
215</head>
216<body>
217    <header>
218        <div class="container">
219            <nav>
220                <a href="{{ nav_home_url }}" class="site-title">{{ site_title }}</a>
221                <div class="nav-links">
222                    {{ section_nav|raw? }}
223                    <a href="{{ nav_archives_url }}">Archives</a>
224                    <a href="{{ nav_tags_url }}">Tags</a>
225                    <a href="{{ nav_about_url }}">About</a>
226                    <div class="nav-actions">
227                        <div class="search-wrapper" id="searchWrapper">
228                            <input type="text" class="search-input" id="searchInput" placeholder="Search..." autocomplete="off">
229                            <span class="search-btn" aria-hidden="true">
230                                <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
231                                    <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" />
232                                </svg>
233                            </span>
234                            <div class="search-results" id="searchResults"></div>
235                        </div>
236                        {{ lang_switcher|raw? }}
237                        <button class="theme-toggle" aria-label="Toggle theme" type="button">
238                            <svg class="icon-sun" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
239                                <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" />
240                            </svg>
241                            <svg class="icon-moon" 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="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" />
243                            </svg>
244                        </button>
245                    </div>
246                </div>
247            </nav>
248        </div>
249    </header>
250    <main>
251        <div class="container">
252            {{ content|raw }}
253        </div>
254    </main>
255    <footer>
256        <div class="container">
257            <p>&copy; {{ year }} {{ site_title }}. Built with <a href="https://github.com/longcipher/typstify">Typstify</a>.</p>
258        </div>
259    </footer>
260    <script src="{{ base_path }}/assets/main.js" defer></script>
261    {{ custom_js|raw? }}
262</body>
263</html>"##;
264
265/// Default page template (for standalone pages).
266pub const DEFAULT_PAGE_TEMPLATE: &str = r#"<article class="page">
267    <h1>{{ title }}</h1>
268    <div class="content">
269        {{ content|raw }}
270    </div>
271</article>"#;
272
273/// Default post template (for blog posts with metadata).
274pub const DEFAULT_POST_TEMPLATE: &str = r#"<article class="post">
275    <header>
276        <h1>{{ title }}</h1>
277        <time datetime="{{ date_iso }}">{{ date_formatted }}</time>
278        {{ tags_html|raw? }}
279    </header>
280    <div class="content">
281        {{ content|raw }}
282    </div>
283</article>"#;
284
285/// Default list template (for index pages).
286pub const DEFAULT_LIST_TEMPLATE: &str = r#"<section class="post-list">
287    <h1>{{ title }}</h1>
288    <ul>
289        {{ items|raw }}
290    </ul>
291    <div class="pagination">{{ pagination|raw? }}</div>
292</section>"#;
293
294/// Default taxonomy term template (for tag/category pages).
295pub const DEFAULT_TAXONOMY_TEMPLATE: &str = r#"<section class="taxonomy post-list">
296    <h1>{{ taxonomy_name }}: <span>{{ term }}</span></h1>
297    <ul>
298        {{ items|raw }}
299    </ul>
300    <div class="pagination">{{ pagination|raw? }}</div>
301</section>"#;
302
303/// Default redirect template for URL aliases.
304pub const DEFAULT_REDIRECT_TEMPLATE: &str = r#"<!DOCTYPE html>
305<html>
306<head>
307    <meta charset="UTF-8">
308    <meta http-equiv="refresh" content="0; url={{ redirect_url }}">
309    <link rel="canonical" href="{{ redirect_url }}">
310    <title>Redirecting...</title>
311</head>
312<body>
313    <p>Redirecting to <a href="{{ redirect_url }}">{{ redirect_url }}</a></p>
314</body>
315</html>"#;
316
317/// Default tags index template (lists all tags with counts).
318pub const DEFAULT_TAGS_INDEX_TEMPLATE: &str = r#"<section class="taxonomy-index">
319    <h1>Tags</h1>
320    <div class="tags-cloud">
321        {{ items|raw }}
322    </div>
323</section>"#;
324
325/// Default categories index template (lists all categories with counts).
326pub const DEFAULT_CATEGORIES_INDEX_TEMPLATE: &str = r#"<section class="taxonomy-index">
327    <h1>Categories</h1>
328    <ul class="categories-list">
329        {{ items|raw }}
330    </ul>
331</section>"#;
332
333/// Default archives template (lists all posts grouped by year).
334pub const DEFAULT_ARCHIVES_TEMPLATE: &str = r#"<section class="archives">
335    <h1>Archives</h1>
336    {{ items|raw }}
337</section>"#;
338
339/// Default section template (lists all posts in a section).
340pub const DEFAULT_SECTION_TEMPLATE: &str = r#"<section class="section-list post-list">
341    <h1>{{ title }}</h1>
342    <p class="section-description">{{ description? }}</p>
343    <ul>
344        {{ items|raw }}
345    </ul>
346    <div class="pagination">{{ pagination|raw? }}</div>
347</section>"#;
348
349/// Default short template (minimalist layout).
350pub const DEFAULT_SHORT_TEMPLATE: &str = r#"<div class="short-item">
351    <time class="short-date" datetime="{{ date_iso }}">{{ date_formatted }}</time>
352    <div class="short-content">
353        {{ content|raw }}
354    </div>
355</div>"#;
356
357/// Default shorts section template (minimalist layout).
358pub const DEFAULT_SHORTS_SECTION_TEMPLATE: &str = r#"<section class="shorts-section">
359    <h1>{{ title }}</h1>
360    <p class="section-description">{{ description? }}</p>
361    <div class="short-list">
362        {{ items|raw }}
363    </div>
364    <div class="pagination">{{ pagination|raw? }}</div>
365</section>"#;
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    #[test]
372    fn test_template_simple_render() {
373        let template = Template::new("test", "Hello, {{ name }}!");
374        let mut ctx = TemplateContext::new();
375        ctx.insert("name", "World");
376
377        let result = template.render(&ctx).unwrap();
378        assert_eq!(result, "Hello, World!");
379    }
380
381    #[test]
382    fn test_template_multiple_variables() {
383        let template = Template::new(
384            "test",
385            "{{ greeting }}, {{ name }}! Welcome to {{ place }}.",
386        );
387        let ctx = TemplateContext::new()
388            .with_var("greeting", "Hello")
389            .with_var("name", "User")
390            .with_var("place", "Typstify");
391
392        let result = template.render(&ctx).unwrap();
393        assert_eq!(result, "Hello, User! Welcome to Typstify.");
394    }
395
396    #[test]
397    fn test_template_optional_variable() {
398        let template = Template::new("test", "Hello{{ suffix? }}!");
399        let ctx = TemplateContext::new();
400
401        let result = template.render(&ctx).unwrap();
402        assert_eq!(result, "Hello!");
403
404        let ctx = TemplateContext::new().with_var("suffix", ", World");
405        let result = template.render(&ctx).unwrap();
406        assert_eq!(result, "Hello, World!");
407    }
408
409    #[test]
410    fn test_template_missing_required_variable() {
411        let template = Template::new("test", "Hello, {{ name }}!");
412        let ctx = TemplateContext::new();
413
414        let result = template.render(&ctx);
415        assert!(matches!(result, Err(TemplateError::MissingVariable(_))));
416    }
417
418    #[test]
419    fn test_template_registry() {
420        let registry = TemplateRegistry::new();
421
422        assert!(registry.get("base").is_some());
423        assert!(registry.get("page").is_some());
424        assert!(registry.get("post").is_some());
425        assert!(registry.get("list").is_some());
426        assert!(registry.get("nonexistent").is_none());
427    }
428
429    #[test]
430    fn test_render_base_template() {
431        let registry = TemplateRegistry::new();
432        let ctx = TemplateContext::new()
433            .with_var("lang", "en")
434            .with_var("title", "My Page")
435            .with_var("base_path", "")
436            .with_var("canonical_url", "https://example.com/my-page")
437            .with_var("content", "<p>Hello!</p>")
438            .with_var("site_title", "My Site")
439            .with_var("year", "2026")
440            // Navigation URLs
441            .with_var("nav_home_url", "/")
442            .with_var("section_nav", r#"<a href="/posts">Posts</a>"#)
443            .with_var("nav_archives_url", "/archives")
444            .with_var("nav_tags_url", "/tags")
445            .with_var("nav_about_url", "/about");
446
447        let result = registry.render("base", &ctx).unwrap();
448        assert!(result.contains("<!DOCTYPE html>"));
449        assert!(result.contains("<title>My Page</title>"));
450        assert!(result.contains("<p>Hello!</p>"));
451    }
452
453    #[test]
454    fn test_auto_escaping() {
455        let template = Template::new("test", "Hello, {{ name }}!");
456        let ctx = TemplateContext::new().with_var("name", "<b>bold</b>");
457
458        let result = template.render(&ctx).unwrap();
459        assert_eq!(result, "Hello, &lt;b&gt;bold&lt;/b&gt;!");
460        assert!(!result.contains("<b>"));
461    }
462
463    #[test]
464    fn test_raw_suffix() {
465        let template = Template::new("test", "{{ content|raw }}");
466        let ctx = TemplateContext::new().with_var("content", "<p>Hello</p>");
467
468        let result = template.render(&ctx).unwrap();
469        assert_eq!(result, "<p>Hello</p>");
470    }
471
472    #[test]
473    fn test_raw_with_optional() {
474        let template = Template::new("test", "{{ content|raw? }}");
475        let ctx = TemplateContext::new();
476
477        let result = template.render(&ctx).unwrap();
478        assert_eq!(result, "");
479
480        let ctx = TemplateContext::new().with_var("content", "<b>bold</b>");
481        let result = template.render(&ctx).unwrap();
482        assert_eq!(result, "<b>bold</b>");
483    }
484
485    #[test]
486    fn test_html_escape_in_attribute() {
487        let template = Template::new("test", r#"<meta name="description" content="{{ desc }}">"#);
488        let ctx = TemplateContext::new().with_var("desc", "A \"quoted\" description");
489
490        let result = template.render(&ctx).unwrap();
491        assert!(result.contains("A &quot;quoted&quot; description"));
492    }
493}