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    }
152
153    /// Register a template.
154    pub fn register(&mut self, template: Template) {
155        self.templates.insert(template.name.clone(), template);
156    }
157
158    /// Get a template by name.
159    #[must_use]
160    pub fn get(&self, name: &str) -> Option<&Template> {
161        self.templates.get(name)
162    }
163
164    /// Render a named template with the given context.
165    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
173/// Default base HTML template.
174pub 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>&copy; {{ 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
694/// Default page template (for standalone pages).
695pub const DEFAULT_PAGE_TEMPLATE: &str = r#"<article class="page">
696    <h1>{{ title }}</h1>
697    <div class="content">
698        {{ content }}
699    </div>
700</article>"#;
701
702/// Default post template (for blog posts with metadata).
703pub 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
714/// Default list template (for index pages).
715pub 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
723/// Default taxonomy term template (for tag/category pages).
724pub 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
732/// Default redirect template for URL aliases.
733pub 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}