@@ -47,6 +47,98 @@ const FILTER_ALIASES: &[(&str, &str)] = &[
4747/// HTML, JSON) and must not be rewritten.
4848const VERBATIM_BODIES: &[&str] = &["@style", "@script", "@markdown", "@head"];
4949
50+/// The function name a layout file becomes: `layout.hl` → `layout`,
51+/// `base.hl` → `base-layout` (so it can't shadow an element like `@main`).
52+pub fn layout_function_name(file: &str) -> String {
53+ let stem = std::path::Path::new(file.trim_matches('"'))
54+ .file_stem()
55+ .map(|s| s.to_string_lossy().to_string())
56+ .unwrap_or_default();
57+ let stem: String = stem
58+ .chars()
59+ .map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' })
60+ .collect();
61+ if stem.ends_with("layout") {
62+ stem
63+ } else {
64+ format!("{}-layout", stem)
65+ }
66+}
67+
68+/// Turn a layout file (one used with `@extends`) into a function: its
69+/// content becomes the body of `@let NAME`, so `@slot` and `@children`
70+/// fill in from the call. Top-level `@let` and `@include` blocks stay
71+/// outside, so pages still see the layout's definitions. Returns `None`
72+/// if the file already defines the function.
73+pub fn convert_layout(input: &str, name: &str) -> Option<String> {
74+ let definition = format!("@let {}", name);
75+ if input.lines().any(|l| l.trim() == definition) {
76+ return None;
77+ }
78+ let lines: Vec<&str> = input.lines().collect();
79+ let mut hoisted: Vec<String> = Vec::new();
80+ let mut body: Vec<String> = Vec::new();
81+ let mut i = 0;
82+ while i < lines.len() {
83+ let line = lines[i];
84+ if line.starts_with("@let ") || line.starts_with("@include ") {
85+ // The definition runs through its indented body and any
86+ // bracket continuation lines.
87+ let mut depth = bracket_depth(line);
88+ let mut end = i + 1;
89+ while end < lines.len() {
90+ let next = lines[end];
91+ let continues = depth > 0
92+ || next.starts_with([' ', '\t'])
93+ || (next.trim().is_empty()
94+ && lines[end..]
95+ .iter()
96+ .find(|l| !l.trim().is_empty())
97+ .is_some_and(|l| l.starts_with([' ', '\t'])));
98+ if !continues {
99+ break;
100+ }
101+ depth += bracket_depth(next);
102+ end += 1;
103+ }
104+ hoisted.extend(lines[i..end].iter().map(|l| l.to_string()));
105+ i = end;
106+ continue;
107+ }
108+ body.push(if line.trim().is_empty() {
109+ String::new()
110+ } else {
111+ format!(" {}", line)
112+ });
113+ i += 1;
114+ }
115+ while body.first().is_some_and(|l| l.is_empty()) {
116+ body.remove(0);
117+ }
118+ while body.last().is_some_and(|l| l.is_empty()) {
119+ body.pop();
120+ }
121+ let mut out = hoisted;
122+ if !out.is_empty() && out.last().is_some_and(|l| !l.trim().is_empty()) {
123+ out.push(String::new());
124+ }
125+ out.push(definition);
126+ out.extend(body);
127+ let mut text = out.join("\n");
128+ text.push('\n');
129+ Some(text)
130+}
131+
132+fn bracket_depth(line: &str) -> i32 {
133+ line.chars()
134+ .map(|c| match c {
135+ '[' => 1,
136+ ']' => -1,
137+ _ => 0,
138+ })
139+ .sum()
140+}
141+
50142pub fn upgrade(input: &str) -> Upgrade {
51143 let mut manual = Vec::new();
52144 let (folded, mut changes) = fold_head_directives(input, &mut manual);
@@ -381,6 +473,27 @@ fn rewrite_block(
381473 return None;
382474 }
383475
476+ // @extends layout.hl + the rest of the page → @include layout.hl and a
477+ // call to the layout function with the rest of the page as children
478+ if let Some(file) = trimmed.strip_prefix("@extends ") {
479+ let file = file.trim();
480+ let mut out = vec![
481+ format!("{pad}@include {}", file),
482+ format!("{pad}@{}", layout_function_name(file)),
483+ ];
484+ out.extend(lines[i + 1..].iter().map(|l| {
485+ if l.trim().is_empty() {
486+ String::new()
487+ } else {
488+ format!(" {}", l)
489+ }
490+ }));
491+ return Some(Block {
492+ lines: out,
493+ end: lines.len(),
494+ });
495+ }
496+
384497 // @defer → its body, dedented
385498 if trimmed == "@defer" || trimmed.starts_with("@defer ") {
386499 return Some(Block {
@@ -397,20 +510,26 @@ fn rewrite_block(
397510 return Some(Block { lines: out, end });
398511 }
399512
400− // @layout file with a body running to the end of the file →
401− // @extends file, body dedented (its content fills @children).
402− if let Some(rest) = trimmed.strip_prefix("@layout ") {
513+ // @layout file.hl with a body → @include file.hl and a call to the
514+ // layout function (see `convert_layout`), body as its children.
515+ if let Some(rest) = trimmed.strip_prefix("@layout ")
516+ && rest.trim().ends_with(".hl")
517+ {
403518 if end < lines.len() && lines[end..].iter().any(|l| !l.trim().is_empty()) {
404519 manual.push((
405520 i + 1,
406− "@layout was removed: replace it with @extends (content after the \
407− @layout block has to move into the layout)"
521+ "@layout was removed: call the layout as a function (content after the \
522+ @layout block has to move into its children)"
408523 .to_string(),
409524 ));
410525 return None;
411526 }
412− let mut out = vec![format!("{pad}@extends {}", rest.trim())];
413− out.extend(dedent_block(body, indent));
527+ let file = rest.trim();
528+ let mut out = vec![
529+ format!("{pad}@include {}", file),
530+ format!("{pad}@{}", layout_function_name(file)),
531+ ];
532+ out.extend(body.iter().map(|l| l.to_string()));
414533 return Some(Block { lines: out, end });
415534 }
416535
@@ -1263,7 +1382,7 @@ mod tests {
12631382 fn with_and_layout() {
12641383 assert_eq!(up("@with $a as b\n @text $b"), "@let b $a\n@text $b");
12651384 assert_eq!(up("@defer Loading\n @text x"), "@text x");
1266− assert_eq!(up("@layout base.hl\n @text x\n"), "@extends base.hl\n@text x\n");
1385+ assert_eq!(up("@layout base.hl\n @text x\n"), "@include base.hl\n@base-layout\n @text x\n");
12671386 let r = upgrade("@layout base.hl\n @text x\n@text after");
12681387 assert_eq!(r.output, "@layout base.hl\n @text x\n@text after");
12691388 assert_eq!(r.manual.len(), 1);
@@ -1430,6 +1549,28 @@ mod tests {
14301549 );
14311550 }
14321551
1552+ #[test]
1553+ fn layouts_become_functions() {
1554+ assert_eq!(
1555+ up("@extends layout.hl\n@slot header\n @text Hi\n@paragraph Body\n"),
1556+ "@include layout.hl\n@layout\n @slot header\n @text Hi\n @paragraph Body\n"
1557+ );
1558+ assert_eq!(super::layout_function_name("parts/base.hl"), "base-layout");
1559+ assert_eq!(
1560+ super::convert_layout("@page Site\n@column\n @children\n", "layout").unwrap(),
1561+ "@let layout\n @page Site\n @column\n @children\n"
1562+ );
1563+ assert!(super::convert_layout("@let layout\n x\n", "layout").is_none());
1564+ assert_eq!(
1565+ super::convert_layout(
1566+ "@page Site\n@let accent #f00\n@let box [\n padding 4\n]\n@let card\n @el\n\n @children\n@el [color $accent]\n @children\n",
1567+ "layout"
1568+ )
1569+ .unwrap(),
1570+ "@let accent #f00\n@let box [\n padding 4\n]\n@let card\n @el\n\n @children\n\n@let layout\n @page Site\n @el [color $accent]\n @children\n"
1571+ );
1572+ }
1573+
14331574 #[test]
14341575 fn verbatim_regions_are_untouched() {
14351576 let src = "@script\n for (x of y) {}\n@raw \"\"\"\n@unless\n\"\"\"\n@style\n .p { }";