use crate::ast::{GlobalEntry, Node};
use crate::error::ParseError;
use std::collections::{HashMap, HashSet};
pub type PushesAndGlobals = (HashMap<String, Vec<Node>>, HashMap<String, GlobalEntry>);
pub fn resolve(
nodes: Vec<Node>,
load: &mut impl FnMut(&str) -> Result<Vec<Node>, ParseError>,
) -> Result<Vec<Node>, ParseError> {
let (nodes, _) = resolve_with_context(nodes, load)?;
Ok(nodes)
}
pub fn resolve_with_context(
nodes: Vec<Node>,
load: &mut impl FnMut(&str) -> Result<Vec<Node>, ParseError>,
) -> Result<(Vec<Node>, PushesAndGlobals), ParseError> {
let mut pushes: HashMap<String, Vec<Node>> = HashMap::new();
let mut globals: HashMap<String, GlobalEntry> = HashMap::new();
let resolved = resolve_inner(nodes, load, &mut HashSet::new(), &mut pushes, &mut globals)?;
let resolved = substitute_stacks(resolved, &pushes);
let resolved = substitute_globals(resolved, &globals);
Ok((resolved, (pushes, globals)))
}
fn resolve_inner(
nodes: Vec<Node>,
load: &mut impl FnMut(&str) -> Result<Vec<Node>, ParseError>,
seen: &mut HashSet<String>,
pushes: &mut HashMap<String, Vec<Node>>,
globals: &mut HashMap<String, GlobalEntry>,
) -> Result<Vec<Node>, ParseError> {
collect_pushes(&nodes, pushes, load)?;
collect_globals(&nodes, globals, load)?;
let extends_name = nodes.iter().find_map(|n| match n {
Node::Extends(name) => Some(name.clone()),
_ => None,
});
let Some(parent_name) = extends_name else {
return Ok(nodes);
};
if !seen.insert(parent_name.clone()) {
return Err(ParseError::new(format!(
"cycle detected in @extends chain (revisited `{parent_name}`)"
)));
}
let mut sections = HashMap::new();
for node in nodes {
if let Node::Section { name, body } = node {
sections.insert(name, body);
}
}
let parent_nodes = load(&parent_name)?;
let resolved_parent = resolve_inner(parent_nodes, load, seen, pushes, globals)?;
Ok(substitute_yields(resolved_parent, §ions))
}
fn collect_pushes(
nodes: &[Node],
pushes: &mut HashMap<String, Vec<Node>>,
load: &mut impl FnMut(&str) -> Result<Vec<Node>, ParseError>,
) -> Result<(), ParseError> {
for node in nodes {
match node {
Node::Push { name, body } => {
pushes.entry(name.clone()).or_default().extend(body.clone());
collect_pushes(body, pushes, load)?;
}
Node::If {
then_branch,
else_branch,
..
}
| Node::Can {
then_branch,
else_branch,
..
}
| Node::Role {
then_branch,
else_branch,
..
} => {
collect_pushes(then_branch, pushes, load)?;
collect_pushes(else_branch, pushes, load)?;
}
Node::Foreach { body, .. } => {
if contains_push(body) {
return Err(ParseError::new(
"`@push` inside `@foreach` isn't supported - pushed content is \
resolved once at compile time, not once per loop iteration, so a \
per-item push would silently render the wrong thing. Build the \
string yourself inside the loop instead.",
));
}
collect_pushes(body, pushes, load)?;
}
Node::Section { body, .. }
| Node::LoadOnce(body)
| Node::Live { body, .. }
| Node::Spa(body) => collect_pushes(body, pushes, load)?,
Node::Resource { name, slot, .. } => {
collect_pushes(slot, pushes, load)?;
let resource_nodes = load(name)?;
collect_pushes(&resource_nodes, pushes, load)?;
}
_ => {}
}
}
Ok(())
}
fn contains_push(nodes: &[Node]) -> bool {
nodes.iter().any(|n| match n {
Node::Push { .. } => true,
Node::If {
then_branch,
else_branch,
..
}
| Node::Can {
then_branch,
else_branch,
..
}
| Node::Role {
then_branch,
else_branch,
..
} => contains_push(then_branch) || contains_push(else_branch),
Node::Foreach { body, .. }
| Node::Section { body, .. }
| Node::LoadOnce(body)
| Node::Resource { slot: body, .. }
| Node::Live { body, .. }
| Node::Spa(body) => contains_push(body),
_ => false,
})
}
fn collect_globals(
nodes: &[Node],
globals: &mut HashMap<String, GlobalEntry>,
load: &mut impl FnMut(&str) -> Result<Vec<Node>, ParseError>,
) -> Result<(), ParseError> {
let mut local = HashMap::new();
collect_globals_into(nodes, &mut local, load)?;
for (name, entry) in local {
globals.entry(name).or_insert(entry);
}
Ok(())
}
fn collect_globals_into(
nodes: &[Node],
local: &mut HashMap<String, GlobalEntry>,
load: &mut impl FnMut(&str) -> Result<Vec<Node>, ParseError>,
) -> Result<(), ParseError> {
for node in nodes {
match node {
Node::Globals(entries) => {
for entry in entries {
local.insert(entry.name.clone(), entry.clone());
}
}
Node::If {
then_branch,
else_branch,
..
} if contains_globals(then_branch) || contains_globals(else_branch) => {
return Err(ParseError::new(
"`@globals` inside `@if`/`@elseif`/`@else` isn't supported - global \
overrides are resolved once at compile time, from every branch \
unconditionally, not based on which branch the condition actually \
selects at render time, so the result would silently ignore the \
condition. Set the global unconditionally, or compute the value with a \
conditional expression instead (e.g. \
`title = if some_cond { \"A\" } else { \"B\" }`).",
));
}
Node::Can {
then_branch,
else_branch,
..
} if contains_globals(then_branch) || contains_globals(else_branch) => {
return Err(ParseError::new(
"`@globals` inside `@can`/`@else` isn't supported - same reason as inside \
`@if`: global overrides are resolved once at compile time, from every \
branch unconditionally, not based on whether the current user actually \
has the permission at render time.",
));
}
Node::Role {
then_branch,
else_branch,
..
} if contains_globals(then_branch) || contains_globals(else_branch) => {
return Err(ParseError::new(
"`@globals` inside `@role`/`@else` isn't supported - same reason as \
inside `@if`: global overrides are resolved once at compile time, from \
every branch unconditionally, not based on whether the current user \
actually has the role at render time.",
));
}
Node::Foreach { body, .. } => {
if contains_globals(body) {
return Err(ParseError::new(
"`@globals` inside `@foreach` isn't supported - global overrides are \
resolved once at compile time, not once per loop iteration, so a \
per-item value has no coherent meaning.",
));
}
collect_globals_into(body, local, load)?;
}
Node::Section { body, .. }
| Node::Push { body, .. }
| Node::LoadOnce(body)
| Node::Live { body, .. }
| Node::Spa(body) => {
collect_globals_into(body, local, load)?;
}
Node::Resource { name, slot, .. } => {
collect_globals_into(slot, local, load)?;
let resource_nodes = load(name)?;
collect_globals_into(&resource_nodes, local, load)?;
}
_ => {}
}
}
Ok(())
}
fn contains_globals(nodes: &[Node]) -> bool {
nodes.iter().any(|n| match n {
Node::Globals(_) => true,
Node::If {
then_branch,
else_branch,
..
}
| Node::Can {
then_branch,
else_branch,
..
}
| Node::Role {
then_branch,
else_branch,
..
} => contains_globals(then_branch) || contains_globals(else_branch),
Node::Foreach { body, .. }
| Node::Section { body, .. }
| Node::Push { body, .. }
| Node::LoadOnce(body)
| Node::Resource { slot: body, .. }
| Node::Live { body, .. }
| Node::Spa(body) => contains_globals(body),
_ => false,
})
}
pub fn substitute_globals(nodes: Vec<Node>, globals: &HashMap<String, GlobalEntry>) -> Vec<Node> {
nodes
.into_iter()
.flat_map(|node| -> Vec<Node> {
match node {
Node::Global { name, fallback } => match globals.get(&name) {
Some(entry) if entry.persist => vec![Node::PersistGlobal {
cookie_name: name,
fallback_expr: entry.expr.clone(),
}],
Some(entry) => vec![Node::Interpolate {
expr: entry.expr.clone(),
escape: true,
}],
None => match fallback {
Some(expr) => vec![Node::Interpolate { expr, escape: true }],
None => vec![],
},
},
Node::If {
cond,
then_branch,
else_branch,
} => vec![Node::If {
cond,
then_branch: substitute_globals(then_branch, globals),
else_branch: substitute_globals(else_branch, globals),
}],
Node::Can {
permission,
then_branch,
else_branch,
} => vec![Node::Can {
permission,
then_branch: substitute_globals(then_branch, globals),
else_branch: substitute_globals(else_branch, globals),
}],
Node::Role {
role,
then_branch,
else_branch,
} => vec![Node::Role {
role,
then_branch: substitute_globals(then_branch, globals),
else_branch: substitute_globals(else_branch, globals),
}],
Node::Foreach {
binding,
iter,
body,
} => vec![Node::Foreach {
binding,
iter,
body: substitute_globals(body, globals),
}],
Node::Section { name, body } => vec![Node::Section {
name,
body: substitute_globals(body, globals),
}],
Node::LoadOnce(body) => vec![Node::LoadOnce(substitute_globals(body, globals))],
Node::Spa(body) => vec![Node::Spa(substitute_globals(body, globals))],
Node::Resource { name, props, slot } => vec![Node::Resource {
name,
props,
slot: substitute_globals(slot, globals),
}],
Node::Live { channel, body } => vec![Node::Live {
channel,
body: substitute_globals(body, globals),
}],
other => vec![other],
}
})
.collect()
}
pub fn substitute_stacks(nodes: Vec<Node>, pushes: &HashMap<String, Vec<Node>>) -> Vec<Node> {
nodes
.into_iter()
.flat_map(|node| -> Vec<Node> {
match node {
Node::Stack(name) => {
let spliced = pushes.get(&name).cloned().unwrap_or_default();
if spliced.is_empty() {
vec![Node::Stack(name)]
} else {
spliced
}
}
Node::If {
cond,
then_branch,
else_branch,
} => vec![Node::If {
cond,
then_branch: substitute_stacks(then_branch, pushes),
else_branch: substitute_stacks(else_branch, pushes),
}],
Node::Can {
permission,
then_branch,
else_branch,
} => vec![Node::Can {
permission,
then_branch: substitute_stacks(then_branch, pushes),
else_branch: substitute_stacks(else_branch, pushes),
}],
Node::Role {
role,
then_branch,
else_branch,
} => vec![Node::Role {
role,
then_branch: substitute_stacks(then_branch, pushes),
else_branch: substitute_stacks(else_branch, pushes),
}],
Node::Foreach {
binding,
iter,
body,
} => vec![Node::Foreach {
binding,
iter,
body: substitute_stacks(body, pushes),
}],
Node::Section { name, body } => vec![Node::Section {
name,
body: substitute_stacks(body, pushes),
}],
Node::LoadOnce(body) => vec![Node::LoadOnce(substitute_stacks(body, pushes))],
Node::Spa(body) => vec![Node::Spa(substitute_stacks(body, pushes))],
Node::Resource { name, props, slot } => vec![Node::Resource {
name,
props,
slot: substitute_stacks(slot, pushes),
}],
Node::Live { channel, body } => vec![Node::Live {
channel,
body: substitute_stacks(body, pushes),
}],
other => vec![other],
}
})
.collect()
}
fn substitute_yields(nodes: Vec<Node>, sections: &HashMap<String, Vec<Node>>) -> Vec<Node> {
nodes
.into_iter()
.flat_map(|node| -> Vec<Node> {
match node {
Node::Yield(name) => sections.get(&name).cloned().unwrap_or_default(),
Node::If {
cond,
then_branch,
else_branch,
} => vec![Node::If {
cond,
then_branch: substitute_yields(then_branch, sections),
else_branch: substitute_yields(else_branch, sections),
}],
Node::Can {
permission,
then_branch,
else_branch,
} => vec![Node::Can {
permission,
then_branch: substitute_yields(then_branch, sections),
else_branch: substitute_yields(else_branch, sections),
}],
Node::Role {
role,
then_branch,
else_branch,
} => vec![Node::Role {
role,
then_branch: substitute_yields(then_branch, sections),
else_branch: substitute_yields(else_branch, sections),
}],
Node::Foreach {
binding,
iter,
body,
} => vec![Node::Foreach {
binding,
iter,
body: substitute_yields(body, sections),
}],
Node::Section { name, body } => vec![Node::Section {
name,
body: substitute_yields(body, sections),
}],
Node::LoadOnce(body) => vec![Node::LoadOnce(substitute_yields(body, sections))],
Node::Spa(body) => vec![Node::Spa(substitute_yields(body, sections))],
Node::Resource { name, props, slot } => vec![Node::Resource {
name,
props,
slot: substitute_yields(slot, sections),
}],
Node::Live { channel, body } => vec![Node::Live {
channel,
body: substitute_yields(body, sections),
}],
other => vec![other],
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse;
#[test]
fn substitutes_yield_with_matching_section() {
let layout = parse("<body>@yield('content')</body>").unwrap();
let child = parse("@extends('layout')@section('content')hi@endsection").unwrap();
let resolved = resolve(child, &mut |name| {
assert_eq!(name, "layout");
Ok(layout.clone())
})
.unwrap();
assert_eq!(
resolved,
vec![
Node::Text("<body>".to_string()),
Node::Text("hi".to_string()),
Node::Text("</body>".to_string()),
]
);
}
#[test]
fn yield_with_no_matching_section_becomes_empty() {
let layout = parse("<body>@yield('content')</body>").unwrap();
let child = parse("@extends('layout')").unwrap();
let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
assert_eq!(
resolved,
vec![
Node::Text("<body>".to_string()),
Node::Text("</body>".to_string()),
]
);
}
#[test]
fn no_extends_returns_nodes_unchanged() {
let nodes = parse("just text").unwrap();
let resolved = resolve(nodes.clone(), &mut |_| unreachable!()).unwrap();
assert_eq!(resolved, nodes);
}
#[test]
fn multi_level_layout_chain_resolves() {
let base = parse("[@yield('body')]").unwrap();
let app = parse("@extends('base')@section('body')<@yield('inner')>@endsection").unwrap();
let page = parse("@extends('app')@section('inner')hi@endsection").unwrap();
let resolved = resolve(page, &mut |name| match name {
"app" => Ok(app.clone()),
"base" => Ok(base.clone()),
other => panic!("unexpected load({other})"),
})
.unwrap();
let rendered: String = resolved
.iter()
.map(|n| match n {
Node::Text(t) => t.as_str(),
_ => "",
})
.collect();
assert_eq!(rendered, "[<hi>]");
}
#[test]
fn yield_inside_conditional_is_substituted() {
let layout = parse("@if(show) @yield('content') @endif").unwrap();
let child = parse("@extends('layout')@section('content')hi@endsection").unwrap();
let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
let Node::If { then_branch, .. } = &resolved[0] else {
panic!("expected If node");
};
assert!(then_branch.contains(&Node::Text("hi".to_string())));
}
#[test]
fn yield_inside_spa_is_substituted() {
let layout = parse("@spa @yield('content') @endspa").unwrap();
let child = parse("@extends('layout')@section('content')hi@endsection").unwrap();
let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
let Node::Spa(body) = &resolved[0] else {
panic!("expected Spa node");
};
assert!(body.contains(&Node::Text("hi".to_string())));
}
#[test]
fn yield_inside_an_elseif_branch_is_substituted() {
let layout = parse("@if(a) skip @elseif(b) @yield('content') @else skip @endif").unwrap();
let child = parse("@extends('layout')@section('content')hi@endsection").unwrap();
let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
let Node::If {
else_branch: outer_else,
..
} = &resolved[0]
else {
panic!("expected outer If node");
};
let Node::If {
then_branch: nested_then,
..
} = &outer_else[0]
else {
panic!("expected nested If node from @elseif");
};
assert!(nested_then.contains(&Node::Text("hi".to_string())));
}
#[test]
fn self_extending_template_errors_instead_of_overflowing() {
let a = parse("@extends('a')").unwrap();
let result = resolve(a.clone(), &mut |name| {
assert_eq!(name, "a");
Ok(a.clone())
});
assert!(result.is_err(), "expected a cycle error, got Ok");
}
#[test]
fn mutually_extending_templates_error_instead_of_overflowing() {
let a = parse("@extends('b')").unwrap();
let b = parse("@extends('a')").unwrap();
let result = resolve(a, &mut |name| match name {
"a" => Ok(a_clone()),
"b" => Ok(b.clone()),
other => panic!("unexpected load({other})"),
});
assert!(result.is_err(), "expected a cycle error, got Ok");
fn a_clone() -> Vec<Node> {
parse("@extends('b')").unwrap()
}
}
#[test]
fn stack_renders_a_single_push() {
let layout = parse("<head>@stack('scripts')</head>").unwrap();
let child = parse("@extends('layout')@push('scripts')<script>a</script>@endpush").unwrap();
let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
assert_eq!(
resolved,
vec![
Node::Text("<head>".to_string()),
Node::Text("<script>a</script>".to_string()),
Node::Text("</head>".to_string()),
]
);
}
#[test]
fn multiple_pushes_to_the_same_stack_accumulate_in_source_order() {
let layout = parse("@stack('scripts')").unwrap();
let child = parse(
"@extends('layout')\
@push('scripts')one@endpush\
@push('scripts')two@endpush",
)
.unwrap();
let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
assert_eq!(
resolved,
vec![Node::Text("one".to_string()), Node::Text("two".to_string())]
);
}
#[test]
fn pushes_from_every_level_of_a_layout_chain_reach_the_base_stack() {
let base = parse("[@stack('scripts')]").unwrap();
let app = parse("@extends('base')@push('scripts')from-app@endpush").unwrap();
let page = parse("@extends('app')@push('scripts')from-page@endpush").unwrap();
let resolved = resolve(page, &mut |name| match name {
"app" => Ok(app.clone()),
"base" => Ok(base.clone()),
other => panic!("unexpected load({other})"),
})
.unwrap();
let rendered: String = resolved
.iter()
.map(|n| match n {
Node::Text(t) => t.as_str(),
_ => "",
})
.collect();
assert_eq!(rendered, "[from-pagefrom-app]");
}
#[test]
fn stack_with_no_matching_push_leaves_a_runtime_drain_marker() {
let layout = parse("<head>@stack('scripts')</head>").unwrap();
let child = parse("@extends('layout')").unwrap();
let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
assert_eq!(
resolved,
vec![
Node::Text("<head>".to_string()),
Node::Stack("scripts".to_string()),
Node::Text("</head>".to_string()),
]
);
}
#[test]
fn a_push_inside_a_resource_taged_templates_own_file_reaches_an_outer_stack() {
let head = parse("@push('head')<title>hi</title>@endpush").unwrap();
let page = parse("<head>@stack('head')</head><resource:head></resource:head>").unwrap();
let resolved = resolve(page, &mut |name| {
assert_eq!(name, "head");
Ok(head.clone())
})
.unwrap();
let rendered: String = resolved
.iter()
.map(|n| match n {
Node::Text(t) => t.as_str(),
_ => "",
})
.collect();
assert_eq!(rendered, "<head><title>hi</title></head>");
}
#[test]
fn a_stack_inside_a_resource_tagged_templates_own_file_receives_an_outer_push() {
let layout = parse("<head>@stack('head')</head>").unwrap();
let page =
parse("@push('head')<title>hi</title>@endpush<resource:layout></resource:layout>")
.unwrap();
let (_, (pushes, _)) = resolve_with_context(page, &mut |name| {
assert_eq!(name, "layout");
Ok(layout.clone())
})
.unwrap();
let resolved_layout = substitute_stacks(layout, &pushes);
let rendered: String = resolved_layout
.iter()
.map(|n| match n {
Node::Text(t) => t.as_str(),
_ => "",
})
.collect();
assert_eq!(rendered, "<head><title>hi</title></head>");
}
#[test]
fn push_nested_inside_another_push_still_reaches_its_own_stack() {
let layout = parse("[@stack('outer')][@stack('inner')]").unwrap();
let child =
parse("@extends('layout')@push('outer')@push('inner')nested@endpush@endpush").unwrap();
let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
let rendered: String = resolved
.iter()
.map(|n| match n {
Node::Text(t) => t.as_str(),
_ => "",
})
.collect();
assert_eq!(rendered, "[][nested]");
}
#[test]
fn push_inside_foreach_is_rejected_with_a_clear_error() {
let layout = parse("@stack('scripts')").unwrap();
let child = parse(
"@extends('layout')\
@foreach(item in items)@push('scripts'){{ item }}@endpush@endforeach",
)
.unwrap();
let err = resolve(child, &mut |_| Ok(layout.clone())).unwrap_err();
assert!(err.to_string().contains("@push"));
assert!(err.to_string().contains("@foreach"));
}
#[test]
fn push_inside_a_nested_foreach_is_also_rejected() {
let layout = parse("@stack('scripts')").unwrap();
let child = parse(
"@extends('layout')\
@foreach(group in groups)@foreach(item in group.items)\
@push('scripts'){{ item }}@endpush\
@endforeach@endforeach",
)
.unwrap();
assert!(resolve(child, &mut |_| Ok(layout.clone())).is_err());
}
#[test]
fn global_reaches_through_an_indifferent_middle_layout() {
let base = parse("<title>@global(title)</title>").unwrap();
let app = parse("@extends('base')").unwrap();
let page = parse("@extends('app')@globals\ntitle = \"Hi\"\n@endglobals").unwrap();
let resolved = resolve(page, &mut |name| match name {
"app" => Ok(app.clone()),
"base" => Ok(base.clone()),
other => panic!("unexpected load({other})"),
})
.unwrap();
assert_eq!(
resolved,
vec![
Node::Text("<title>".to_string()),
Node::Interpolate {
expr: "\"Hi\"".to_string(),
escape: true,
},
Node::Text("</title>".to_string()),
]
);
}
#[test]
fn globals_from_child_page_override_a_middle_layout_that_also_sets_it() {
let base = parse("<title>@global(title)</title>").unwrap();
let app = parse("@extends('base')@globals\ntitle = \"App default\"\n@endglobals").unwrap();
let page =
parse("@extends('app')@globals\ntitle = \"Page specific\"\n@endglobals").unwrap();
let resolved = resolve(page, &mut |name| match name {
"app" => Ok(app.clone()),
"base" => Ok(base.clone()),
other => panic!("unexpected load({other})"),
})
.unwrap();
assert_eq!(
resolved,
vec![
Node::Text("<title>".to_string()),
Node::Interpolate {
expr: "\"Page specific\"".to_string(),
escape: true,
},
Node::Text("</title>".to_string()),
]
);
}
#[test]
fn global_with_no_matching_globals_becomes_empty() {
let layout = parse("<title>@global(title)</title>").unwrap();
let child = parse("@extends('layout')").unwrap();
let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
assert_eq!(
resolved,
vec![
Node::Text("<title>".to_string()),
Node::Text("</title>".to_string()),
]
);
}
#[test]
fn global_falls_back_to_its_default_when_nothing_sets_it() {
let layout = parse(r#"<title>@global(title, "Larust")</title>"#).unwrap();
let child = parse("@extends('layout')").unwrap();
let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
assert_eq!(
resolved,
vec![
Node::Text("<title>".to_string()),
Node::Interpolate {
expr: "\"Larust\"".to_string(),
escape: true,
},
Node::Text("</title>".to_string()),
]
);
}
#[test]
fn a_matching_globals_entry_wins_over_the_fallback() {
let layout = parse(r#"<title>@global(title, "Larust")</title>"#).unwrap();
let child = parse("@extends('layout')@globals\ntitle = \"Page\"\n@endglobals").unwrap();
let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
assert_eq!(
resolved,
vec![
Node::Text("<title>".to_string()),
Node::Interpolate {
expr: "\"Page\"".to_string(),
escape: true,
},
Node::Text("</title>".to_string()),
]
);
}
#[test]
fn multiple_globals_blocks_accumulate_different_names() {
let layout = parse("[@global(a)][@global(b)]").unwrap();
let child = parse(
"@extends('layout')\
@globals\na = \"1\"\n@endglobals\
@globals\nb = \"2\"\n@endglobals",
)
.unwrap();
let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
let rendered: String = resolved
.iter()
.map(|n| match n {
Node::Text(t) => t.as_str(),
Node::Interpolate { expr, .. } => expr.as_str(),
_ => "",
})
.collect();
assert_eq!(rendered, "[\"1\"][\"2\"]");
}
#[test]
fn globals_inside_foreach_is_rejected_with_a_clear_error() {
let layout = parse("@global(title)").unwrap();
let child = parse(
"@extends('layout')\
@foreach(item in items)@globals\ntitle = item\n@endglobals@endforeach",
)
.unwrap();
let err = resolve(child, &mut |_| Ok(layout.clone())).unwrap_err();
assert!(err.to_string().contains("@globals"));
assert!(err.to_string().contains("@foreach"));
}
#[test]
fn globals_inside_if_is_rejected_with_a_clear_error() {
let layout = parse("@global(title)").unwrap();
let child = parse(
"@extends('layout')\
@if(is_admin)@globals\ntitle = \"Admin\"\n@endglobals\
@else@globals\ntitle = \"User\"\n@endglobals@endif",
)
.unwrap();
let err = resolve(child, &mut |_| Ok(layout.clone())).unwrap_err();
assert!(err.to_string().contains("@globals"));
assert!(err.to_string().contains("@if"));
}
#[test]
fn globals_inside_can_is_rejected_with_a_clear_error() {
let layout = parse("@global(title)").unwrap();
let child = parse(
"@extends('layout')\
@can(Permission::EditPosts)@globals\ntitle = \"Editor\"\n@endglobals@endcan",
)
.unwrap();
let err = resolve(child, &mut |_| Ok(layout.clone())).unwrap_err();
assert!(err.to_string().contains("@globals"));
assert!(err.to_string().contains("@can"));
}
#[test]
fn globals_inside_role_is_rejected_with_a_clear_error() {
let layout = parse("@global(title)").unwrap();
let child = parse(
"@extends('layout')\
@role(Role::Admin)@globals\ntitle = \"Admin\"\n@endglobals@endrole",
)
.unwrap();
let err = resolve(child, &mut |_| Ok(layout.clone())).unwrap_err();
assert!(err.to_string().contains("@globals"));
assert!(err.to_string().contains("@role"));
}
#[test]
fn global_placeholder_inside_can_is_still_substituted() {
let layout = parse("@can(Permission::EditPosts)@global(title)@endcan").unwrap();
let child = parse("@extends('layout')@globals\ntitle = \"Hi\"\n@endglobals").unwrap();
let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
let Node::Can { then_branch, .. } = &resolved[0] else {
panic!("expected Can node");
};
assert_eq!(
then_branch[0],
Node::Interpolate {
expr: "\"Hi\"".to_string(),
escape: true,
}
);
}
#[test]
fn globals_inside_elseif_is_also_rejected() {
let layout = parse("@global(title)").unwrap();
let child = parse(
"@extends('layout')\
@if(a) x @elseif(b)@globals\ntitle = \"B\"\n@endglobals@endif",
)
.unwrap();
assert!(resolve(child, &mut |_| Ok(layout.clone())).is_err());
}
#[test]
fn global_works_without_any_extends_at_all() {
let nodes =
parse("@globals\ntitle = \"Solo\"\n@endglobals<title>@global(title)</title>").unwrap();
let resolved = resolve(nodes, &mut |_| unreachable!()).unwrap();
let rendered: String = resolved
.iter()
.map(|n| match n {
Node::Text(t) => t.as_str(),
Node::Interpolate { expr, .. } => expr.as_str(),
_ => "",
})
.collect();
assert_eq!(rendered, "<title>\"Solo\"</title>");
}
#[test]
fn persist_global_substitutes_to_a_persist_global_node_not_interpolate() {
let layout = parse(r#"<html data-theme="@global(theme, "dark")">"#).unwrap();
let child =
parse("@extends('layout')@globals\npersist theme = \"light\"\n@endglobals").unwrap();
let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
assert_eq!(
resolved,
vec![
Node::Text("<html data-theme=\"".to_string()),
Node::PersistGlobal {
cookie_name: "theme".to_string(),
fallback_expr: "\"light\"".to_string(),
},
Node::Text("\">".to_string()),
]
);
}
#[test]
fn a_non_persist_global_still_substitutes_to_interpolate() {
let layout = parse("<title>@global(title)</title>").unwrap();
let child = parse("@extends('layout')@globals\ntitle = \"Hi\"\n@endglobals").unwrap();
let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
assert_eq!(
resolved,
vec![
Node::Text("<title>".to_string()),
Node::Interpolate {
expr: "\"Hi\"".to_string(),
escape: true,
},
Node::Text("</title>".to_string()),
]
);
}
#[test]
fn a_child_pages_persist_entry_still_overrides_a_middle_layout_that_also_sets_it() {
let base = parse("<title>@global(theme, \"dark\")</title>").unwrap();
let app = parse("@extends('base')@globals\npersist theme = \"app-default\"\n@endglobals")
.unwrap();
let page = parse("@extends('app')@globals\npersist theme = \"page-specific\"\n@endglobals")
.unwrap();
let resolved = resolve(page, &mut |name| match name {
"app" => Ok(app.clone()),
"base" => Ok(base.clone()),
other => panic!("unexpected load({other})"),
})
.unwrap();
assert_eq!(
resolved,
vec![
Node::Text("<title>".to_string()),
Node::PersistGlobal {
cookie_name: "theme".to_string(),
fallback_expr: "\"page-specific\"".to_string(),
},
Node::Text("</title>".to_string()),
]
);
}
}