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 }
152
153 pub fn register(&mut self, template: Template) {
155 self.templates.insert(template.name.clone(), template);
156 }
157
158 #[must_use]
160 pub fn get(&self, name: &str) -> Option<&Template> {
161 self.templates.get(name)
162 }
163
164 pub fn render(&self, name: &str, context: &TemplateContext) -> Result<String> {
166 let template = self
167 .get(name)
168 .ok_or_else(|| TemplateError::NotFound(name.to_string()))?;
169 template.render(context)
170 }
171}
172
173pub const DEFAULT_BASE_TEMPLATE: &str = r##"<!DOCTYPE html>
175<html lang="{{ lang }}" class="scroll-smooth">
176<head>
177 <meta charset="UTF-8">
178 <meta name="viewport" content="width=device-width, initial-scale=1.0">
179 <title>{{ title }}{{ site_title_suffix? }}</title>
180 <meta name="description" content="{{ description? }}">
181 <meta name="author" content="{{ author? }}">
182 <link rel="canonical" href="{{ canonical_url }}">
183 <link rel="preconnect" href="https://fonts.googleapis.com">
184 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
185 <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
186 {{ custom_css? }}
187 <style>
188 /* CSS Variables for Light/Dark Themes */
189 :root {
190 --color-primary: #3B82F6;
191 --color-primary-hover: #2563EB;
192 --color-secondary: #60A5FA;
193 --color-cta: #F97316;
194 --color-cta-hover: #EA580C;
195 --color-bg: #F8FAFC;
196 --color-bg-secondary: #FFFFFF;
197 --color-text: #1E293B;
198 --color-text-secondary: #475569;
199 --color-text-muted: #64748B;
200 --color-border: #E2E8F0;
201 --color-code-bg: #F1F5F9;
202 --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
203 --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
204 color-scheme: light;
205 }
206
207 [data-theme="dark"] {
208 --color-primary: #60A5FA;
209 --color-primary-hover: #93C5FD;
210 --color-secondary: #3B82F6;
211 --color-cta: #FB923C;
212 --color-cta-hover: #FDBA74;
213 --color-bg: #0F172A;
214 --color-bg-secondary: #1E293B;
215 --color-text: #F1F5F9;
216 --color-text-secondary: #CBD5E1;
217 --color-text-muted: #94A3B8;
218 --color-border: #334155;
219 --color-code-bg: #1E293B;
220 --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
221 --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3);
222 color-scheme: dark;
223 }
224
225 @media (prefers-color-scheme: dark) {
226 :root:not([data-theme="light"]) {
227 --color-primary: #60A5FA;
228 --color-primary-hover: #93C5FD;
229 --color-secondary: #3B82F6;
230 --color-cta: #FB923C;
231 --color-cta-hover: #FDBA74;
232 --color-bg: #0F172A;
233 --color-bg-secondary: #1E293B;
234 --color-text: #F1F5F9;
235 --color-text-secondary: #CBD5E1;
236 --color-text-muted: #94A3B8;
237 --color-border: #334155;
238 --color-code-bg: #1E293B;
239 --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
240 --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3);
241 color-scheme: dark;
242 }
243 }
244
245 /* Reset & Base */
246 *, *::before, *::after { box-sizing: border-box; }
247 * { margin: 0; padding: 0; }
248
249 html {
250 font-size: 16px;
251 -webkit-font-smoothing: antialiased;
252 -moz-osx-font-smoothing: grayscale;
253 }
254
255 body {
256 font-family: 'Inter', system-ui, -apple-system, sans-serif;
257 font-weight: 400;
258 line-height: 1.7;
259 color: var(--color-text);
260 background-color: var(--color-bg);
261 min-height: 100vh;
262 display: flex;
263 flex-direction: column;
264 transition: background-color 0.2s ease, color 0.2s ease;
265 }
266
267 /* Layout */
268 .container {
269 width: 100%;
270 max-width: 720px;
271 margin: 0 auto;
272 padding: 0 1.5rem;
273 }
274
275 /* Header */
276 header {
277 position: sticky;
278 top: 0;
279 z-index: 50;
280 background-color: var(--color-bg);
281 border-bottom: 1px solid var(--color-border);
282 backdrop-filter: blur(8px);
283 -webkit-backdrop-filter: blur(8px);
284 background-color: rgba(248, 250, 252, 0.9);
285 }
286
287 [data-theme="dark"] header {
288 background-color: rgba(15, 23, 42, 0.9);
289 }
290
291 @media (prefers-color-scheme: dark) {
292 :root:not([data-theme="light"]) header {
293 background-color: rgba(15, 23, 42, 0.9);
294 }
295 }
296
297 header nav {
298 display: flex;
299 align-items: center;
300 justify-content: space-between;
301 padding: 1rem 0;
302 }
303
304 .site-title {
305 font-size: 1.125rem;
306 font-weight: 600;
307 color: var(--color-text);
308 text-decoration: none;
309 letter-spacing: -0.025em;
310 transition: color 0.2s ease;
311 }
312
313 .site-title:hover {
314 color: var(--color-primary);
315 }
316
317 .nav-links {
318 display: flex;
319 align-items: center;
320 gap: 1.5rem;
321 }
322
323 .nav-links a {
324 font-size: 0.875rem;
325 font-weight: 500;
326 color: var(--color-text-secondary);
327 text-decoration: none;
328 transition: color 0.2s ease;
329 }
330
331 .nav-links a:hover {
332 color: var(--color-primary);
333 }
334
335 /* Theme Toggle Button */
336 .theme-toggle {
337 display: flex;
338 align-items: center;
339 justify-content: center;
340 width: 2.25rem;
341 height: 2.25rem;
342 border-radius: 0.5rem;
343 border: 1px solid var(--color-border);
344 background-color: var(--color-bg-secondary);
345 cursor: pointer;
346 transition: all 0.2s ease;
347 }
348
349 .theme-toggle:hover {
350 border-color: var(--color-primary);
351 background-color: var(--color-bg);
352 }
353
354 .theme-toggle svg {
355 width: 1.125rem;
356 height: 1.125rem;
357 color: var(--color-text-secondary);
358 }
359
360 .theme-toggle .icon-sun { display: none; }
361 .theme-toggle .icon-moon { display: block; }
362
363 [data-theme="dark"] .theme-toggle .icon-sun { display: block; }
364 [data-theme="dark"] .theme-toggle .icon-moon { display: none; }
365
366 @media (prefers-color-scheme: dark) {
367 :root:not([data-theme="light"]) .theme-toggle .icon-sun { display: block; }
368 :root:not([data-theme="light"]) .theme-toggle .icon-moon { display: none; }
369 }
370
371 /* Main Content */
372 main {
373 flex: 1;
374 padding: 3rem 0;
375 }
376
377 /* Typography */
378 h1, h2, h3, h4, h5, h6 {
379 font-weight: 600;
380 line-height: 1.3;
381 letter-spacing: -0.025em;
382 color: var(--color-text);
383 }
384
385 h1 { font-size: 2rem; margin-bottom: 1rem; }
386 h2 { font-size: 1.5rem; margin: 2rem 0 0.75rem; }
387 h3 { font-size: 1.25rem; margin: 1.5rem 0 0.5rem; }
388 h4 { font-size: 1.125rem; margin: 1.25rem 0 0.5rem; }
389
390 p { margin-bottom: 1.25rem; }
391
392 a {
393 color: var(--color-primary);
394 text-decoration: none;
395 transition: color 0.15s ease;
396 }
397
398 a:hover {
399 color: var(--color-primary-hover);
400 text-decoration: underline;
401 }
402
403 /* Lists */
404 ul, ol {
405 padding-left: 1.5rem;
406 margin-bottom: 1.25rem;
407 }
408
409 li { margin-bottom: 0.375rem; }
410 li::marker { color: var(--color-text-muted); }
411
412 /* Code */
413 code {
414 font-family: 'SF Mono', ui-monospace, 'Cascadia Code', Menlo, Consolas, monospace;
415 font-size: 0.875em;
416 background-color: var(--color-code-bg);
417 padding: 0.125rem 0.375rem;
418 border-radius: 0.25rem;
419 }
420
421 pre {
422 background-color: var(--color-code-bg);
423 padding: 1rem;
424 border-radius: 0.5rem;
425 overflow-x: auto;
426 margin-bottom: 1.5rem;
427 border: 1px solid var(--color-border);
428 }
429
430 pre code {
431 background: none;
432 padding: 0;
433 font-size: 0.8125rem;
434 line-height: 1.6;
435 }
436
437 /* Blockquote */
438 blockquote {
439 border-left: 3px solid var(--color-primary);
440 padding-left: 1rem;
441 margin: 1.5rem 0;
442 color: var(--color-text-secondary);
443 font-style: italic;
444 }
445
446 /* Images */
447 img {
448 max-width: 100%;
449 height: auto;
450 border-radius: 0.5rem;
451 }
452
453 /* Tables */
454 table {
455 width: 100%;
456 border-collapse: collapse;
457 margin: 1.5rem 0;
458 font-size: 0.875rem;
459 }
460
461 th, td {
462 padding: 0.75rem;
463 text-align: left;
464 border-bottom: 1px solid var(--color-border);
465 }
466
467 th {
468 font-weight: 600;
469 background-color: var(--color-bg-secondary);
470 }
471
472 /* Horizontal Rule */
473 hr {
474 border: none;
475 border-top: 1px solid var(--color-border);
476 margin: 2rem 0;
477 }
478
479 /* Footer */
480 footer {
481 border-top: 1px solid var(--color-border);
482 padding: 2rem 0;
483 margin-top: auto;
484 }
485
486 footer p {
487 font-size: 0.875rem;
488 color: var(--color-text-muted);
489 text-align: center;
490 margin: 0;
491 }
492
493 /* Article Styles */
494 article header {
495 position: static;
496 background: none;
497 border: none;
498 backdrop-filter: none;
499 padding: 0;
500 margin-bottom: 2rem;
501 }
502
503 article header h1 {
504 margin-bottom: 0.75rem;
505 }
506
507 article time {
508 display: block;
509 font-size: 0.875rem;
510 color: var(--color-text-muted);
511 margin-bottom: 0.5rem;
512 }
513
514 /* Tags */
515 .tags {
516 display: flex;
517 flex-wrap: wrap;
518 gap: 0.5rem;
519 margin-top: 0.75rem;
520 }
521
522 .tags a {
523 display: inline-flex;
524 align-items: center;
525 padding: 0.25rem 0.75rem;
526 font-size: 0.75rem;
527 font-weight: 500;
528 color: var(--color-primary);
529 background-color: var(--color-code-bg);
530 border-radius: 9999px;
531 text-decoration: none;
532 transition: all 0.15s ease;
533 }
534
535 .tags a:hover {
536 background-color: var(--color-primary);
537 color: white;
538 text-decoration: none;
539 }
540
541 /* Post List */
542 .post-list ul {
543 list-style: none;
544 padding: 0;
545 }
546
547 .post-list li {
548 display: flex;
549 justify-content: space-between;
550 align-items: baseline;
551 gap: 1rem;
552 padding: 1rem 0;
553 border-bottom: 1px solid var(--color-border);
554 }
555
556 .post-list li:first-child {
557 padding-top: 0;
558 }
559
560 .post-list li a {
561 font-weight: 500;
562 color: var(--color-text);
563 text-decoration: none;
564 transition: color 0.15s ease;
565 }
566
567 .post-list li a:hover {
568 color: var(--color-primary);
569 }
570
571 .post-list time {
572 flex-shrink: 0;
573 font-size: 0.8125rem;
574 color: var(--color-text-muted);
575 font-variant-numeric: tabular-nums;
576 }
577
578 /* Pagination */
579 .pagination {
580 display: flex;
581 justify-content: center;
582 align-items: center;
583 gap: 1rem;
584 margin-top: 2rem;
585 font-size: 0.875rem;
586 }
587
588 .pagination a {
589 font-weight: 500;
590 }
591
592 /* Taxonomy */
593 .taxonomy h1 {
594 display: flex;
595 align-items: center;
596 gap: 0.5rem;
597 }
598
599 .taxonomy h1 span {
600 color: var(--color-text-muted);
601 font-weight: 400;
602 }
603
604 /* Responsive */
605 @media (max-width: 640px) {
606 html { font-size: 15px; }
607 h1 { font-size: 1.75rem; }
608 h2 { font-size: 1.375rem; }
609 .container { padding: 0 1rem; }
610 main { padding: 2rem 0; }
611 .nav-links { gap: 1rem; }
612 .post-list li { flex-direction: column; gap: 0.25rem; }
613 }
614
615 /* Reduced Motion */
616 @media (prefers-reduced-motion: reduce) {
617 *, *::before, *::after {
618 animation-duration: 0.01ms !important;
619 animation-iteration-count: 1 !important;
620 transition-duration: 0.01ms !important;
621 }
622 }
623 </style>
624</head>
625<body>
626 <header>
627 <div class="container">
628 <nav>
629 <a href="/" class="site-title">{{ site_title }}</a>
630 <div class="nav-links">
631 <a href="/about">About</a>
632 <a href="/tags">Tags</a>
633 <button class="theme-toggle" aria-label="Toggle theme" type="button">
634 <svg class="icon-sun" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
635 <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" />
636 </svg>
637 <svg class="icon-moon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
638 <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" />
639 </svg>
640 </button>
641 </div>
642 </nav>
643 </div>
644 </header>
645 <main>
646 <div class="container">
647 {{ content }}
648 </div>
649 </main>
650 <footer>
651 <div class="container">
652 <p>© {{ year }} {{ site_title }}. Built with <a href="https://github.com/longcipher/typstify">Typstify</a>.</p>
653 </div>
654 </footer>
655 <script>
656 (function() {
657 const toggle = document.querySelector('.theme-toggle');
658 const html = document.documentElement;
659
660 // Get saved theme or use system preference
661 function getTheme() {
662 const saved = localStorage.getItem('theme');
663 if (saved) return saved;
664 return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
665 }
666
667 // Apply theme
668 function setTheme(theme) {
669 html.setAttribute('data-theme', theme);
670 localStorage.setItem('theme', theme);
671 }
672
673 // Initialize
674 setTheme(getTheme());
675
676 // Toggle on click
677 toggle.addEventListener('click', () => {
678 const current = html.getAttribute('data-theme') || getTheme();
679 setTheme(current === 'dark' ? 'light' : 'dark');
680 });
681
682 // Listen for system changes
683 window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
684 if (!localStorage.getItem('theme')) {
685 setTheme(e.matches ? 'dark' : 'light');
686 }
687 });
688 })();
689 </script>
690 {{ custom_js? }}
691</body>
692</html>"##;
693
694pub const DEFAULT_PAGE_TEMPLATE: &str = r#"<article class="page">
696 <h1>{{ title }}</h1>
697 <div class="content">
698 {{ content }}
699 </div>
700</article>"#;
701
702pub const DEFAULT_POST_TEMPLATE: &str = r#"<article class="post">
704 <header>
705 <h1>{{ title }}</h1>
706 <time datetime="{{ date_iso }}">{{ date_formatted }}</time>
707 {{ tags_html? }}
708 </header>
709 <div class="content">
710 {{ content }}
711 </div>
712</article>"#;
713
714pub const DEFAULT_LIST_TEMPLATE: &str = r#"<section class="post-list">
716 <h1>{{ title }}</h1>
717 <ul>
718 {{ items }}
719 </ul>
720 <div class="pagination">{{ pagination? }}</div>
721</section>"#;
722
723pub const DEFAULT_TAXONOMY_TEMPLATE: &str = r#"<section class="taxonomy post-list">
725 <h1>{{ taxonomy_name }}: <span>{{ term }}</span></h1>
726 <ul>
727 {{ items }}
728 </ul>
729 <div class="pagination">{{ pagination? }}</div>
730</section>"#;
731
732pub const DEFAULT_REDIRECT_TEMPLATE: &str = r#"<!DOCTYPE html>
734<html>
735<head>
736 <meta charset="UTF-8">
737 <meta http-equiv="refresh" content="0; url={{ redirect_url }}">
738 <link rel="canonical" href="{{ redirect_url }}">
739 <title>Redirecting...</title>
740</head>
741<body>
742 <p>Redirecting to <a href="{{ redirect_url }}">{{ redirect_url }}</a></p>
743</body>
744</html>"#;
745
746#[cfg(test)]
747mod tests {
748 use super::*;
749
750 #[test]
751 fn test_template_simple_render() {
752 let template = Template::new("test", "Hello, {{ name }}!");
753 let mut ctx = TemplateContext::new();
754 ctx.insert("name", "World");
755
756 let result = template.render(&ctx).unwrap();
757 assert_eq!(result, "Hello, World!");
758 }
759
760 #[test]
761 fn test_template_multiple_variables() {
762 let template = Template::new(
763 "test",
764 "{{ greeting }}, {{ name }}! Welcome to {{ place }}.",
765 );
766 let ctx = TemplateContext::new()
767 .with_var("greeting", "Hello")
768 .with_var("name", "User")
769 .with_var("place", "Typstify");
770
771 let result = template.render(&ctx).unwrap();
772 assert_eq!(result, "Hello, User! Welcome to Typstify.");
773 }
774
775 #[test]
776 fn test_template_optional_variable() {
777 let template = Template::new("test", "Hello{{ suffix? }}!");
778 let ctx = TemplateContext::new();
779
780 let result = template.render(&ctx).unwrap();
781 assert_eq!(result, "Hello!");
782
783 let ctx = TemplateContext::new().with_var("suffix", ", World");
784 let result = template.render(&ctx).unwrap();
785 assert_eq!(result, "Hello, World!");
786 }
787
788 #[test]
789 fn test_template_missing_required_variable() {
790 let template = Template::new("test", "Hello, {{ name }}!");
791 let ctx = TemplateContext::new();
792
793 let result = template.render(&ctx);
794 assert!(matches!(result, Err(TemplateError::MissingVariable(_))));
795 }
796
797 #[test]
798 fn test_template_registry() {
799 let registry = TemplateRegistry::new();
800
801 assert!(registry.get("base").is_some());
802 assert!(registry.get("page").is_some());
803 assert!(registry.get("post").is_some());
804 assert!(registry.get("list").is_some());
805 assert!(registry.get("nonexistent").is_none());
806 }
807
808 #[test]
809 fn test_render_base_template() {
810 let registry = TemplateRegistry::new();
811 let ctx = TemplateContext::new()
812 .with_var("lang", "en")
813 .with_var("title", "My Page")
814 .with_var("canonical_url", "https://example.com/my-page")
815 .with_var("content", "<p>Hello!</p>")
816 .with_var("site_title", "My Site")
817 .with_var("year", "2026");
818
819 let result = registry.render("base", &ctx).unwrap();
820 assert!(result.contains("<!DOCTYPE html>"));
821 assert!(result.contains("<title>My Page</title>"));
822 assert!(result.contains("<p>Hello!</p>"));
823 }
824}