@@ -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(®ion, *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(®ion, *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−}