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