@@ -1,1989 +0,0 @@
1−//! `htmlang upgrade`: rewrite removed or renamed syntax to its current form.
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.
8−/// Result of upgrading one file.
12− /// Constructs that need a manual rewrite: (1-based line, message).
13− pub manual: Vec<(usize, String)>,
16−/// Element aliases and their canonical names.
17−const ELEMENT_ALIASES: &[(&str, &str)] = &[
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"),
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"),
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"];
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('"'))
53− .map(|s| s.to_string_lossy().to_string())
54− .unwrap_or_default();
55− let stem: String = stem
57− .map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' })
59− if stem.ends_with("layout") {
62− format!("{}-layout", stem)
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) {
76− let lines: Vec<&str> = input.lines().collect();
77− let mut hoisted: Vec<String> = Vec::new();
78− let mut body: Vec<String> = Vec::new();
80− while i < lines.len() {
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);
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()
94− .find(|l| !l.trim().is_empty())
95− .is_some_and(|l| l.starts_with([' ', '\t'])));
99− depth += bracket_depth(next);
102− hoisted.extend(lines[i..end].iter().map(|l| l.to_string()));
106− body.push(if line.trim().is_empty() {
109− format!(" {}", line)
113− while body.first().is_some_and(|l| l.is_empty()) {
116− while body.last().is_some_and(|l| l.is_empty()) {
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());
123− out.push(definition);
125− let mut text = out.join("\n");
130−fn bracket_depth(line: &str) -> i32 {
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;
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
154− let mut user_defined: Vec<&str> = Vec::new();
156− while j < lines.len() {
157− if let Some(end) = triple_quote_end(&lines, j) {
161− if VERBATIM_BODIES.iter().any(|d| starts_directive(lines[j].trim(), d)) {
162− j = block_end(&lines, j);
165− if let Some(name) = lines[j]
167− .strip_prefix("@let ")
168− .and_then(|rest| rest.split_whitespace().next())
170− user_defined.push(name);
175− while i < lines.len() {
176− let line = lines[i];
177− let trimmed = line.trim();
178− let indent = indent_of(line);
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!(
186− after_open.strip_suffix("\"\"\"").unwrap_or(after_open)
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}") }
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()));
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);
223− out.extend(lines[i + 1..end].iter().map(|l| l.to_string()));
228− // --- Block rewrites ---
230− && let Some(block) = rewrite_block(&lines, i, &mut manual, &user_defined)
232− out.extend(block.lines);
238− // Directives removed without a replacement: drop the line
240− && ["@assert ", "@warn ", "@debug ", "@log ", "@deprecated "]
242− .any(|d| trimmed.starts_with(d) && !user_defined.contains(&&d[1..d.len() - 1]))
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);
255− if attr_depth == 0 {
256− element_attrs = takes_element_attributes(new_line.trim_start(), &user_defined);
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 {
268− let mut output = out.join("\n");
269− if input.ends_with('\n') {
272− let (output, unaliased) = drop_include_aliases(&output);
273− let (output, data_changes) = upgrade_lists(&output, &mut manual);
276− changes: changes + unaliased + data_changes,
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;
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;
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))
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"
314− let Some((names, list)) = trimmed.strip_prefix("@each ").and_then(|r| r.split_once(" in "))
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(' '));
329− if vars.len() >= 2 && literal && destructures {
330− let json_text = |s: &str| format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""));
331− let records: Vec<String> = items
334− let parts: Vec<&str> = item.splitn(vars.len(), ' ').collect();
335− let fields: Vec<String> = vars
339− format!("{}: {}", json_text(var), json_text(parts.get(k).unwrap_or(&"")))
342− format!("{{{}}}", fields.join(", "))
345− let data = vars.join("-");
346− let index = if uses_index { ", $index" } else { "" };
348− format!("{pad}@data ${} [{}]", data, records.join(", ")),
349− format!("{pad}@each $item{} in ${}", index, data),
351− for body_line in &lines[i + 1..end] {
352− let mut body_line = replace_var(body_line, "_index", "$index");
354− body_line = replace_var(&body_line, var, &format!("$item.{}", var));
356− new.push(body_line);
358− let added = new.len();
359− lines.splice(i..end, new);
364− // (A second variable named like an index already is one.)
365− let index_name = vars
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) {
372− "the second variable of `@each` is now always the index: if `{}` holds \
373− records, write `@each $item in {}` and use `$item.KEY`",
379− let index = match vars.get(1) {
380− Some(index) => index.clone(),
382− lines[i] = format!("{pad}@each ${}, $index in {}", vars[0], list);
386− for body_line in &mut lines[i + 1..end] {
387− *body_line = replace_var(body_line, "_index", &format!("${}", index));
393− (lines.join("\n"), changes)
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") {
402− let trimmed = line.trim_start();
403− let expression_line = ["@if ", "@else if "].iter().any(|p| trimmed.starts_with(p))
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('{') {
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))
425− Some(name) => out.push_str(&format!("${{length(${})}}", name)),
426− None => out.push_str(&rest[pos..pos + 1 + end]),
428− rest = &after[end..];
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 ")
444− aliases.push(alias.trim().to_string());
445− lines.push(format!("{}@include {}", &line[..line.len() - trimmed.len()], file.trim()));
447− lines.push(line.to_string());
450− if aliases.is_empty() {
451− return (text.to_string(), 0);
453− let mut out = lines.join("\n");
454− for alias in &aliases {
456− .replace(&format!("@{}.", alias), "@")
457− .replace(&format!("${}.", alias), "$");
459− (out, aliases.len())
467−/// Rewrites of directives that own an indented block.
471− manual: &mut Vec<(usize, String)>,
472− user_defined: &[&str],
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];
481− // @keyframes NAME with `from [opacity 0]` lines → the CSS rule in @style
482− if let Some(name) = trimmed.strip_prefix("@keyframes ") {
484− format!("{pad}@style"),
485− format!("{pad} @keyframes {} {{", name.trim()),
487− for line in body.iter().filter(|l| !l.trim().is_empty()) {
488− out.push(format!("{pad} {}", keyframe_css(line.trim())));
490− out.push(format!("{pad} }}"));
491− return Some(Block { lines: out, end });
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")
500− let (attrs, text) = match rest.strip_prefix('[') {
502− let close = after.find(']')?;
503− (Some(&after[..close]), after[close + 1..].trim())
505− None => (None, rest.trim()),
507− if text.is_empty() {
510− let attrs = match attrs {
511− Some(a) if !a.trim().is_empty() => format!("[title={}, {}]", text, a.trim()),
512− _ => format!("[title={}]", text),
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 });
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));
524− while j < body.len() {
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..]
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));
542− out.push(l.to_string());
546− return Some(Block { lines: out, end });
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()?;
558− .find_map(|a| a.trim().strip_prefix("lang "))
559− .map(|v| v.trim().to_string())
561− let active = if !explicit.is_empty() {
562− explicit.to_string()
564− page_lang.unwrap_or_else(|| "en".to_string())
566− let mut locale = active.clone();
567− let mut out = Vec::new();
568− let mut others = Vec::new();
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());
582− && let Some((key, value)) = t.split_once(' ')
584− out.push(format!("{pad}@let t.{} {}", key, value.trim()));
587− if !others.is_empty() {
591− "kept the '{}' strings; move the other locales ({}) to JSON files and load \
592− them with `@data $t locales/$lang.json`",
598− return Some(Block { lines: out, end });
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;
608− while j < body.len() {
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('"') {
617− format!("\"{}\"", case)
619− let keyword = if first { "@if" } else { "@else if" };
621− format!("{pad}{} {} == {}", keyword, value, quoted)
622− } else if t == "@default" {
623− format!("{pad}@else")
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));
639− return Some(Block { lines: out, end });
642− // @theme / `name value` lines → `@let name value` and `@let --name value`
643− if trimmed == "@theme" {
644− let mut out = Vec::new();
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));
655− return Some(Block { lines: out, end });
658− // @json-ld with an indented JSON body → a script in @head
659− if trimmed == "@json-ld" {
661− format!("{pad}@head"),
662− format!("{pad} <script type=\"application/ld+json\">"),
664− out.extend(dedent_block(body, indent).into_iter().map(|l| {
668− format!("{pad} {}", l.trim_start())
671− out.push(format!("{pad} </script>"));
672− return Some(Block { lines: out, end });
675− if trimmed.starts_with("@manifest") {
678− "@manifest was removed: write a manifest.json file and add \
679− `<link rel=\"manifest\" href=\"manifest.json\">` to @head"
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();
690− format!("{pad}@include {}", file),
691− format!("{pad}@{}", layout_function_name(file)),
693− out.extend(lines[i + 1..].iter().map(|l| {
694− if l.trim().is_empty() {
706− // @defer → its body, dedented
707− if trimmed == "@defer" || trimmed.starts_with("@defer ") {
709− lines: dedent_block(body, indent),
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 });
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")
727− if end < lines.len() && lines[end..].iter().any(|l| !l.trim().is_empty()) {
730− "@layout was removed: call the layout as a function (content after the \
731− @layout block has to move into its children)"
736− let file = rest.trim();
738− format!("{pad}@include {}", file),
739− format!("{pad}@{}", layout_function_name(file)),
741− out.extend(body.iter().map(|l| l.to_string()));
742− return Some(Block { lines: out, end });
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()
750− match trimmed["@scope".len()..].trim() {
751− "" => "@scope {".to_string(),
752− sel => format!("@scope ({}) {{", sel),
755− let mut out = vec![format!("{pad}@style"), format!("{pad} {prelude}")];
758− .filter(|l| !l.trim().is_empty())
759− .map(|l| format!("{pad} {}", l.trim())),
761− out.push(format!("{pad} }}"));
762− return Some(Block { lines: out, end });
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;
771− if let Some((key, value)) = l.trim().split_once(' ') {
772− if value.contains('$') {
775− "@css-property with variables must be rewritten as @style by hand"
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()),
789− format!("{pad}@style"),
790− format!("{pad} @property {} {{", name.trim()),
791− format!("{pad} syntax:{};", syntax),
792− format!("{pad} inherits:{};", inherits),
794− if let Some(initial) = initial {
795− out.push(format!("{pad} initial-value:{};", initial));
797− out.push(format!("{pad} }}"));
798− return Some(Block { lines: out, end });
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 {
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)"
813− // Renders nothing: drop the block entirely.
819− let mut out = vec![format!("{pad}@each $_ in 1..{}", count)];
820− // @repeat exposed $_count; @each doesn't, so inline it.
823− .map(|l| replace_var(l, "_count", &count.to_string())),
825− return Some(Block { lines: out, end });
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()))
844− Some(attr) => attrs.push((i, attr)),
845− None => keep.push(*line),
848− if attrs.is_empty() {
849− return (input.to_string(), 0);
851− let Some(page) = keep.iter().position(|l| l.starts_with("@page ")) else {
852− for (i, _) in &attrs {
855− "this directive now goes in @page's attributes, but the file has no @page line"
859− return (input.to_string(), 0);
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),
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') {
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();
882− let Some(list) = list.trim_end().strip_suffix(']') else {
883− return line.to_string();
885− let declarations: Vec<String> = split_top_level_commas(list)
887− .filter_map(|part| part.trim().split_once(' '))
888− .map(|(key, value)| format!("{}: {};", key.trim(), value.trim()))
890− format!("{} {{ {} }}", selector.trim(), declarations.join(" "))
893−/// Single-line directive rewrites.
894−fn rewrite_directive_line(
898− manual: &mut Vec<(usize, 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);
906− for old in ["@fn ", "@define ", "@mixin ", "@component "] {
907− if let Some(rest) = trimmed.strip_prefix(old) {
908− return format!("{pad}@let {}", rest);
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());
915− if let Some(url) = trimmed.strip_prefix("@base ") {
916− return format!("{pad}@head\n{pad} <base href=\"{}\">", url.trim());
918− if let Some(rest) = trimmed.strip_prefix("@og ")
919− && let Some((key, value)) = rest.trim().split_once(' ')
921− let value = value.trim();
924− .and_then(|v| v.strip_suffix('"'))
926− return format!("{pad}@meta og:{} {}", key, value);
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(" ~ ")
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
940− let part = part.trim();
941− if let Some(text) = part.strip_prefix('"').and_then(|p| p.strip_suffix('"')) {
943− } else if !part.contains(' ') && !part.contains('(') {
946− format!("${{{}}}", part)
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
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));
960− joined.push_str(piece);
963− return format!("{pad}@let {} \"{}\"", name, joined);
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())
970− return format!("{pad}@let {} = {}", name, value.trim());
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()),
978− let name = var.to_lowercase().replace('-', "_");
979− return format!("{pad}@data ${} env:{}{}", name, var, default);
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())),
988− .rsplit_once(" as ")
989− .map(|(p, n)| (n.trim().to_string(), p.trim().trim_matches('"').to_string())),
991− if let Some((name, pattern)) = parsed {
995− "collection values are now `${name}.STEM.key` (they were `${name}_STEM_key`), \
996− and `${name}` lists the stems separated by commas"
999− return format!("{pad}@data ${} {}", name, pattern);
1002− if trimmed.starts_with("@each ") && trimmed.trim_end().ends_with(']') && trimmed.contains("[page ") {
1005− "@each pagination ([page N]) was removed: split the list or filter it with @if"
1008− return line.to_string();
1010− if let Some(rest) = trimmed.strip_prefix("@data ")
1011− && !rest.trim_start().starts_with('$')
1015− "@data needs a name now: write `@data $name FILE` and use `$name.key` for `$key`"
1018− return line.to_string();
1020− if trimmed.starts_with("@fetch ") {
1023− "@fetch was removed: download the data before building and load it with \
1024− `@data $name file.json`"
1027− return line.to_string();
1029− if let Some(rest) = trimmed.strip_prefix("@font-face ")
1030− && let Some((name, url)) = rest.trim().split_once(' ')
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')",
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>"
1045− if trimmed.starts_with("@breakpoint ") {
1048− "@breakpoint was removed (its prefixes never took effect): write the media \
1049− query in an @style block"
1052− return line.to_string();
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),
1061− let attrs: Vec<String> = split_top_level_commas(attrs)
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(),
1070− let list = std::iter::once("inline".to_string())
1072− .collect::<Vec<_>>()
1074− return format!("{pad}@image [{}] {}", list, file);
1076− if let Some(rest) = trimmed.strip_prefix("@import ") {
1077− return format!("{pad}@include {}", rest);
1079− if let Some(rest) = trimmed.strip_prefix("@unless ") {
1080− return format!("{pad}@if not {}", rest);
1082− if let Some(rest) = trimmed.strip_prefix("@for ") {
1083− return format!("{pad}@each {}", rest);
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("")
1090− rest.split(|c: char| c.is_whitespace() || c == ',')
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();
1098− return format!("{pad}@include {}", file);
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(['=', '[', '"']) {
1109− if value.contains(" ~ ") {
1112− [" * ", " / ", " + ", " - "].iter().any(|op| {
1113− value.split_once(op).is_some_and(|(l, r)| {
1114− let operand = |s: &str| {
1116− s.parse::<f64>().is_ok() || (s.starts_with('$') && !s.contains(' '))
1118− operand(l) && operand(r)
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();
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
1141− .find(|(a, _)| *a == name && !user_defined.contains(a));
1142− if let Some((_, canonical)) = alias {
1144− out.push_str(canonical);
1150− let ch = line[i..].chars().next().unwrap();
1152− i += ch.len_utf8();
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",
1172−/// Components the standard library had, which forwarded attributes to an
1174−const STD_COMPONENTS: &[&str] = &[
1175− "badge", "tag", "chip", "avatar", "spacer", "tooltip", "carousel", "breadcrumb",
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 {
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];
1189− // An attribute bundle: `@let card [...]`
1190− return rest[name_end..]
1193− .is_some_and(|(_, v)| v.trim_start().starts_with('['));
1195− !user_defined.contains(&name)
1196− && (htmlang_core::ast::ElementKind::from_name(name).is_some()
1197− || STD_COMPONENTS.contains(&name))
1200−fn rewrite_attr_regions(
1203− element_attrs: &mut bool,
1204− user_defined: &[&str],
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();
1212− let mut out = String::with_capacity(line.len());
1213− let mut region = String::new();
1214− for ch in line.chars() {
1217− '[' => *depth += 1,
1218− ']' => *depth -= 1,
1222− out.push_str(&rewrite_attr_list(®ion, *element_attrs));
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);
1243− out.push_str(&rewrite_attr_list(®ion, *element_attrs));
1247−/// Rewrite one attribute list's contents (without the brackets).
1248−/// Split `key value if condition` at its ` if ` (outside parentheses and
1250−fn split_trailing_if(attr: &str) -> Option<(&str, &str)> {
1252− let mut quote = None;
1253− for (i, c) in attr.char_indices() {
1255− '"' | '\'' if quote == Some(c) => quote = None,
1256− '"' | '\'' if quote.is_none() => quote = Some(c),
1257− _ if quote.is_some() => {}
1260− _ if depth == 0 && attr[i..].starts_with(" if ") => {
1261− return Some((&attr[..i], attr[i + 4..].trim()));
1269−fn rewrite_attr_list(list: &str, element_attrs: bool) -> String {
1270− if list.is_empty() {
1271− return String::new();
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;
1282− let lead = segment.len() - segment.trim_start().len();
1283− let (ws, body) = segment.split_at(lead);
1287− } else if !dropped_leading || ws.contains('\n') {
1291− // `key if cond` → `if(cond, key)`
1292− if let Some((attr, condition)) = split_trailing_if(body.trim_end()) {
1293− out.push_str(&format!(
1296− rewrite_attr_list(attr, element_attrs)
1298− out.push_str(&body[body.trim_end().len()..]);
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(),
1310− // `blur N` / `backdrop-blur N` → the filter they generated
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| {
1317− if !v.is_empty() && v.parse::<f64>().is_ok() && v != "0" {
1324− "blur" => format!("{prefix}filter blur({})", px(value)),
1325− "backdrop-blur" => format!("{prefix}backdrop-filter blur({})", px(value)),
1329− // `gradient A B [ANGLE]` → the `background` it generated
1330− let body = match body.strip_prefix("gradient ") {
1332− let parts: Vec<&str> = v.split_whitespace().collect();
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(),
1342− format!("background linear-gradient({})", gradient)
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))
1351− Some((cond, a, b)) => format!("{} if({}, {}, {})", key, cond.trim(), a.trim(), b.trim()),
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(' ') {
1361− && !key.contains('=')
1362− && (HTML_ATTRIBUTES.contains(&key)
1363− || key.starts_with("aria-")
1364− || key.starts_with("data-")) =>
1366− format!("{}={}", key, value.trim())
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),
1379− match ATTR_RENAMES.iter().find(|(from, _)| *from == base) {
1381− out.push_str(prefix);
1383− out.push_str(value);
1385− None => out.push_str(&body),
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);
1397− let args = split_top_level_commas(inner);
1398− let [condition, branches @ ..] = args.as_slice() else {
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)
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),
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}")
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",
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)) {
1445− match rest.as_slice() {
1446− [] => format!("{base} {width} solid"),
1447− rest => format!("{base} {width} solid {}", rest.join(" ")),
1452− Some(format!("{prefix}{css}"))
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 {
1460− split_top_level_commas(list)
1462− .any(|attr| attr.split_whitespace().next() == Some(key))
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;
1470− for (i, c) in s.char_indices() {
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]);
1483− parts.push(&s[start..]);
1487−/// Rename filter aliases in `$var|filter` chains.
1488−fn rename_filters(line: &str) -> String {
1489− if !line.contains('|') {
1490− return line.to_string();
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..];
1506− out.push_str(rest);
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('|') {
1518− let trimmed = line.trim_start();
1519− let expression_line = ["@if ", "@else if "]
1521− .any(|p| trimmed.starts_with(p))
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..];
1546− .find(|c: char| matches!(c, ':' | '|' | ',' | ']' | '}' | ')' | '!' | '?' | ';') || c.is_whitespace())
1547− .unwrap_or(arg.len());
1548− filter_end += 1 + arg_len;
1550− let mut parts = tail[1..filter_end].split(':');
1551− let name = parts.next().unwrap_or("");
1552− let args: Vec<String> = parts
1554− if a.parse::<f64>().is_ok() || a.starts_with('#') {
1557− format!("\"{}\"", a)
1561− expr = if args.is_empty() {
1562− format!("{}({})", name, expr)
1564− format!("{}({}, {})", name, expr, args.join(", "))
1567− tail = &tail[filter_end..];
1569− if converted && !expression_line {
1570− out.push_str(&format!("${{{}}}", expr));
1572− out.push_str(&expr);
1576− out.push_str(rest);
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);
1591− out.push_str(value);
1593− rest = &rest[pos + needle.len()..];
1595− out.push_str(rest);
1599−fn indent_of(line: &str) -> usize {
1600− line.len() - line.trim_start().len()
1603−fn starts_directive(trimmed: &str, directive: &str) -> bool {
1605− .strip_prefix(directive)
1606− .is_some_and(|rest| rest.is_empty() || rest.starts_with([' ', '[']))
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;
1615− while j < lines.len() {
1617− if l.trim().is_empty() {
1621− if indent_of(l) <= indent {
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
1640− while j < lines.len() {
1641− if lines[j].trim() == "\"\"\"" {
1642− return Some(j + 1);
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
1653− .filter(|l| !l.trim().is_empty())
1654− .map(|l| indent_of(l))
1656− .unwrap_or(owner_indent);
1657− let shift = body_indent.saturating_sub(owner_indent);
1660− if l.trim().is_empty() {
1663− l.get(shift..).unwrap_or(l.trim_start()).to_string()
1671− use super::upgrade;
1673− fn up(src: &str) -> String {
1674− upgrade(src).output
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);
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]");
1698− up("@repeat 2\n @text $_count"),
1699− "@each $_ in 1..2\n @text 2"
1704− fn switch_to_match() {
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]"
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);
1722− fn css_directives_become_style() {
1724− up("@scope .card\n .t { color: red; }"),
1725− "@style\n @scope (.card) {\n .t { color: red; }\n }"
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 }"
1734− fn attributes_and_filters() {
1736− up("@el [...$card, animate fade 1s, hover:inset-area top] x"),
1737− "@el [$card, animation fade 1s, hover:position-area top] x"
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");
1746− fn standard_library_migrations() {
1748− up("@el [truncate, height 20]\n@el [no-scrollbar]"),
1749− "@el [$truncate, height 20]\n@el [scrollbar-width none]"
1752− up("@el [gradient #f00 #00f 45deg, padding 4]"),
1753− "@el [background linear-gradient(45deg,#f00,#00f), padding 4]"
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");
1760− fn head_directives_and_debugging() {
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"
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);
1775− fn html_attributes_use_equals() {
1777− up("@input [type email, name e, required, padding 8]"),
1778− "@input [type=email, name=e, required, padding 8]"
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.
1791− up("Read {@abbr [title HyperText] HTML} now"),
1792− "Read {@abbr [title=HyperText] HTML} now"
1795− up("@raw \"\"\"\n@let button $x\n\"\"\"\n@button [type submit] Go"),
1796− "@raw\n @let button $x\n@button [type=submit] Go"
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]");
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");
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");
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"
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);
1825− fn css_alias_attributes() {
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]"
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]");
1835− fn small_directives() {
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"
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"
1845− up("@json-ld\n {\"a\": 1}"),
1846− "@head\n <script type=\"application/ld+json\">\n {\"a\": 1}\n </script>"
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>"
1852− assert_eq!(up("@deprecated old\n@let x 1"), "@let x 1");
1853− assert_eq!(upgrade("@breakpoint tablet 600").manual.len(), 1);
1857− fn filters_become_functions() {
1858− assert_eq!(up("@text $name|uppercase!"), "@text ${uppercase($name)}!");
1860− up("@el [color $base|darken:10, background $base|mix:#ffffff:50]"),
1861− "@el [color ${darken($base, 10)}, background ${mix($base, #ffffff, 50)}]"
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");
1870− fn svg_becomes_inline_image() {
1871− assert_eq!(up("@svg icons/a.svg"), "@image [inline] icons/a.svg");
1873− up("@svg [width 24, color red, class icon] a.svg"),
1874− "@image [inline, width 24, color red, class=icon] a.svg"
1879− fn definitions_in_verbatim_bodies_are_text() {
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"
1887− fn style_aliases_become_css() {
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"
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"
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");
1900− let once = up("@el [bold, border 1 red]\n");
1901− assert_eq!(up(&once), once);
1905− fn loops_and_lists() {
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"
1911− up("@each $x in a, b\n @text $_index: $x\n"),
1912− "@each $x, $index in a, b\n @text $index: $x\n"
1915− up("@if $posts._count > 0\n @text ${$posts._count} posts\n"),
1916− "@if length($posts) > 0\n @text ${length($posts)} posts\n"
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);
1923− fn raw_strings_become_indented_bodies() {
1924− assert_eq!(up("@raw \"\"\"<br>\"\"\"\n"), "@raw <br>\n");
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"
1932− fn keyframes_move_into_style() {
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"
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);
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");
1953− fn conditional_attributes_become_if() {
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"
1958− assert_eq!(up("@el [shadow if $on]\n"), "@el [if($on, box-shadow)]\n");
1962− fn layouts_become_functions() {
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"
1967− assert_eq!(super::layout_function_name("parts/base.hl"), "base-layout");
1969− super::convert_layout("@page Site\n@el\n @children\n", "layout").unwrap(),
1970− "@let layout\n @page Site\n @el\n @children\n"
1972− assert!(super::convert_layout("@let layout\n x\n", "layout").is_none());
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",
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"
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]");