Skip to main content

zeno_blade/
slots.rs

1use std::collections::HashMap;
2use std::sync::{Arc, Mutex, OnceLock};
3use std::path::Path;
4use std::time::SystemTime;
5use zenocore::{Engine, Node, Scope, SlotMeta, Diagnostic, Value};
6
7use crate::transpiler::transpile_blade_native;
8
9pub struct HtmlBuffer(pub Mutex<String>);
10
11#[derive(Clone)]
12pub struct SectionMap(pub Arc<Mutex<HashMap<String, Node>>>);
13
14#[derive(Clone)]
15pub struct StackMap(pub Arc<Mutex<HashMap<String, Vec<Node>>>>);
16
17struct CacheEntry {
18    node: Node,
19    mtime: SystemTime,
20}
21
22static BLADE_CACHE: OnceLock<Mutex<HashMap<String, CacheEntry>>> = OnceLock::new();
23
24fn get_blade_cache() -> &'static Mutex<HashMap<String, CacheEntry>> {
25    BLADE_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
26}
27
28/// Clears the entire in-memory template cache.
29/// All templates will be re-read from disk and re-parsed on the next request.
30pub fn clear_blade_cache() {
31    if let Some(cache) = BLADE_CACHE.get() {
32        let mut guard = cache.lock().unwrap();
33        guard.clear();
34    }
35}
36
37/// Returns the cached AST if the file has not changed since last parse,
38/// otherwise re-reads from disk, re-parses, and updates the cache.
39/// This provides hot reload by default: RAM-fast when unchanged, auto-refresh when modified.
40fn get_cached_or_parse(full_path: &str) -> Result<Node, String> {
41    // Fetch current mtime before acquiring the lock
42    let current_mtime = std::fs::metadata(full_path)
43        .and_then(|m| m.modified())
44        .ok();
45
46    {
47        let guard = get_blade_cache().lock().unwrap();
48        if let Some(entry) = guard.get(full_path) {
49            let is_fresh = current_mtime.map_or(false, |cur| cur == entry.mtime);
50            if is_fresh {
51                return Ok(entry.node.clone()); // serve from RAM
52            }
53        }
54    }
55
56    // Cache miss or file changed — read from disk and re-parse
57    let content = std::fs::read_to_string(full_path)
58        .map_err(|e| format!("view not found: {}. Error: {}", full_path, e))?;
59    let node = transpile_blade_native(&content, full_path)?;
60
61    // Re-read mtime after parsing to capture the exact mtime of the content we just read
62    let mtime = std::fs::metadata(full_path)
63        .and_then(|m| m.modified())
64        .unwrap_or(SystemTime::UNIX_EPOCH);
65
66    let mut guard = get_blade_cache().lock().unwrap();
67    guard.insert(full_path.to_string(), CacheEntry { node: node.clone(), mtime });
68
69    Ok(node)
70}
71
72fn make_error(node: &Node, msg: String, slot: Option<String>) -> Diagnostic {
73    Diagnostic {
74        r#type: "error".to_string(),
75        message: msg,
76        filename: node.filename.clone(),
77        line: node.line,
78        col: node.col,
79        slot,
80    }
81}
82
83fn escape_html(s: &str) -> String {
84    let mut escaped = String::with_capacity(s.len());
85    for c in s.chars() {
86        match c {
87            '&' => escaped.push_str("&amp;"),
88            '\'' => escaped.push_str("&#39;"),
89            '"' => escaped.push_str("&quot;"),
90            '<' => escaped.push_str("&lt;"),
91            '>' => escaped.push_str("&gt;"),
92            _ => escaped.push(c),
93        }
94    }
95    escaped
96}
97
98fn get_view_root(scope: &Arc<Scope>) -> String {
99    if let Some(val) = scope.get("_view_root") {
100        let s = val.to_string_coerce();
101        if !s.is_empty() {
102            return s;
103        }
104    }
105    "views".to_string()
106}
107
108fn ensure_blade_ext(path: &str) -> String {
109    if !path.ends_with(".blade.zl") {
110        format!("{}.blade.zl", path)
111    } else {
112        path.to_string()
113    }
114}
115
116fn resolve_node_value(engine: &Engine, node: &Node, scope: &Arc<Scope>) -> Value {
117    if let Some(ref val_str) = node.value {
118        let dummy = Node {
119            name: String::new(),
120            value: Some(val_str.clone()),
121            children: Vec::new(),
122            line: node.line,
123            col: node.col,
124            filename: node.filename.clone(),
125        };
126        engine.resolve_shorthand_value(&dummy, scope)
127    } else {
128        Value::Nil
129    }
130}
131
132pub fn register_blade_slots(eng: &mut Engine) {
133    // 1. __native_write
134    eng.register(
135        "__native_write",
136        Arc::new(|_engine, ctx, node, scope| {
137            if let Some(ref val) = node.value {
138                let resolved_val = if val.starts_with('$') {
139                    let key = &val[1..];
140                    scope.get(key).unwrap_or(Value::String(val.clone()))
141                } else {
142                    Value::String(val.clone())
143                };
144                if let Some(buf) = ctx.get::<HtmlBuffer>("httpWriter") {
145                    let mut guard = buf.0.lock().unwrap();
146                    guard.push_str(&resolved_val.to_string_coerce());
147                }
148            }
149            Ok(())
150        }),
151        SlotMeta {
152            description: "Writes raw string value to output buffer".to_string(),
153            example: "__native_write: 'hello'".to_string(),
154            inputs: HashMap::new(),
155            required_blocks: Vec::new(),
156            value_type: "any".to_string(),
157        },
158    );
159
160    // 2. __native_write_safe
161    eng.register(
162        "__native_write_safe",
163        Arc::new(|engine, ctx, node, scope| {
164            let val = resolve_node_value(engine, node, scope);
165            if let Some(buf) = ctx.get::<HtmlBuffer>("httpWriter") {
166                let mut guard = buf.0.lock().unwrap();
167                guard.push_str(&escape_html(&val.to_string_coerce()));
168            }
169            Ok(())
170        }),
171        SlotMeta {
172            description: "Writes HTML-escaped string value to output buffer".to_string(),
173            example: "__native_write_safe: $name".to_string(),
174            inputs: HashMap::new(),
175            required_blocks: Vec::new(),
176            value_type: "any".to_string(),
177        },
178    );
179
180    // 3. view.root
181    eng.register(
182        "view.root",
183        Arc::new(|engine, _ctx, node, scope| {
184            let val = resolve_node_value(engine, node, scope);
185            let root_path = val.to_string_coerce();
186            if root_path.is_empty() {
187                return Err(make_error(node, "view.root path is required".to_string(), Some("view.root".to_string())));
188            }
189            scope.set("_view_root", Value::String(root_path));
190            Ok(())
191        }),
192        SlotMeta {
193            description: "Sets base directory for Blade views".to_string(),
194            example: "view.root: 'apps/blog/resources/views'".to_string(),
195            inputs: HashMap::new(),
196            required_blocks: Vec::new(),
197            value_type: "string".to_string(),
198        },
199    );
200
201    // 4. view.blade
202    eng.register(
203        "view.blade",
204        Arc::new(|engine, ctx, node, scope| {
205            // Injects CSRF
206            let token = ctx.get::<String>("csrf_token").map(|t| t.to_string()).unwrap_or_else(|| "".to_string());
207            scope.set("csrf_token", Value::String(token.clone()));
208            scope.set("csrf_field", Value::String(format!(r#"<input type="hidden" name="gorilla.csrf.Token" value="{}">"#, token)));
209
210            let val = resolve_node_value(engine, node, scope);
211            let view_file = val.to_string_coerce();
212            if view_file.is_empty() {
213                return Err(make_error(node, "view.blade view file is required".to_string(), Some("view.blade".to_string())));
214            }
215
216            let view_root = get_view_root(scope);
217            let filename = ensure_blade_ext(&view_file);
218            let full_path = Path::new(&view_root).join(filename);
219            let full_path_str = full_path.to_string_lossy();
220
221            let program_node = get_cached_or_parse(&full_path_str)
222                .map_err(|e| make_error(node, e, Some("view.blade".to_string())))?;
223
224            // Bind other children attributes to scope
225            for child in &node.children {
226                if child.name == "file" {
227                    continue;
228                }
229                let val = engine.resolve_shorthand_value(child, scope);
230                scope.set(&child.name, val);
231            }
232
233            // Ensure sections/stacks maps are initialized in context
234            if ctx.get::<SectionMap>("__sections").is_none() {
235                ctx.set("__sections", SectionMap(Arc::new(Mutex::new(HashMap::new()))));
236            }
237            if ctx.get::<StackMap>("__stacks").is_none() {
238                ctx.set("__stacks", StackMap(Arc::new(Mutex::new(HashMap::new()))));
239            }
240
241            engine.execute(ctx, &program_node, scope)?;
242            Ok(())
243        }),
244        SlotMeta {
245            description: "Renders Blade view template".to_string(),
246            example: "view.blade: 'welcome' { $title: 'Home' }".to_string(),
247            inputs: HashMap::new(),
248            required_blocks: Vec::new(),
249            value_type: "string".to_string(),
250        },
251    );
252
253    // 5. view.extends
254    eng.register(
255        "view.extends",
256        Arc::new(|engine, ctx, node, scope| {
257            let val = resolve_node_value(engine, node, scope);
258            let layout_file = val.to_string_coerce();
259            if layout_file.is_empty() {
260                return Err(make_error(node, "view.extends layout file is required".to_string(), Some("view.extends".to_string())));
261            }
262
263            let view_root = get_view_root(scope);
264            let filename = ensure_blade_ext(&layout_file);
265            let full_path = Path::new(&view_root).join(filename);
266            let full_path_str = full_path.to_string_lossy();
267
268            let layout_node = get_cached_or_parse(&full_path_str)
269                .map_err(|e| make_error(node, e, Some("view.extends".to_string())))?;
270
271            engine.execute(ctx, &layout_node, scope)?;
272            Ok(())
273        }),
274        SlotMeta {
275            description: "Extends layout template".to_string(),
276            example: "view.extends: 'layouts.app'".to_string(),
277            inputs: HashMap::new(),
278            required_blocks: Vec::new(),
279            value_type: "string".to_string(),
280        },
281    );
282
283    // 6. section.define
284    eng.register(
285        "section.define",
286        Arc::new(|engine, ctx, node, scope| {
287            let val = resolve_node_value(engine, node, scope);
288            let name = val.to_string_coerce();
289            let body = node.children.iter().find(|c| c.name == "do");
290            if let Some(b) = body {
291                if let Some(sections) = ctx.get::<SectionMap>("__sections") {
292                    let mut guard = sections.0.lock().unwrap();
293                    guard.insert(name, b.clone());
294                }
295            }
296            Ok(())
297        }),
298        SlotMeta {
299            description: "Defines layout section content".to_string(),
300            example: "section.define: 'content' { ... }".to_string(),
301            inputs: HashMap::new(),
302            required_blocks: Vec::new(),
303            value_type: "string".to_string(),
304        },
305    );
306
307    // 7. section.yield
308    eng.register(
309        "section.yield",
310        Arc::new(|engine, ctx, node, scope| {
311            let val = resolve_node_value(engine, node, scope);
312            let name = val.to_string_coerce();
313            if let Some(sections) = ctx.get::<SectionMap>("__sections") {
314                let guard = sections.0.lock().unwrap();
315                if let Some(body) = guard.get(&name) {
316                    for child in &body.children {
317                        engine.execute(ctx, child, scope)?;
318                    }
319                }
320            }
321            Ok(())
322        }),
323        SlotMeta {
324            description: "Yields defined layout section content".to_string(),
325            example: "section.yield: 'content'".to_string(),
326            inputs: HashMap::new(),
327            required_blocks: Vec::new(),
328            value_type: "string".to_string(),
329        },
330    );
331
332    // 8. view.include
333    eng.register(
334        "view.include",
335        Arc::new(|engine, ctx, node, scope| {
336            let val = resolve_node_value(engine, node, scope);
337            let view_file = val.to_string_coerce();
338            if view_file.is_empty() {
339                return Ok(());
340            }
341
342            let mut include_data = HashMap::new();
343            if !node.children.is_empty() {
344                let data_node = &node.children[0];
345                if data_node.name == "data_map" {
346                    for child in &data_node.children {
347                        let val = engine.resolve_shorthand_value(child, scope);
348                        include_data.insert(child.name.clone(), val);
349                    }
350                } else if data_node.name == "data_var" {
351                    let val = engine.resolve_shorthand_value(data_node, scope);
352                    if let Value::Map(m) = val {
353                        include_data = m;
354                    }
355                }
356            }
357
358            let inner_scope = Scope::new(Some(scope.clone()));
359            for (k, v) in include_data {
360                inner_scope.set(&k, v);
361            }
362
363            let view_root = get_view_root(scope);
364            let filename = ensure_blade_ext(&view_file);
365            let full_path = Path::new(&view_root).join(filename);
366            let full_path_str = full_path.to_string_lossy();
367
368            let include_node = get_cached_or_parse(&full_path_str)
369                .map_err(|e| make_error(node, e, Some("view.include".to_string())))?;
370
371            engine.execute(ctx, &include_node, &inner_scope)?;
372            Ok(())
373        }),
374        SlotMeta {
375            description: "Includes partial template".to_string(),
376            example: "view.include: 'partials.header' { $user: $user }".to_string(),
377            inputs: HashMap::new(),
378            required_blocks: Vec::new(),
379            value_type: "string".to_string(),
380        },
381    );
382
383    // 9. view.push
384    eng.register(
385        "view.push",
386        Arc::new(|engine, ctx, node, scope| {
387            let val = resolve_node_value(engine, node, scope);
388            let name = val.to_string_coerce();
389            let body = node.children.iter().find(|c| c.name == "do");
390            if let Some(b) = body {
391                if let Some(stacks) = ctx.get::<StackMap>("__stacks") {
392                    let mut guard = stacks.0.lock().unwrap();
393                    guard.entry(name).or_insert_with(Vec::new).push(b.clone());
394                }
395            }
396            Ok(())
397        }),
398        SlotMeta {
399            description: "Pushes content block to a stack".to_string(),
400            example: "view.push: 'scripts' { ... }".to_string(),
401            inputs: HashMap::new(),
402            required_blocks: Vec::new(),
403            value_type: "string".to_string(),
404        },
405    );
406
407    // 10. view.stack
408    eng.register(
409        "view.stack",
410        Arc::new(|engine, ctx, node, scope| {
411            let val = resolve_node_value(engine, node, scope);
412            let name = val.to_string_coerce();
413            if let Some(stacks) = ctx.get::<StackMap>("__stacks") {
414                let guard = stacks.0.lock().unwrap();
415                if let Some(nodes) = guard.get(&name) {
416                    for n in nodes {
417                        for child in &n.children {
418                            engine.execute(ctx, child, scope)?;
419                        }
420                    }
421                }
422            }
423            Ok(())
424        }),
425        SlotMeta {
426            description: "Renders pushed stack content".to_string(),
427            example: "view.stack: 'scripts'".to_string(),
428            inputs: HashMap::new(),
429            required_blocks: Vec::new(),
430            value_type: "string".to_string(),
431        },
432    );
433
434    // 11. view.component
435    eng.register(
436        "view.component",
437        Arc::new(|engine, ctx, node, scope| {
438            let val = resolve_node_value(engine, node, scope);
439            let comp_name = val.to_string_coerce();
440            if comp_name.is_empty() {
441                return Ok(());
442            }
443
444            let comp_path = comp_name.replace('.', "/");
445            let view_root = get_view_root(scope);
446            let filename = format!("components/{}.blade.zl", comp_path);
447            let full_path = Path::new(&view_root).join(filename);
448            let full_path_str = full_path.to_string_lossy();
449
450            let new_scope = Scope::new(None);
451
452            let mut slot_content = String::new();
453            let mut slots = HashMap::new();
454
455            for child in &node.children {
456                if child.name == "slot" {
457                    let original_writer = ctx.get::<HtmlBuffer>("httpWriter").ok_or_else(|| {
458                        make_error(node, "httpWriter not found in context".to_string(), Some("view.component".to_string()))
459                    })?;
460                    let child_slot_content = {
461                        let mut guard = original_writer.0.lock().unwrap();
462                        let original_content = std::mem::take(&mut *guard);
463                        drop(guard);
464
465                        for c in &child.children {
466                            engine.execute(ctx, c, scope)?;
467                        }
468
469                        let mut guard = original_writer.0.lock().unwrap();
470                        std::mem::replace(&mut *guard, original_content)
471                    };
472                    if let Some(ref slot_name) = child.value {
473                        slots.insert(slot_name.clone(), Value::String(child_slot_content));
474                    }
475                } else if child.name == "default_slot" {
476                    let original_writer = ctx.get::<HtmlBuffer>("httpWriter").ok_or_else(|| {
477                        make_error(node, "httpWriter not found in context".to_string(), Some("view.component".to_string()))
478                    })?;
479                    let default_slot_content = {
480                        let mut guard = original_writer.0.lock().unwrap();
481                        let original_content = std::mem::take(&mut *guard);
482                        drop(guard);
483
484                        for c in &child.children {
485                            engine.execute(ctx, c, scope)?;
486                        }
487
488                        let mut guard = original_writer.0.lock().unwrap();
489                        std::mem::replace(&mut *guard, original_content)
490                    };
491                    slot_content = default_slot_content;
492                } else {
493                    let val = engine.resolve_shorthand_value(child, scope);
494                    let clean_name = child.name.strip_prefix(':').unwrap_or(&child.name).to_string();
495                    new_scope.set(&clean_name, val);
496                }
497            }
498
499            new_scope.set("slot", Value::String(slot_content));
500            for (k, v) in slots {
501                new_scope.set(&k, v);
502            }
503
504            let comp_root = get_cached_or_parse(&full_path_str)
505                .map_err(|e| make_error(node, e, Some("view.component".to_string())))?;
506
507            engine.execute(ctx, &comp_root, &new_scope)?;
508            Ok(())
509        }),
510        SlotMeta {
511            description: "Renders a Blade Component".to_string(),
512            example: "view.component: 'alert' { $type: 'error' }".to_string(),
513            inputs: HashMap::new(),
514            required_blocks: Vec::new(),
515            value_type: "string".to_string(),
516        },
517    );
518
519    // 12. view.class
520    eng.register(
521        "view.class",
522        Arc::new(|engine, ctx, node, scope| {
523            let mut classes = Vec::new();
524
525            if !node.children.is_empty() {
526                let data_node = &node.children[0];
527                if data_node.name == "data_map" {
528                    for child in &data_node.children {
529                        let resolved_val = engine.resolve_shorthand_value(child, scope);
530                        if resolved_val.to_bool() {
531                            classes.push(child.name.clone());
532                        }
533                    }
534                }
535            }
536
537            if !classes.is_empty() {
538                if let Some(buf) = ctx.get::<HtmlBuffer>("httpWriter") {
539                    let mut guard = buf.0.lock().unwrap();
540                    guard.push_str(&format!(r#"class="{}""#, classes.join(" ")));
541                }
542            }
543            Ok(())
544        }),
545        SlotMeta {
546            description: "Renders class attribute with conditional classes".to_string(),
547            example: "view.class: ['btn' => true, 'btn-primary' => $is_primary]".to_string(),
548            inputs: HashMap::new(),
549            required_blocks: Vec::new(),
550            value_type: "any".to_string(),
551        },
552    );
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558    use zenocore::Context;
559    use zenocore::parser::parse_string;
560
561    #[test]
562    fn test_slots_blade_flow() {
563        let _ = std::fs::create_dir_all("test_views/layouts");
564        std::fs::write("test_views/layouts/app.blade.zl", "Header\n@yield('content')\nFooter").unwrap();
565        std::fs::write("test_views/home.blade.zl", "Welcome {{ $name }}!").unwrap();
566        std::fs::write("test_views/post.blade.zl", "@extends('layouts/app')\n@section('content')\nPost content: {{ $title }}\n@endsection").unwrap();
567
568        let mut engine = Engine::new();
569        zenocore::slots::register_logic_slots(&mut engine);
570        register_blade_slots(&mut engine);
571
572        let mut ctx = Context::new();
573        let buf = HtmlBuffer(Mutex::new(String::new()));
574        ctx.set("httpWriter", buf);
575
576        let scope = Scope::new(None);
577        scope.set("_view_root", Value::String("test_views".to_string()));
578        scope.set("name", Value::String("Budi".to_string()));
579        scope.set("title", Value::String("My Post".to_string()));
580
581        let node = parse_string("view.blade: 'post'", "test.zl").unwrap();
582        engine.execute(&mut ctx, &node, &scope).unwrap();
583
584        let writer = ctx.get::<HtmlBuffer>("httpWriter").unwrap();
585        let output = writer.0.lock().unwrap().clone();
586        println!("=== OUTPUT: {:?} ===", output);
587        if let Some(sections) = ctx.get::<SectionMap>("__sections") {
588            let guard = sections.0.lock().unwrap();
589            println!("=== SECTIONS keys: {:?} ===", guard.keys().collect::<Vec<_>>());
590        }
591
592        assert!(output.contains("Header"));
593        assert!(output.contains("Post content: My Post"));
594        assert!(output.contains("Footer"));
595
596        // Clean up
597        let _ = std::fs::remove_dir_all("test_views");
598    }
599
600    #[test]
601    fn test_new_blade_features() {
602        let _ = std::fs::create_dir_all("test_views_new/components");
603        
604        std::fs::write("test_views_new/components/alert.blade.zl", r#"<div @class(['alert', 'alert-danger' => $is_danger])>
605    <div class="header">{{ $header }}</div>
606    <span class="title">{{ $title }}</span>
607    <div class="content">{{ $slot }}</div>
608</div>"#).unwrap();
609
610        std::fs::write("test_views_new/main.blade.zl", r#"<x-alert title="System Error" :is_danger="true">
611    <x-slot name="header">Error Header</x-slot>
612    Something went wrong!
613</x-alert>
614
615<form method="POST">
616    @method('PUT')
617    @csrf
618</form>
619
620@forelse($items as $item)
621    Item: {{ $item }}
622@empty
623    No items found.
624@endforelse
625
626@forelse($empty_items as $item)
627    Item: {{ $item }}
628@empty
629    Empty list fallback.
630@endforelse
631"#).unwrap();
632
633        let mut engine = Engine::new();
634        zenocore::slots::register_logic_slots(&mut engine);
635        register_blade_slots(&mut engine);
636
637        let mut ctx = Context::new();
638        ctx.set("csrf_token", "dummy_token".to_string());
639        let buf = HtmlBuffer(Mutex::new(String::new()));
640        ctx.set("httpWriter", buf);
641
642        let scope = Scope::new(None);
643        scope.set("_view_root", Value::String("test_views_new".to_string()));
644        
645        let mut items = Vec::new();
646        items.push(Value::String("Apple".to_string()));
647        items.push(Value::String("Banana".to_string()));
648        scope.set("items", Value::List(items));
649        
650        scope.set("empty_items", Value::List(Vec::new()));
651
652        let node = parse_string("view.blade: 'main'", "test.zl").unwrap();
653        engine.execute(&mut ctx, &node, &scope).unwrap();
654
655        let writer = ctx.get::<HtmlBuffer>("httpWriter").unwrap();
656        let output = writer.0.lock().unwrap().clone();
657        println!("=== OUTPUT: \n{}\n===", output);
658
659        assert!(output.contains(r#"class="alert alert-danger""#));
660        assert!(output.contains(r#"<div class="header">Error Header</div>"#));
661        assert!(output.contains(r#"<span class="title">System Error</span>"#));
662        assert!(output.contains("Something went wrong!"));
663        
664        assert!(output.contains(r#"<input type="hidden" name="_method" value="PUT">"#));
665        assert!(output.contains(r#"<input type="hidden" name="gorilla.csrf.Token" value="dummy_token">"#));
666        
667        assert!(output.contains("Item: Apple"));
668        assert!(output.contains("Item: Banana"));
669        assert!(output.contains("Empty list fallback."));
670        assert!(!output.contains("No items found."));
671
672        let _ = std::fs::remove_dir_all("test_views_new");
673    }
674}