1use std::collections::HashMap;
7
8use thiserror::Error;
9use typstify_core::utils::html_escape;
10
11#[derive(Debug, Error)]
13pub enum TemplateError {
14 #[error("missing required variable: {0}")]
16 MissingVariable(String),
17
18 #[error("template not found: {0}")]
20 NotFound(String),
21
22 #[error("invalid template syntax: {0}")]
24 InvalidSyntax(String),
25}
26
27pub type Result<T> = std::result::Result<T, TemplateError>;
29
30#[derive(Debug, Clone, Default)]
32pub struct TemplateContext {
33 variables: HashMap<String, String>,
34}
35
36impl TemplateContext {
37 #[must_use]
39 pub fn new() -> Self {
40 Self::default()
41 }
42
43 pub fn insert(&mut self, key: impl Into<String>, value: impl Into<String>) {
45 self.variables.insert(key.into(), value.into());
46 }
47
48 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 #[must_use]
56 pub fn get(&self, key: &str) -> Option<&str> {
57 self.variables.get(key).map(String::as_str)
58 }
59
60 #[must_use]
62 pub fn contains(&self, key: &str) -> bool {
63 self.variables.contains_key(key)
64 }
65}
66
67#[derive(Debug, Clone)]
71pub struct Template {
72 name: String,
73 content: String,
74}
75
76impl Template {
77 #[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 #[must_use]
88 pub fn name(&self) -> &str {
89 &self.name
90 }
91
92 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 let (var_name, optional) = if let Some(stripped) = var_name.strip_suffix('?') {
111 (stripped, true)
112 } else {
113 (var_name, false)
114 };
115
116 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#[derive(Debug, Clone, Default)]
141pub struct TemplateRegistry {
142 templates: HashMap<String, Template>,
143}
144
145impl TemplateRegistry {
146 #[must_use]
148 pub fn new() -> Self {
149 let mut registry = Self::default();
150 registry.register_defaults();
151 registry
152 }
153
154 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 pub fn register(&mut self, template: Template) {
175 self.templates.insert(template.name.clone(), template);
176 }
177
178 #[must_use]
180 pub fn get(&self, name: &str) -> Option<&Template> {
181 self.templates.get(name)
182 }
183
184 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
193pub 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>© {{ 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
265pub 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
273pub 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
285pub 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
294pub 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
303pub 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
317pub 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
325pub 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
333pub const DEFAULT_ARCHIVES_TEMPLATE: &str = r#"<section class="archives">
335 <h1>Archives</h1>
336 {{ items|raw }}
337</section>"#;
338
339pub 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
349pub 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
357pub 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 .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, <b>bold</b>!");
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 "quoted" description"));
492 }
493}