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
28pub 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
37fn get_cached_or_parse(full_path: &str) -> Result<Node, String> {
41 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()); }
53 }
54 }
55
56 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 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("&"),
88 '\'' => escaped.push_str("'"),
89 '"' => escaped.push_str("""),
90 '<' => escaped.push_str("<"),
91 '>' => escaped.push_str(">"),
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 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 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 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 eng.register(
203 "view.blade",
204 Arc::new(|engine, ctx, node, scope| {
205 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 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 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 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 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 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 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 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 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 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 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 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}