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 }}" 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>© {{ 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
268pub 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
276pub 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
288pub 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
297pub 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
306pub 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
320pub 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
328pub 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
336pub const DEFAULT_ARCHIVES_TEMPLATE: &str = r#"<section class="archives">
338 <h1>Archives</h1>
339 {{ items|raw }}
340</section>"#;
341
342pub 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
352pub 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
360pub 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 .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, <b>bold</b>!");
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 "quoted" description"));
495 }
496}