1use std::collections::HashMap;
7
8use thiserror::Error;
9
10#[derive(Debug, Error)]
12pub enum TemplateError {
13 #[error("missing required variable: {0}")]
15 MissingVariable(String),
16
17 #[error("template not found: {0}")]
19 NotFound(String),
20
21 #[error("invalid template syntax: {0}")]
23 InvalidSyntax(String),
24}
25
26pub type Result<T> = std::result::Result<T, TemplateError>;
28
29#[derive(Debug, Clone, Default)]
31pub struct TemplateContext {
32 variables: HashMap<String, String>,
33}
34
35impl TemplateContext {
36 #[must_use]
38 pub fn new() -> Self {
39 Self::default()
40 }
41
42 pub fn insert(&mut self, key: impl Into<String>, value: impl Into<String>) {
44 self.variables.insert(key.into(), value.into());
45 }
46
47 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 #[must_use]
55 pub fn get(&self, key: &str) -> Option<&str> {
56 self.variables.get(key).map(String::as_str)
57 }
58
59 #[must_use]
61 pub fn contains(&self, key: &str) -> bool {
62 self.variables.contains_key(key)
63 }
64}
65
66#[derive(Debug, Clone)]
70pub struct Template {
71 name: String,
72 content: String,
73}
74
75impl Template {
76 #[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 #[must_use]
87 pub fn name(&self) -> &str {
88 &self.name
89 }
90
91 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 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#[derive(Debug, Clone, Default)]
130pub struct TemplateRegistry {
131 templates: HashMap<String, Template>,
132}
133
134impl TemplateRegistry {
135 #[must_use]
137 pub fn new() -> Self {
138 let mut registry = Self::default();
139 registry.register_defaults();
140 registry
141 }
142
143 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 pub fn register(&mut self, template: Template) {
162 self.templates.insert(template.name.clone(), template);
163 }
164
165 #[must_use]
167 pub fn get(&self, name: &str) -> Option<&Template> {
168 self.templates.get(name)
169 }
170
171 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
180pub 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>© {{ 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
255pub const DEFAULT_PAGE_TEMPLATE: &str = r#"<article class="page">
257 <h1>{{ title }}</h1>
258 <div class="content">
259 {{ content }}
260 </div>
261</article>"#;
262
263pub 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
275pub 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
284pub 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
293pub 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
307pub 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
315pub 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
323pub const DEFAULT_ARCHIVES_TEMPLATE: &str = r#"<section class="archives">
325 <h1>Archives</h1>
326 {{ items }}
327</section>"#;
328
329pub 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 .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}