on master help sign in
Make layouts ordinary functions: remove @extends
17f5762iko
DESIGN.md+15−13
@@ -396,31 +396,33 @@ A range counts down when its start is greater than its end.
396|---|---|
396|---|---|
397| `@include file.hl` | Insert another file: its content and definitions (a library of `@let`s emits nothing) |
397| `@include file.hl` | Insert another file: its content and definitions (a library of `@let`s emits nothing) |
398| `@include lib.hl as ui` | Take only its definitions, named `ui.name` |
398| `@include lib.hl as ui` | Take only its definitions, named `ui.name` |
399−| `@extends layout.hl` | Render this page inside a layout (below) |
400| `@data file.json` / `@data $name file.json` | Load JSON values as variables (`$name.key`) |
399| `@data file.json` / `@data $name file.json` | Load JSON values as variables (`$name.key`) |
401| `@data $name dir/*.json` | Load each file as `$name.STEM.key`; `$name` lists the stems |
400| `@data $name dir/*.json` | Load each file as `$name.STEM.key`; `$name` lists the stems |
402| `@data $name env:NAME [default]` | Read an environment variable |
401| `@data $name env:NAME [default]` | Read an environment variable |
403| `@markdown` / `@markdown file.md` | Markdown, converted to HTML |
402| `@markdown` / `@markdown file.md` | Markdown, converted to HTML |
404| `@image [inline] file.svg` | Inline an SVG file (`width`, `height`, `color`, `class=`, `id=` apply to it) |
403| `@image [inline] file.svg` | Inline an SVG file (`width`, `height`, `color`, `class=`, `id=` apply to it) |
405
404
406−With `@extends`, the layout marks where content goes with `@slot name` (named
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−blocks) and `@children` (everything in the page outside `@slot` blocks):
407+(everything in the call outside `@slot` blocks):
408
408
409```
409```
410-- layout.hl
410-- layout.hl
411+@let layout
411−@page My Site
412+ @page My Site
412−@column [max-width 800, center-x]
413+ @column [max-width 800, center-x]
413− @slot header
414+ @slot header
414− @text Default header
415+ @text Default header
415− @children
416+ @children
416```
417```
417
418
418```
419```
419-- page.hl
420-- page.hl
420−@extends layout.hl
421+@include layout.hl
422+@layout
421−@slot header
423+ @slot header
422− @text About us
424+ @text About us
423−@paragraph This fills @children.
425+ @paragraph This fills @children.
424```
426```
425
427
426## Page and head
428## Page and head
@@ -475,7 +477,7 @@ removed form with its replacement.
475| `@svg [attrs] file.svg` | `@image [inline, attrs] file.svg` |
477| `@svg [attrs] file.svg` | `@image [inline, attrs] file.svg` |
476| `gap-x`, `gap-y`, `shadow`, `blur N`, `truncate`, `critical` | `column-gap`, `row-gap`, `box-shadow`, `filter blur(N)`, `$truncate`, nothing |
478| `gap-x`, `gap-y`, `shadow`, `blur N`, `truncate`, `critical` | `column-gap`, `row-gap`, `box-shadow`, `filter blur(N)`, `$truncate`, nothing |
477| `@with $x as y` | `@let y $x` |
479| `@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) |
479| `@scope`, `@starting-style`, `@css-property` | The CSS rule in `@style` |
481| `@scope`, `@starting-style`, `@css-property` | The CSS rule in `@style` |
480| `@lang`, `@favicon`, `@canonical`, `@base` | `@page [lang ..., favicon ..., canonical ..., base ...] Title` |
482| `@lang`, `@favicon`, `@canonical`, `@base` | `@page [lang ..., favicon ..., canonical ..., base ...] Title` |
481| `@og KEY VALUE` | `@meta og:KEY VALUE` |
483| `@og KEY VALUE` | `@meta og:KEY VALUE` |
README.md+1−1
@@ -121,4 +121,4 @@ Control flow runs at compile time:
121```
121```
122
122
123See [DESIGN.md](DESIGN.md) for the full language: files and data (`@include`,
123See [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 {
1499 }
1499 }
1500
1500
1501
1501
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−
1635
1502
1636 // --- Function call ---
1503 // --- Function call ---
1637
1504
@@ -2145,7 +2012,6 @@ const KNOWN_DIRECTIVES: &[&str] = &[
2145 "head",
2012 "head",
2146 "style",
2013 "style",
2147 "warn",
2014 "warn",
2148− "extends",
2149 "assert",
2015 "assert",
2150 "markdown",
2016 "markdown",
2151 "data",
2017 "data",
@@ -2195,7 +2061,12 @@ const REMOVED_SYNTAX: &[(&str, &str)] = &[
2195 ("@with", "use `@let alias $source`"),
2061 ("@with", "use `@let alias $source`"),
2196 (
2062 (
2197 "@layout",
2063 "@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",
2199 ),
2070 ),
2200 ("@scope", "write the `@scope` rule in an `@style` block"),
2071 ("@scope", "write the `@scope` rule in an `@style` block"),
2201 (
2072 (
editors/vscode/README.md+1−1
@@ -15,7 +15,7 @@ Syntax highlighting, snippets, and LSP-backed intelligence for [htmlang](https:/
15 values, and color swatches.
15 values, and color swatches.
16- **Go to definition / rename** — for `$variables`, `@fn` calls, `@define`
16- **Go to definition / rename** — for `$variables`, `@fn` calls, `@define`
17 bundles, and `@include` targets.
17 bundles, and `@include` targets.
18−- **Document links** — cmd-click on `@include`, `@import`, `@use`, `@extends`.
18+- **Document links** — cmd-click on `@include`.
19- **Code lens** — per-definition reference counts for `@fn` / `@let` / `@define`.
19- **Code lens** — per-definition reference counts for `@fn` / `@let` / `@define`.
20- **Document symbols + workspace symbols** — outline view and `Ctrl-T` search
20- **Document symbols + workspace symbols** — outline view and `Ctrl-T` search
21 scan both open documents and every `.hl` file in the workspace.
21 scan both open documents and every `.hl` file in the workspace.
editors/vscode/snippets/htmlang.json+7−6
@@ -184,14 +184,15 @@
184 ],
184 ],
185 "description": "Include another file (a library of @let definitions emits nothing)"
185 "description": "Include another file (a library of @let definitions emits nothing)"
186 },
186 },
187− "Template inheritance": {
187+ "Layout": {
188− "prefix": "@extends",
188+ "prefix": "@layout",
189 "body": [
189 "body": [
190− "@extends ${1:layout.hl}",
190+ "@include ${1:layout.hl}",
191− "@slot ${2:content}",
191+ "@${2:layout}",
192+ " @slot ${3:header}",
192− " $0"
193+ " $0"
193 ],
194 ],
194− "description": "Extend a base layout template"
195+ "description": "Render the page inside a layout function"
195 },
196 },
196 "Script (external)": {
197 "Script (external)": {
197 "prefix": "@script-src",
198 "prefix": "@script-src",
editors/vscode/syntaxes/htmlang.tmLanguage.json+1−1
@@ -87,7 +87,7 @@
87 }
87 }
88 },
88 },
89 "include-directive": {
89 "include-directive": {
90− "match": "(@(?:include|extends|markdown|svg|data))\\s+(.*)",
90+ "match": "(@(?:include|markdown|data))\\s+(.*)",
91 "captures": {
91 "captures": {
92 "1": {
92 "1": {
93 "name": "keyword.control.directive.htmlang"
93 "name": "keyword.control.directive.htmlang"
src/bin/htmlang_lsp/analysis.rs+6−9
@@ -476,14 +476,11 @@ pub(crate) fn code_actions(
476 .unwrap_or("")
476 .unwrap_or("")
477 .to_string()
477 .to_string()
478 });
478 });
479− let already_imported = text.lines().any(|l| {
479+ let already_included = text
480− let t = l.trim();
480+ .lines()
481− t == format!("@import {}", rel)
482− || t == format!("@include {}", rel)
481+ .any(|l| l.trim() == format!("@include {}", rel));
483− });
482+ if !already_included {
484− if !already_imported {
485− // Offer @import (all definitions)
486− let import_line = format!("@import {}\n", rel);
483+ let import_line = format!("@include {}\n", rel);
487 let edit = TextEdit {
484 let edit = TextEdit {
488 range: Range::new(
485 range: Range::new(
489 Position::new(0, 0),
486 Position::new(0, 0),
@@ -495,7 +492,7 @@ pub(crate) fn code_actions(
495 changes.insert(uri.clone(), vec![edit]);
492 changes.insert(uri.clone(), vec![edit]);
496 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
493 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
497 title: format!(
494 title: format!(
498− "Add '@import {}' for @{}",
495+ "Add '@include {}' for @{}",
499 rel, fn_name
496 rel, fn_name
500 ),
497 ),
501 kind: Some(CodeActionKind::QUICKFIX),
498 kind: Some(CodeActionKind::QUICKFIX),
src/bin/htmlang_lsp/docs.rs−1
@@ -95,7 +95,6 @@ pub(crate) const DIRECTIVES: &[Doc] = &[
95 "@let primary #3b82f6\n@let gap = 8 * 2\n@let card [padding 20]\n@let button $label\n @el [$card] $label",
95 "@let primary #3b82f6\n@let gap = 8 * 2\n@let card [padding 20]\n@let button $label\n @el [$card] $label",
96 ),
96 ),
97 doc("include", "Inserts another `.hl` file here, with its definitions.", "@include header.hl"),
97 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"),
99 doc("raw", "Pastes HTML into the output verbatim.", "@raw \"\"\"<div></div>\"\"\""),
98 doc("raw", "Pastes HTML into the output verbatim.", "@raw \"\"\"<div></div>\"\"\""),
100 doc("markdown", "Markdown (indented body or file), converted to HTML.", "@markdown\n # Title"),
99 doc("markdown", "Markdown (indented body or file), converted to HTML.", "@markdown\n # Title"),
101 doc("style", "Raw CSS; overrides generated styles.", "@style\n .note { color: gray; }"),
100 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 {
376 let lines: Vec<&str> = text.lines().collect();
376 let lines: Vec<&str> = text.lines().collect();
377 if let Some(line) = lines.get(pos.line as usize) {
377 if let Some(line) = lines.get(pos.line as usize) {
378 let trimmed = line.trim_start();
378 let trimmed = line.trim_start();
379− if trimmed.starts_with("@include ")
379+ if trimmed.starts_with("@include ") {
380− || trimmed.starts_with("@import ")
381− || trimmed.starts_with("@extends ")
382− {
383 let items = path_completions(uri, pos);
380 let items = path_completions(uri, pos);
384 if !items.is_empty() {
381 if !items.is_empty() {
385 return Ok(Some(CompletionResponse::Array(items)));
382 return Ok(Some(CompletionResponse::Array(items)));
@@ -704,10 +701,6 @@ impl LanguageServer for Backend {
704 let indent = raw_line.len() - trimmed.len();
701 let indent = raw_line.len() - trimmed.len();
705 let (prefix, filename) = if let Some(rest) = trimmed.strip_prefix("@include ") {
702 let (prefix, filename) = if let Some(rest) = trimmed.strip_prefix("@include ") {
706 ("@include ", rest)
703 ("@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)
711 } else {
704 } else {
712 continue;
705 continue;
713 };
706 };
src/bin/htmlang_lsp/navigation.rs+2−5
@@ -17,12 +17,9 @@ pub(crate) fn definition_at(
17 let lines: Vec<&str> = text.lines().collect();
17 let lines: Vec<&str> = text.lines().collect();
18 let line = lines.get(position.line as usize)?;
18 let line = lines.get(position.line as usize)?;
19
19
20− // Check for @include/@import file path navigation
20+ // Check for @include file path navigation
21 let trimmed = line.trim();
21 let trimmed = line.trim();
22− if let Some(filename) = trimmed
22+ if let Some(filename) = trimmed.strip_prefix("@include ") {
23− .strip_prefix("@include ")
24− .or_else(|| trimmed.strip_prefix("@import "))
25− {
26 let filename = filename.trim();
23 let filename = filename.trim();
27 if !filename.is_empty() {
24 if !filename.is_empty() {
28 let file_path = uri.to_file_path().ok()?;
25 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}}
310 )
310 )
311}
311}
312
312
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+
313fn collect_hl_files_recursive(dir: &Path) -> Vec<PathBuf> {
321fn collect_hl_files_recursive(dir: &Path) -> Vec<PathBuf> {
314 let mut files = Vec::new();
322 let mut files = Vec::new();
315 collect_hl_recursive_inner(dir, &mut files);
323 collect_hl_recursive_inner(dir, &mut files);
@@ -1012,9 +1020,37 @@ fn main() {
1012 eprintln!("no .hl files found in {}", target);
1020 eprintln!("no .hl files found in {}", target);
1013 process::exit(1);
1021 process::exit(1);
1014 }
1022 }
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+ }
1015 let mut total_changes = 0usize;
1046 let mut total_changes = 0usize;
1016 let mut needs_manual = false;
1047 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) {
1018 let input = match fs::read_to_string(file) {
1054 let input = match fs::read_to_string(file) {
1019 Ok(s) => s,
1055 Ok(s) => s,
1020 Err(_) => continue,
1056 Err(_) => continue,
@@ -1039,6 +1075,23 @@ fn main() {
1039 }
1075 }
1040 }
1076 }
1041 }
1077 }
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+ }
1042 if total_changes == 0 && !needs_manual {
1095 if total_changes == 0 && !needs_manual {
1043 eprintln!("no upgrades needed — all files are up to date");
1096 eprintln!("no upgrades needed — all files are up to date");
1044 } else if total_changes > 0 {
1097 } else if total_changes > 0 {
src/upgrade.rs+149−8
@@ -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"];
49
49
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+
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;
382 }
474 }
383
475
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+
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 });
398 }
511 }
399
512
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")
517+ {
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((
405 i + 1,
520 i + 1,
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(),
409 ));
524 ));
410 return None;
525 return None;
411 }
526 }
412− let mut out = vec![format!("{pad}@extends {}", rest.trim())];
527+ let file = rest.trim();
528+ let mut out = vec![
529+ format!("{pad}@include {}", file),
530+ format!("{pad}@{}", layout_function_name(file)),
531+ ];
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 });
415 }
534 }
416
535
@@ -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 {
1430 );
1549 );
1431 }
1550 }
1432
1551
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+
1433 #[test]
1574 #[test]
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 { }";
tests/docs.rs+1−1
@@ -2,7 +2,7 @@
2//! language. A fenced block without a language tag is htmlang unless it is a
2//! language. A fenced block without a language tag is htmlang unless it is a
3//! shell session (lines starting with `htmlang ` / `cargo `) or the syntax
3//! shell session (lines starting with `htmlang ` / `cargo `) or the syntax
4//! template in DESIGN.md. Blocks whose first line is `-- name.hl` are written
4//! 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.
6
6
7use std::path::Path;
7use std::path::Path;
8
8
tests/snapshots.rs+6−5
@@ -3110,15 +3110,16 @@ fn deprecated_was_removed() {
3110}
3110}
3111
3111
3112#[test]
3112#[test]
3113−fn test_extends_directive() {
3113+fn test_extends_was_removed() {
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");
3114+ let result = htmlang::parser::parse("@extends layout.hl\n@slot content\n Hello");
3116 let has_error = result.diagnostics.iter().any(|d| {
3115 let has_error = result.diagnostics.iter().any(|d| {
3116+ d.message.contains("was removed")
3117+ && d.message.contains("function")
3117− d.message.contains("cannot extend") && d.severity == htmlang::parser::Severity::Error
3118+ && d.severity == htmlang::parser::Severity::Error
3118 });
3119 });
3119 assert!(
3120 assert!(
3120 has_error,
3121 has_error,
3121− "@extends should report error for missing file, got: {:?}",
3122+ "@extends should point at layout functions, got: {:?}",
3122 result.diagnostics
3123 result.diagnostics
3123 );
3124 );
3124}
3125}