1use crate::ast::{GlobalEntry, Node};
2use crate::error::ParseError;
3use std::collections::{HashMap, HashSet};
4
5pub type PushesAndGlobals = (HashMap<String, Vec<Node>>, HashMap<String, GlobalEntry>);
10
11pub fn resolve(
18 nodes: Vec<Node>,
19 load: &mut impl FnMut(&str) -> Result<Vec<Node>, ParseError>,
20) -> Result<Vec<Node>, ParseError> {
21 let (nodes, _) = resolve_with_context(nodes, load)?;
22 Ok(nodes)
23}
24
25pub fn resolve_with_context(
40 nodes: Vec<Node>,
41 load: &mut impl FnMut(&str) -> Result<Vec<Node>, ParseError>,
42) -> Result<(Vec<Node>, PushesAndGlobals), ParseError> {
43 let mut pushes: HashMap<String, Vec<Node>> = HashMap::new();
44 let mut globals: HashMap<String, GlobalEntry> = HashMap::new();
45 let resolved = resolve_inner(nodes, load, &mut HashSet::new(), &mut pushes, &mut globals)?;
46 let resolved = substitute_stacks(resolved, &pushes);
47 let resolved = substitute_globals(resolved, &globals);
48 Ok((resolved, (pushes, globals)))
49}
50
51fn resolve_inner(
52 nodes: Vec<Node>,
53 load: &mut impl FnMut(&str) -> Result<Vec<Node>, ParseError>,
54 seen: &mut HashSet<String>,
55 pushes: &mut HashMap<String, Vec<Node>>,
56 globals: &mut HashMap<String, GlobalEntry>,
57) -> Result<Vec<Node>, ParseError> {
58 collect_pushes(&nodes, pushes, load)?;
68 collect_globals(&nodes, globals, load)?;
77
78 let extends_name = nodes.iter().find_map(|n| match n {
79 Node::Extends(name) => Some(name.clone()),
80 _ => None,
81 });
82
83 let Some(parent_name) = extends_name else {
84 return Ok(nodes);
85 };
86
87 if !seen.insert(parent_name.clone()) {
91 return Err(ParseError::new(format!(
92 "cycle detected in @extends chain (revisited `{parent_name}`)"
93 )));
94 }
95
96 let mut sections = HashMap::new();
97 for node in nodes {
98 if let Node::Section { name, body } = node {
99 sections.insert(name, body);
100 }
101 }
102
103 let parent_nodes = load(&parent_name)?;
104 let resolved_parent = resolve_inner(parent_nodes, load, seen, pushes, globals)?;
105 Ok(substitute_yields(resolved_parent, §ions))
106}
107
108fn collect_pushes(
135 nodes: &[Node],
136 pushes: &mut HashMap<String, Vec<Node>>,
137 load: &mut impl FnMut(&str) -> Result<Vec<Node>, ParseError>,
138) -> Result<(), ParseError> {
139 for node in nodes {
140 match node {
141 Node::Push { name, body } => {
142 pushes.entry(name.clone()).or_default().extend(body.clone());
143 collect_pushes(body, pushes, load)?;
144 }
145 Node::If {
146 then_branch,
147 else_branch,
148 ..
149 }
150 | Node::Can {
151 then_branch,
152 else_branch,
153 ..
154 }
155 | Node::Role {
156 then_branch,
157 else_branch,
158 ..
159 } => {
160 collect_pushes(then_branch, pushes, load)?;
161 collect_pushes(else_branch, pushes, load)?;
162 }
163 Node::Foreach { body, .. } => {
164 if contains_push(body) {
165 return Err(ParseError::new(
166 "`@push` inside `@foreach` isn't supported - pushed content is \
167 resolved once at compile time, not once per loop iteration, so a \
168 per-item push would silently render the wrong thing. Build the \
169 string yourself inside the loop instead.",
170 ));
171 }
172 collect_pushes(body, pushes, load)?;
173 }
174 Node::Section { body, .. }
175 | Node::LoadOnce(body)
176 | Node::Live { body, .. }
177 | Node::Spa(body) => collect_pushes(body, pushes, load)?,
178 Node::Resource { name, slot, .. } => {
179 collect_pushes(slot, pushes, load)?;
180 let resource_nodes = load(name)?;
181 collect_pushes(&resource_nodes, pushes, load)?;
182 }
183 _ => {}
184 }
185 }
186 Ok(())
187}
188
189fn contains_push(nodes: &[Node]) -> bool {
194 nodes.iter().any(|n| match n {
195 Node::Push { .. } => true,
196 Node::If {
197 then_branch,
198 else_branch,
199 ..
200 }
201 | Node::Can {
202 then_branch,
203 else_branch,
204 ..
205 }
206 | Node::Role {
207 then_branch,
208 else_branch,
209 ..
210 } => contains_push(then_branch) || contains_push(else_branch),
211 Node::Foreach { body, .. }
212 | Node::Section { body, .. }
213 | Node::LoadOnce(body)
214 | Node::Resource { slot: body, .. }
215 | Node::Live { body, .. }
216 | Node::Spa(body) => contains_push(body),
217 _ => false,
218 })
219}
220
221fn collect_globals(
237 nodes: &[Node],
238 globals: &mut HashMap<String, GlobalEntry>,
239 load: &mut impl FnMut(&str) -> Result<Vec<Node>, ParseError>,
240) -> Result<(), ParseError> {
241 let mut local = HashMap::new();
242 collect_globals_into(nodes, &mut local, load)?;
243 for (name, entry) in local {
244 globals.entry(name).or_insert(entry);
245 }
246 Ok(())
247}
248
249fn collect_globals_into(
255 nodes: &[Node],
256 local: &mut HashMap<String, GlobalEntry>,
257 load: &mut impl FnMut(&str) -> Result<Vec<Node>, ParseError>,
258) -> Result<(), ParseError> {
259 for node in nodes {
260 match node {
261 Node::Globals(entries) => {
262 for entry in entries {
263 local.insert(entry.name.clone(), entry.clone());
264 }
265 }
266 Node::If {
277 then_branch,
278 else_branch,
279 ..
280 } if contains_globals(then_branch) || contains_globals(else_branch) => {
281 return Err(ParseError::new(
282 "`@globals` inside `@if`/`@elseif`/`@else` isn't supported - global \
283 overrides are resolved once at compile time, from every branch \
284 unconditionally, not based on which branch the condition actually \
285 selects at render time, so the result would silently ignore the \
286 condition. Set the global unconditionally, or compute the value with a \
287 conditional expression instead (e.g. \
288 `title = if some_cond { \"A\" } else { \"B\" }`).",
289 ));
290 }
291 Node::Can {
292 then_branch,
293 else_branch,
294 ..
295 } if contains_globals(then_branch) || contains_globals(else_branch) => {
296 return Err(ParseError::new(
297 "`@globals` inside `@can`/`@else` isn't supported - same reason as inside \
298 `@if`: global overrides are resolved once at compile time, from every \
299 branch unconditionally, not based on whether the current user actually \
300 has the permission at render time.",
301 ));
302 }
303 Node::Role {
304 then_branch,
305 else_branch,
306 ..
307 } if contains_globals(then_branch) || contains_globals(else_branch) => {
308 return Err(ParseError::new(
309 "`@globals` inside `@role`/`@else` isn't supported - same reason as \
310 inside `@if`: global overrides are resolved once at compile time, from \
311 every branch unconditionally, not based on whether the current user \
312 actually has the role at render time.",
313 ));
314 }
315 Node::Foreach { body, .. } => {
316 if contains_globals(body) {
317 return Err(ParseError::new(
318 "`@globals` inside `@foreach` isn't supported - global overrides are \
319 resolved once at compile time, not once per loop iteration, so a \
320 per-item value has no coherent meaning.",
321 ));
322 }
323 collect_globals_into(body, local, load)?;
324 }
325 Node::Section { body, .. }
326 | Node::Push { body, .. }
327 | Node::LoadOnce(body)
328 | Node::Live { body, .. }
329 | Node::Spa(body) => {
330 collect_globals_into(body, local, load)?;
331 }
332 Node::Resource { name, slot, .. } => {
333 collect_globals_into(slot, local, load)?;
334 let resource_nodes = load(name)?;
335 collect_globals_into(&resource_nodes, local, load)?;
336 }
337 _ => {}
338 }
339 }
340 Ok(())
341}
342
343fn contains_globals(nodes: &[Node]) -> bool {
349 nodes.iter().any(|n| match n {
350 Node::Globals(_) => true,
351 Node::If {
352 then_branch,
353 else_branch,
354 ..
355 }
356 | Node::Can {
357 then_branch,
358 else_branch,
359 ..
360 }
361 | Node::Role {
362 then_branch,
363 else_branch,
364 ..
365 } => contains_globals(then_branch) || contains_globals(else_branch),
366 Node::Foreach { body, .. }
367 | Node::Section { body, .. }
368 | Node::Push { body, .. }
369 | Node::LoadOnce(body)
370 | Node::Resource { slot: body, .. }
371 | Node::Live { body, .. }
372 | Node::Spa(body) => contains_globals(body),
373 _ => false,
374 })
375}
376
377pub fn substitute_globals(nodes: Vec<Node>, globals: &HashMap<String, GlobalEntry>) -> Vec<Node> {
387 nodes
388 .into_iter()
389 .flat_map(|node| -> Vec<Node> {
390 match node {
391 Node::Global { name, fallback } => match globals.get(&name) {
392 Some(entry) if entry.persist => vec![Node::PersistGlobal {
393 cookie_name: name,
394 fallback_expr: entry.expr.clone(),
395 }],
396 Some(entry) => vec![Node::Interpolate {
397 expr: entry.expr.clone(),
398 escape: true,
399 }],
400 None => match fallback {
401 Some(expr) => vec![Node::Interpolate { expr, escape: true }],
402 None => vec![],
403 },
404 },
405 Node::If {
406 cond,
407 then_branch,
408 else_branch,
409 } => vec![Node::If {
410 cond,
411 then_branch: substitute_globals(then_branch, globals),
412 else_branch: substitute_globals(else_branch, globals),
413 }],
414 Node::Can {
415 permission,
416 then_branch,
417 else_branch,
418 } => vec![Node::Can {
419 permission,
420 then_branch: substitute_globals(then_branch, globals),
421 else_branch: substitute_globals(else_branch, globals),
422 }],
423 Node::Role {
424 role,
425 then_branch,
426 else_branch,
427 } => vec![Node::Role {
428 role,
429 then_branch: substitute_globals(then_branch, globals),
430 else_branch: substitute_globals(else_branch, globals),
431 }],
432 Node::Foreach {
433 binding,
434 iter,
435 body,
436 } => vec![Node::Foreach {
437 binding,
438 iter,
439 body: substitute_globals(body, globals),
440 }],
441 Node::Section { name, body } => vec![Node::Section {
442 name,
443 body: substitute_globals(body, globals),
444 }],
445 Node::LoadOnce(body) => vec![Node::LoadOnce(substitute_globals(body, globals))],
446 Node::Spa(body) => vec![Node::Spa(substitute_globals(body, globals))],
447 Node::Resource { name, props, slot } => vec![Node::Resource {
448 name,
449 props,
450 slot: substitute_globals(slot, globals),
451 }],
452 Node::Live { channel, body } => vec![Node::Live {
453 channel,
454 body: substitute_globals(body, globals),
455 }],
456 other => vec![other],
457 }
458 })
459 .collect()
460}
461
462pub fn substitute_stacks(nodes: Vec<Node>, pushes: &HashMap<String, Vec<Node>>) -> Vec<Node> {
487 nodes
488 .into_iter()
489 .flat_map(|node| -> Vec<Node> {
490 match node {
491 Node::Stack(name) => {
492 let spliced = pushes.get(&name).cloned().unwrap_or_default();
493 if spliced.is_empty() {
494 vec![Node::Stack(name)]
495 } else {
496 spliced
497 }
498 }
499 Node::If {
500 cond,
501 then_branch,
502 else_branch,
503 } => vec![Node::If {
504 cond,
505 then_branch: substitute_stacks(then_branch, pushes),
506 else_branch: substitute_stacks(else_branch, pushes),
507 }],
508 Node::Can {
509 permission,
510 then_branch,
511 else_branch,
512 } => vec![Node::Can {
513 permission,
514 then_branch: substitute_stacks(then_branch, pushes),
515 else_branch: substitute_stacks(else_branch, pushes),
516 }],
517 Node::Role {
518 role,
519 then_branch,
520 else_branch,
521 } => vec![Node::Role {
522 role,
523 then_branch: substitute_stacks(then_branch, pushes),
524 else_branch: substitute_stacks(else_branch, pushes),
525 }],
526 Node::Foreach {
527 binding,
528 iter,
529 body,
530 } => vec![Node::Foreach {
531 binding,
532 iter,
533 body: substitute_stacks(body, pushes),
534 }],
535 Node::Section { name, body } => vec![Node::Section {
536 name,
537 body: substitute_stacks(body, pushes),
538 }],
539 Node::LoadOnce(body) => vec![Node::LoadOnce(substitute_stacks(body, pushes))],
540 Node::Spa(body) => vec![Node::Spa(substitute_stacks(body, pushes))],
541 Node::Resource { name, props, slot } => vec![Node::Resource {
542 name,
543 props,
544 slot: substitute_stacks(slot, pushes),
545 }],
546 Node::Live { channel, body } => vec![Node::Live {
547 channel,
548 body: substitute_stacks(body, pushes),
549 }],
550 other => vec![other],
551 }
552 })
553 .collect()
554}
555
556fn substitute_yields(nodes: Vec<Node>, sections: &HashMap<String, Vec<Node>>) -> Vec<Node> {
557 nodes
558 .into_iter()
559 .flat_map(|node| -> Vec<Node> {
560 match node {
561 Node::Yield(name) => sections.get(&name).cloned().unwrap_or_default(),
562 Node::If {
563 cond,
564 then_branch,
565 else_branch,
566 } => vec![Node::If {
567 cond,
568 then_branch: substitute_yields(then_branch, sections),
569 else_branch: substitute_yields(else_branch, sections),
570 }],
571 Node::Can {
572 permission,
573 then_branch,
574 else_branch,
575 } => vec![Node::Can {
576 permission,
577 then_branch: substitute_yields(then_branch, sections),
578 else_branch: substitute_yields(else_branch, sections),
579 }],
580 Node::Role {
581 role,
582 then_branch,
583 else_branch,
584 } => vec![Node::Role {
585 role,
586 then_branch: substitute_yields(then_branch, sections),
587 else_branch: substitute_yields(else_branch, sections),
588 }],
589 Node::Foreach {
590 binding,
591 iter,
592 body,
593 } => vec![Node::Foreach {
594 binding,
595 iter,
596 body: substitute_yields(body, sections),
597 }],
598 Node::Section { name, body } => vec![Node::Section {
599 name,
600 body: substitute_yields(body, sections),
601 }],
602 Node::LoadOnce(body) => vec![Node::LoadOnce(substitute_yields(body, sections))],
603 Node::Spa(body) => vec![Node::Spa(substitute_yields(body, sections))],
604 Node::Resource { name, props, slot } => vec![Node::Resource {
605 name,
606 props,
607 slot: substitute_yields(slot, sections),
608 }],
609 Node::Live { channel, body } => vec![Node::Live {
610 channel,
611 body: substitute_yields(body, sections),
612 }],
613 other => vec![other],
614 }
615 })
616 .collect()
617}
618
619#[cfg(test)]
620mod tests {
621 use super::*;
622 use crate::parser::parse;
623
624 #[test]
625 fn substitutes_yield_with_matching_section() {
626 let layout = parse("<body>@yield('content')</body>").unwrap();
627 let child = parse("@extends('layout')@section('content')hi@endsection").unwrap();
628
629 let resolved = resolve(child, &mut |name| {
630 assert_eq!(name, "layout");
631 Ok(layout.clone())
632 })
633 .unwrap();
634
635 assert_eq!(
636 resolved,
637 vec![
638 Node::Text("<body>".to_string()),
639 Node::Text("hi".to_string()),
640 Node::Text("</body>".to_string()),
641 ]
642 );
643 }
644
645 #[test]
646 fn yield_with_no_matching_section_becomes_empty() {
647 let layout = parse("<body>@yield('content')</body>").unwrap();
648 let child = parse("@extends('layout')").unwrap();
649
650 let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
651
652 assert_eq!(
653 resolved,
654 vec![
655 Node::Text("<body>".to_string()),
656 Node::Text("</body>".to_string()),
657 ]
658 );
659 }
660
661 #[test]
662 fn no_extends_returns_nodes_unchanged() {
663 let nodes = parse("just text").unwrap();
664 let resolved = resolve(nodes.clone(), &mut |_| unreachable!()).unwrap();
665 assert_eq!(resolved, nodes);
666 }
667
668 #[test]
669 fn multi_level_layout_chain_resolves() {
670 let base = parse("[@yield('body')]").unwrap();
671 let app = parse("@extends('base')@section('body')<@yield('inner')>@endsection").unwrap();
672 let page = parse("@extends('app')@section('inner')hi@endsection").unwrap();
673
674 let resolved = resolve(page, &mut |name| match name {
675 "app" => Ok(app.clone()),
676 "base" => Ok(base.clone()),
677 other => panic!("unexpected load({other})"),
678 })
679 .unwrap();
680
681 let rendered: String = resolved
682 .iter()
683 .map(|n| match n {
684 Node::Text(t) => t.as_str(),
685 _ => "",
686 })
687 .collect();
688 assert_eq!(rendered, "[<hi>]");
689 }
690
691 #[test]
692 fn yield_inside_conditional_is_substituted() {
693 let layout = parse("@if(show) @yield('content') @endif").unwrap();
694 let child = parse("@extends('layout')@section('content')hi@endsection").unwrap();
695
696 let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
697
698 let Node::If { then_branch, .. } = &resolved[0] else {
699 panic!("expected If node");
700 };
701 assert!(then_branch.contains(&Node::Text("hi".to_string())));
702 }
703
704 #[test]
705 fn yield_inside_spa_is_substituted() {
706 let layout = parse("@spa @yield('content') @endspa").unwrap();
711 let child = parse("@extends('layout')@section('content')hi@endsection").unwrap();
712
713 let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
714
715 let Node::Spa(body) = &resolved[0] else {
716 panic!("expected Spa node");
717 };
718 assert!(body.contains(&Node::Text("hi".to_string())));
719 }
720
721 #[test]
722 fn yield_inside_an_elseif_branch_is_substituted() {
723 let layout = parse("@if(a) skip @elseif(b) @yield('content') @else skip @endif").unwrap();
729 let child = parse("@extends('layout')@section('content')hi@endsection").unwrap();
730
731 let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
732
733 let Node::If {
734 else_branch: outer_else,
735 ..
736 } = &resolved[0]
737 else {
738 panic!("expected outer If node");
739 };
740 let Node::If {
741 then_branch: nested_then,
742 ..
743 } = &outer_else[0]
744 else {
745 panic!("expected nested If node from @elseif");
746 };
747 assert!(nested_then.contains(&Node::Text("hi".to_string())));
748 }
749
750 #[test]
751 fn self_extending_template_errors_instead_of_overflowing() {
752 let a = parse("@extends('a')").unwrap();
753
754 let result = resolve(a.clone(), &mut |name| {
755 assert_eq!(name, "a");
756 Ok(a.clone())
757 });
758
759 assert!(result.is_err(), "expected a cycle error, got Ok");
760 }
761
762 #[test]
763 fn mutually_extending_templates_error_instead_of_overflowing() {
764 let a = parse("@extends('b')").unwrap();
765 let b = parse("@extends('a')").unwrap();
766
767 let result = resolve(a, &mut |name| match name {
768 "a" => Ok(a_clone()),
769 "b" => Ok(b.clone()),
770 other => panic!("unexpected load({other})"),
771 });
772
773 assert!(result.is_err(), "expected a cycle error, got Ok");
774
775 fn a_clone() -> Vec<Node> {
776 parse("@extends('b')").unwrap()
777 }
778 }
779
780 #[test]
781 fn stack_renders_a_single_push() {
782 let layout = parse("<head>@stack('scripts')</head>").unwrap();
783 let child = parse("@extends('layout')@push('scripts')<script>a</script>@endpush").unwrap();
784
785 let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
786
787 assert_eq!(
788 resolved,
789 vec![
790 Node::Text("<head>".to_string()),
791 Node::Text("<script>a</script>".to_string()),
792 Node::Text("</head>".to_string()),
793 ]
794 );
795 }
796
797 #[test]
798 fn multiple_pushes_to_the_same_stack_accumulate_in_source_order() {
799 let layout = parse("@stack('scripts')").unwrap();
803 let child = parse(
804 "@extends('layout')\
805 @push('scripts')one@endpush\
806 @push('scripts')two@endpush",
807 )
808 .unwrap();
809
810 let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
811
812 assert_eq!(
813 resolved,
814 vec![Node::Text("one".to_string()), Node::Text("two".to_string())]
815 );
816 }
817
818 #[test]
819 fn pushes_from_every_level_of_a_layout_chain_reach_the_base_stack() {
820 let base = parse("[@stack('scripts')]").unwrap();
825 let app = parse("@extends('base')@push('scripts')from-app@endpush").unwrap();
826 let page = parse("@extends('app')@push('scripts')from-page@endpush").unwrap();
827
828 let resolved = resolve(page, &mut |name| match name {
829 "app" => Ok(app.clone()),
830 "base" => Ok(base.clone()),
831 other => panic!("unexpected load({other})"),
832 })
833 .unwrap();
834
835 let rendered: String = resolved
836 .iter()
837 .map(|n| match n {
838 Node::Text(t) => t.as_str(),
839 _ => "",
840 })
841 .collect();
842 assert_eq!(rendered, "[from-pagefrom-app]");
843 }
844
845 #[test]
846 fn stack_with_no_matching_push_leaves_a_runtime_drain_marker() {
847 let layout = parse("<head>@stack('scripts')</head>").unwrap();
855 let child = parse("@extends('layout')").unwrap();
856
857 let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
858
859 assert_eq!(
860 resolved,
861 vec![
862 Node::Text("<head>".to_string()),
863 Node::Stack("scripts".to_string()),
864 Node::Text("</head>".to_string()),
865 ]
866 );
867 }
868
869 #[test]
870 fn a_push_inside_a_resource_taged_templates_own_file_reaches_an_outer_stack() {
871 let head = parse("@push('head')<title>hi</title>@endpush").unwrap();
881 let page = parse("<head>@stack('head')</head><resource:head></resource:head>").unwrap();
882
883 let resolved = resolve(page, &mut |name| {
884 assert_eq!(name, "head");
885 Ok(head.clone())
886 })
887 .unwrap();
888
889 let rendered: String = resolved
890 .iter()
891 .map(|n| match n {
892 Node::Text(t) => t.as_str(),
893 _ => "",
894 })
895 .collect();
896 assert_eq!(rendered, "<head><title>hi</title></head>");
897 }
898
899 #[test]
900 fn a_stack_inside_a_resource_tagged_templates_own_file_receives_an_outer_push() {
901 let layout = parse("<head>@stack('head')</head>").unwrap();
911 let page =
912 parse("@push('head')<title>hi</title>@endpush<resource:layout></resource:layout>")
913 .unwrap();
914
915 let (_, (pushes, _)) = resolve_with_context(page, &mut |name| {
916 assert_eq!(name, "layout");
917 Ok(layout.clone())
918 })
919 .unwrap();
920
921 let resolved_layout = substitute_stacks(layout, &pushes);
922 let rendered: String = resolved_layout
923 .iter()
924 .map(|n| match n {
925 Node::Text(t) => t.as_str(),
926 _ => "",
927 })
928 .collect();
929 assert_eq!(rendered, "<head><title>hi</title></head>");
930 }
931
932 #[test]
933 fn push_nested_inside_another_push_still_reaches_its_own_stack() {
934 let layout = parse("[@stack('outer')][@stack('inner')]").unwrap();
939 let child =
940 parse("@extends('layout')@push('outer')@push('inner')nested@endpush@endpush").unwrap();
941
942 let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
943
944 let rendered: String = resolved
945 .iter()
946 .map(|n| match n {
947 Node::Text(t) => t.as_str(),
948 _ => "",
949 })
950 .collect();
951 assert_eq!(rendered, "[][nested]");
952 }
953
954 #[test]
955 fn push_inside_foreach_is_rejected_with_a_clear_error() {
956 let layout = parse("@stack('scripts')").unwrap();
964 let child = parse(
965 "@extends('layout')\
966 @foreach(item in items)@push('scripts'){{ item }}@endpush@endforeach",
967 )
968 .unwrap();
969
970 let err = resolve(child, &mut |_| Ok(layout.clone())).unwrap_err();
971 assert!(err.to_string().contains("@push"));
972 assert!(err.to_string().contains("@foreach"));
973 }
974
975 #[test]
976 fn push_inside_a_nested_foreach_is_also_rejected() {
977 let layout = parse("@stack('scripts')").unwrap();
978 let child = parse(
979 "@extends('layout')\
980 @foreach(group in groups)@foreach(item in group.items)\
981 @push('scripts'){{ item }}@endpush\
982 @endforeach@endforeach",
983 )
984 .unwrap();
985
986 assert!(resolve(child, &mut |_| Ok(layout.clone())).is_err());
987 }
988
989 #[test]
990 fn global_reaches_through_an_indifferent_middle_layout() {
991 let base = parse("<title>@global(title)</title>").unwrap();
998 let app = parse("@extends('base')").unwrap();
999 let page = parse("@extends('app')@globals\ntitle = \"Hi\"\n@endglobals").unwrap();
1000
1001 let resolved = resolve(page, &mut |name| match name {
1002 "app" => Ok(app.clone()),
1003 "base" => Ok(base.clone()),
1004 other => panic!("unexpected load({other})"),
1005 })
1006 .unwrap();
1007
1008 assert_eq!(
1009 resolved,
1010 vec![
1011 Node::Text("<title>".to_string()),
1012 Node::Interpolate {
1013 expr: "\"Hi\"".to_string(),
1014 escape: true,
1015 },
1016 Node::Text("</title>".to_string()),
1017 ]
1018 );
1019 }
1020
1021 #[test]
1022 fn globals_from_child_page_override_a_middle_layout_that_also_sets_it() {
1023 let base = parse("<title>@global(title)</title>").unwrap();
1024 let app = parse("@extends('base')@globals\ntitle = \"App default\"\n@endglobals").unwrap();
1025 let page =
1026 parse("@extends('app')@globals\ntitle = \"Page specific\"\n@endglobals").unwrap();
1027
1028 let resolved = resolve(page, &mut |name| match name {
1029 "app" => Ok(app.clone()),
1030 "base" => Ok(base.clone()),
1031 other => panic!("unexpected load({other})"),
1032 })
1033 .unwrap();
1034
1035 assert_eq!(
1036 resolved,
1037 vec![
1038 Node::Text("<title>".to_string()),
1039 Node::Interpolate {
1040 expr: "\"Page specific\"".to_string(),
1041 escape: true,
1042 },
1043 Node::Text("</title>".to_string()),
1044 ]
1045 );
1046 }
1047
1048 #[test]
1049 fn global_with_no_matching_globals_becomes_empty() {
1050 let layout = parse("<title>@global(title)</title>").unwrap();
1051 let child = parse("@extends('layout')").unwrap();
1052
1053 let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
1054
1055 assert_eq!(
1056 resolved,
1057 vec![
1058 Node::Text("<title>".to_string()),
1059 Node::Text("</title>".to_string()),
1060 ]
1061 );
1062 }
1063
1064 #[test]
1065 fn global_falls_back_to_its_default_when_nothing_sets_it() {
1066 let layout = parse(r#"<title>@global(title, "Larust")</title>"#).unwrap();
1067 let child = parse("@extends('layout')").unwrap();
1068
1069 let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
1070
1071 assert_eq!(
1072 resolved,
1073 vec![
1074 Node::Text("<title>".to_string()),
1075 Node::Interpolate {
1076 expr: "\"Larust\"".to_string(),
1077 escape: true,
1078 },
1079 Node::Text("</title>".to_string()),
1080 ]
1081 );
1082 }
1083
1084 #[test]
1085 fn a_matching_globals_entry_wins_over_the_fallback() {
1086 let layout = parse(r#"<title>@global(title, "Larust")</title>"#).unwrap();
1087 let child = parse("@extends('layout')@globals\ntitle = \"Page\"\n@endglobals").unwrap();
1088
1089 let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
1090
1091 assert_eq!(
1092 resolved,
1093 vec![
1094 Node::Text("<title>".to_string()),
1095 Node::Interpolate {
1096 expr: "\"Page\"".to_string(),
1097 escape: true,
1098 },
1099 Node::Text("</title>".to_string()),
1100 ]
1101 );
1102 }
1103
1104 #[test]
1105 fn multiple_globals_blocks_accumulate_different_names() {
1106 let layout = parse("[@global(a)][@global(b)]").unwrap();
1107 let child = parse(
1108 "@extends('layout')\
1109 @globals\na = \"1\"\n@endglobals\
1110 @globals\nb = \"2\"\n@endglobals",
1111 )
1112 .unwrap();
1113
1114 let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
1115
1116 let rendered: String = resolved
1117 .iter()
1118 .map(|n| match n {
1119 Node::Text(t) => t.as_str(),
1120 Node::Interpolate { expr, .. } => expr.as_str(),
1121 _ => "",
1122 })
1123 .collect();
1124 assert_eq!(rendered, "[\"1\"][\"2\"]");
1125 }
1126
1127 #[test]
1128 fn globals_inside_foreach_is_rejected_with_a_clear_error() {
1129 let layout = parse("@global(title)").unwrap();
1130 let child = parse(
1131 "@extends('layout')\
1132 @foreach(item in items)@globals\ntitle = item\n@endglobals@endforeach",
1133 )
1134 .unwrap();
1135
1136 let err = resolve(child, &mut |_| Ok(layout.clone())).unwrap_err();
1137 assert!(err.to_string().contains("@globals"));
1138 assert!(err.to_string().contains("@foreach"));
1139 }
1140
1141 #[test]
1142 fn globals_inside_if_is_rejected_with_a_clear_error() {
1143 let layout = parse("@global(title)").unwrap();
1149 let child = parse(
1150 "@extends('layout')\
1151 @if(is_admin)@globals\ntitle = \"Admin\"\n@endglobals\
1152 @else@globals\ntitle = \"User\"\n@endglobals@endif",
1153 )
1154 .unwrap();
1155
1156 let err = resolve(child, &mut |_| Ok(layout.clone())).unwrap_err();
1157 assert!(err.to_string().contains("@globals"));
1158 assert!(err.to_string().contains("@if"));
1159 }
1160
1161 #[test]
1162 fn globals_inside_can_is_rejected_with_a_clear_error() {
1163 let layout = parse("@global(title)").unwrap();
1164 let child = parse(
1165 "@extends('layout')\
1166 @can(Permission::EditPosts)@globals\ntitle = \"Editor\"\n@endglobals@endcan",
1167 )
1168 .unwrap();
1169
1170 let err = resolve(child, &mut |_| Ok(layout.clone())).unwrap_err();
1171 assert!(err.to_string().contains("@globals"));
1172 assert!(err.to_string().contains("@can"));
1173 }
1174
1175 #[test]
1176 fn globals_inside_role_is_rejected_with_a_clear_error() {
1177 let layout = parse("@global(title)").unwrap();
1178 let child = parse(
1179 "@extends('layout')\
1180 @role(Role::Admin)@globals\ntitle = \"Admin\"\n@endglobals@endrole",
1181 )
1182 .unwrap();
1183
1184 let err = resolve(child, &mut |_| Ok(layout.clone())).unwrap_err();
1185 assert!(err.to_string().contains("@globals"));
1186 assert!(err.to_string().contains("@role"));
1187 }
1188
1189 #[test]
1190 fn global_placeholder_inside_can_is_still_substituted() {
1191 let layout = parse("@can(Permission::EditPosts)@global(title)@endcan").unwrap();
1196 let child = parse("@extends('layout')@globals\ntitle = \"Hi\"\n@endglobals").unwrap();
1197
1198 let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
1199
1200 let Node::Can { then_branch, .. } = &resolved[0] else {
1201 panic!("expected Can node");
1202 };
1203 assert_eq!(
1204 then_branch[0],
1205 Node::Interpolate {
1206 expr: "\"Hi\"".to_string(),
1207 escape: true,
1208 }
1209 );
1210 }
1211
1212 #[test]
1213 fn globals_inside_elseif_is_also_rejected() {
1214 let layout = parse("@global(title)").unwrap();
1218 let child = parse(
1219 "@extends('layout')\
1220 @if(a) x @elseif(b)@globals\ntitle = \"B\"\n@endglobals@endif",
1221 )
1222 .unwrap();
1223
1224 assert!(resolve(child, &mut |_| Ok(layout.clone())).is_err());
1225 }
1226
1227 #[test]
1228 fn global_works_without_any_extends_at_all() {
1229 let nodes =
1235 parse("@globals\ntitle = \"Solo\"\n@endglobals<title>@global(title)</title>").unwrap();
1236
1237 let resolved = resolve(nodes, &mut |_| unreachable!()).unwrap();
1238
1239 let rendered: String = resolved
1247 .iter()
1248 .map(|n| match n {
1249 Node::Text(t) => t.as_str(),
1250 Node::Interpolate { expr, .. } => expr.as_str(),
1251 _ => "",
1252 })
1253 .collect();
1254 assert_eq!(rendered, "<title>\"Solo\"</title>");
1255 }
1256
1257 #[test]
1258 fn persist_global_substitutes_to_a_persist_global_node_not_interpolate() {
1259 let layout = parse(r#"<html data-theme="@global(theme, "dark")">"#).unwrap();
1260 let child =
1261 parse("@extends('layout')@globals\npersist theme = \"light\"\n@endglobals").unwrap();
1262
1263 let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
1264
1265 assert_eq!(
1266 resolved,
1267 vec![
1268 Node::Text("<html data-theme=\"".to_string()),
1269 Node::PersistGlobal {
1270 cookie_name: "theme".to_string(),
1271 fallback_expr: "\"light\"".to_string(),
1272 },
1273 Node::Text("\">".to_string()),
1274 ]
1275 );
1276 }
1277
1278 #[test]
1279 fn a_non_persist_global_still_substitutes_to_interpolate() {
1280 let layout = parse("<title>@global(title)</title>").unwrap();
1281 let child = parse("@extends('layout')@globals\ntitle = \"Hi\"\n@endglobals").unwrap();
1282
1283 let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
1284
1285 assert_eq!(
1286 resolved,
1287 vec![
1288 Node::Text("<title>".to_string()),
1289 Node::Interpolate {
1290 expr: "\"Hi\"".to_string(),
1291 escape: true,
1292 },
1293 Node::Text("</title>".to_string()),
1294 ]
1295 );
1296 }
1297
1298 #[test]
1299 fn a_child_pages_persist_entry_still_overrides_a_middle_layout_that_also_sets_it() {
1300 let base = parse("<title>@global(theme, \"dark\")</title>").unwrap();
1306 let app = parse("@extends('base')@globals\npersist theme = \"app-default\"\n@endglobals")
1307 .unwrap();
1308 let page = parse("@extends('app')@globals\npersist theme = \"page-specific\"\n@endglobals")
1309 .unwrap();
1310
1311 let resolved = resolve(page, &mut |name| match name {
1312 "app" => Ok(app.clone()),
1313 "base" => Ok(base.clone()),
1314 other => panic!("unexpected load({other})"),
1315 })
1316 .unwrap();
1317
1318 assert_eq!(
1319 resolved,
1320 vec![
1321 Node::Text("<title>".to_string()),
1322 Node::PersistGlobal {
1323 cookie_name: "theme".to_string(),
1324 fallback_expr: "\"page-specific\"".to_string(),
1325 },
1326 Node::Text("</title>".to_string()),
1327 ]
1328 );
1329 }
1330}