on master help sign in
Make layouts ordinary functions: remove @extends

A layout is now a @let function in its own file, called with @include and @NAME; @slot and @children work as they do in any function. htmlang upgrade converts both the pages and the layouts they extend. Also drop the language server's leftover @import handling (its quick fix inserted the removed directive).

commit17f5762f1926d0e8e46f571b2dcbb8d63b516667 authoriko <mail@iko.soy> dateSat 26 Sep 2026, 02:37 (2 weeks ago) parent7176267
14 files changed+250−195
DESIGN.md+15−13 README.md+1−1 crates/htmlang-core/src/parser.rs+6−135 editors/vscode/README.md+1−1 editors/vscode/snippets/htmlang.json+7−6 editors/vscode/syntaxes/htmlang.tmLanguage.json+1−1 src/bin/htmlang_lsp/analysis.rs+6−9 src/bin/htmlang_lsp/docs.rs−1 src/bin/htmlang_lsp/main.rs+1−8 src/bin/htmlang_lsp/navigation.rs+2−5 src/main.rs+54−1 src/upgrade.rs+149−8 tests/docs.rs+1−1 tests/snapshots.rs+6−5
DESIGN.md+15−13
@@ -396,31 +396,33 @@ A range counts down when its start is greater than its end.
396396|---|---|
397397| `@include file.hl` | Insert another file: its content and definitions (a library of `@let`s emits nothing) |
398398| `@include lib.hl as ui` | Take only its definitions, named `ui.name` |
399−| `@extends layout.hl` | Render this page inside a layout (below) |
400399| `@data file.json` / `@data $name file.json` | Load JSON values as variables (`$name.key`) |
401400| `@data $name dir/*.json` | Load each file as `$name.STEM.key`; `$name` lists the stems |
402401| `@data $name env:NAME [default]` | Read an environment variable |
403402| `@markdown` / `@markdown file.md` | Markdown, converted to HTML |
404403| `@image [inline] file.svg` | Inline an SVG file (`width`, `height`, `color`, `class=`, `id=` apply to it) |
405404
406−With `@extends`, the layout marks where content goes with `@slot name` (named
407−blocks) and `@children` (everything in the page outside `@slot` blocks):
405+A layout is an ordinary function in its own file. It marks where content goes
406+with `@slot name` (named blocks, with default content) and `@children`
407+(everything in the call outside `@slot` blocks):
408408
409409```
410410-- layout.hl
411−@page My Site
412−@column [max-width 800, center-x]
413− @slot header
414− @text Default header
415− @children
411+@let layout
412+ @page My Site
413+ @column [max-width 800, center-x]
414+ @slot header
415+ @text Default header
416+ @children
416417```
417418
418419```
419420-- page.hl
420−@extends layout.hl
421−@slot header
422− @text About us
423−@paragraph This fills @children.
421+@include layout.hl
422+@layout
423+ @slot header
424+ @text About us
425+ @paragraph This fills @children.
424426```
425427
426428## Page and head
@@ -475,7 +477,7 @@ removed form with its replacement.
475477| `@svg [attrs] file.svg` | `@image [inline, attrs] file.svg` |
476478| `gap-x`, `gap-y`, `shadow`, `blur N`, `truncate`, `critical` | `column-gap`, `row-gap`, `box-shadow`, `filter blur(N)`, `$truncate`, nothing |
477479| `@with $x as y` | `@let y $x` |
478−| `@layout file` | `@extends file` |
480+| `@extends layout.hl`, `@layout file` | `@include layout.hl` and a call to the layout, now a function (both files are converted) |
479481| `@scope`, `@starting-style`, `@css-property` | The CSS rule in `@style` |
480482| `@lang`, `@favicon`, `@canonical`, `@base` | `@page [lang ..., favicon ..., canonical ..., base ...] Title` |
481483| `@og KEY VALUE` | `@meta og:KEY VALUE` |
README.md+1−1
@@ -121,4 +121,4 @@ Control flow runs at compile time:
121121```
122122
123123See [DESIGN.md](DESIGN.md) for the full language: files and data (`@include`,
124−`@extends`, `@data`), page metadata, expressions and CSS.
124+layouts, `@data`), page metadata, expressions and CSS.
crates/htmlang-core/src/parser.rs+6−135
@@ -1499,139 +1499,6 @@ impl Parser {
14991499 }
15001500
15011501
1502− // --- @extends (template inheritance) ---
1503−
1504− if let Some(rest) = content.strip_prefix("@extends ") {
1505− let filename = substitute_vars(rest.trim(), &ctx.variables);
1506− let resolved = match &ctx.base_path {
1507− Some(base) => base.join(&filename),
1508− None => PathBuf::from(&filename),
1509− };
1510−
1511− if ctx.include_stack.contains(&resolved) {
1512− ctx.diagnostics.push(Diagnostic {
1513− line: line_num,
1514− column: None,
1515− message: format!("circular extends '{}'", filename),
1516− severity: Severity::Error,
1517− source_line: Some(content.clone()),
1518− });
1519− return Ok(None);
1520− }
1521−
1522− let extends_text = if let Some(cached) = ctx.file_cache.get(&resolved) {
1523− cached.clone()
1524− } else {
1525− match std::fs::read_to_string(&resolved) {
1526− Ok(text) => {
1527− ctx.file_cache.insert(resolved.clone(), text.clone());
1528− text
1529− }
1530− Err(e) => {
1531− ctx.diagnostics.push(Diagnostic {
1532− line: line_num,
1533− column: None,
1534− message: format!("cannot extend '{}': {}", filename, e),
1535− severity: Severity::Error,
1536− source_line: Some(content.clone()),
1537− });
1538− return Ok(None);
1539− }
1540− }
1541− };
1542−
1543− // Collect slot blocks defined in the extending file. Everything
1544− // else becomes the default content, filling the layout's
1545− // @children.
1546− let mut slot_contents: HashMap<String, Vec<Line>> = HashMap::new();
1547− let mut default_lines: Vec<Line> = Vec::new();
1548− while self.pos < self.lines.len() {
1549− let line_indent = self.lines[self.pos].indent;
1550− if line_indent < current_indent {
1551− break;
1552− }
1553− if let LineContent::Normal(ref s) = self.lines[self.pos].content {
1554− let trimmed = s.trim();
1555− if let Some(slot_name) = trimmed.strip_prefix("@slot ") {
1556− let slot_name = slot_name.trim().to_string();
1557− self.pos += 1;
1558− let mut slot_lines = Vec::new();
1559− while self.pos < self.lines.len()
1560− && self.lines[self.pos].indent > line_indent
1561− {
1562− slot_lines.push(self.lines[self.pos].clone());
1563− self.pos += 1;
1564− }
1565− slot_contents.insert(slot_name, slot_lines);
1566− continue;
1567− }
1568− }
1569− default_lines.push(self.lines[self.pos].clone());
1570− self.pos += 1;
1571− }
1572− let default_nodes = if default_lines.is_empty() {
1573− Vec::new()
1574− } else {
1575− let min_indent = default_lines.iter().map(|l| l.indent).min().unwrap_or(0);
1576− let adjusted: Vec<Line> = default_lines
1577− .iter()
1578− .map(|l| Line {
1579− indent: l.indent - min_indent,
1580− content: l.content.clone(),
1581− line_num: l.line_num,
1582− })
1583− .collect();
1584− let mut default_parser = Parser {
1585− lines: adjusted,
1586− pos: 0,
1587− };
1588− default_parser.parse_children(0, ctx)
1589− };
1590−
1591− // Parse slot contents into nodes
1592− let mut slot_nodes: HashMap<String, Vec<Node>> = HashMap::new();
1593− for (name, lines) in &slot_contents {
1594− if lines.is_empty() {
1595− continue;
1596− }
1597− let min_indent = lines.iter().map(|l| l.indent).min().unwrap_or(0);
1598− let adjusted: Vec<Line> = lines
1599− .iter()
1600− .map(|l| Line {
1601− indent: l.indent - min_indent,
1602− content: l.content.clone(),
1603− line_num: l.line_num,
1604− })
1605− .collect();
1606− let mut slot_parser = Parser {
1607− lines: adjusted,
1608− pos: 0,
1609− };
1610− let nodes = slot_parser.parse_children(0, ctx);
1611− slot_nodes.insert(name.clone(), nodes);
1612− }
1613−
1614− // Parse the base layout file
1615− ctx.included_files.push(resolved.clone());
1616− ctx.include_stack.push(resolved.clone());
1617− let saved_base = ctx.base_path.clone();
1618− ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
1619−
1620− let extends_lines = preprocess(&extends_text);
1621− let mut extends_parser = Parser {
1622− lines: extends_lines,
1623− pos: 0,
1624− };
1625− let layout_nodes = extends_parser.parse_children(0, ctx);
1626−
1627− ctx.base_path = saved_base;
1628− ctx.include_stack.pop();
1629−
1630− // Fill @slot placeholders and @children in the layout
1631− let result_nodes = replace_children_and_slots(layout_nodes, &default_nodes, &slot_nodes);
1632− return Ok(Some(result_nodes));
1633− }
1634−
16351502
16361503 // --- Function call ---
16371504
@@ -2145,7 +2012,6 @@ const KNOWN_DIRECTIVES: &[&str] = &[
21452012 "head",
21462013 "style",
21472014 "warn",
2148− "extends",
21492015 "assert",
21502016 "markdown",
21512017 "data",
@@ -2195,7 +2061,12 @@ const REMOVED_SYNTAX: &[(&str, &str)] = &[
21952061 ("@with", "use `@let alias $source`"),
21962062 (
21972063 "@layout",
2198− "use `@extends` (page content outside `@slot` blocks fills `@children`)",
2064+ "make the layout a function (`@let layout` with `@children`) and call it",
2065+ ),
2066+ (
2067+ "@extends",
2068+ "make the layout a function (`@let layout` with `@children` and `@slot`), \
2069+ `@include` its file and call it",
21992070 ),
22002071 ("@scope", "write the `@scope` rule in an `@style` block"),
22012072 (
editors/vscode/README.md+1−1
@@ -15,7 +15,7 @@ Syntax highlighting, snippets, and LSP-backed intelligence for [htmlang](https:/
1515 values, and color swatches.
1616- **Go to definition / rename** — for `$variables`, `@fn` calls, `@define`
1717 bundles, and `@include` targets.
18−- **Document links** — cmd-click on `@include`, `@import`, `@use`, `@extends`.
18+- **Document links** — cmd-click on `@include`.
1919- **Code lens** — per-definition reference counts for `@fn` / `@let` / `@define`.
2020- **Document symbols + workspace symbols** — outline view and `Ctrl-T` search
2121 scan both open documents and every `.hl` file in the workspace.
editors/vscode/snippets/htmlang.json+7−6
@@ -184,14 +184,15 @@
184184 ],
185185 "description": "Include another file (a library of @let definitions emits nothing)"
186186 },
187− "Template inheritance": {
188− "prefix": "@extends",
187+ "Layout": {
188+ "prefix": "@layout",
189189 "body": [
190− "@extends ${1:layout.hl}",
191− "@slot ${2:content}",
192− " $0"
190+ "@include ${1:layout.hl}",
191+ "@${2:layout}",
192+ " @slot ${3:header}",
193+ " $0"
193194 ],
194− "description": "Extend a base layout template"
195+ "description": "Render the page inside a layout function"
195196 },
196197 "Script (external)": {
197198 "prefix": "@script-src",
editors/vscode/syntaxes/htmlang.tmLanguage.json+1−1
@@ -87,7 +87,7 @@
8787 }
8888 },
8989 "include-directive": {
90− "match": "(@(?:include|extends|markdown|svg|data))\\s+(.*)",
90+ "match": "(@(?:include|markdown|data))\\s+(.*)",
9191 "captures": {
9292 "1": {
9393 "name": "keyword.control.directive.htmlang"
src/bin/htmlang_lsp/analysis.rs+6−9
@@ -476,14 +476,11 @@ pub(crate) fn code_actions(
476476 .unwrap_or("")
477477 .to_string()
478478 });
479− let already_imported = text.lines().any(|l| {
480− let t = l.trim();
481− t == format!("@import {}", rel)
482− || t == format!("@include {}", rel)
483− });
484− if !already_imported {
485− // Offer @import (all definitions)
486− let import_line = format!("@import {}\n", rel);
479+ let already_included = text
480+ .lines()
481+ .any(|l| l.trim() == format!("@include {}", rel));
482+ if !already_included {
483+ let import_line = format!("@include {}\n", rel);
487484 let edit = TextEdit {
488485 range: Range::new(
489486 Position::new(0, 0),
@@ -495,7 +492,7 @@ pub(crate) fn code_actions(
495492 changes.insert(uri.clone(), vec![edit]);
496493 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
497494 title: format!(
498− "Add '@import {}' for @{}",
495+ "Add '@include {}' for @{}",
499496 rel, fn_name
500497 ),
501498 kind: Some(CodeActionKind::QUICKFIX),
src/bin/htmlang_lsp/docs.rs−1
@@ -95,7 +95,6 @@ pub(crate) const DIRECTIVES: &[Doc] = &[
9595 "@let primary #3b82f6\n@let gap = 8 * 2\n@let card [padding 20]\n@let button $label\n @el [$card] $label",
9696 ),
9797 doc("include", "Inserts another `.hl` file here, with its definitions.", "@include header.hl"),
98− doc("extends", "Renders this page inside a layout; `@slot` blocks fill its slots.", "@extends layout.hl"),
9998 doc("raw", "Pastes HTML into the output verbatim.", "@raw \"\"\"<div></div>\"\"\""),
10099 doc("markdown", "Markdown (indented body or file), converted to HTML.", "@markdown\n # Title"),
101100 doc("style", "Raw CSS; overrides generated styles.", "@style\n .note { color: gray; }"),
src/bin/htmlang_lsp/main.rs+1−8
@@ -376,10 +376,7 @@ impl LanguageServer for Backend {
376376 let lines: Vec<&str> = text.lines().collect();
377377 if let Some(line) = lines.get(pos.line as usize) {
378378 let trimmed = line.trim_start();
379− if trimmed.starts_with("@include ")
380− || trimmed.starts_with("@import ")
381− || trimmed.starts_with("@extends ")
382− {
379+ if trimmed.starts_with("@include ") {
383380 let items = path_completions(uri, pos);
384381 if !items.is_empty() {
385382 return Ok(Some(CompletionResponse::Array(items)));
@@ -704,10 +701,6 @@ impl LanguageServer for Backend {
704701 let indent = raw_line.len() - trimmed.len();
705702 let (prefix, filename) = if let Some(rest) = trimmed.strip_prefix("@include ") {
706703 ("@include ", rest)
707− } else if let Some(rest) = trimmed.strip_prefix("@import ") {
708− ("@import ", rest)
709− } else if let Some(rest) = trimmed.strip_prefix("@extends ") {
710− ("@extends ", rest)
711704 } else {
712705 continue;
713706 };
src/bin/htmlang_lsp/navigation.rs+2−5
@@ -17,12 +17,9 @@ pub(crate) fn definition_at(
1717 let lines: Vec<&str> = text.lines().collect();
1818 let line = lines.get(position.line as usize)?;
1919
20− // Check for @include/@import file path navigation
20+ // Check for @include file path navigation
2121 let trimmed = line.trim();
22− if let Some(filename) = trimmed
23− .strip_prefix("@include ")
24− .or_else(|| trimmed.strip_prefix("@import "))
25− {
22+ if let Some(filename) = trimmed.strip_prefix("@include ") {
2623 let filename = filename.trim();
2724 if !filename.is_empty() {
2825 let file_path = uri.to_file_path().ok()?;
src/main.rs+54−1
@@ -310,6 +310,14 @@ h1{{color:#ff6b6b;margin-bottom:1rem;font-size:1.5rem}}
310310 )
311311}
312312
313+/// Whether two paths name the same file (by canonical path when both exist).
314+fn same_file(a: &Path, b: &Path) -> bool {
315+ match (a.canonicalize(), b.canonicalize()) {
316+ (Ok(a), Ok(b)) => a == b,
317+ _ => a == b,
318+ }
319+}
320+
313321fn collect_hl_files_recursive(dir: &Path) -> Vec<PathBuf> {
314322 let mut files = Vec::new();
315323 collect_hl_recursive_inner(dir, &mut files);
@@ -1012,9 +1020,37 @@ fn main() {
10121020 eprintln!("no .hl files found in {}", target);
10131021 process::exit(1);
10141022 }
1023+ // Layouts used with `@extends` (or the older `@layout file.hl`)
1024+ // become functions; find them before the pages are rewritten.
1025+ let mut layouts: Vec<(PathBuf, String)> = Vec::new();
1026+ for file in &hl_files {
1027+ let Ok(input) = fs::read_to_string(file) else {
1028+ continue;
1029+ };
1030+ for line in input.lines() {
1031+ let trimmed = line.trim();
1032+ let Some(arg) = trimmed
1033+ .strip_prefix("@extends ")
1034+ .or_else(|| trimmed.strip_prefix("@layout "))
1035+ .map(|a| a.trim().trim_matches('"'))
1036+ .filter(|a| a.ends_with(".hl"))
1037+ else {
1038+ continue;
1039+ };
1040+ let layout = file.parent().unwrap_or(Path::new(".")).join(arg);
1041+ if !layouts.iter().any(|(p, _)| same_file(p, &layout)) {
1042+ layouts.push((layout, htmlang::upgrade::layout_function_name(arg)));
1043+ }
1044+ }
1045+ }
10151046 let mut total_changes = 0usize;
10161047 let mut needs_manual = false;
1017− for file in &hl_files {
1048+ let outside: Vec<PathBuf> = layouts
1049+ .iter()
1050+ .map(|(p, _)| p.clone())
1051+ .filter(|p| !hl_files.iter().any(|f| same_file(f, p)))
1052+ .collect();
1053+ for file in hl_files.iter().chain(&outside) {
10181054 let input = match fs::read_to_string(file) {
10191055 Ok(s) => s,
10201056 Err(_) => continue,
@@ -1039,6 +1075,23 @@ fn main() {
10391075 }
10401076 }
10411077 }
1078+ for (layout, name) in &layouts {
1079+ let Ok(input) = fs::read_to_string(layout) else {
1080+ eprintln!("{}: layout not found, convert it by hand", layout.display());
1081+ needs_manual = true;
1082+ continue;
1083+ };
1084+ let Some(output) = htmlang::upgrade::convert_layout(&input, name) else {
1085+ continue;
1086+ };
1087+ match fs::write(layout, output) {
1088+ Ok(()) => {
1089+ eprintln!("upgraded {} (layout is now `@let {}`)", layout.display(), name);
1090+ total_changes += 1;
1091+ }
1092+ Err(e) => eprintln!("error: {}: {}", layout.display(), e),
1093+ }
1094+ }
10421095 if total_changes == 0 && !needs_manual {
10431096 eprintln!("no upgrades needed — all files are up to date");
10441097 } else if total_changes > 0 {
src/upgrade.rs+149−8
@@ -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 { }";
tests/docs.rs+1−1
@@ -2,7 +2,7 @@
22//! language. A fenced block without a language tag is htmlang unless it is a
33//! shell session (lines starting with `htmlang ` / `cargo `) or the syntax
44//! template in DESIGN.md. Blocks whose first line is `-- name.hl` are written
5−//! to disk first so `@extends` / `@include` between them resolve.
5+//! to disk first so `@include` between them resolves.
66
77use std::path::Path;
88
tests/snapshots.rs+6−5
@@ -3110,15 +3110,16 @@ fn deprecated_was_removed() {
31103110}
31113111
31123112#[test]
3113−fn test_extends_directive() {
3114− // Can't test with actual files, but verify parse error for missing file
3115− let result = htmlang::parser::parse("@extends nonexistent.hl\n@slot content\n Hello");
3113+fn test_extends_was_removed() {
3114+ let result = htmlang::parser::parse("@extends layout.hl\n@slot content\n Hello");
31163115 let has_error = result.diagnostics.iter().any(|d| {
3117− d.message.contains("cannot extend") && d.severity == htmlang::parser::Severity::Error
3116+ d.message.contains("was removed")
3117+ && d.message.contains("function")
3118+ && d.severity == htmlang::parser::Severity::Error
31183119 });
31193120 assert!(
31203121 has_error,
3121− "@extends should report error for missing file, got: {:?}",
3122+ "@extends should point at layout functions, got: {:?}",
31223123 result.diagnostics
31233124 );
31243125}