on master help sign in
Remove everything for older versions of the language

The htmlang upgrade command, its migrations, the removed-syntax hints and errors (REMOVED_SYNTAX, removed attributes, old filters, include aliases, @each pagination, key if cond, bare [attrs] lines) and their tests and docs are gone. Old names now get the ordinary unknown-element and unknown-attribute diagnostics.

commit32f2947944752817e50aa520b8fd2143fb2d850c authoriko <mail@iko.soy> dateSat 26 Sep 2026, 04:10 (2 weeks ago) parent72fcaf0
11 files changed+6−2602
CONTRIBUTING.md+2−4 DESIGN.md−51 README.md−1 crates/htmlang-core/src/parser.rs+2−276 src/cli.rs+1−2 src/lib.rs−1 src/main.rs−106 src/upgrade.rs−1989 tests/docs.rs−2 tests/regressions.rs+1−6 tests/snapshots.rs−164
CONTRIBUTING.md+2−4
@@ -26,7 +26,7 @@ Linux, macOS, and Windows. Your change should pass all three.
2626 - `std.hl` — the standard library, written in htmlang and loaded before
2727 every file.
2828- `crates/htmlang-wasm/` — thin wrapper exposing `compile` to the web playground.
29−- `src/` — CLI, dev server, formatter, and `upgrade` (syntax migrations).
29+- `src/` — CLI, dev server and formatter.
3030- `src/bin/htmlang_lsp/` — language server binary (`htmlang-lsp`). Its
3131 completions come from the compiler's tables; hover and completion text live
3232 in `docs.rs`.
@@ -48,9 +48,7 @@ Linux, macOS, and Windows. Your change should pass all three.
4848 describe it in the LSP's `docs.rs`.
49493. Document it in `DESIGN.md` (the examples there are compiled by the tests).
5050 If it's user-facing, also update `README.md`.
51−4. If it removes or renames syntax, add a rewrite to `src/upgrade.rs` and a
52− hint to `REMOVED_SYNTAX` in the parser.
53−5. If it changes the CLI surface, update the `--help` output.
51+4. If it changes the CLI surface, update the `--help` output.
5452
5553## Style
5654
DESIGN.md−51
@@ -456,54 +456,3 @@ link). Translations are a JSON file per locale: `@data $t locales/$lang.json`.
456456 .note { color: gray; }
457457@el [animation fade-in 0.3s ease, class=note] Fades in
458458```
459−
460−## Upgrading older files
461−
462−`htmlang upgrade [dir|file]` rewrites removed syntax; the compiler reports each
463−removed form with its replacement.
464−
465−| Removed | Use instead |
466−|---|---|
467−| `@fn`, `@define`, `@mixin`, `@component` | `@let` (function, bundle; `@style` in a function body is scoped) |
468−| `@unless COND` | `@if not COND` |
469−| `@for $i in A..B`, `@repeat N` | `@each $i in A..B`, `@each $_ in 1..N` |
470−| `grid` (attribute) | `@grid`, or `display grid` |
471−| `@switch`, `@match` | `@if $x == a` / `@else if` / `@else` |
472−| `@use "file" names`, `@import file` | `@include file` |
473−| `@collection`, `@env`, `@translations`, `@fetch` | `@data $name SOURCE` (glob, `env:NAME`, or a JSON file per locale) |
474−| `@theme` | `@let --name value` lines |
475−| `@json-ld`, `@font-face`, `@manifest` | Raw HTML in `@head`, CSS in `@style` |
476−| `@breakpoint`, `@deprecated` | A media query in `@style`; nothing |
477−| `@svg [attrs] file.svg` | `@image [inline, attrs] file.svg` |
478−| `gap-x`, `gap-y`, `shadow`, `blur N`, `truncate`, `critical` | `column-gap`, `row-gap`, `box-shadow`, `filter blur(N)`, `$truncate`, nothing |
479−| `@with $x as y` | `@let y $x` |
480−| `@extends layout.hl`, `@layout file` | `@include layout.hl` and a call to the layout, now a function (both files are converted) |
481−| `@scope`, `@starting-style`, `@css-property` | The CSS rule in `@style` |
482−| `@lang`, `@favicon` | `@page [lang ..., favicon ...] Title` |
483−| `@canonical URL`, `@base URL` | `<link rel="canonical" href="URL">`, `<base href="URL">` in `@head` |
484−| `@og KEY VALUE` | `@meta og:KEY VALUE` |
485−| `@assert`, `@warn`, `@debug`, `@log` | Nothing (the lines are removed) |
486−| `@defer` | Its content, directly |
487−| `@col`, `@p`, `@img`, `@btn`, `@divider`, `@opt` | `@el`, `@paragraph`, `@image`, `@button`, `@hr`, `@option` |
488−| `type email`, `id main` (HTML attributes written as styles) | `type=email`, `id=main` |
489−| `skeleton`, `no-scrollbar`, `gradient A B` | `$skeleton`, `$no-scrollbar`, `background linear-gradient(A, B)` |
490−| `@tooltip TEXT` | `@tooltip [tip TEXT] TEXT` |
491−| `@let x $a + 1` (computed without `=`) | `@let x = $a + 1` |
492−| `COND ? A : B` | `if(COND, A, B)` |
493−| `key if COND` (in attributes) | `if(COND, key)` |
494−| `$a ~ " " ~ $b` | `"$a $b"` |
495−| `@each $x in LIST [page N]` | Split the list, or filter it with `@if` |
496−| `...$bundle` | `$bundle` |
497−| `bold`, `italic`, `underline`, `hidden` | `font-weight bold`, `font-style italic`, `text-decoration underline`, `display none` |
498−| `size`, `font`, `rounded`, `padding-x/y`, `margin-x/y` | `font-size`, `font-family`, `border-radius`, `padding-inline/block`, `margin-inline/block` |
499−| `border N COLOR` (solid implied) | `border N solid COLOR` |
500−| `@each $a, $b in A x, B y` (items split on spaces) | Records: `@data $rows [{"a": "A", "b": "x"}, ...]` and `@each $row in $rows` with `$row.a` |
501−| `$_index` | `@each $item, $i in ...` |
502−| `$posts._count`, `$posts._keys`, `$posts.STEM.key` | `length($posts)`; nothing; `@each $post in $posts` with `$post.file` |
503−| `@raw """ ... """` | `@raw` with an indented body |
504−| `@keyframes NAME` with `from [opacity 0]` | The `@keyframes` rule in `@style` |
505−| `@include lib.hl as ui` | `@include lib.hl`, without the `ui.` prefix |
506−| `@data file.json` (keys as `$key`) | `@data $name file.json` (keys as `$name.key`) |
507−| `[attrs]` alone on a line | `@el [attrs]` |
508−| `animate`, `inset-area` | `animation`, `position-area` |
509−| `$x\|uppercase`, `$c\|darken:10` (filters) | `${uppercase($x)}`, `${darken($c, 10)}` |
README.md−1
@@ -54,7 +54,6 @@ htmlang serve . # dev server with live reload
5454| `check <file\|dir> [--format json]` | Report diagnostics without writing output |
5555| `lint <file\|dir> [--format json]` | Stricter checks (accessibility, nesting) |
5656| `fmt <file.hl>` | Format a file in place |
57−| `upgrade [dir\|file]` | Rewrite syntax from older versions of the language |
5857| `lsp` | Run the language server over stdio |
5958
6059Compiling directly also takes `--dev`, `--strict`, `--partial`,
crates/htmlang-core/src/parser.rs+2−276
@@ -229,20 +229,6 @@ impl Evaluator {
229229 fn eval(&mut self, node: &Syntax, ctx: &mut ParseContext) -> Result<Option<Vec<Node>>, ParseError> {
230230 let line_num = node.line();
231231 ctx.current_line = line_num;
232− if !matches!(node, Syntax::Raw { .. })
233− && let Some(filter) = old_filter_syntax(&node.source())
234− {
235− ctx.diagnostics.push(Diagnostic {
236− line: line_num,
237− column: None,
238− message: format!(
239− "`${}|{}` filters are functions now: write `${{{}(${})}}` (run `htmlang upgrade`)",
240− filter.0, filter.1, filter.1, filter.0
241− ),
242− severity: Severity::Warning,
243− source_line: Some(node.source()),
244− });
245− }
246232 let (content, current_indent, children) = match node {
247233 Syntax::Raw { text, .. } => return Ok(Some(vec![Node::Raw(text.clone())])),
248234 Syntax::Function { name, params, defaults, body, .. } => {
@@ -272,18 +258,6 @@ impl Evaluator {
272258
273259 // --- Directives ---
274260
275− // Removed syntax gets a pointer to its replacement — unless the
276− // name is a user function (e.g. `@let divider`).
277− if content.starts_with('@')
278− && !ctx.functions.contains_key(extract_element_name(&content))
279− && let Some(hint) = removed_syntax_hint(&content)
280− {
281− return Err(ParseError {
282− line: line_num,
283− message: hint,
284− });
285− }
286−
287261 // @page [lang en, favicon /f.png] Title
288262 if let Some(rest) = content.strip_prefix("@page ") {
289263 let rest = rest.trim_start();
@@ -446,17 +420,6 @@ impl Evaluator {
446420
447421 if let Some(rest) = content.strip_prefix("@include ") {
448422 let rest = rest.trim();
449− if let Some((file, alias)) = rest.rsplit_once(" as ") {
450− return Err(ParseError {
451− line: line_num,
452− message: format!(
453− "`@include ... as` was removed: write `@include {}` and drop the `{}.` \
454− prefix (run `htmlang upgrade`)",
455− file.trim(),
456− alias.trim()
457− ),
458− });
459− }
460423 let filename = rest
461424 .strip_prefix('"')
462425 .and_then(|f| f.strip_suffix('"'))
@@ -656,26 +619,6 @@ impl Evaluator {
656619
657620 // --- Bare text ---
658621
659− // `[attrs]` alone on a line used to be an anonymous @el
660− if let Some(list) = content.strip_prefix('[')
661− && (list.is_empty()
662− || list
663− .split([',', ']'])
664− .next()
665− .and_then(|first| first.split_whitespace().next())
666− .is_some_and(crate::vocab::is_style_attribute))
667− {
668− ctx.diagnostics.push(Diagnostic {
669− line: line_num,
670− column: None,
671− message: "a line starting with `[` is text: for an element, write `@el [...]` \
672− (run `htmlang upgrade`)"
673− .to_string(),
674− severity: Severity::Warning,
675− source_line: Some(content.clone()),
676− });
677− }
678−
679622 let var_warnings = check_undefined_vars(
680623 &protect_escapes(&content),
681624 &ctx.variables,
@@ -805,14 +748,6 @@ impl Evaluator {
805748 }
806749 let (item, index) = (names[0], names.get(1).copied());
807750 let list_src = list_src.trim();
808− if list_src.ends_with(']') && list_src.contains("[page ") {
809− return Err(ParseError {
810− line: line_num,
811− message: "@each pagination (`[page N]`) was removed: split the list or \
812− filter it with @if"
813− .to_string(),
814− });
815− }
816751 track_var_refs(list_src, &mut ctx.used_variables);
817752
818753 // A list loaded from JSON, by name
@@ -1314,12 +1249,6 @@ fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseErro
13141249 if let Some(kind) = ElementKind::from_name(s) {
13151250 return Ok(kind);
13161251 }
1317− if let Some(hint) = removed_syntax_hint(&format!("@{}", s)) {
1318− return Err(ParseError {
1319− line: line_num,
1320− message: hint,
1321− });
1322− }
13231252 let all_known: Vec<&str> = ElementKind::all_names()
13241253 .chain(KNOWN_DIRECTIVES.iter().copied())
13251254 .collect();
@@ -1333,137 +1262,6 @@ fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseErro
13331262 })
13341263}
13351264
1336−/// Directives and element names removed when the language was simplified,
1337−/// with what replaces each. `htmlang upgrade` rewrites all of them.
1338−const REMOVED_SYNTAX: &[(&str, &str)] = &[
1339− ("@fn", "use `@let name $param` with an indented body"),
1340− ("@define", "use `@let name [attributes]`"),
1341− ("@mixin", "use `@let name [attributes]`"),
1342− ("@unless", "use `@if not <condition>`"),
1343− ("@for", "use `@each $i in 1..10`"),
1344− ("@repeat", "use `@each $_ in 1..N`"),
1345− (
1346− "@switch",
1347− "use `@match`, with `@let __switch [...]` inside a case for its attributes",
1348− ),
1349− ("@use", "use `@include`"),
1350− (
1351− "@import",
1352− "use `@include` (a file with only definitions emits no content)",
1353− ),
1354− ("@with", "use `@let alias $source`"),
1355− (
1356− "@layout",
1357− "make the layout a function (`@let layout` with `@children`) and call it",
1358− ),
1359− (
1360− "@extends",
1361− "make the layout a function (`@let layout` with `@children` and `@slot`), \
1362− `@include` its file and call it",
1363− ),
1364− ("@scope", "write the `@scope` rule in an `@style` block"),
1365− (
1366− "@starting-style",
1367− "write the `@starting-style` rule in an `@style` block",
1368− ),
1369− ("@css-property", "write an `@property` rule in an `@style` block"),
1370− ("@lang", "use `@page [lang ...] Title`"),
1371− ("@favicon", "use `@page [favicon ...] Title`"),
1372− ("@canonical", "write `<link rel=\"canonical\" href=\"...\">` in `@head`"),
1373− ("@base", "write `<base href=\"...\">` in `@head`"),
1374− ("@og", "use `@meta og:NAME VALUE`"),
1375− ("@debug", "removed: a layout needs no compile-time messages"),
1376− ("@svg", "use `@image [inline] file.svg`"),
1377− ("@match", "use `@if $x == a` / `@else if $x == b` / `@else`"),
1378− ("@case", "use `@if $x == a` / `@else if $x == b` / `@else`"),
1379− ("@default", "use `@else` in an `@if` chain"),
1380− ("@theme", "use a `@let --name value` line per token"),
1381− (
1382− "@json-ld",
1383− "put a `<script type=\"application/ld+json\">` in `@head`",
1384− ),
1385− ("@font-face", "write the `@font-face` rule in an `@style` block"),
1386− (
1387− "@manifest",
1388− "write a manifest.json file and link it from `@head`",
1389− ),
1390− ("@breakpoint", "write the media query in an `@style` block"),
1391− ("@deprecated", "remove it"),
1392− (
1393− "@collection",
1394− "use `@data $name dir/*.json` (each file becomes `$name.STEM.key`)",
1395− ),
1396− ("@env", "use `@data $name env:NAME [default]`"),
1397− (
1398− "@fetch",
1399− "download the data before building and use `@data $name file.json`",
1400− ),
1401− (
1402− "@translations",
1403− "put each locale's strings in a JSON file and use `@data $t locales/$lang.json`",
1404− ),
1405− ("@defer", "remove it: the content is already in the page"),
1406− ("@keyframes", "write the CSS `@keyframes` rule in `@style`"),
1407− ("@log", "removed: a layout needs no compile-time messages"),
1408− ("@warn", "removed: a layout needs no compile-time messages"),
1409− ("@assert", "removed: a layout needs no compile-time checks"),
1410− (
1411− "@component",
1412− "use `@let`: an `@style` block in a function body is scoped to it",
1413− ),
1414− ("@col", "use `@el`"),
1415− ("@p", "use `@paragraph`"),
1416− ("@img", "use `@image`"),
1417− ("@btn", "use `@button`"),
1418− ("@divider", "use `@hr`"),
1419− ("@opt", "use `@option`"),
1420−];
1421−
1422−/// Attributes that became standard-library bundles or plain CSS.
1423−fn removed_attribute_hint(name: &str) -> Option<&'static str> {
1424− match name {
1425− "skeleton" => Some("write the animation in `@style`"),
1426− "no-scrollbar" => Some("use `scrollbar-width none`"),
1427− "gradient" => Some("use `background linear-gradient(...)`"),
1428− "animate" => Some("use `animation`"),
1429− "inset-area" => Some("use `position-area`"),
1430− "gap-x" => Some("use `column-gap`"),
1431− "gap-y" => Some("use `row-gap`"),
1432− "shadow" => Some("use `box-shadow`"),
1433− "blur" => Some("use `filter blur(...)`"),
1434− "backdrop-blur" => Some("use `backdrop-filter blur(...)`"),
1435− "truncate" => Some("use the `$truncate` bundle"),
1436− "critical" => Some("remove it"),
1437− "grid" => Some("use `@grid`, or `display grid`"),
1438− "bold" => Some("use `font-weight bold`"),
1439− "italic" => Some("use `font-style italic`"),
1440− "underline" => Some("use `text-decoration underline`"),
1441− "size" => Some("use `font-size`"),
1442− "rounded" => Some("use `border-radius`"),
1443− "hidden" => Some("use `display none`"),
1444− "padding-x" => Some("use `padding-inline`"),
1445− "padding-y" => Some("use `padding-block`"),
1446− "margin-x" => Some("use `margin-inline`"),
1447− "margin-y" => Some("use `margin-block`"),
1448− _ => None,
1449− }
1450−}
1451−
1452−/// If `content` starts with removed syntax, the error message for it.
1453−fn removed_syntax_hint(content: &str) -> Option<String> {
1454− let trimmed = content.trim_start();
1455− REMOVED_SYNTAX.iter().find_map(|(name, replacement)| {
1456− let rest = trimmed.strip_prefix(name)?;
1457− if !(rest.is_empty() || rest.starts_with([' ', '['])) {
1458− return None;
1459− }
1460− Some(format!(
1461− "`{}` was removed: {} (run `htmlang upgrade` to rewrite it automatically)",
1462− name, replacement
1463− ))
1464− })
1465−}
1466−
14671265/// Format include/import stack as a readable chain for error messages.
14681266fn format_include_chain(stack: &[PathBuf]) -> String {
14691267 stack
@@ -2092,19 +1890,6 @@ fn parse_attr_list(
20921890
20931891 track_var_refs(part, &mut ctx.used_variables);
20941892
2095− if let Some((attr, condition)) = split_trailing_if(part) {
2096− ctx.diagnostics.push(Diagnostic {
2097− line: line_num,
2098− column: None,
2099− message: format!(
2100− "`KEY if CONDITION` was removed: write `if({}, {})`",
2101− condition, attr
2102− ),
2103− severity: Severity::Error,
2104− source_line: None,
2105− });
2106− continue;
2107− }
21081893 // A value `if(cond, a, b)` picks `a` or `b` (free text) by `cond`;
21091894 // an empty choice leaves the attribute out.
21101895 let Some(part) = choose_if_value(part, ctx, line_num) else {
@@ -2171,22 +1956,7 @@ fn parse_attr_list(
21711956 let base_key = crate::vocab::base_attribute(attr.key.as_str());
21721957 let is_boolean_html =
21731958 attr.value.is_none() && crate::vocab::BOOLEAN_HTML_ATTRS.contains(&base_key);
2174− // A removed attribute that shares its name with a CSS property
2175− // (bare `grid`) still gets its hint.
2176− let removed = removed_attribute_hint(base_key)
2177− .filter(|_| attr.value.is_none() || !crate::vocab::is_css_property(base_key));
2178− if let Some(hint) = removed {
2179− ctx.diagnostics.push(Diagnostic {
2180− line: line_num,
2181− column: None,
2182− message: format!(
2183− "attribute '{}' was removed: {} (run `htmlang upgrade`)",
2184− base_key, hint
2185− ),
2186− severity: Severity::Warning,
2187− source_line: None,
2188− });
2189− } else if is_boolean_html || crate::vocab::is_style_attribute(base_key) {
1959+ if is_boolean_html || crate::vocab::is_style_attribute(base_key) {
21901960 validate_attr_value(&attr, line_num, ctx);
21911961 } else if crate::vocab::HTML_ATTRIBUTES.contains(&base_key)
21921962 || base_key.starts_with("aria-")
@@ -2196,7 +1966,7 @@ fn parse_attr_list(
21961966 line: line_num,
21971967 column: None,
21981968 message: format!(
2199− "'{}' is an HTML attribute: write `{}={}` (run `htmlang upgrade`)",
1969+ "'{}' is an HTML attribute: write `{}={}`",
22001970 attr.key,
22011971 attr.key,
22021972 attr.value.as_deref().unwrap_or("")
@@ -2481,27 +2251,6 @@ fn numeric_range(start: i64, end: i64, step: i64) -> Vec<String> {
24812251
24822252
24832253
2484−/// Find a leftover `key if condition` (ignoring ` if ` inside parentheses
2485−/// or quotes), to point at `if()`.
2486−fn split_trailing_if(part: &str) -> Option<(&str, &str)> {
2487− let mut depth = 0;
2488− let mut quote = None;
2489− for (i, c) in part.char_indices() {
2490− match c {
2491− '"' | '\'' if quote == Some(c) => quote = None,
2492− '"' | '\'' if quote.is_none() => quote = Some(c),
2493− _ if quote.is_some() => {}
2494− '(' => depth += 1,
2495− ')' => depth -= 1,
2496− _ if depth == 0 && part[i..].starts_with(" if ") => {
2497− return Some((&part[..i], part[i + 4..].trim()));
2498− }
2499− _ => {}
2500− }
2501− }
2502− None
2503−}
2504−
25052254/// Evaluate `if(cond, a)` or `if(cond, a, b)` when it is all of `text`,
25062255/// returning the chosen branch (empty when `cond` fails and there is no `b`).
25072256fn choose_if(text: &str, ctx: &mut ParseContext, line: usize) -> Option<String> {
@@ -3043,29 +2792,6 @@ fn inline_svg(node: Node, line_num: usize, ctx: &mut ParseContext) -> Node {
30432792 Node::Raw(svg)
30442793}
30452794
3046−/// The first `$name|filter` (old filter syntax) in `text`, as (name, filter).
3047−fn old_filter_syntax(text: &str) -> Option<(String, String)> {
3048− const FILTERS: &[&str] = &[
3049− "uppercase", "lowercase", "capitalize", "trim", "length", "reverse", "truncate",
3050− "replace", "default", "lighten", "darken", "alpha", "mix",
3051− ];
3052− let mut rest = text;
3053− while let Some(pos) = rest.find('$') {
3054− let after = &rest[pos + 1..];
3055− let end = after
3056− .find(|c: char| !(c.is_alphanumeric() || matches!(c, '_' | '-' | '.')))
3057− .unwrap_or(after.len());
3058− if let Some(filter) = after[end..].strip_prefix('|') {
3059− let name_end = filter.find(|c: char| !c.is_ascii_alphabetic()).unwrap_or(filter.len());
3060− if end > 0 && FILTERS.contains(&&filter[..name_end]) {
3061− return Some((after[..end].to_string(), filter[..name_end].to_string()));
3062− }
3063− }
3064− rest = &after[end..];
3065− }
3066− None
3067−}
3068−
30692795/// Index of the `}` matching the `{` that `s` starts with (skipping
30702796/// braces inside string literals).
30712797fn matching_brace(s: &str) -> Option<usize> {
src/cli.rs+1−2
@@ -3,7 +3,7 @@ use std::process;
33
44/// Subcommands understood by the CLI.
55pub const COMMANDS: &[&str] = &[
6− "build", "serve", "watch", "check", "lint", "fmt", "upgrade", "lsp",
6+ "build", "serve", "watch", "check", "lint", "fmt", "lsp",
77];
88
99pub fn print_help() {
@@ -26,7 +26,6 @@ Commands:
2626 lint <file.hl | dir> [--format json]
2727 Stricter checks (accessibility, nesting)
2828 fmt <file.hl> Format a file in place
29− upgrade [dir|file] Rewrite removed or renamed syntax
3029 lsp Start the language server (stdio)
3130
3231Options:
src/lib.rs−1
@@ -4,4 +4,3 @@ pub use htmlang_core::parser;
44pub use htmlang_core::vocab;
55pub mod fmt;
66pub mod serve;
7−pub mod upgrade;
src/main.rs−106
@@ -310,14 +310,6 @@ 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−
321313fn collect_hl_files_recursive(dir: &Path) -> Vec<PathBuf> {
322314 let mut files = Vec::new();
323315 collect_hl_recursive_inner(dir, &mut files);
@@ -1006,104 +998,6 @@ fn main() {
1006998 }
1007999
10081000
1009− // Handle "upgrade" subcommand: rewrite removed or renamed syntax to its
1010− // current form.
1011− if args.len() >= 2 && args[1] == "upgrade" {
1012− let target = if args.len() >= 3 { &args[2] } else { "." };
1013− let path = Path::new(target);
1014− let hl_files = if path.is_dir() {
1015− collect_hl_files_recursive(path)
1016− } else {
1017− vec![PathBuf::from(target)]
1018− };
1019− if hl_files.is_empty() {
1020− eprintln!("no .hl files found in {}", target);
1021− process::exit(1);
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− }
1046− let mut total_changes = 0usize;
1047− let mut needs_manual = false;
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) {
1054− let input = match fs::read_to_string(file) {
1055− Ok(s) => s,
1056− Err(_) => continue,
1057− };
1058− let result = htmlang::upgrade::upgrade(&input);
1059− for (line, message) in &result.manual {
1060− eprintln!("{}:{}: manual change needed: {}", file.display(), line, message);
1061− needs_manual = true;
1062− }
1063− if result.changes > 0 {
1064− match fs::write(file, &result.output) {
1065− Ok(()) => {
1066− eprintln!(
1067− "upgraded {} ({} change{})",
1068− file.display(),
1069− result.changes,
1070− if result.changes == 1 { "" } else { "s" }
1071− );
1072− total_changes += result.changes;
1073− }
1074− Err(e) => eprintln!("error: {}: {}", file.display(), e),
1075− }
1076− }
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− }
1095− if total_changes == 0 && !needs_manual {
1096− eprintln!("no upgrades needed — all files are up to date");
1097− } else if total_changes > 0 {
1098− eprintln!("\n{} total change(s) applied", total_changes);
1099− }
1100− if needs_manual {
1101− process::exit(1);
1102− }
1103− return;
1104− }
1105−
1106−
11071001 let mut i = 1;
11081002 while i < args.len() {
11091003 match args[i].as_str() {
src/upgrade.rsdeleted−1989
@@ -1,1989 +0,0 @@
1−//! `htmlang upgrade`: rewrite removed or renamed syntax to its current form.
2−//!
3−//! Every rewrite here is meant to be behavior-preserving: the upgraded file
4−//! compiles to the same HTML as the original did before the syntax was
5−//! removed. Constructs that can't be rewritten mechanically are reported in
6−//! [`Upgrade::manual`] and left untouched.
7−
8−/// Result of upgrading one file.
9−pub struct Upgrade {
10− pub output: String,
11− pub changes: usize,
12− /// Constructs that need a manual rewrite: (1-based line, message).
13− pub manual: Vec<(usize, String)>,
14−}
15−
16−/// Element aliases and their canonical names.
17−const ELEMENT_ALIASES: &[(&str, &str)] = &[
18− ("col", "el"),
19− ("p", "paragraph"),
20− ("img", "image"),
21− ("btn", "button"),
22− ("divider", "hr"),
23− ("opt", "option"),
24−];
25−
26−/// Attribute keys that were renamed.
27−const ATTR_RENAMES: &[(&str, &str)] = &[
28− ("animate", "animation"),
29− ("inset-area", "position-area"),
30− ("align-center", "center-x"),
31− ("gap-x", "column-gap"),
32− ("gap-y", "row-gap"),
33− ("shadow", "box-shadow"),
34−];
35−
36−/// Variable filter aliases (`$x|upper`) and their canonical names.
37−const FILTER_ALIASES: &[(&str, &str)] = &[
38− ("upper", "uppercase"),
39− ("lower", "lowercase"),
40− ("cap", "capitalize"),
41− ("len", "length"),
42−];
43−
44−/// Directives whose indented bodies are foreign text (CSS, JS, Markdown,
45−/// HTML, JSON) and must not be rewritten.
46−const VERBATIM_BODIES: &[&str] = &["@style", "@script", "@markdown", "@head", "@raw"];
47−
48−/// The function name a layout file becomes: `layout.hl` → `layout`,
49−/// `base.hl` → `base-layout` (so it can't shadow an element like `@main`).
50−pub fn layout_function_name(file: &str) -> String {
51− let stem = std::path::Path::new(file.trim_matches('"'))
52− .file_stem()
53− .map(|s| s.to_string_lossy().to_string())
54− .unwrap_or_default();
55− let stem: String = stem
56− .chars()
57− .map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' })
58− .collect();
59− if stem.ends_with("layout") {
60− stem
61− } else {
62− format!("{}-layout", stem)
63− }
64−}
65−
66−/// Turn a layout file (one used with `@extends`) into a function: its
67−/// content becomes the body of `@let NAME`, so `@slot` and `@children`
68−/// fill in from the call. Top-level `@let` and `@include` blocks stay
69−/// outside, so pages still see the layout's definitions. Returns `None`
70−/// if the file already defines the function.
71−pub fn convert_layout(input: &str, name: &str) -> Option<String> {
72− let definition = format!("@let {}", name);
73− if input.lines().any(|l| l.trim() == definition) {
74− return None;
75− }
76− let lines: Vec<&str> = input.lines().collect();
77− let mut hoisted: Vec<String> = Vec::new();
78− let mut body: Vec<String> = Vec::new();
79− let mut i = 0;
80− while i < lines.len() {
81− let line = lines[i];
82− if line.starts_with("@let ") || line.starts_with("@include ") {
83− // The definition runs through its indented body and any
84− // bracket continuation lines.
85− let mut depth = bracket_depth(line);
86− let mut end = i + 1;
87− while end < lines.len() {
88− let next = lines[end];
89− let continues = depth > 0
90− || next.starts_with([' ', '\t'])
91− || (next.trim().is_empty()
92− && lines[end..]
93− .iter()
94− .find(|l| !l.trim().is_empty())
95− .is_some_and(|l| l.starts_with([' ', '\t'])));
96− if !continues {
97− break;
98− }
99− depth += bracket_depth(next);
100− end += 1;
101− }
102− hoisted.extend(lines[i..end].iter().map(|l| l.to_string()));
103− i = end;
104− continue;
105− }
106− body.push(if line.trim().is_empty() {
107− String::new()
108− } else {
109− format!(" {}", line)
110− });
111− i += 1;
112− }
113− while body.first().is_some_and(|l| l.is_empty()) {
114− body.remove(0);
115− }
116− while body.last().is_some_and(|l| l.is_empty()) {
117− body.pop();
118− }
119− let mut out = hoisted;
120− if !out.is_empty() && out.last().is_some_and(|l| !l.trim().is_empty()) {
121− out.push(String::new());
122− }
123− out.push(definition);
124− out.extend(body);
125− let mut text = out.join("\n");
126− text.push('\n');
127− Some(text)
128−}
129−
130−fn bracket_depth(line: &str) -> i32 {
131− line.chars()
132− .map(|c| match c {
133− '[' => 1,
134− ']' => -1,
135− _ => 0,
136− })
137− .sum()
138−}
139−
140−pub fn upgrade(input: &str) -> Upgrade {
141− let mut manual = Vec::new();
142− let (folded, mut changes) = fold_head_directives(input, &mut manual);
143− let lines: Vec<&str> = folded.lines().collect();
144− let mut out: Vec<String> = Vec::with_capacity(lines.len());
145− let mut attr_depth = 0i32;
146− // Whether the attribute list being rewritten belongs to an element (or
147− // a bundle for elements), where HTML attributes now need `key=value`.
148− let mut element_attrs = false;
149− let mut i = 0;
150− // A file may define its own function named like an old alias (e.g.
151− // `@let divider`); calls to it must not be renamed.
152− // (Lines inside `"""` strings and verbatim bodies don't count: they're
153− // text.)
154− let mut user_defined: Vec<&str> = Vec::new();
155− let mut j = 0;
156− while j < lines.len() {
157− if let Some(end) = triple_quote_end(&lines, j) {
158− j = end;
159− continue;
160− }
161− if VERBATIM_BODIES.iter().any(|d| starts_directive(lines[j].trim(), d)) {
162− j = block_end(&lines, j);
163− continue;
164− }
165− if let Some(name) = lines[j]
166− .trim()
167− .strip_prefix("@let ")
168− .and_then(|rest| rest.split_whitespace().next())
169− {
170− user_defined.push(name);
171− }
172− j += 1;
173− }
174−
175− while i < lines.len() {
176− let line = lines[i];
177− let trimmed = line.trim();
178− let indent = indent_of(line);
179−
180− // `@raw """…"""` → `@raw` with the content as its indented body
181− if let Some(after_open) = trimmed.strip_prefix("@raw \"\"\"") {
182− let pad = " ".repeat(indent);
183− match triple_quote_end(&lines, i) {
184− None => out.push(format!(
185− "{pad}@raw {}",
186− after_open.strip_suffix("\"\"\"").unwrap_or(after_open)
187− )),
188− Some(end) => {
189− out.push(format!("{pad}@raw"));
190− let closed = end <= lines.len() && lines[end - 1].trim() == "\"\"\"";
191− let body_end = if closed { end - 1 } else { end };
192− let content = std::iter::once(after_open)
193− .filter(|l| !l.is_empty())
194− .chain(lines[i + 1..body_end].iter().copied());
195− out.extend(content.map(|l| {
196− if l.trim().is_empty() { String::new() } else { format!("{pad} {l}") }
197− }));
198− i = end;
199− changes += 1;
200− continue;
201− }
202− }
203− changes += 1;
204− i += 1;
205− continue;
206− }
207−
208− // Copy verbatim regions unchanged.
209− if let Some(end) = triple_quote_end(&lines, i) {
210− out.extend(lines[i..end].iter().map(|l| l.to_string()));
211− i = end;
212− continue;
213− }
214− if attr_depth == 0 && VERBATIM_BODIES.iter().any(|d| starts_directive(trimmed, d)) {
215− // The directive line is upgraded like any other; its body is not.
216− let end = block_end(&lines, i);
217− let mut header = rewrite_attr_regions(line, &mut attr_depth, &mut true, &user_defined);
218− header = convert_filters(&header);
219− if header != line {
220− changes += 1;
221− }
222− out.push(header);
223− out.extend(lines[i + 1..end].iter().map(|l| l.to_string()));
224− i = end;
225− continue;
226− }
227−
228− // --- Block rewrites ---
229− if attr_depth == 0
230− && let Some(block) = rewrite_block(&lines, i, &mut manual, &user_defined)
231− {
232− out.extend(block.lines);
233− changes += 1;
234− i = block.end;
235− continue;
236− }
237−
238− // Directives removed without a replacement: drop the line
239− if attr_depth == 0
240− && ["@assert ", "@warn ", "@debug ", "@log ", "@deprecated "]
241− .iter()
242− .any(|d| trimmed.starts_with(d) && !user_defined.contains(&&d[1..d.len() - 1]))
243− {
244− changes += 1;
245− i += 1;
246− continue;
247− }
248−
249− // --- Line rewrites ---
250− let mut new_line = line.to_string();
251− if attr_depth == 0 {
252− new_line = rewrite_directive_line(&new_line, indent, i, &mut manual);
253− new_line = rename_elements(&new_line, &user_defined);
254− }
255− if attr_depth == 0 {
256− element_attrs = takes_element_attributes(new_line.trim_start(), &user_defined);
257− }
258− new_line =
259− rewrite_attr_regions(&new_line, &mut attr_depth, &mut element_attrs, &user_defined);
260− new_line = convert_filters(&new_line);
261− if new_line != line {
262− changes += 1;
263− }
264− out.push(new_line);
265− i += 1;
266− }
267−
268− let mut output = out.join("\n");
269− if input.ends_with('\n') {
270− output.push('\n');
271− }
272− let (output, unaliased) = drop_include_aliases(&output);
273− let (output, data_changes) = upgrade_lists(&output, &mut manual);
274− Upgrade {
275− output,
276− changes: changes + unaliased + data_changes,
277− manual,
278− }
279−}
280−
281−/// Lists and loops, on the otherwise upgraded text (line numbers in notes
282−/// are the upgraded file's):
283−/// - `@each $label, $url in Home /, About /about` (items split on spaces)
284−/// → inline records: `@data $label-url [{"label": "Home", ...}]` and
285−/// `@each $item in $label-url` with `$item.label`
286−/// - `$_index` → a named index: `@each $x, $index in ...`
287−/// - `$posts._count` → `length($posts)`
288−fn upgrade_lists(text: &str, manual: &mut Vec<(usize, String)>) -> (String, usize) {
289− let mut lines: Vec<String> = text.split('\n').map(String::from).collect();
290− let mut changes = 0;
291− let mut i = 0;
292− while i < lines.len() {
293− let line = lines[i].clone();
294− let trimmed = line.trim_start();
295− let pad = &line[..line.len() - trimmed.len()];
296− if let Some(count_line) = replace_counts(&line) {
297− lines[i] = count_line;
298− changes += 1;
299− continue;
300− }
301− if let Some(rest) = trimmed.strip_prefix("@data $")
302− && let Some((name, source)) = rest.split_once(' ')
303− && source.contains('*')
304− && text.contains(&format!("${}.", name))
305− {
306− manual.push((
307− i + 1,
308− format!(
309− "`${name}` is now a list with one record per file: use `@each $item in ${name}` \
310− and `$item.key` (`$item.file` is the file name); `${name}.STEM.key` no longer works"
311− ),
312− ));
313− }
314− let Some((names, list)) = trimmed.strip_prefix("@each ").and_then(|r| r.split_once(" in "))
315− else {
316− i += 1;
317− continue;
318− };
319− let vars: Vec<String> =
320− names.split(',').map(|v| v.trim().trim_start_matches('$').to_string()).collect();
321− let list = list.trim().to_string();
322− let lines_ref: Vec<&str> = lines.iter().map(String::as_str).collect();
323− let end = block_end(&lines_ref, i);
324− let uses_index = lines[i + 1..end].iter().any(|l| replace_var(l, "_index", "\0") != *l);
325− let items: Vec<&str> = list.split(',').map(str::trim).filter(|s| !s.is_empty()).collect();
326− let literal = !list.starts_with('$') && !list.contains("..");
327− let destructures = vars.len() > 2 || items.iter().any(|item| item.contains(' '));
328−
329− if vars.len() >= 2 && literal && destructures {
330− let json_text = |s: &str| format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""));
331− let records: Vec<String> = items
332− .iter()
333− .map(|item| {
334− let parts: Vec<&str> = item.splitn(vars.len(), ' ').collect();
335− let fields: Vec<String> = vars
336− .iter()
337− .enumerate()
338− .map(|(k, var)| {
339− format!("{}: {}", json_text(var), json_text(parts.get(k).unwrap_or(&"")))
340− })
341− .collect();
342− format!("{{{}}}", fields.join(", "))
343− })
344− .collect();
345− let data = vars.join("-");
346− let index = if uses_index { ", $index" } else { "" };
347− let mut new = vec![
348− format!("{pad}@data ${} [{}]", data, records.join(", ")),
349− format!("{pad}@each $item{} in ${}", index, data),
350− ];
351− for body_line in &lines[i + 1..end] {
352− let mut body_line = replace_var(body_line, "_index", "$index");
353− for var in &vars {
354− body_line = replace_var(&body_line, var, &format!("$item.{}", var));
355− }
356− new.push(body_line);
357− }
358− let added = new.len();
359− lines.splice(i..end, new);
360− changes += 1;
361− i += added;
362− continue;
363− }
364− // (A second variable named like an index already is one.)
365− let index_name = vars
366− .get(1)
367− .is_some_and(|v| ["i", "j", "k", "n", "idx", "index", "pos", "position", "num"].contains(&v.as_str()));
368− if vars.len() > 2 || (vars.len() == 2 && list.starts_with('$') && !index_name) {
369− manual.push((
370− i + 1,
371− format!(
372− "the second variable of `@each` is now always the index: if `{}` holds \
373− records, write `@each $item in {}` and use `$item.KEY`",
374− list, list
375− ),
376− ));
377− }
378− if uses_index {
379− let index = match vars.get(1) {
380− Some(index) => index.clone(),
381− None => {
382− lines[i] = format!("{pad}@each ${}, $index in {}", vars[0], list);
383− "index".to_string()
384− }
385− };
386− for body_line in &mut lines[i + 1..end] {
387− *body_line = replace_var(body_line, "_index", &format!("${}", index));
388− }
389− changes += 1;
390− }
391− i += 1;
392− }
393− (lines.join("\n"), changes)
394−}
395−
396−/// `$posts._count` → `length($posts)` in an expression, `${length($posts)}`
397−/// elsewhere. `None` when the line has none.
398−fn replace_counts(line: &str) -> Option<String> {
399− if !line.contains("._count") {
400− return None;
401− }
402− let trimmed = line.trim_start();
403− let expression_line = ["@if ", "@else if "].iter().any(|p| trimmed.starts_with(p))
404− || trimmed
405− .strip_prefix("@let ")
406− .and_then(|r| r.split_once(' '))
407− .is_some_and(|(_, v)| v.trim_start().starts_with('='));
408− let is_name = |c: char| c.is_alphanumeric() || matches!(c, '_' | '-' | '.');
409− let mut out = String::new();
410− let mut rest = line;
411− // Inside `${...}`: already an expression
412− let mut depth = 0usize;
413− while let Some(pos) = rest.find('$') {
414− depth = depth.saturating_sub(rest[..pos].matches('}').count());
415− out.push_str(&rest[..pos]);
416− let after = &rest[pos + 1..];
417− if after.starts_with('{') {
418− depth += 1;
419− }
420− let end = after.find(|c: char| !is_name(c)).unwrap_or(after.len());
421− match after[..end].strip_suffix("._count") {
422− Some(name) if expression_line || depth > 0 => {
423− out.push_str(&format!("length(${})", name))
424− }
425− Some(name) => out.push_str(&format!("${{length(${})}}", name)),
426− None => out.push_str(&rest[pos..pos + 1 + end]),
427− }
428− rest = &after[end..];
429− }
430− out.push_str(rest);
431− Some(out)
432−}
433−
434−/// `@include lib.hl as ui` → `@include lib.hl`, and `@ui.card` / `$ui.x`
435−/// lose their `ui.` prefix.
436−fn drop_include_aliases(text: &str) -> (String, usize) {
437− let mut aliases = Vec::new();
438− let mut lines: Vec<String> = Vec::new();
439− for line in text.split('\n') {
440− let trimmed = line.trim_start();
441− if let Some(rest) = trimmed.strip_prefix("@include ")
442− && let Some((file, alias)) = rest.rsplit_once(" as ")
443− {
444− aliases.push(alias.trim().to_string());
445− lines.push(format!("{}@include {}", &line[..line.len() - trimmed.len()], file.trim()));
446− } else {
447− lines.push(line.to_string());
448− }
449− }
450− if aliases.is_empty() {
451− return (text.to_string(), 0);
452− }
453− let mut out = lines.join("\n");
454− for alias in &aliases {
455− out = out
456− .replace(&format!("@{}.", alias), "@")
457− .replace(&format!("${}.", alias), "$");
458− }
459− (out, aliases.len())
460−}
461−
462−struct Block {
463− lines: Vec<String>,
464− end: usize,
465−}
466−
467−/// Rewrites of directives that own an indented block.
468−fn rewrite_block(
469− lines: &[&str],
470− i: usize,
471− manual: &mut Vec<(usize, String)>,
472− user_defined: &[&str],
473−) -> Option<Block> {
474− let line = lines[i];
475− let trimmed = line.trim();
476− let indent = indent_of(line);
477− let pad = " ".repeat(indent);
478− let end = block_end(lines, i);
479− let body = &lines[i + 1..end];
480−
481− // @keyframes NAME with `from [opacity 0]` lines → the CSS rule in @style
482− if let Some(name) = trimmed.strip_prefix("@keyframes ") {
483− let mut out = vec![
484− format!("{pad}@style"),
485− format!("{pad} @keyframes {} {{", name.trim()),
486− ];
487− for line in body.iter().filter(|l| !l.trim().is_empty()) {
488− out.push(format!("{pad} {}", keyframe_css(line.trim())));
489− }
490− out.push(format!("{pad} }}"));
491− return Some(Block { lines: out, end });
492− }
493−
494− // @tooltip TEXT (text shown and used as the hover tip) →
495− // @text [title=TEXT] TEXT
496− if let Some(rest) = trimmed.strip_prefix("@tooltip ")
497− && !user_defined.contains(&"tooltip")
498− && !has_attr_key(rest, "tip")
499− {
500− let (attrs, text) = match rest.strip_prefix('[') {
501− Some(after) => {
502− let close = after.find(']')?;
503− (Some(&after[..close]), after[close + 1..].trim())
504− }
505− None => (None, rest.trim()),
506− };
507− if text.is_empty() {
508− return None;
509− }
510− let attrs = match attrs {
511− Some(a) if !a.trim().is_empty() => format!("[title={}, {}]", text, a.trim()),
512− _ => format!("[title={}]", text),
513− };
514− let mut out = vec![format!("{pad}@text {} {}", attrs, text)];
515− out.extend(body.iter().map(|l| l.to_string()));
516− return Some(Block { lines: out, end });
517− }
518−
519− // @switch $v / @case x [attrs] → @match $v / @case x / @let __switch [attrs]
520− if let Some(rest) = trimmed.strip_prefix("@switch ") {
521− let mut out = vec![format!("{pad}@match {}", rest.trim())];
522− let case_indent = body.iter().find(|l| !l.trim().is_empty()).map(|l| indent_of(l));
523− let mut j = 0;
524− while j < body.len() {
525− let l = body[j];
526− let t = l.trim();
527− let is_case_line = Some(indent_of(l)) == case_indent
528− && (t.starts_with("@case ") || t == "@default" || t.starts_with("@default "));
529− let bracket = t.find('[').filter(|_| is_case_line);
530− if let Some(bracket) = bracket {
531− let head = t[..bracket].trim_end();
532− let attrs = &t[bracket..];
533− out.push(format!("{}{}", " ".repeat(indent_of(l)), head));
534− let body_indent = body[j + 1..]
535− .iter()
536− .find(|b| !b.trim().is_empty())
537− .map(|b| indent_of(b))
538− .filter(|&n| n > indent_of(l))
539− .unwrap_or(indent_of(l) + 2);
540− out.push(format!("{}@let __switch {}", " ".repeat(body_indent), attrs));
541− } else {
542− out.push(l.to_string());
543− }
544− j += 1;
545− }
546− return Some(Block { lines: out, end });
547− }
548−
549− // @translations [LOCALE] with `locale:` sections of `key value` lines →
550− // `@let t.key value` for the active locale
551− if trimmed == "@translations" || trimmed.starts_with("@translations ") {
552− let explicit = trimmed["@translations".len()..].trim();
553− let page_lang = lines.iter().find_map(|l| {
554− let rest = l.trim().strip_prefix("@page [")?;
555− let attrs = rest.split(']').next()?;
556− attrs
557− .split(',')
558− .find_map(|a| a.trim().strip_prefix("lang "))
559− .map(|v| v.trim().to_string())
560− });
561− let active = if !explicit.is_empty() {
562− explicit.to_string()
563− } else {
564− page_lang.unwrap_or_else(|| "en".to_string())
565− };
566− let mut locale = active.clone();
567− let mut out = Vec::new();
568− let mut others = Vec::new();
569− for l in body {
570− let t = l.trim();
571− if t.is_empty() {
572− continue;
573− }
574− if let Some(name) = t.strip_suffix(':').filter(|n| !n.contains(' ')) {
575− locale = name.to_string();
576− if locale != active && !others.contains(&locale) {
577− others.push(locale.clone());
578− }
579− continue;
580− }
581− if locale == active
582− && let Some((key, value)) = t.split_once(' ')
583− {
584− out.push(format!("{pad}@let t.{} {}", key, value.trim()));
585− }
586− }
587− if !others.is_empty() {
588− manual.push((
589− i + 1,
590− format!(
591− "kept the '{}' strings; move the other locales ({}) to JSON files and load \
592− them with `@data $t locales/$lang.json`",
593− active,
594− others.join(", ")
595− ),
596− ));
597− }
598− return Some(Block { lines: out, end });
599− }
600−
601− // @match $v / @case a / @default → @if $v == "a" / @else if ... / @else
602− if let Some(value) = trimmed.strip_prefix("@match ") {
603− let value = value.trim();
604− let case_indent = body.iter().find(|l| !l.trim().is_empty()).map(|l| indent_of(l));
605− let mut out = Vec::new();
606− let mut first = true;
607− let mut j = 0;
608− while j < body.len() {
609− let l = body[j];
610− let t = l.trim();
611− if Some(indent_of(l)) == case_indent {
612− let head = if let Some(case) = t.strip_prefix("@case ") {
613− let case = case.trim();
614− let quoted = if case.starts_with('"') {
615− case.to_string()
616− } else {
617− format!("\"{}\"", case)
618− };
619− let keyword = if first { "@if" } else { "@else if" };
620− first = false;
621− format!("{pad}{} {} == {}", keyword, value, quoted)
622− } else if t == "@default" {
623− format!("{pad}@else")
624− } else {
625− j += 1;
626− continue;
627− };
628− out.push(head);
629− // The case body moves up one level, under the @if.
630− let body_end = (j + 1..body.len())
631− .find(|&k| !body[k].trim().is_empty() && Some(indent_of(body[k])) <= case_indent)
632− .unwrap_or(body.len());
633− out.extend(dedent_block(&body[j + 1..body_end], indent + 2));
634− j = body_end;
635− } else {
636− j += 1;
637− }
638− }
639− return Some(Block { lines: out, end });
640− }
641−
642− // @theme / `name value` lines → `@let name value` and `@let --name value`
643− if trimmed == "@theme" {
644− let mut out = Vec::new();
645− for l in body {
646− if let Some((name, value)) = l.trim().split_once(' ') {
647− let value = value.trim();
648− out.push(format!("{pad}@let {} {}", name, value));
649− out.push(format!("{pad}@let --{} {}", name, value));
650− if name == "primary" || name == "theme-color" {
651− out.push(format!("{pad}@meta theme-color {}", value));
652− }
653− }
654− }
655− return Some(Block { lines: out, end });
656− }
657−
658− // @json-ld with an indented JSON body → a script in @head
659− if trimmed == "@json-ld" {
660− let mut out = vec![
661− format!("{pad}@head"),
662− format!("{pad} <script type=\"application/ld+json\">"),
663− ];
664− out.extend(dedent_block(body, indent).into_iter().map(|l| {
665− if l.is_empty() {
666− l
667− } else {
668− format!("{pad} {}", l.trim_start())
669− }
670− }));
671− out.push(format!("{pad} </script>"));
672− return Some(Block { lines: out, end });
673− }
674−
675− if trimmed.starts_with("@manifest") {
676− manual.push((
677− i + 1,
678− "@manifest was removed: write a manifest.json file and add \
679− `<link rel=\"manifest\" href=\"manifest.json\">` to @head"
680− .to_string(),
681− ));
682− return None;
683− }
684−
685− // @extends layout.hl + the rest of the page → @include layout.hl and a
686− // call to the layout function with the rest of the page as children
687− if let Some(file) = trimmed.strip_prefix("@extends ") {
688− let file = file.trim();
689− let mut out = vec![
690− format!("{pad}@include {}", file),
691− format!("{pad}@{}", layout_function_name(file)),
692− ];
693− out.extend(lines[i + 1..].iter().map(|l| {
694− if l.trim().is_empty() {
695− String::new()
696− } else {
697− format!(" {}", l)
698− }
699− }));
700− return Some(Block {
701− lines: out,
702− end: lines.len(),
703− });
704− }
705−
706− // @defer → its body, dedented
707− if trimmed == "@defer" || trimmed.starts_with("@defer ") {
708− return Some(Block {
709− lines: dedent_block(body, indent),
710− end,
711− });
712− }
713−
714− // @with $x as y → @let y $x, body dedented
715− if let Some(rest) = trimmed.strip_prefix("@with ") {
716− let (source, alias) = rest.split_once(" as ")?;
717− let mut out = vec![format!("{pad}@let {} {}", alias.trim(), source.trim())];
718− out.extend(dedent_block(body, indent));
719− return Some(Block { lines: out, end });
720− }
721−
722− // @layout file.hl with a body → @include file.hl and a call to the
723− // layout function (see `convert_layout`), body as its children.
724− if let Some(rest) = trimmed.strip_prefix("@layout ")
725− && rest.trim().ends_with(".hl")
726− {
727− if end < lines.len() && lines[end..].iter().any(|l| !l.trim().is_empty()) {
728− manual.push((
729− i + 1,
730− "@layout was removed: call the layout as a function (content after the \
731− @layout block has to move into its children)"
732− .to_string(),
733− ));
734− return None;
735− }
736− let file = rest.trim();
737− let mut out = vec![
738− format!("{pad}@include {}", file),
739− format!("{pad}@{}", layout_function_name(file)),
740− ];
741− out.extend(body.iter().map(|l| l.to_string()));
742− return Some(Block { lines: out, end });
743− }
744−
745− // @scope sel / @starting-style → an @style block with the CSS at-rule
746− if trimmed == "@scope" || trimmed.starts_with("@scope ") || trimmed == "@starting-style" {
747− let prelude = if trimmed == "@starting-style" {
748− "@starting-style {".to_string()
749− } else {
750− match trimmed["@scope".len()..].trim() {
751− "" => "@scope {".to_string(),
752− sel => format!("@scope ({}) {{", sel),
753− }
754− };
755− let mut out = vec![format!("{pad}@style"), format!("{pad} {prelude}")];
756− out.extend(
757− body.iter()
758− .filter(|l| !l.trim().is_empty())
759− .map(|l| format!("{pad} {}", l.trim())),
760− );
761− out.push(format!("{pad} }}"));
762− return Some(Block { lines: out, end });
763− }
764−
765− // @css-property --name / key value lines → @style with @property
766− if let Some(name) = trimmed.strip_prefix("@css-property ") {
767− let mut syntax = "\"*\"".to_string();
768− let mut inherits = "false".to_string();
769− let mut initial = None;
770− for l in body {
771− if let Some((key, value)) = l.trim().split_once(' ') {
772− if value.contains('$') {
773− manual.push((
774− i + 1,
775− "@css-property with variables must be rewritten as @style by hand"
776− .to_string(),
777− ));
778− return None;
779− }
780− match key {
781− "syntax" => syntax = value.trim().to_string(),
782− "inherits" => inherits = value.trim().to_string(),
783− "initial-value" | "initial_value" => initial = Some(value.trim().to_string()),
784− _ => {}
785− }
786− }
787− }
788− let mut out = vec![
789− format!("{pad}@style"),
790− format!("{pad} @property {} {{", name.trim()),
791− format!("{pad} syntax:{};", syntax),
792− format!("{pad} inherits:{};", inherits),
793− ];
794− if let Some(initial) = initial {
795− out.push(format!("{pad} initial-value:{};", initial));
796− }
797− out.push(format!("{pad} }}"));
798− return Some(Block { lines: out, end });
799− }
800−
801− // @repeat N → @each $_ in 1..N (literal counts only)
802− if let Some(rest) = trimmed.strip_prefix("@repeat ") {
803− let Ok(count) = rest.trim().parse::<u64>() else {
804− manual.push((
805− i + 1,
806− "@repeat with a variable count was removed: use @each $_ in 1..$n \
807− (note: 1..0 counts down, so guard a zero count with @if)"
808− .to_string(),
809− ));
810− return None;
811− };
812− if count == 0 {
813− // Renders nothing: drop the block entirely.
814− return Some(Block {
815− lines: Vec::new(),
816− end,
817− });
818− }
819− let mut out = vec![format!("{pad}@each $_ in 1..{}", count)];
820− // @repeat exposed $_count; @each doesn't, so inline it.
821− out.extend(
822− body.iter()
823− .map(|l| replace_var(l, "_count", &count.to_string())),
824− );
825− return Some(Block { lines: out, end });
826− }
827−
828− None
829−}
830−
831−/// Move `@lang` and `@favicon` into the `@page`
832−/// line's attributes: `@page [lang en, favicon /f.png] Title`.
833−fn fold_head_directives(input: &str, manual: &mut Vec<(usize, String)>) -> (String, usize) {
834− const FOLDED: &[&str] = &["lang", "favicon"];
835− let lines: Vec<&str> = input.lines().collect();
836− let mut attrs = Vec::new();
837− let mut keep = Vec::with_capacity(lines.len());
838− for (i, line) in lines.iter().enumerate() {
839− let folded = FOLDED.iter().find_map(|key| {
840− let rest = line.strip_prefix(&format!("@{} ", key))?;
841− Some(format!("{} {}", key, rest.trim()))
842− });
843− match folded {
844− Some(attr) => attrs.push((i, attr)),
845− None => keep.push(*line),
846− }
847− }
848− if attrs.is_empty() {
849− return (input.to_string(), 0);
850− }
851− let Some(page) = keep.iter().position(|l| l.starts_with("@page ")) else {
852− for (i, _) in &attrs {
853− manual.push((
854− i + 1,
855− "this directive now goes in @page's attributes, but the file has no @page line"
856− .to_string(),
857− ));
858− }
859− return (input.to_string(), 0);
860− };
861− let list = attrs.iter().map(|(_, a)| a.as_str()).collect::<Vec<_>>().join(", ");
862− let title = &keep[page]["@page ".len()..];
863− let new_page = match title.strip_prefix('[') {
864− Some(rest) => format!("@page [{}, {}", list, rest),
865− None => format!("@page [{}] {}", list, title),
866− };
867− let mut out: Vec<String> = keep.iter().map(|l| l.to_string()).collect();
868− out[page] = new_page;
869− let mut text = out.join("\n");
870− if input.ends_with('\n') {
871− text.push('\n');
872− }
873− (text, attrs.len())
874−}
875−
876−/// `from [opacity 0, transform none]` → `from { opacity: 0; transform: none; }`;
877−/// raw CSS is returned unchanged.
878−fn keyframe_css(line: &str) -> String {
879− let Some((selector, list)) = line.split_once('[') else {
880− return line.to_string();
881− };
882− let Some(list) = list.trim_end().strip_suffix(']') else {
883− return line.to_string();
884− };
885− let declarations: Vec<String> = split_top_level_commas(list)
886− .into_iter()
887− .filter_map(|part| part.trim().split_once(' '))
888− .map(|(key, value)| format!("{}: {};", key.trim(), value.trim()))
889− .collect();
890− format!("{} {{ {} }}", selector.trim(), declarations.join(" "))
891−}
892−
893−/// Single-line directive rewrites.
894−fn rewrite_directive_line(
895− line: &str,
896− indent: usize,
897− idx: usize,
898− manual: &mut Vec<(usize, String)>,
899−) -> String {
900− let trimmed = line.trim_start();
901− let pad = " ".repeat(indent);
902− // `[attrs]` on its own line was an anonymous @el
903− if trimmed.starts_with('[') {
904− return format!("{pad}@el {}", trimmed);
905− }
906− for old in ["@fn ", "@define ", "@mixin ", "@component "] {
907− if let Some(rest) = trimmed.strip_prefix(old) {
908− return format!("{pad}@let {}", rest);
909− }
910− }
911− // `@canonical URL` / `@base URL` → the HTML line in @head
912− if let Some(url) = trimmed.strip_prefix("@canonical ") {
913− return format!("{pad}@head\n{pad} <link rel=\"canonical\" href=\"{}\">", url.trim());
914− }
915− if let Some(url) = trimmed.strip_prefix("@base ") {
916− return format!("{pad}@head\n{pad} <base href=\"{}\">", url.trim());
917− }
918− if let Some(rest) = trimmed.strip_prefix("@og ")
919− && let Some((key, value)) = rest.trim().split_once(' ')
920− {
921− let value = value.trim();
922− let value = value
923− .strip_prefix('"')
924− .and_then(|v| v.strip_suffix('"'))
925− .unwrap_or(value);
926− return format!("{pad}@meta og:{} {}", key, value);
927− }
928− // `@let x = $a ~ " " ~ $b` → `@let x "$a $b"` (`~` was removed:
929− // interpolated strings do the same)
930− if let Some(rest) = trimmed.strip_prefix("@let ")
931− && let Some((name, value)) = rest.split_once(' ')
932− && value.contains(" ~ ")
933− {
934− let expression = value.trim();
935− let expression = expression.strip_prefix('=').unwrap_or(expression);
936− let is_var_char = |c: char| c.is_alphanumeric() || matches!(c, '_' | '-' | '.');
937− let pieces: Vec<String> = expression
938− .split(" ~ ")
939− .map(|part| {
940− let part = part.trim();
941− if let Some(text) = part.strip_prefix('"').and_then(|p| p.strip_suffix('"')) {
942− text.to_string()
943− } else if !part.contains(' ') && !part.contains('(') {
944− part.to_string()
945− } else {
946− format!("${{{}}}", part)
947− }
948− })
949− .collect();
950− let mut joined = String::new();
951− for (k, piece) in pieces.iter().enumerate() {
952− // `$n` directly followed by text would read as a longer name.
953− let next_continues_name = pieces
954− .get(k + 1)
955− .and_then(|next| next.chars().next())
956− .is_some_and(is_var_char);
957− if piece.starts_with('$') && !piece.starts_with("${") && next_continues_name {
958− joined.push_str(&format!("${{{}}}", piece));
959− } else {
960− joined.push_str(piece);
961− }
962− }
963− return format!("{pad}@let {} \"{}\"", name, joined);
964− }
965− // `@let x $a + 4` computed its value; computing now needs `=`.
966− if let Some(rest) = trimmed.strip_prefix("@let ")
967− && let Some((name, value)) = rest.split_once(' ')
968− && is_old_arithmetic(value.trim())
969− {
970− return format!("{pad}@let {} = {}", name, value.trim());
971− }
972− if let Some(rest) = trimmed.strip_prefix("@env ") {
973− let rest = rest.trim();
974− let (var, default) = match rest.split_once(char::is_whitespace) {
975− Some((var, default)) => (var, format!(" {}", default.trim())),
976− None => (rest, String::new()),
977− };
978− let name = var.to_lowercase().replace('-', "_");
979− return format!("{pad}@data ${} env:{}{}", name, var, default);
980− }
981− if let Some(rest) = trimmed.strip_prefix("@collection ") {
982− let rest = rest.trim();
983− let parsed = match rest.strip_prefix('$') {
984− Some(named) => named
985− .split_once(char::is_whitespace)
986− .map(|(n, p)| (n.to_string(), p.trim().trim_matches('"').to_string())),
987− None => rest
988− .rsplit_once(" as ")
989− .map(|(p, n)| (n.trim().to_string(), p.trim().trim_matches('"').to_string())),
990− };
991− if let Some((name, pattern)) = parsed {
992− manual.push((
993− idx + 1,
994− format!(
995− "collection values are now `${name}.STEM.key` (they were `${name}_STEM_key`), \
996− and `${name}` lists the stems separated by commas"
997− ),
998− ));
999− return format!("{pad}@data ${} {}", name, pattern);
1000− }
1001− }
1002− if trimmed.starts_with("@each ") && trimmed.trim_end().ends_with(']') && trimmed.contains("[page ") {
1003− manual.push((
1004− idx + 1,
1005− "@each pagination ([page N]) was removed: split the list or filter it with @if"
1006− .to_string(),
1007− ));
1008− return line.to_string();
1009− }
1010− if let Some(rest) = trimmed.strip_prefix("@data ")
1011− && !rest.trim_start().starts_with('$')
1012− {
1013− manual.push((
1014− idx + 1,
1015− "@data needs a name now: write `@data $name FILE` and use `$name.key` for `$key`"
1016− .to_string(),
1017− ));
1018− return line.to_string();
1019− }
1020− if trimmed.starts_with("@fetch ") {
1021− manual.push((
1022− idx + 1,
1023− "@fetch was removed: download the data before building and load it with \
1024− `@data $name file.json`"
1025− .to_string(),
1026− ));
1027− return line.to_string();
1028− }
1029− if let Some(rest) = trimmed.strip_prefix("@font-face ")
1030− && let Some((name, url)) = rest.trim().split_once(' ')
1031− {
1032− let url = url.trim();
1033− let format = match url.rsplit('.').next() {
1034− Some("woff2") => " format('woff2')",
1035− Some("woff") => " format('woff')",
1036− Some("ttf") => " format('truetype')",
1037− Some("otf") => " format('opentype')",
1038− _ => "",
1039− };
1040− return format!(
1041− "{pad}@style\n{pad} @font-face {{ font-family: '{name}'; src: url('{url}'){format}; font-display: swap; }}\n\
1042− {pad}@head\n{pad} <link rel=\"preload\" href=\"{url}\" as=\"font\" crossorigin>"
1043− );
1044− }
1045− if trimmed.starts_with("@breakpoint ") {
1046− manual.push((
1047− idx + 1,
1048− "@breakpoint was removed (its prefixes never took effect): write the media \
1049− query in an @style block"
1050− .to_string(),
1051− ));
1052− return line.to_string();
1053− }
1054− // @svg [attrs] file.svg → @image [inline, attrs] file.svg
1055− if let Some(rest) = trimmed.strip_prefix("@svg ") {
1056− let rest = rest.trim();
1057− let (attrs, file) = match rest.strip_prefix('[').and_then(|r| r.split_once(']')) {
1058− Some((attrs, file)) => (attrs.trim(), file.trim()),
1059− None => ("", rest),
1060− };
1061− let attrs: Vec<String> = split_top_level_commas(attrs)
1062− .into_iter()
1063− .map(str::trim)
1064− .filter(|a| !a.is_empty())
1065− .map(|a| match a.split_once(' ') {
1066− Some((key @ ("class" | "id"), value)) => format!("{}={}", key, value.trim()),
1067− _ => a.to_string(),
1068− })
1069− .collect();
1070− let list = std::iter::once("inline".to_string())
1071− .chain(attrs)
1072− .collect::<Vec<_>>()
1073− .join(", ");
1074− return format!("{pad}@image [{}] {}", list, file);
1075− }
1076− if let Some(rest) = trimmed.strip_prefix("@import ") {
1077− return format!("{pad}@include {}", rest);
1078− }
1079− if let Some(rest) = trimmed.strip_prefix("@unless ") {
1080− return format!("{pad}@if not {}", rest);
1081− }
1082− if let Some(rest) = trimmed.strip_prefix("@for ") {
1083− return format!("{pad}@each {}", rest);
1084− }
1085− if let Some(rest) = trimmed.strip_prefix("@use ") {
1086− let rest = rest.trim();
1087− let file = if let Some(q) = rest.strip_prefix('"') {
1088− q.split('"').next().unwrap_or("")
1089− } else {
1090− rest.split(|c: char| c.is_whitespace() || c == ',')
1091− .next()
1092− .unwrap_or("")
1093− };
1094− if file.is_empty() {
1095− manual.push((idx + 1, "could not read the file name in @use".to_string()));
1096− return line.to_string();
1097− }
1098− return format!("{pad}@include {}", file);
1099− }
1100− line.to_string()
1101−}
1102−
1103−/// Did the old `@let` evaluate this bare value? It did for one arithmetic
1104−/// operator between two operands (`$base * 2`) and for `~` concatenation.
1105−fn is_old_arithmetic(value: &str) -> bool {
1106− if value.starts_with(['=', '[', '"']) {
1107− return false;
1108− }
1109− if value.contains(" ~ ") {
1110− return true;
1111− }
1112− [" * ", " / ", " + ", " - "].iter().any(|op| {
1113− value.split_once(op).is_some_and(|(l, r)| {
1114− let operand = |s: &str| {
1115− let s = s.trim();
1116− s.parse::<f64>().is_ok() || (s.starts_with('$') && !s.contains(' '))
1117− };
1118− operand(l) && operand(r)
1119− })
1120− })
1121−}
1122−
1123−/// Rename element aliases wherever an element name can appear: at the start
1124−/// of a line, after a `>` chain, or at the start of an inline `{@...}`.
1125−fn rename_elements(line: &str, user_defined: &[&str]) -> String {
1126− let mut out = String::with_capacity(line.len());
1127− let bytes = line.as_bytes();
1128− let mut i = 0;
1129− while i < line.len() {
1130− if bytes[i] == b'@' {
1131− let before = line[..i].trim_end();
1132− let at_element_position =
1133− before.is_empty() || before.ends_with('>') || line[..i].ends_with('{');
1134− if at_element_position {
1135− let name_end = line[i + 1..]
1136− .find(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '_'))
1137− .map_or(line.len(), |p| i + 1 + p);
1138− let name = &line[i + 1..name_end];
1139− let alias = ELEMENT_ALIASES
1140− .iter()
1141− .find(|(a, _)| *a == name && !user_defined.contains(a));
1142− if let Some((_, canonical)) = alias {
1143− out.push('@');
1144− out.push_str(canonical);
1145− i = name_end;
1146− continue;
1147− }
1148− }
1149− }
1150− let ch = line[i..].chars().next().unwrap();
1151− out.push(ch);
1152− i += ch.len_utf8();
1153− }
1154− out
1155−}
1156−
1157−/// Apply attribute-level rewrites inside `[...]` regions. `depth` carries an
1158−/// unclosed bracket over to the following (continuation) lines.
1159−/// Attributes that became HTML when written `key value`, or were meant as
1160−/// HTML but dropped; they are now written `key=value`.
1161−const HTML_ATTRIBUTES: &[&str] = &[
1162− "accept", "action", "allow", "alt", "autocomplete", "blocking", "class", "cols", "colspan",
1163− "datetime", "decoding", "dir", "download", "enctype", "enterkeyhint", "fetchpriority", "for",
1164− "formaction", "formmethod", "formtarget", "headers", "high", "href", "hreflang", "id",
1165− "inputmode", "label", "lang", "list", "loading", "low", "max", "maxlength", "media", "method",
1166− "min", "name", "optimum", "pattern", "placeholder", "popover", "popovertarget",
1167− "popovertargetaction", "poster", "preload", "referrerpolicy", "rel", "role", "rows",
1168− "rowspan", "sandbox", "scope", "sizes", "span", "spellcheck", "src", "srcset", "start",
1169− "step", "tabindex", "target", "title", "translate", "type", "value",
1170−];
1171−
1172−/// Components the standard library had, which forwarded attributes to an
1173−/// element.
1174−const STD_COMPONENTS: &[&str] = &[
1175− "badge", "tag", "chip", "avatar", "spacer", "tooltip", "carousel", "breadcrumb",
1176−];
1177−
1178−/// Does this line's attribute list style an element (as opposed to passing
1179−/// function parameters or directive options)?
1180−fn takes_element_attributes(trimmed: &str, user_defined: &[&str]) -> bool {
1181− let Some(rest) = trimmed.strip_prefix('@') else {
1182− return false;
1183− };
1184− let name_end = rest
1185− .find(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '_'))
1186− .unwrap_or(rest.len());
1187− let name = &rest[..name_end];
1188− if name == "let" {
1189− // An attribute bundle: `@let card [...]`
1190− return rest[name_end..]
1191− .trim_start()
1192− .split_once(' ')
1193− .is_some_and(|(_, v)| v.trim_start().starts_with('['));
1194− }
1195− !user_defined.contains(&name)
1196− && (htmlang_core::ast::ElementKind::from_name(name).is_some()
1197− || STD_COMPONENTS.contains(&name))
1198−}
1199−
1200−fn rewrite_attr_regions(
1201− line: &str,
1202− depth: &mut i32,
1203− element_attrs: &mut bool,
1204− user_defined: &[&str],
1205−) -> String {
1206− let trimmed = line.trim_start();
1207− // In a text line, only inline elements (`{@abbr [...]}`) have attributes.
1208− let text_line = !trimmed.starts_with('@');
1209− if *depth == 0 && text_line && !line.contains("{@") {
1210− return line.to_string();
1211− }
1212− let mut out = String::with_capacity(line.len());
1213− let mut region = String::new();
1214− for ch in line.chars() {
1215− if *depth > 0 {
1216− match ch {
1217− '[' => *depth += 1,
1218− ']' => *depth -= 1,
1219− _ => {}
1220− }
1221− if *depth == 0 {
1222− out.push_str(&rewrite_attr_list(&region, *element_attrs));
1223− region.clear();
1224− out.push(ch);
1225− } else {
1226− region.push(ch);
1227− }
1228− continue;
1229− }
1230− if ch == '[' {
1231− if !text_line {
1232− *depth = 1;
1233− } else if let Some(brace) = out.rfind('{') {
1234− let name = out[brace + 1..].trim_end();
1235− if name.starts_with('@') && !name.contains(char::is_whitespace) {
1236− *element_attrs = takes_element_attributes(name, user_defined);
1237− *depth = 1;
1238− }
1239− }
1240− }
1241− out.push(ch);
1242− }
1243− out.push_str(&rewrite_attr_list(&region, *element_attrs));
1244− out
1245−}
1246−
1247−/// Rewrite one attribute list's contents (without the brackets).
1248−/// Split `key value if condition` at its ` if ` (outside parentheses and
1249−/// quotes).
1250−fn split_trailing_if(attr: &str) -> Option<(&str, &str)> {
1251− let mut depth = 0;
1252− let mut quote = None;
1253− for (i, c) in attr.char_indices() {
1254− match c {
1255− '"' | '\'' if quote == Some(c) => quote = None,
1256− '"' | '\'' if quote.is_none() => quote = Some(c),
1257− _ if quote.is_some() => {}
1258− '(' => depth += 1,
1259− ')' => depth -= 1,
1260− _ if depth == 0 && attr[i..].starts_with(" if ") => {
1261− return Some((&attr[..i], attr[i + 4..].trim()));
1262− }
1263− _ => {}
1264− }
1265− }
1266− None
1267−}
1268−
1269−fn rewrite_attr_list(list: &str, element_attrs: bool) -> String {
1270− if list.is_empty() {
1271− return String::new();
1272− }
1273− let mut out = String::with_capacity(list.len());
1274− let mut first = true;
1275− let mut dropped_leading = false;
1276− for segment in split_top_level_commas(list) {
1277− // `critical` (inline styles) was removed without a replacement
1278− if segment.trim() == "critical" {
1279− dropped_leading |= first;
1280− continue;
1281− }
1282− let lead = segment.len() - segment.trim_start().len();
1283− let (ws, body) = segment.split_at(lead);
1284− if !first {
1285− out.push(',');
1286− out.push_str(ws);
1287− } else if !dropped_leading || ws.contains('\n') {
1288− out.push_str(ws);
1289− }
1290− first = false;
1291− // `key if cond` → `if(cond, key)`
1292− if let Some((attr, condition)) = split_trailing_if(body.trim_end()) {
1293− out.push_str(&format!(
1294− "if({}, {})",
1295− condition,
1296− rewrite_attr_list(attr, element_attrs)
1297− ));
1298− out.push_str(&body[body.trim_end().len()..]);
1299− continue;
1300− }
1301− // `...$bundle` spread → `$bundle`
1302− let body = body.strip_prefix("...$").map_or(body.to_string(), |b| format!("${b}"));
1303− // Built-in style attributes that became standard-library bundles
1304− let body = match body.trim_end() {
1305− "truncate" => format!("${}", body),
1306− "no-scrollbar" => "scrollbar-width none".to_string(),
1307− "grid" => "display grid".to_string(),
1308− _ => body,
1309− };
1310− // `blur N` / `backdrop-blur N` → the filter they generated
1311− let body = {
1312− let (key, value) = body.split_once(' ').unwrap_or((&body, ""));
1313− let (prefix, base) = key.rsplit_once(':').map_or(("", key), |(p, b)| (p, b));
1314− let prefix = if prefix.is_empty() { String::new() } else { format!("{prefix}:") };
1315− let px = |v: &str| {
1316− let v = v.trim();
1317− if !v.is_empty() && v.parse::<f64>().is_ok() && v != "0" {
1318− format!("{v}px")
1319− } else {
1320− v.to_string()
1321− }
1322− };
1323− match base {
1324− "blur" => format!("{prefix}filter blur({})", px(value)),
1325− "backdrop-blur" => format!("{prefix}backdrop-filter blur({})", px(value)),
1326− _ => body.clone(),
1327− }
1328− };
1329− // `gradient A B [ANGLE]` → the `background` it generated
1330− let body = match body.strip_prefix("gradient ") {
1331− Some(v) => {
1332− let parts: Vec<&str> = v.split_whitespace().collect();
1333− let angle = parts
1334− .get(2)
1335− .filter(|a| a.ends_with("deg") || a.ends_with("turn") || a.ends_with("rad"));
1336− let gradient = match (parts.as_slice(), angle) {
1337− ([a, b, ..], Some(angle)) => format!("{},{},{}", angle, a, b),
1338− ([a, b, ..], None) => format!("{},{}", a, b),
1339− ([a], _) => format!("{},transparent", a),
1340− _ => String::new(),
1341− };
1342− format!("background linear-gradient({})", gradient)
1343− }
1344− None => body,
1345− };
1346− // `key COND ? A : B` → `key if(COND, A, B)`
1347− let body = match body.split_once(' ') {
1348− Some((key, value)) => match value.split_once(" ? ").and_then(|(cond, rest)| {
1349− rest.split_once(" : ").map(|(a, b)| (cond, a, b))
1350− }) {
1351− Some((cond, a, b)) => format!("{} if({}, {}, {})", key, cond.trim(), a.trim(), b.trim()),
1352− None => body,
1353− },
1354− None => body,
1355− };
1356− // HTML attributes with a value: `type email` → `type=email`
1357− // (already-converted `key=value` attributes are left alone)
1358− let body = match body.split_once(' ') {
1359− Some((key, value))
1360− if element_attrs
1361− && !key.contains('=')
1362− && (HTML_ATTRIBUTES.contains(&key)
1363− || key.starts_with("aria-")
1364− || key.starts_with("data-")) =>
1365− {
1366− format!("{}={}", key, value.trim())
1367− }
1368− _ => body,
1369− };
1370− // Style aliases that became their CSS properties
1371− let body = if element_attrs { css_alias_or_choice(&body).unwrap_or(body) } else { body };
1372− // Renamed keys (keeping any `hover:` / `md:` style prefix)
1373− let key_end = body.find(char::is_whitespace).unwrap_or(body.len());
1374− let (key, value) = body.split_at(key_end);
1375− let (prefix, base) = match key.rfind(':') {
1376− Some(p) => key.split_at(p + 1),
1377− None => ("", key),
1378− };
1379− match ATTR_RENAMES.iter().find(|(from, _)| *from == base) {
1380− Some((_, to)) => {
1381− out.push_str(prefix);
1382− out.push_str(to);
1383− out.push_str(value);
1384− }
1385− None => out.push_str(&body),
1386− }
1387− }
1388− out
1389−}
1390−
1391−/// `css_alias`, also inside the branches of a whole-attribute
1392−/// `if(cond, a, b)`.
1393−fn css_alias_or_choice(attr: &str) -> Option<String> {
1394− let Some(inner) = attr.trim_end().strip_prefix("if(").and_then(|a| a.strip_suffix(')')) else {
1395− return css_alias(attr);
1396− };
1397− let args = split_top_level_commas(inner);
1398− let [condition, branches @ ..] = args.as_slice() else {
1399− return None;
1400− };
1401− let branches: Vec<String> =
1402− branches.iter().map(|b| css_alias(b.trim()).unwrap_or_else(|| b.trim().to_string())).collect();
1403− let new = format!("if({}, {})", condition.trim(), branches.join(", "));
1404− (new != attr.trim_end()).then_some(new)
1405−}
1406−
1407−/// A style alias as the CSS it stood for (keeping any `hover:` / `md:`
1408−/// prefix): `bold` → `font-weight bold`, `size 18` → `font-size 18`,
1409−/// `border 1 red` → `border 1 solid red`. `None` if `attr` isn't one.
1410−fn css_alias(attr: &str) -> Option<String> {
1411− let (key, value) = match attr.split_once(' ') {
1412− Some((key, value)) => (key, Some(value.trim())),
1413− None => (attr.trim(), None),
1414− };
1415− let (prefix, base) = key.rsplit_once(':').map_or(("", key), |(p, b)| (p, b));
1416− let prefix = if prefix.is_empty() { String::new() } else { format!("{prefix}:") };
1417− let css = match (base, value) {
1418− ("bold", None) => "font-weight bold".to_string(),
1419− ("italic", None) => "font-style italic".to_string(),
1420− ("underline", None) => "text-decoration underline".to_string(),
1421− ("hidden", None) => "display none".to_string(),
1422− ("size", Some(v)) => format!("font-size {v}"),
1423− ("rounded", Some(v)) => format!("border-radius {v}"),
1424− ("padding-x", Some(v)) => format!("padding-inline {v}"),
1425− ("padding-y", Some(v)) => format!("padding-block {v}"),
1426− ("margin-x", Some(v)) => format!("margin-inline {v}"),
1427− ("margin-y", Some(v)) => format!("margin-block {v}"),
1428− // `font Inter` named a family; a CSS `font` shorthand has a size
1429− ("font", Some(v)) if !v.split_whitespace().any(|t| t.starts_with(|c: char| c.is_ascii_digit())) => {
1430− format!("font-family {v}")
1431− }
1432− ("border" | "border-top" | "border-right" | "border-bottom" | "border-left", Some(v)) => {
1433− const STYLES: &[&str] = &[
1434− "none", "hidden", "dotted", "dashed", "solid", "double", "groove", "ridge",
1435− "inset", "outset",
1436− ];
1437− let mut parts = v.split_whitespace();
1438− let width = parts.next()?;
1439− let is_width = width.starts_with(|c: char| c.is_ascii_digit())
1440− || ["thin", "medium", "thick"].contains(&width);
1441− let rest: Vec<&str> = parts.collect();
1442− if !is_width || width == "0" || rest.first().is_some_and(|s| STYLES.contains(s)) {
1443− return None;
1444− }
1445− match rest.as_slice() {
1446− [] => format!("{base} {width} solid"),
1447− rest => format!("{base} {width} solid {}", rest.join(" ")),
1448− }
1449− }
1450− _ => return None,
1451− };
1452− Some(format!("{prefix}{css}"))
1453−}
1454−
1455−/// Does the attribute list at the start of `rest` (if any) have `key`?
1456−fn has_attr_key(rest: &str, key: &str) -> bool {
1457− let Some(list) = rest.strip_prefix('[').and_then(|r| r.split(']').next()) else {
1458− return false;
1459− };
1460− split_top_level_commas(list)
1461− .iter()
1462− .any(|attr| attr.split_whitespace().next() == Some(key))
1463−}
1464−
1465−fn split_top_level_commas(s: &str) -> Vec<&str> {
1466− let mut parts = Vec::new();
1467− let mut depth = 0i32;
1468− let mut in_quotes = false;
1469− let mut start = 0;
1470− for (i, c) in s.char_indices() {
1471− match c {
1472− '"' => in_quotes = !in_quotes,
1473− _ if in_quotes => {}
1474− '(' | '[' => depth += 1,
1475− ')' | ']' => depth -= 1,
1476− ',' if depth == 0 => {
1477− parts.push(&s[start..i]);
1478− start = i + 1;
1479− }
1480− _ => {}
1481− }
1482− }
1483− parts.push(&s[start..]);
1484− parts
1485−}
1486−
1487−/// Rename filter aliases in `$var|filter` chains.
1488−fn rename_filters(line: &str) -> String {
1489− if !line.contains('|') {
1490− return line.to_string();
1491− }
1492− let mut out = String::with_capacity(line.len());
1493− let mut rest = line;
1494− while let Some(pos) = rest.find('|') {
1495− out.push_str(&rest[..=pos]);
1496− rest = &rest[pos + 1..];
1497− let name_end = rest
1498− .find(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '_'))
1499− .unwrap_or(rest.len());
1500− let name = &rest[..name_end];
1501− if let Some((_, canonical)) = FILTER_ALIASES.iter().find(|(a, _)| *a == name) {
1502− out.push_str(canonical);
1503− rest = &rest[name_end..];
1504− }
1505− }
1506− out.push_str(rest);
1507− out
1508−}
1509−
1510−/// Turn `$name|filter:arg|...` chains into function calls: bare in
1511−/// expressions (`@if`, `@else if`, `@let x = ...`), and as
1512−/// `${...}` interpolation everywhere else.
1513−fn convert_filters(line: &str) -> String {
1514− let line = rename_filters(line);
1515− if !line.contains('|') {
1516− return line;
1517− }
1518− let trimmed = line.trim_start();
1519− let expression_line = ["@if ", "@else if "]
1520− .iter()
1521− .any(|p| trimmed.starts_with(p))
1522− || trimmed
1523− .strip_prefix("@let ")
1524− .and_then(|r| r.split_once(' '))
1525− .is_some_and(|(_, v)| v.trim_start().starts_with('='));
1526− let is_name = |c: char| c.is_alphanumeric() || matches!(c, '_' | '-' | '.');
1527− let mut out = String::with_capacity(line.len() + 8);
1528− let mut rest = line.as_str();
1529− while let Some(pos) = rest.find('$') {
1530− out.push_str(&rest[..pos]);
1531− let after = &rest[pos + 1..];
1532− let name_end = after.find(|c: char| !is_name(c)).unwrap_or(after.len());
1533− let mut expr = format!("${}", &after[..name_end]);
1534− let mut tail = &after[name_end..];
1535− let mut converted = false;
1536− while name_end > 0 && tail.starts_with('|') {
1537− // A filter is a name (letters) with optional `:arg` parts.
1538− let body = &tail[1..];
1539− let name_len = body
1540− .find(|c: char| !(c.is_ascii_alphabetic() || c == '-'))
1541− .unwrap_or(body.len());
1542− let mut filter_end = 1 + name_len;
1543− while tail[filter_end..].starts_with(':') {
1544− let arg = &tail[filter_end + 1..];
1545− let arg_len = arg
1546− .find(|c: char| matches!(c, ':' | '|' | ',' | ']' | '}' | ')' | '!' | '?' | ';') || c.is_whitespace())
1547− .unwrap_or(arg.len());
1548− filter_end += 1 + arg_len;
1549− }
1550− let mut parts = tail[1..filter_end].split(':');
1551− let name = parts.next().unwrap_or("");
1552− let args: Vec<String> = parts
1553− .map(|a| {
1554− if a.parse::<f64>().is_ok() || a.starts_with('#') {
1555− a.to_string()
1556− } else {
1557− format!("\"{}\"", a)
1558− }
1559− })
1560− .collect();
1561− expr = if args.is_empty() {
1562− format!("{}({})", name, expr)
1563− } else {
1564− format!("{}({}, {})", name, expr, args.join(", "))
1565− };
1566− converted = true;
1567− tail = &tail[filter_end..];
1568− }
1569− if converted && !expression_line {
1570− out.push_str(&format!("${{{}}}", expr));
1571− } else {
1572− out.push_str(&expr);
1573− }
1574− rest = tail;
1575− }
1576− out.push_str(rest);
1577− out
1578−}
1579−
1580−/// Replace whole-token `$name` references with `value`.
1581−fn replace_var(line: &str, name: &str, value: &str) -> String {
1582− let needle = format!("${name}");
1583− let mut out = String::with_capacity(line.len());
1584− let mut rest = line;
1585− while let Some(pos) = rest.find(&needle) {
1586− let after = rest[pos + needle.len()..].chars().next();
1587− out.push_str(&rest[..pos]);
1588− if after.is_some_and(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
1589− out.push_str(&needle);
1590− } else {
1591− out.push_str(value);
1592− }
1593− rest = &rest[pos + needle.len()..];
1594− }
1595− out.push_str(rest);
1596− out
1597−}
1598−
1599−fn indent_of(line: &str) -> usize {
1600− line.len() - line.trim_start().len()
1601−}
1602−
1603−fn starts_directive(trimmed: &str, directive: &str) -> bool {
1604− trimmed
1605− .strip_prefix(directive)
1606− .is_some_and(|rest| rest.is_empty() || rest.starts_with([' ', '[']))
1607−}
1608−
1609−/// Index just past the indented block owned by `lines[i]` (trailing blank
1610−/// lines are not part of the block).
1611−fn block_end(lines: &[&str], i: usize) -> usize {
1612− let indent = indent_of(lines[i]);
1613− let mut end = i + 1;
1614− let mut j = i + 1;
1615− while j < lines.len() {
1616− let l = lines[j];
1617− if l.trim().is_empty() {
1618− j += 1;
1619− continue;
1620− }
1621− if indent_of(l) <= indent {
1622− break;
1623− }
1624− j += 1;
1625− end = j;
1626− }
1627− end
1628−}
1629−
1630−/// If `lines[i]` opens a multi-line `"""` string (`@raw """` or
1631−/// `@let name """`), the index just past its closing `"""`.
1632−fn triple_quote_end(lines: &[&str], i: usize) -> Option<usize> {
1633− let trimmed = lines[i].trim();
1634− let open = trimmed.find("\"\"\"")?;
1635− let after = &trimmed[open + 3..];
1636− if after.contains("\"\"\"") {
1637− return None; // single-line
1638− }
1639− let mut j = i + 1;
1640− while j < lines.len() {
1641− if lines[j].trim() == "\"\"\"" {
1642− return Some(j + 1);
1643− }
1644− j += 1;
1645− }
1646− Some(lines.len())
1647−}
1648−
1649−/// Remove the extra indentation a block had under its (removed) owner.
1650−fn dedent_block(body: &[&str], owner_indent: usize) -> Vec<String> {
1651− let body_indent = body
1652− .iter()
1653− .filter(|l| !l.trim().is_empty())
1654− .map(|l| indent_of(l))
1655− .min()
1656− .unwrap_or(owner_indent);
1657− let shift = body_indent.saturating_sub(owner_indent);
1658− body.iter()
1659− .map(|l| {
1660− if l.trim().is_empty() {
1661− String::new()
1662− } else {
1663− l.get(shift..).unwrap_or(l.trim_start()).to_string()
1664− }
1665− })
1666− .collect()
1667−}
1668−
1669−#[cfg(test)]
1670−mod tests {
1671− use super::upgrade;
1672−
1673− fn up(src: &str) -> String {
1674− upgrade(src).output
1675− }
1676−
1677− #[test]
1678− fn element_aliases() {
1679− assert_eq!(up("@col [padding 4]\n @p hi"), "@el [padding 4]\n @paragraph hi");
1680− assert_eq!(up("@el > @btn Go"), "@el > @button Go");
1681− assert_eq!(up("Say {@img a.png} now"), "Say {@image a.png} now");
1682− // Not aliases: @page, @paragraph, text mentioning @p
1683− assert_eq!(up("@page T\nemail me @p"), "@page T\nemail me @p");
1684− // A user function named like an alias is left alone.
1685− let src = "@let divider\n @hr\n@divider";
1686− assert_eq!(up(src), src);
1687− }
1688−
1689− #[test]
1690− fn directives() {
1691− assert_eq!(up("@unless $x\n hi"), "@if not $x\n hi");
1692− assert_eq!(up("@fn card $t\n @text $t"), "@let card $t\n @text $t");
1693− assert_eq!(up("@define c [bold]\n@mixin m [italic]"), "@let c [font-weight bold]\n@let m [font-style italic]");
1694− assert_eq!(up("@for $i in 1..3\n $i"), "@each $i in 1..3\n $i");
1695− assert_eq!(up("@use \"lib.hl\" a, b"), "@include lib.hl");
1696− assert_eq!(up("@import theme.hl\n@import \"ui.hl\" as ui\n@ui.card [x $ui.gap]"), "@include theme.hl\n@include \"ui.hl\"\n@card [x $gap]");
1697− assert_eq!(
1698− up("@repeat 2\n @text $_count"),
1699− "@each $_ in 1..2\n @text 2"
1700− );
1701− }
1702−
1703− #[test]
1704− fn switch_to_match() {
1705− assert_eq!(
1706− up("@switch $v\n @case a [color red]\n @text A\n @default [color blue]"),
1707− "@match $v\n @case a\n @let __switch [color red]\n @text A\n @default\n @let __switch [color blue]"
1708− );
1709− }
1710−
1711− #[test]
1712− fn with_and_layout() {
1713− assert_eq!(up("@with $a as b\n @text $b"), "@let b $a\n@text $b");
1714− assert_eq!(up("@defer Loading\n @text x"), "@text x");
1715− assert_eq!(up("@layout base.hl\n @text x\n"), "@include base.hl\n@base-layout\n @text x\n");
1716− let r = upgrade("@layout base.hl\n @text x\n@text after");
1717− assert_eq!(r.output, "@layout base.hl\n @text x\n@text after");
1718− assert_eq!(r.manual.len(), 1);
1719− }
1720−
1721− #[test]
1722− fn css_directives_become_style() {
1723− assert_eq!(
1724− up("@scope .card\n .t { color: red; }"),
1725− "@style\n @scope (.card) {\n .t { color: red; }\n }"
1726− );
1727− assert_eq!(
1728− up("@css-property --x\n syntax \"<color>\"\n initial-value #000"),
1729− "@style\n @property --x {\n syntax:\"<color>\";\n inherits:false;\n initial-value:#000;\n }"
1730− );
1731− }
1732−
1733− #[test]
1734− fn attributes_and_filters() {
1735− assert_eq!(
1736− up("@el [...$card, animate fade 1s, hover:inset-area top] x"),
1737− "@el [$card, animation fade 1s, hover:position-area top] x"
1738− );
1739− assert_eq!(up("@el [\n animate spin 1s\n]"), "@el [\n animation spin 1s\n]");
1740− assert_eq!(up("@text $name|upper|len"), "@text ${length(uppercase($name))}");
1741− // Text that merely mentions a renamed word is untouched.
1742− assert_eq!(up("@text [opacity 1] please animate this"), "@text [opacity 1] please animate this");
1743− }
1744−
1745− #[test]
1746− fn standard_library_migrations() {
1747− assert_eq!(
1748− up("@el [truncate, height 20]\n@el [no-scrollbar]"),
1749− "@el [$truncate, height 20]\n@el [scrollbar-width none]"
1750− );
1751− assert_eq!(
1752− up("@el [gradient #f00 #00f 45deg, padding 4]"),
1753− "@el [background linear-gradient(45deg,#f00,#00f), padding 4]"
1754− );
1755− assert_eq!(up("@tooltip Hover me"), "@text [title=Hover me] Hover me");
1756− assert_eq!(up("@tooltip [tip X] Y"), "@tooltip [tip X] Y");
1757− }
1758−
1759− #[test]
1760− fn head_directives_and_debugging() {
1761− assert_eq!(
1762− up("@page Home\n@lang en\n@favicon /f.png\n@text hi\n"),
1763− "@page [lang en, favicon /f.png] Home\n@text hi\n"
1764− );
1765− assert_eq!(up("@og title \"My Page\""), "@meta og:title My Page");
1766− assert_eq!(up("@canonical https://x.dev/a"), "@head\n <link rel=\"canonical\" href=\"https://x.dev/a\">");
1767− assert_eq!(up("@debug hi $x\n@log $a $b\n@assert $x == 1\n@warn hi\n@text x"), "@text x");
1768− assert_eq!(up("@component card $t\n @text $t"), "@let card $t\n @text $t");
1769− assert_eq!(up("@el [color $on ? green : gray]"), "@el [color if($on, green, gray)]");
1770− let r = upgrade("@lang en\n@text hi");
1771− assert_eq!(r.manual.len(), 1);
1772− }
1773−
1774− #[test]
1775− fn html_attributes_use_equals() {
1776− assert_eq!(
1777− up("@input [type email, name e, required, padding 8]"),
1778− "@input [type=email, name=e, required, padding 8]"
1779− );
1780− assert_eq!(up("[id main, aria-label Close]\n x"), "@el [id=main, aria-label=Close]\n x");
1781− // Idempotent: a second run changes nothing.
1782− let once = up("@el [aria-label Main menu]");
1783− assert_eq!(once, "@el [aria-label=Main menu]");
1784− assert_eq!(up(&once), once);
1785− assert_eq!(up("@let card [class note, padding 4]"), "@let card [class=note, padding 4]");
1786− // Function parameters and directive options are left alone.
1787− assert_eq!(up("@let card $title\n @text $title\n@card [title Hi]"), "@let card $title\n @text $title\n@card [title Hi]");
1788− assert_eq!(up("@page [lang en] Home"), "@page [lang en] Home");
1789− // Inline elements in text, and `@let` inside a raw string.
1790− assert_eq!(
1791− up("Read {@abbr [title HyperText] HTML} now"),
1792− "Read {@abbr [title=HyperText] HTML} now"
1793− );
1794− assert_eq!(
1795− up("@raw \"\"\"\n@let button $x\n\"\"\"\n@button [type submit] Go"),
1796− "@raw\n @let button $x\n@button [type=submit] Go"
1797− );
1798− // Multi-line lists keep their element context.
1799− assert_eq!(up("@input [\n type email,\n padding 8\n]"), "@input [\n type=email,\n padding 8\n]");
1800− }
1801−
1802− #[test]
1803− fn computed_let_needs_equals() {
1804− assert_eq!(up("@let gap $base + 4"), "@let gap = $base + 4");
1805− assert_eq!(up("@let full $a ~ \" \" ~ $b"), "@let full \"$a $b\"");
1806− assert_eq!(up("@let x = $n ~ \"px\""), "@let x \"${$n}px\"");
1807− assert_eq!(up("@let x = 1 + 2"), "@let x = 1 + 2");
1808− assert_eq!(up("@let area 1 / span 2"), "@let area 1 / span 2");
1809− assert_eq!(up("@let card $title $tone=primary\n @text $title"), "@let card $title $tone=primary\n @text $title");
1810− }
1811−
1812− #[test]
1813− fn data_directives() {
1814− assert_eq!(up("@env API_URL http://x"), "@data $api_url env:API_URL http://x");
1815− assert_eq!(up("@collection $posts \"posts/*.json\""), "@data $posts posts/*.json");
1816− assert_eq!(
1817− up("@page [lang fr] T\n@translations\n en:\n hi Hello\n fr:\n hi Bonjour\n@text $t.hi"),
1818− "@page [lang fr] T\n@let t.hi Bonjour\n@text $t.hi"
1819− );
1820− assert_eq!(upgrade("@translations\n en:\n hi Hello\n fr:\n hi Salut").manual.len(), 1);
1821− assert_eq!(upgrade("@fetch $d http://x").manual.len(), 1);
1822− }
1823−
1824− #[test]
1825− fn css_alias_attributes() {
1826− assert_eq!(
1827− up("@el [gap-x 4, hover:shadow 0 1px red, blur 4, backdrop-blur 2px, truncate, critical]"),
1828− "@el [column-gap 4, hover:box-shadow 0 1px red, filter blur(4px), backdrop-filter blur(2px), $truncate]"
1829− );
1830− assert_eq!(up("@el [critical, padding 4]"), "@el [padding 4]");
1831− assert_eq!(up("@el [grid, grid-cols 3]"), "@el [display grid, grid-cols 3]");
1832− }
1833−
1834− #[test]
1835− fn small_directives() {
1836− assert_eq!(
1837− up("@match $v\n @case a\n @text A\n @case b c\n @text B\n @default\n @text D"),
1838− "@if $v == \"a\"\n @text A\n@else if $v == \"b c\"\n @text B\n@else\n @text D"
1839− );
1840− assert_eq!(
1841− up("@theme\n primary #3b82f6\n radius 8"),
1842− "@let primary #3b82f6\n@let --primary #3b82f6\n@meta theme-color #3b82f6\n@let radius 8\n@let --radius 8"
1843− );
1844− assert_eq!(
1845− up("@json-ld\n {\"a\": 1}"),
1846− "@head\n <script type=\"application/ld+json\">\n {\"a\": 1}\n </script>"
1847− );
1848− assert_eq!(
1849− up("@font-face Inter fonts/inter.woff2"),
1850− "@style\n @font-face { font-family: 'Inter'; src: url('fonts/inter.woff2') format('woff2'); font-display: swap; }\n@head\n <link rel=\"preload\" href=\"fonts/inter.woff2\" as=\"font\" crossorigin>"
1851− );
1852− assert_eq!(up("@deprecated old\n@let x 1"), "@let x 1");
1853− assert_eq!(upgrade("@breakpoint tablet 600").manual.len(), 1);
1854− }
1855−
1856− #[test]
1857− fn filters_become_functions() {
1858− assert_eq!(up("@text $name|uppercase!"), "@text ${uppercase($name)}!");
1859− assert_eq!(
1860− up("@el [color $base|darken:10, background $base|mix:#ffffff:50]"),
1861− "@el [color ${darken($base, 10)}, background ${mix($base, #ffffff, 50)}]"
1862− );
1863− assert_eq!(up("@if $name|length > 3\n x"), "@if length($name) > 3\n x");
1864− assert_eq!(up("@let short = $title|truncate:5"), "@let short = truncate($title, 5)");
1865− assert_eq!(up("Hi $who|default:friend"), "Hi ${default($who, \"friend\")}");
1866− assert_eq!(up("a | b $x | c"), "a | b $x | c");
1867− }
1868−
1869− #[test]
1870− fn svg_becomes_inline_image() {
1871− assert_eq!(up("@svg icons/a.svg"), "@image [inline] icons/a.svg");
1872− assert_eq!(
1873− up("@svg [width 24, color red, class icon] a.svg"),
1874− "@image [inline, width 24, color red, class=icon] a.svg"
1875− );
1876− }
1877−
1878− #[test]
1879− fn definitions_in_verbatim_bodies_are_text() {
1880− assert_eq!(
1881− up("@raw\n @let button $x\n@button [bold] Go\n"),
1882− "@raw\n @let button $x\n@button [font-weight bold] Go\n"
1883− );
1884− }
1885−
1886− #[test]
1887− fn style_aliases_become_css() {
1888− assert_eq!(
1889− up("@el [bold, hover:underline, size 18, rounded 8, padding-x 4, md:hidden, font \"Inter, sans-serif\"]\n"),
1890− "@el [font-weight bold, hover:text-decoration underline, font-size 18, border-radius 8, padding-inline 4, md:display none, font-family \"Inter, sans-serif\"]\n"
1891− );
1892− assert_eq!(
1893− up("@el [border 1 #e5e7eb, focus:border-top 2px $c, border 0, border 1px dashed red, font 16px Inter]\n"),
1894− "@el [border 1 solid #e5e7eb, focus:border-top 2px solid $c, border 0, border 1px dashed red, font 16px Inter]\n"
1895− );
1896− // HTML attributes and function parameters are left alone
1897− assert_eq!(up("@select [size=4]\n@let card $size\n @el\n@card [size 3]\n"), "@select [size=4]\n@let card $size\n @el\n@card [size 3]\n");
1898− assert_eq!(up("@el [if($on, bold, italic)]\n"), "@el [if($on, font-weight bold, font-style italic)]\n");
1899− // Idempotent
1900− let once = up("@el [bold, border 1 red]\n");
1901− assert_eq!(up(&once), once);
1902− }
1903−
1904− #[test]
1905− fn loops_and_lists() {
1906− assert_eq!(
1907− up("@each $label, $url in About us /about, Home /\n @link $url $label\n"),
1908− "@data $label-url [{\"label\": \"About\", \"url\": \"us /about\"}, {\"label\": \"Home\", \"url\": \"/\"}]\n@each $item in $label-url\n @link $item.url $item.label\n"
1909− );
1910− assert_eq!(
1911− up("@each $x in a, b\n @text $_index: $x\n"),
1912− "@each $x, $index in a, b\n @text $index: $x\n"
1913− );
1914− assert_eq!(
1915− up("@if $posts._count > 0\n @text ${$posts._count} posts\n"),
1916− "@if length($posts) > 0\n @text ${length($posts)} posts\n"
1917− );
1918− let result = super::upgrade("@data $p posts/*.json\n@text $p.a.title\n@each $a, $b in $list\n x\n");
1919− assert_eq!(result.manual.len(), 2, "{:?}", result.manual);
1920− }
1921−
1922− #[test]
1923− fn raw_strings_become_indented_bodies() {
1924− assert_eq!(up("@raw \"\"\"<br>\"\"\"\n"), "@raw <br>\n");
1925− assert_eq!(
1926− up("@el\n @raw \"\"\"\n<div>\n\n <p>x</p>\n</div>\n\"\"\"\n@text y\n"),
1927− "@el\n @raw\n <div>\n\n <p>x</p>\n </div>\n@text y\n"
1928− );
1929− }
1930−
1931− #[test]
1932− fn keyframes_move_into_style() {
1933− assert_eq!(
1934− up("@keyframes fade\n from [opacity 0, transform translate(0, 4px)]\n 50% { opacity: 0.5; }\n to [opacity 1]\n@text x\n"),
1935− "@style\n @keyframes fade {\n from { opacity: 0; transform: translate(0, 4px); }\n 50% { opacity: 0.5; }\n to { opacity: 1; }\n }\n@text x\n"
1936− );
1937− }
1938−
1939− #[test]
1940− fn unnamed_data_needs_a_manual_name() {
1941− let result = super::upgrade("@data site.json\n@data $ok ok.json\n");
1942− assert_eq!(result.manual.len(), 1);
1943− assert_eq!(result.manual[0].0, 1);
1944− }
1945−
1946− #[test]
1947− fn bare_attribute_lines_become_el() {
1948− assert_eq!(up("[padding 4]\n x\n"), "@el [padding 4]\n x\n");
1949− assert_eq!(up(" [\n padding 4\n ] > @link / Home\n"), " @el [\n padding 4\n ] > @link / Home\n");
1950− }
1951−
1952− #[test]
1953− fn conditional_attributes_become_if() {
1954− assert_eq!(
1955− up("@button [padding 10 if $big, disabled if $x == \"a b\", color if($on, red, blue)] Go\n"),
1956− "@button [if($big, padding 10), if($x == \"a b\", disabled), color if($on, red, blue)] Go\n"
1957− );
1958− assert_eq!(up("@el [shadow if $on]\n"), "@el [if($on, box-shadow)]\n");
1959− }
1960−
1961− #[test]
1962− fn layouts_become_functions() {
1963− assert_eq!(
1964− up("@extends layout.hl\n@slot header\n @text Hi\n@paragraph Body\n"),
1965− "@include layout.hl\n@layout\n @slot header\n @text Hi\n @paragraph Body\n"
1966− );
1967− assert_eq!(super::layout_function_name("parts/base.hl"), "base-layout");
1968− assert_eq!(
1969− super::convert_layout("@page Site\n@el\n @children\n", "layout").unwrap(),
1970− "@let layout\n @page Site\n @el\n @children\n"
1971− );
1972− assert!(super::convert_layout("@let layout\n x\n", "layout").is_none());
1973− assert_eq!(
1974− super::convert_layout(
1975− "@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",
1976− "layout"
1977− )
1978− .unwrap(),
1979− "@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"
1980− );
1981− }
1982−
1983− #[test]
1984− fn verbatim_regions_are_untouched() {
1985− let src = "@script\n for (x of y) {}\n@raw\n @unless\n@style\n .p { }\n@let s \"\"\"\n@unless\n\"\"\"";
1986− assert_eq!(up(src), src);
1987− assert_eq!(up("@script [src app.js, defer]"), "@script [src=app.js, defer]");
1988− }
1989−}
tests/docs.rs−2
@@ -72,9 +72,7 @@ fn check_doc(file: &str) {
7272 || d.message.contains("unknown")
7373 || d.message.contains("undefined variable")
7474 || d.message.contains("is an HTML attribute")
75− || d.message.contains("was removed")
7675 || d.message.contains("no single root")
77− || d.message.contains("filters are functions now")
7876 })
7977 .map(|d| format!("line {}: {}", d.line, d.message))
8078 .collect();
tests/regressions.rs+1−6
@@ -423,12 +423,7 @@ fn raw_takes_an_indented_body() {
423423}
424424
425425#[test]
426−fn style_aliases_point_to_css() {
427− let result = parser::parse("@el [bold, size 18, font-weight bold]\n x");
428− let messages: Vec<&str> = result.diagnostics.iter().map(|d| d.message.as_str()).collect();
429− assert!(messages.iter().any(|m| m.contains("`font-weight bold`")), "{:?}", messages);
430− assert!(messages.iter().any(|m| m.contains("`font-size`")), "{:?}", messages);
431− // Border shorthands get px like other lengths
426+fn border_shorthands_get_px() {
432427 let out = compile("@el [border 1 solid red, border-radius 4 4 0 0] x");
433428 assert!(out.contains("border:1px solid red"), "{}", out);
434429 assert!(out.contains("border-radius:4px 4px 0 0"), "{}", out);
tests/snapshots.rs−164
@@ -725,59 +725,6 @@ fn ol_renders_ol() {
725725 assert!(output.contains("<li"));
726726}
727727
728−#[test]
729−fn removed_aliases_point_to_upgrade() {
730− for (src, replacement) in [
731− ("@divider", "@hr"),
732− ("@el > @col x", "@el"),
733− ("Say {@img a.png}", "@image"),
734− ] {
735− let diags = parse_diagnostics(src);
736− assert!(
737− diags.iter().any(|d| d.message.contains(replacement)
738− && d.message.contains("htmlang upgrade")),
739− "{:?} should point to {}: {:?}",
740− src,
741− replacement,
742− diags
743− );
744− }
745−}
746−
747−#[test]
748−fn removed_directives_point_to_upgrade() {
749− for src in [
750− "@unless $x\n hi",
751− "@for $i in 1..2\n $i",
752− "@repeat 2\n hi",
753− "@switch $v\n @case a\n hi",
754− "@use \"a.hl\" x",
755− "@import a.hl",
756− "@with $a as b\n hi",
757− "@layout base.hl\n hi",
758− "@scope .c\n .t {}",
759− "@starting-style\n .t {}",
760− "@css-property --x\n syntax \"*\"",
761− "@fn card $t\n @text $t",
762− "@define c [bold]",
763− ] {
764− let diags = parse_diagnostics(src);
765− assert!(
766− diags.iter().any(|d| d.message.contains("was removed")
767− && d.message.contains("htmlang upgrade")),
768− "{:?} should report removed syntax: {:?}",
769− src,
770− diags
771− );
772− }
773−}
774−
775−#[test]
776−fn user_function_may_reuse_a_removed_name() {
777− let output = compile("@page T\n@let divider\n @hr\n@divider");
778− assert!(output.contains("<hr"), "{}", output);
779−}
780−
781728// ---------------------------------------------------------------------------
782729// Feature tests: table elements
783730// ---------------------------------------------------------------------------
@@ -1031,16 +978,6 @@ fn aria_live_passthrough() {
1031978 assert!(output.contains("aria-live=\"polite\""));
1032979}
1033980
1034−#[test]
1035−fn defer_directive_was_removed() {
1036− let diags = parse_diagnostics("@page T\n@defer\n @el\n Lazy content");
1037− assert!(
1038− diags.iter().any(|d| d.message.contains("`@defer` was removed")),
1039− "{:?}",
1040− diags
1041− );
1042−}
1043−
1044981#[test]
1045982fn comparison_gte_lte() {
1046983 let output = compile("@page T\n@let x 5\n@if $x >= 5\n @text gte\n@if $x <= 5\n @text lte");
@@ -2789,17 +2726,6 @@ fn test_css_shorthands_output() {
27892726 "backdrop-blur should work, got: {}",
27902727 html
27912728 );
2792−
2793− // `gradient` became plain CSS: the compiler points to the replacement.
2794− let result = htmlang::parser::parse("@el [gradient #fff #000] Content");
2795− assert!(
2796− result
2797− .diagnostics
2798− .iter()
2799− .any(|d| d.message.contains("linear-gradient") && d.message.contains("upgrade")),
2800− "gradient should point to linear-gradient: {:?}",
2801− result.diagnostics
2802− );
28032729}
28042730
28052731#[test]
@@ -2817,16 +2743,6 @@ fn test_import_missing_file() {
28172743 );
28182744}
28192745
2820−#[test]
2821−fn keyframes_was_removed() {
2822− let diags = parse_diagnostics("@keyframes fade\n from [opacity 0]\n");
2823− assert!(
2824− diags.iter().any(|d| d.message.contains("`@keyframes` rule in `@style`")),
2825− "{:?}",
2826− diags
2827− );
2828−}
2829−
28302746#[test]
28312747fn test_theme_directive() {
28322748 let result = htmlang::parser::parse(
@@ -2858,27 +2774,6 @@ fn test_theme_directive() {
28582774 assert!(html.contains("background:#3b82f6"), "{}", html);
28592775}
28602776
2861−#[test]
2862−fn deprecated_was_removed() {
2863− let diags = parse_diagnostics("@deprecated old\n@let card\n @text x");
2864− assert!(diags.iter().any(|d| d.message.contains("`@deprecated` was removed")), "{:?}", diags);
2865−}
2866−
2867−#[test]
2868−fn test_extends_was_removed() {
2869− let result = htmlang::parser::parse("@extends layout.hl\n@slot content\n Hello");
2870− let has_error = result.diagnostics.iter().any(|d| {
2871− d.message.contains("was removed")
2872− && d.message.contains("function")
2873− && d.severity == htmlang::parser::Severity::Error
2874− });
2875− assert!(
2876− has_error,
2877− "@extends should point at layout functions, got: {:?}",
2878− result.diagnostics
2879− );
2880−}
2881−
28822777#[test]
28832778fn test_color_filter_lighten() {
28842779 let result = htmlang::parser::parse(
@@ -3533,19 +3428,6 @@ fn conditional_attribute_can_pick_a_bundle() {
35333428 assert!(!html.contains("bold"), "{}", html);
35343429}
35353430
3536−#[test]
3537−fn key_if_condition_was_removed() {
3538− let result = htmlang::parser::parse("@let on true\n@el [bold if $on]\n test\n");
3539− assert!(
3540− result
3541− .diagnostics
3542− .iter()
3543− .any(|d| d.message.contains("was removed") && d.message.contains("if($on, bold)")),
3544− "{:?}",
3545− result.diagnostics
3546− );
3547−}
3548−
35493431// -----------------------------------------------------------------------
35503432// @component tests
35513433// -----------------------------------------------------------------------
@@ -3634,20 +3516,6 @@ fn minified_strips_comments() {
36343516 assert!(!minified.contains("<!--"));
36353517}
36363518
3637−// -----------------------------------------------------------------------
3638−// Critical CSS test
3639−// -----------------------------------------------------------------------
3640−
3641−#[test]
3642−fn critical_attr_was_removed() {
3643− let diags = parse_diagnostics("@el [critical, padding 20]\n test\n");
3644− assert!(
3645− diags.iter().any(|d| d.message.contains("'critical' was removed")),
3646− "{:?}",
3647− diags
3648− );
3649−}
3650−
36513519// -----------------------------------------------------------------------
36523520// Enhanced a11y warnings
36533521// -----------------------------------------------------------------------
@@ -4069,12 +3937,6 @@ fn starting_style_generates_css() {
40693937 );
40703938}
40713939
4072−#[test]
4073−fn manifest_was_removed() {
4074− let diags = parse_diagnostics("@page App\n@manifest My App\n display standalone\n@text hi");
4075− assert!(diags.iter().any(|d| d.message.contains("`@manifest` was removed")), "{:?}", diags);
4076−}
4077−
40783940#[test]
40793941fn subgrid_support() {
40803942 let output = compile("@el [grid-template-columns subgrid]");
@@ -4127,12 +3989,6 @@ fn snapshot_translations_i18n() {
41273989 snapshot_test("translations_i18n");
41283990}
41293991
4130−#[test]
4131−fn each_pagination_was_removed() {
4132− let diags = parse_diagnostics("@each $item in a,b,c [page 2]\n @text $item");
4133− assert!(diags.iter().any(|d| d.message.contains("pagination")), "{:?}", diags);
4134−}
4135−
41363992#[test]
41373993fn snapshot_env_directive() {
41383994 snapshot_test("env_directive");
@@ -4150,16 +4006,6 @@ fn snapshot_responsive_images() {
41504006
41514007// --- Inline tests for batch 2 ---
41524008
4153−#[test]
4154−fn translations_point_to_data_files() {
4155− let diags = parse_diagnostics("@translations\n en:\n hello Hi");
4156− assert!(
4157− diags.iter().any(|d| d.message.contains("@data $t locales/$lang.json")),
4158− "{:?}",
4159− diags
4160− );
4161−}
4162−
41634009#[test]
41644010fn source_map_generation() {
41654011 let input = "@page Test\n@text [font-weight bold] Hello";
@@ -4238,16 +4084,6 @@ fn env_directive_warning_when_missing() {
42384084 );
42394085}
42404086
4241−#[test]
4242−fn fetch_was_removed() {
4243− let diags = parse_diagnostics("@fetch $data https://example.com/api");
4244− assert!(
4245− diags.iter().any(|d| d.message.contains("`@fetch` was removed")),
4246− "{:?}",
4247− diags
4248− );
4249−}
4250−
42514087#[test]
42524088fn svg_directive_inline() {
42534089 // Create a temporary SVG file