@@ -47,6 +47,98 @@ const FILTER_ALIASES: &[(&str, &str)] = &[
47/// HTML, JSON) and must not be rewritten.
47/// HTML, JSON) and must not be rewritten.
48const VERBATIM_BODIES: &[&str] = &["@style", "@script", "@markdown", "@head"];
48const VERBATIM_BODIES: &[&str] = &["@style", "@script", "@markdown", "@head"];
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('"'))
55+ .map(|s| s.to_string_lossy().to_string())
56+ .unwrap_or_default();
57+ let stem: String = stem
59+ .map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' })
61+ if stem.ends_with("layout") {
64+ format!("{}-layout", stem)
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) {
78+ let lines: Vec<&str> = input.lines().collect();
79+ let mut hoisted: Vec<String> = Vec::new();
80+ let mut body: Vec<String> = Vec::new();
82+ while i < lines.len() {
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);
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()
96+ .find(|l| !l.trim().is_empty())
97+ .is_some_and(|l| l.starts_with([' ', '\t'])));
101+ depth += bracket_depth(next);
104+ hoisted.extend(lines[i..end].iter().map(|l| l.to_string()));
108+ body.push(if line.trim().is_empty() {
111+ format!(" {}", line)
115+ while body.first().is_some_and(|l| l.is_empty()) {
118+ while body.last().is_some_and(|l| l.is_empty()) {
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());
125+ out.push(definition);
127+ let mut text = out.join("\n");
132+fn bracket_depth(line: &str) -> i32 {
50pub fn upgrade(input: &str) -> Upgrade {
142pub fn upgrade(input: &str) -> Upgrade {
51 let mut manual = Vec::new();
143 let mut manual = Vec::new();
52 let (folded, mut changes) = fold_head_directives(input, &mut manual);
144 let (folded, mut changes) = fold_head_directives(input, &mut manual);
@@ -381,6 +473,27 @@ fn rewrite_block(
381 return None;
473 return None;
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();
481+ format!("{pad}@include {}", file),
482+ format!("{pad}@{}", layout_function_name(file)),
484+ out.extend(lines[i + 1..].iter().map(|l| {
485+ if l.trim().is_empty() {
384 // @defer → its body, dedented
497 // @defer → its body, dedented
385 if trimmed == "@defer" || trimmed.starts_with("@defer ") {
498 if trimmed == "@defer" || trimmed.starts_with("@defer ") {
386 return Some(Block {
499 return Some(Block {
@@ -397,20 +510,26 @@ fn rewrite_block(
397 return Some(Block { lines: out, end });
510 return Some(Block { lines: out, end });
400− // @layout file with a body running to the end of the file →
513+ // @layout file.hl with a body → @include file.hl and a call to the
401− // @extends file, body dedented (its content fills @children).
514+ // layout function (see `convert_layout`), body as its children.
402− if let Some(rest) = trimmed.strip_prefix("@layout ") {
515+ if let Some(rest) = trimmed.strip_prefix("@layout ")
516+ && rest.trim().ends_with(".hl")
403 if end < lines.len() && lines[end..].iter().any(|l| !l.trim().is_empty()) {
518 if end < lines.len() && lines[end..].iter().any(|l| !l.trim().is_empty()) {
404 manual.push((
519 manual.push((
406− "@layout was removed: replace it with @extends (content after the \
521+ "@layout was removed: call the layout as a function (content after the \
407− @layout block has to move into the layout)"
522+ @layout block has to move into its children)"
408 .to_string(),
523 .to_string(),
410 return None;
525 return None;
412− let mut out = vec![format!("{pad}@extends {}", rest.trim())];
527+ let file = rest.trim();
529+ format!("{pad}@include {}", file),
530+ format!("{pad}@{}", layout_function_name(file)),
413− out.extend(dedent_block(body, indent));
532+ out.extend(body.iter().map(|l| l.to_string()));
414 return Some(Block { lines: out, end });
533 return Some(Block { lines: out, end });
@@ -1263,7 +1382,7 @@ mod tests {
1263 fn with_and_layout() {
1382 fn with_and_layout() {
1264 assert_eq!(up("@with $a as b\n @text $b"), "@let b $a\n@text $b");
1383 assert_eq!(up("@with $a as b\n @text $b"), "@let b $a\n@text $b");
1265 assert_eq!(up("@defer Loading\n @text x"), "@text x");
1384 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");
1267 let r = upgrade("@layout base.hl\n @text x\n@text after");
1386 let r = upgrade("@layout base.hl\n @text x\n@text after");
1268 assert_eq!(r.output, "@layout base.hl\n @text x\n@text after");
1387 assert_eq!(r.output, "@layout base.hl\n @text x\n@text after");
1269 assert_eq!(r.manual.len(), 1);
1388 assert_eq!(r.manual.len(), 1);
@@ -1430,6 +1549,28 @@ mod tests {
1553+ fn layouts_become_functions() {
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"
1558+ assert_eq!(super::layout_function_name("parts/base.hl"), "base-layout");
1560+ super::convert_layout("@page Site\n@column\n @children\n", "layout").unwrap(),
1561+ "@let layout\n @page Site\n @column\n @children\n"
1563+ assert!(super::convert_layout("@let layout\n x\n", "layout").is_none());
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",
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"
1434 fn verbatim_regions_are_untouched() {
1575 fn verbatim_regions_are_untouched() {
1435 let src = "@script\n for (x of y) {}\n@raw \"\"\"\n@unless\n\"\"\"\n@style\n .p { }";
1576 let src = "@script\n for (x of y) {}\n@raw \"\"\"\n@unless\n\"\"\"\n@style\n .p { }";