on master help sign in
Merge small directive families

- @lang, @favicon, @canonical, @base -> @page [lang .., favicon .., canonical .., base ..] Title - @og KEY VALUE -> @meta og:KEY VALUE (og: names now always emit property=, fixing @meta og:image which emitted name=) - @debug, @log -> @warn - @component -> @let: an @style block at the top of a function body is scoped to the function (tracked in a set instead of a hidden __component_scope_ variable); functions without one get no wrapper - the undocumented `cond ? a : b` value syntax -> if(cond, a, b) htmlang upgrade migrates all of these; the compiler names the replacement for each removed directive.

commit00341fd2b78ad17a0d8d64a9a34d363f71ddd8ec authoriko <mail@iko.soy> dateFri 25 Sep 2026, 20:32 (2 weeks ago) parentfde10f4
12 files changed+222−257
DESIGN.md+6−7 crates/htmlang-core/src/parser.rs+92−211 examples/blog.hl+1−2 examples/demo.hl+3−4 examples/docs.hl+1−2 examples/landing.hl+1−2 src/upgrade.rs+93−4 tests/snapshots.rs+19−16 tests/snapshots/lang_directive.hl+1−2 tests/snapshots/meta_head.html+1−1 tests/snapshots/new_directives.hl+1−3 tests/snapshots/og_directive.hl+3−3
DESIGN.md+6−7
@@ -375,16 +375,15 @@ blocks) and `@children` (everything in the page outside `@slot` blocks):
375375## Page and head
376376
377377`@page TITLE` produces a full HTML document; without it the output is a fragment.
378−Head content comes from `@lang`, `@favicon`, `@meta NAME VALUE`,
379−`@og KEY VALUE`, `@canonical URL`, `@base URL`, `@manifest NAME` (with
380−indented settings), `@font-face NAME URL`, `@json-ld` (indented JSON) and `@head`
381−(indented raw HTML).
378+Its attributes set `lang`, `favicon`, `canonical` and `base`. `@meta NAME VALUE`
379+adds a meta tag (`og:` names become Open Graph `property` tags), and
380+`@font-face NAME URL`, `@manifest NAME` (with indented settings), `@json-ld`
381+(indented JSON) and `@head` (indented raw HTML) add the rest.
382382
383383```
384−@page My Site
385−@lang en
384+@page [lang en, favicon /favicon.png] My Site
386385@meta description A small site
387−@og title My Site
386+@meta og:title My Site
388387```
389388
390389## CSS
crates/htmlang-core/src/parser.rs+92−211
@@ -71,6 +71,8 @@ struct ParseContext {
7171 /// Source line currently being parsed, for diagnostics raised deep
7272 /// inside helpers that don't take a line number.
7373 current_line: usize,
74+ /// Functions whose body has a scoped @style block.
75+ scoped_functions: std::collections::HashSet<String>,
7476 page_title: Option<String>,
7577 lang: Option<String>,
7678 favicon: Option<String>,
@@ -154,6 +156,7 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
154156 let mut parser = Parser { lines, pos: 0 };
155157 let mut ctx = ParseContext {
156158 current_line: 0,
159+ scoped_functions: std::collections::HashSet::new(),
157160 page_title: None,
158161 lang: None,
159162 favicon: None,
@@ -516,20 +519,38 @@ impl Parser {
516519 });
517520 }
518521
522+ // @page [lang en, favicon /f.png, canonical URL, base URL] Title
519523 if let Some(rest) = content.strip_prefix("@page ") {
520− ctx.page_title = Some(substitute_vars(rest, &ctx.variables));
524+ let rest = rest.trim_start();
525+ let title = if rest.starts_with('[') {
526+ let (attrs, title) = parse_attr_brackets_no_validate(rest, line_num, ctx)?;
527+ for attr in attrs {
528+ let value = attr.value.unwrap_or_default();
529+ match attr.key.as_str() {
530+ "lang" => ctx.lang = Some(value),
531+ "favicon" => ctx.favicon = Some(value),
532+ "canonical" => ctx.canonical = Some(value),
533+ "base" => ctx.base_url = Some(value),
534+ other => ctx.diagnostics.push(Diagnostic {
535+ line: line_num,
536+ column: None,
537+ message: format!(
538+ "unknown @page attribute '{}' (expected lang, favicon, canonical or base)",
539+ other
540+ ),
541+ severity: Severity::Warning,
542+ source_line: Some(content.clone()),
543+ }),
544+ }
545+ }
546+ title
547+ } else {
548+ rest.to_string()
549+ };
550+ ctx.page_title = Some(substitute_vars(title.trim(), &ctx.variables));
521551 return Ok(None);
522552 }
523553
524− if let Some(rest) = content.strip_prefix("@lang ") {
525− ctx.lang = Some(substitute_vars(rest.trim(), &ctx.variables));
526− return Ok(None);
527− }
528−
529− if let Some(rest) = content.strip_prefix("@favicon ") {
530− ctx.favicon = Some(substitute_vars(rest.trim(), &ctx.variables));
531− return Ok(None);
532− }
533554
534555 if let Some(rest) = content.strip_prefix("@let ") {
535556 let rest = rest.trim();
@@ -574,6 +595,26 @@ impl Parser {
574595 self.pos += 1;
575596 }
576597
598+ // An @style block at the top of the body is scoped to the
599+ // function: its rules apply inside a `.hl-NAME` wrapper.
600+ let (body_lines, style_lines) = split_style_block(body_lines);
601+ if !style_lines.is_empty() {
602+ let scope_class = format!("hl-{}", name);
603+ let scoped_css: String = style_lines
604+ .iter()
605+ .filter_map(|line| match &line.content {
606+ LineContent::Normal(s) if !s.trim().is_empty() => {
607+ Some(format!(".{} {}\n", scope_class, s.trim()))
608+ }
609+ _ => None,
610+ })
611+ .collect();
612+ if !scoped_css.is_empty() {
613+ ctx.custom_css.push(scoped_css);
614+ }
615+ ctx.scoped_functions.insert(name.clone());
616+ }
617+
577618 ctx.fn_lines.entry(name.clone()).or_insert(line_num);
578619 ctx.functions.insert(
579620 name,
@@ -666,29 +707,19 @@ impl Parser {
666707 return Ok(None);
667708 }
668709
710+ // @meta NAME VALUE; `og:` names become Open Graph property tags
669711 if let Some(rest) = content.strip_prefix("@meta ") {
670712 let rest = rest.trim();
671713 if let Some((name, value)) = rest.split_once(' ') {
672714 let value = substitute_vars(value.trim(), &ctx.variables);
673− ctx.meta_tags.push((name.trim().to_string(), value));
715+ match name.trim().strip_prefix("og:") {
716+ Some(property) => ctx.og_tags.push((property.to_string(), value)),
717+ None => ctx.meta_tags.push((name.trim().to_string(), value)),
718+ }
674719 }
675720 return Ok(None);
676721 }
677722
678− if let Some(rest) = content.strip_prefix("@og ") {
679− let rest = rest.trim();
680− let parts: Vec<&str> = rest.splitn(2, ' ').collect();
681− if parts.len() == 2 {
682− let value = substitute_vars(parts[1].trim(), &ctx.variables);
683− let value = if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
684− value[1..value.len() - 1].to_string()
685− } else {
686− value
687− };
688− ctx.og_tags.push((parts[0].to_string(), value));
689− }
690− return Ok(None);
691− }
692723
693724 if let Some(rest) = content.strip_prefix("@breakpoint ") {
694725 let rest = rest.trim();
@@ -2013,64 +2044,6 @@ impl Parser {
20132044 return Ok(None);
20142045 }
20152046
2016− if let Some(rest) = content.strip_prefix("@debug ") {
2017− let msg = substitute_vars(rest.trim(), &ctx.variables);
2018− eprintln!("debug: line {}: {}", line_num, msg);
2019− return Ok(None);
2020− }
2021−
2022− // --- @log (compile-time variable inspection) ---
2023−
2024− if let Some(rest) = content.strip_prefix("@log ") {
2025− let rest = rest.trim();
2026− // @log $var — shows variable name, value, and type info
2027− let var_names: Vec<&str> = rest.split_whitespace().collect();
2028− for var_name in &var_names {
2029− let name = var_name.strip_prefix('$').unwrap_or(var_name);
2030− ctx.used_variables.insert(name.to_string());
2031− if let Some(val) = ctx.variables.get(name) {
2032− let kind = if val.parse::<f64>().is_ok() {
2033− "number"
2034− } else if val.starts_with('#') && is_valid_hex_color(val) {
2035− "color"
2036− } else if val.contains('/') || val.ends_with(".hl") || val.ends_with(".html") {
2037− "path"
2038− } else {
2039− "string"
2040− };
2041− eprintln!("log: line {}: ${} = \"{}\" ({})", line_num, name, val, kind);
2042− } else if ctx.functions.contains_key(name) {
2043− let def = &ctx.functions[name];
2044− let params: Vec<String> =
2045− def.params.iter().map(|p| format!("${}", p)).collect();
2046− eprintln!(
2047− "log: line {}: @{} ({}) — {} line(s)",
2048− line_num,
2049− name,
2050− params.join(", "),
2051− def.body_lines.len()
2052− );
2053− } else if ctx.defines.contains_key(name) {
2054− let attrs = &ctx.defines[name];
2055− let attr_strs: Vec<String> = attrs
2056− .iter()
2057− .map(|a| match &a.value {
2058− Some(v) => format!("{} {}", a.key, v),
2059− None => a.key.clone(),
2060− })
2061− .collect();
2062− eprintln!(
2063− "log: line {}: ${} = [{}]",
2064− line_num,
2065− name,
2066− attr_strs.join(", ")
2067− );
2068− } else {
2069− eprintln!("log: line {}: ${} is undefined", line_num, name);
2070− }
2071− }
2072− return Ok(None);
2073− }
20742047
20752048 // --- @keyframes directive ---
20762049
@@ -2124,19 +2097,6 @@ impl Parser {
21242097 return Ok(None);
21252098 }
21262099
2127− // --- @canonical directive ---
2128−
2129− if let Some(rest) = content.strip_prefix("@canonical ") {
2130− ctx.canonical = Some(substitute_vars(rest.trim(), &ctx.variables));
2131− return Ok(None);
2132− }
2133−
2134− // --- @base directive ---
2135−
2136− if let Some(rest) = content.strip_prefix("@base ") {
2137− ctx.base_url = Some(substitute_vars(rest.trim(), &ctx.variables));
2138− return Ok(None);
2139− }
21402100
21412101 // --- @font-face directive ---
21422102
@@ -2379,87 +2339,6 @@ impl Parser {
23792339 }
23802340
23812341
2382− // --- @component definition (scoped @fn) ---
2383−
2384− if let Some(rest) = content.strip_prefix("@component ") {
2385− let parts: Vec<&str> = rest.split_whitespace().collect();
2386− if parts.is_empty() {
2387− return Err(ParseError {
2388− line: line_num,
2389− message: "@component requires a name".to_string(),
2390− });
2391− }
2392− let name = parts[0].to_string();
2393− let mut params = Vec::new();
2394− let mut defaults = HashMap::new();
2395− for part in &parts[1..] {
2396− let part = part.strip_prefix('$').unwrap_or(part);
2397− if let Some((param_name, default_val)) = part.split_once('=') {
2398− params.push(param_name.to_string());
2399− defaults.insert(param_name.to_string(), default_val.to_string());
2400− } else {
2401− params.push(part.to_string());
2402− }
2403− }
2404−
2405− // Collect body lines and separate @style blocks from element content
2406− let mut body_lines = Vec::new();
2407− let mut style_lines = Vec::new();
2408− let mut in_style = false;
2409− let mut style_indent = 0;
2410− while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
2411− if let LineContent::Normal(ref s) = self.lines[self.pos].content
2412− && s.trim() == "@style"
2413− {
2414− in_style = true;
2415− style_indent = self.lines[self.pos].indent;
2416− self.pos += 1;
2417− continue;
2418− }
2419− if in_style && self.lines[self.pos].indent > style_indent {
2420− style_lines.push(self.lines[self.pos].clone());
2421− } else {
2422− in_style = false;
2423− body_lines.push(self.lines[self.pos].clone());
2424− }
2425− self.pos += 1;
2426− }
2427−
2428− // Register scoped CSS from @style blocks
2429− if !style_lines.is_empty() {
2430− let scope_class = format!("hl-{}", name);
2431− let mut scoped_css = String::new();
2432− for line in &style_lines {
2433− if let LineContent::Normal(ref s) = line.content {
2434− let trimmed = s.trim();
2435− // Scope selectors by prepending .hl-componentname
2436− if !trimmed.is_empty() {
2437− scoped_css.push_str(&format!(".{} {}\n", scope_class, trimmed));
2438− }
2439− }
2440− }
2441− if !scoped_css.is_empty() {
2442− ctx.custom_css.push(scoped_css);
2443− }
2444− }
2445−
2446− ctx.fn_lines.entry(name.clone()).or_insert(line_num);
2447− ctx.functions.insert(
2448− name.clone(),
2449− FnDef {
2450− params,
2451− defaults,
2452− body_lines,
2453− },
2454− );
2455− // Mark that this function should wrap output with a scoped class
2456− ctx.variables.insert(
2457− format!("__component_scope_{}", name),
2458− format!("hl-{}", name),
2459− );
2460− return Ok(None);
2461− }
2462−
24632342 // --- Function call ---
24642343
24652344 if content.starts_with('@') {
@@ -2660,14 +2539,13 @@ impl Parser {
26602539 }
26612540 }
26622541
2663− // If this is a @component, wrap output in a scoped container
2664− let scope_key = format!("__component_scope_{}", name);
2665− if let Some(scope_class) = ctx.variables.get(&scope_key).cloned() {
2542+ // A function with a scoped @style wraps its output in the scope class
2543+ if ctx.scoped_functions.contains(name) {
26662544 let wrapper = Element {
26672545 kind: ElementKind::El,
26682546 attrs: vec![Attribute {
26692547 key: "class".to_string(),
2670− value: Some(scope_class),
2548+ value: Some(format!("hl-{}", name)),
26712549 }],
26722550 argument: None,
26732551 children: result_nodes,
@@ -2896,21 +2774,13 @@ const KNOWN_DIRECTIVES: &[&str] = &[
28962774 "case",
28972775 "default",
28982776 "warn",
2899− "debug",
2900− "og",
29012777 "breakpoint",
2902− "lang",
2903− "favicon",
29042778 "theme",
29052779 "deprecated",
29062780 "extends",
2907− "canonical",
2908− "base",
29092781 "font-face",
29102782 "json-ld",
29112783 "assert",
2912− "component",
2913− "log",
29142784 "markdown",
29152785 "collection",
29162786 "manifest",
@@ -2965,6 +2835,17 @@ const REMOVED_SYNTAX: &[(&str, &str)] = &[
29652835 "write the `@starting-style` rule in an `@style` block",
29662836 ),
29672837 ("@css-property", "write an `@property` rule in an `@style` block"),
2838+ ("@lang", "use `@page [lang ...] Title`"),
2839+ ("@favicon", "use `@page [favicon ...] Title`"),
2840+ ("@canonical", "use `@page [canonical ...] Title`"),
2841+ ("@base", "use `@page [base ...] Title`"),
2842+ ("@og", "use `@meta og:NAME VALUE`"),
2843+ ("@debug", "use `@warn`"),
2844+ ("@log", "use `@warn`"),
2845+ (
2846+ "@component",
2847+ "use `@let`: an `@style` block in a function body is scoped to it",
2848+ ),
29682849 ("@col", "use `@column`"),
29692850 ("@p", "use `@paragraph`"),
29702851 ("@img", "use `@image`"),
@@ -3663,7 +3544,6 @@ fn parse_attr_list(
36633544
36643545 let attr = if let Some((key, value)) = part.split_once(' ') {
36653546 let value = evaluate_if_expr(value.trim());
3666− let value = substitute_ternary(&value);
36673547 Attribute {
36683548 key: key.trim().to_string(),
36693549 value: Some(value),
@@ -3978,27 +3858,6 @@ fn evaluate_condition(condition: &str) -> bool {
39783858 }
39793859}
39803860
3981−/// Evaluate ternary expressions: `condition ? true_val : false_val` in attribute values.
3982−fn substitute_ternary(input: &str) -> String {
3983− if !input.contains(" ? ") {
3984− return input.to_string();
3985− }
3986− // Find the ternary operator pattern
3987− if let Some(q_pos) = input.find(" ? ") {
3988− let condition = input[..q_pos].trim();
3989− let rest = &input[q_pos + 3..];
3990− if let Some(c_pos) = rest.find(" : ") {
3991− let true_val = rest[..c_pos].trim();
3992− let false_val = rest[c_pos + 3..].trim();
3993− if evaluate_condition(condition) {
3994− return true_val.to_string();
3995− } else {
3996− return false_val.to_string();
3997− }
3998− }
3999− }
4000− input.to_string()
4001−}
40023861
40033862/// Evaluate `if(condition, true_val, false_val)` expressions in attribute values.
40043863fn evaluate_if_expr(input: &str) -> String {
@@ -4851,6 +4710,28 @@ fn parse_json_with_error(input: &str) -> Result<JsonValue, String> {
48514710 }
48524711}
48534712
4713+/// Split a function body into its content and an @style block at the
4714+/// body's top level (the block's CSS lines, without the `@style` line).
4715+fn split_style_block(body: Vec<Line>) -> (Vec<Line>, Vec<Line>) {
4716+ let top = body.iter().map(|l| l.indent).min().unwrap_or(0);
4717+ let mut content = Vec::new();
4718+ let mut style = Vec::new();
4719+ let mut in_style = false;
4720+ for line in body {
4721+ let is_style_header =
4722+ line.indent == top && matches!(&line.content, LineContent::Normal(s) if s.trim() == "@style");
4723+ if is_style_header {
4724+ in_style = true;
4725+ } else if in_style && line.indent > top {
4726+ style.push(line);
4727+ } else {
4728+ in_style = false;
4729+ content.push(line);
4730+ }
4731+ }
4732+ (content, style)
4733+}
4734+
48544735fn parse_json_value(chars: &[char], mut pos: usize) -> Option<(JsonValue, usize)> {
48554736 pos = skip_ws(chars, pos);
48564737 if pos >= chars.len() {
examples/blog.hl+1−2
@@ -1,6 +1,5 @@
11-- A minimal blog template: header + list of posts + post template.
2−@page My Blog
3−@lang en
2+@page [lang en] My Blog
43@let primary #3b82f6
54@let ink #1f2937
65@let muted #6b7280
examples/demo.hl+3−4
@@ -1,9 +1,8 @@
11-- A comprehensive tour of htmlang: layout, style, components, and control flow.
2−@page htmlang Demo
3−@lang en
2+@page [lang en] htmlang Demo
43@meta description A one-page tour of htmlang.
5−@og title htmlang
6−@og type website
4+@meta og:title htmlang
5+@meta og:type website
76
87-- Design tokens become both $vars and CSS custom properties
98@theme
examples/docs.hl+1−2
@@ -1,6 +1,5 @@
11-- A documentation page: sidebar nav + prose content + code samples.
2−@page Docs · Getting Started
3−@lang en
2+@page [lang en] Docs · Getting Started
43@let ink #0f172a
54@let muted #64748b
65@let primary #3b82f6
examples/landing.hl+1−2
@@ -1,6 +1,5 @@
11-- A marketing landing page: hero, feature grid, CTA.
2−@page Launchpad
3−@lang en
2+@page [lang en] Launchpad
43@let primary #2563eb
54@let ink #0f172a
65@let muted #64748b
src/upgrade.rs+93−4
@@ -45,10 +45,10 @@ const FILTER_ALIASES: &[(&str, &str)] = &[
4545const VERBATIM_BODIES: &[&str] = &["@style", "@script", "@markdown", "@head", "@json-ld"];
4646
4747pub fn upgrade(input: &str) -> Upgrade {
48− let lines: Vec<&str> = input.lines().collect();
49− let mut out: Vec<String> = Vec::with_capacity(lines.len());
50− let mut changes = 0;
5148 let mut manual = Vec::new();
49+ let (folded, mut changes) = fold_head_directives(input, &mut manual);
50+ let lines: Vec<&str> = folded.lines().collect();
51+ let mut out: Vec<String> = Vec::with_capacity(lines.len());
5252 let mut attr_depth = 0i32;
5353 let mut i = 0;
5454 // A file may define its own function named like an old alias (e.g.
@@ -327,6 +327,51 @@ fn rewrite_block(
327327 None
328328}
329329
330+/// Move `@lang`, `@favicon`, `@canonical` and `@base` into the `@page`
331+/// line's attributes: `@page [lang en, favicon /f.png] Title`.
332+fn fold_head_directives(input: &str, manual: &mut Vec<(usize, String)>) -> (String, usize) {
333+ const FOLDED: &[&str] = &["lang", "favicon", "canonical", "base"];
334+ let lines: Vec<&str> = input.lines().collect();
335+ let mut attrs = Vec::new();
336+ let mut keep = Vec::with_capacity(lines.len());
337+ for (i, line) in lines.iter().enumerate() {
338+ let folded = FOLDED.iter().find_map(|key| {
339+ let rest = line.strip_prefix(&format!("@{} ", key))?;
340+ Some(format!("{} {}", key, rest.trim()))
341+ });
342+ match folded {
343+ Some(attr) => attrs.push((i, attr)),
344+ None => keep.push(*line),
345+ }
346+ }
347+ if attrs.is_empty() {
348+ return (input.to_string(), 0);
349+ }
350+ let Some(page) = keep.iter().position(|l| l.starts_with("@page ")) else {
351+ for (i, _) in &attrs {
352+ manual.push((
353+ i + 1,
354+ "this directive now goes in @page's attributes, but the file has no @page line"
355+ .to_string(),
356+ ));
357+ }
358+ return (input.to_string(), 0);
359+ };
360+ let list = attrs.iter().map(|(_, a)| a.as_str()).collect::<Vec<_>>().join(", ");
361+ let title = &keep[page]["@page ".len()..];
362+ let new_page = match title.strip_prefix('[') {
363+ Some(rest) => format!("@page [{}, {}", list, rest),
364+ None => format!("@page [{}] {}", list, title),
365+ };
366+ let mut out: Vec<String> = keep.iter().map(|l| l.to_string()).collect();
367+ out[page] = new_page;
368+ let mut text = out.join("\n");
369+ if input.ends_with('\n') {
370+ text.push('\n');
371+ }
372+ (text, attrs.len())
373+}
374+
330375/// Single-line directive rewrites.
331376fn rewrite_directive_line(
332377 line: &str,
@@ -336,11 +381,31 @@ fn rewrite_directive_line(
336381) -> String {
337382 let trimmed = line.trim_start();
338383 let pad = " ".repeat(indent);
339− for old in ["@fn ", "@define ", "@mixin "] {
384+ for old in ["@fn ", "@define ", "@mixin ", "@component "] {
340385 if let Some(rest) = trimmed.strip_prefix(old) {
341386 return format!("{pad}@let {}", rest);
342387 }
343388 }
389+ if let Some(rest) = trimmed.strip_prefix("@og ")
390+ && let Some((key, value)) = rest.trim().split_once(' ')
391+ {
392+ let value = value.trim();
393+ let value = value
394+ .strip_prefix('"')
395+ .and_then(|v| v.strip_suffix('"'))
396+ .unwrap_or(value);
397+ return format!("{pad}@meta og:{} {}", key, value);
398+ }
399+ if let Some(rest) = trimmed.strip_prefix("@debug ") {
400+ return format!("{pad}@warn {}", rest);
401+ }
402+ if let Some(rest) = trimmed.strip_prefix("@log ") {
403+ let shown: Vec<String> = rest
404+ .split_whitespace()
405+ .map(|v| format!("{} = ${}", v.trim_start_matches('$'), v.trim_start_matches('$')))
406+ .collect();
407+ return format!("{pad}@warn {}", shown.join(", "));
408+ }
344409 if let Some(rest) = trimmed.strip_prefix("@unless ") {
345410 return format!("{pad}@if not {}", rest);
346411 }
@@ -472,6 +537,16 @@ fn rewrite_attr_list(list: &str) -> String {
472537 }
473538 None => body,
474539 };
540+ // `key COND ? A : B` → `key if(COND, A, B)`
541+ let body = match body.split_once(' ') {
542+ Some((key, value)) => match value.split_once(" ? ").and_then(|(cond, rest)| {
543+ rest.split_once(" : ").map(|(a, b)| (cond, a, b))
544+ }) {
545+ Some((cond, a, b)) => format!("{} if({}, {}, {})", key, cond.trim(), a.trim(), b.trim()),
546+ None => body,
547+ },
548+ None => body,
549+ };
475550 // Renamed keys (keeping any `hover:` / `md:` style prefix)
476551 let key_end = body.find(char::is_whitespace).unwrap_or(body.len());
477552 let (key, value) = body.split_at(key_end);
@@ -728,6 +803,20 @@ mod tests {
728803 );
729804 }
730805
806+ #[test]
807+ fn head_directives_and_debugging() {
808+ assert_eq!(
809+ up("@page Home\n@lang en\n@favicon /f.png\n@text hi\n"),
810+ "@page [lang en, favicon /f.png] Home\n@text hi\n"
811+ );
812+ assert_eq!(up("@og title \"My Page\""), "@meta og:title My Page");
813+ assert_eq!(up("@debug hi $x\n@log $a $b"), "@warn hi $x\n@warn a = $a, b = $b");
814+ assert_eq!(up("@component card $t\n @text $t"), "@let card $t\n @text $t");
815+ assert_eq!(up("@el [color $on ? green : gray]"), "@el [color if($on, green, gray)]");
816+ let r = upgrade("@lang en\n@text hi");
817+ assert_eq!(r.manual.len(), 1);
818+ }
819+
731820 #[test]
732821 fn verbatim_regions_are_untouched() {
733822 let src = "@script\n for (x of y) {}\n@raw \"\"\"\n@unless\n\"\"\"\n@style\n .p { }";
tests/snapshots.rs+19−16
@@ -976,13 +976,13 @@ fn image_explicit_loading_not_doubled() {
976976
977977#[test]
978978fn ternary_expression_in_attrs() {
979− let output = compile("@page T\n@let active true\n@el [color $active ? green : gray]\n test");
979+ let output = compile("@page T\n@let active true\n@el [color if($active, green, gray)]\n test");
980980 assert!(output.contains("color:green"));
981981}
982982
983983#[test]
984984fn ternary_expression_false() {
985− let output = compile("@page T\n@let active false\n@el [color $active ? green : gray]\n test");
985+ let output = compile("@page T\n@let active false\n@el [color if($active, green, gray)]\n test");
986986 assert!(output.contains("color:gray"));
987987}
988988
@@ -1752,7 +1752,7 @@ fn css_resize() {
17521752
17531753#[test]
17541754fn lang_sets_html_attr() {
1755− let output = compile("@page T\n@lang en\n@text Hello");
1755+ let output = compile("@page [lang en] T\n@text Hello");
17561756 assert!(output.contains("<html lang=\"en\">"));
17571757}
17581758
@@ -1768,7 +1768,7 @@ fn lang_not_present_without_directive() {
17681768#[test]
17691769fn favicon_fallback_href() {
17701770 // Nonexistent file should fall back to href
1771− let output = compile("@page T\n@favicon nonexistent.png\n@text Hello");
1771+ let output = compile("@page [favicon nonexistent.png] T\n@text Hello");
17721772 assert!(output.contains("<link rel=\"icon\" href=\"nonexistent.png\">"));
17731773}
17741774
@@ -2099,7 +2099,7 @@ fn unless_true_hides_content() {
20992099#[test]
21002100fn og_tags_in_output() {
21012101 let output = compile(
2102− "@page T\n@og title \"My Page\"\n@og image \"https://example.com/img.png\"\n@text Hello",
2102+ "@page T\n@meta og:title My Page\n@meta og:image https://example.com/img.png\n@text Hello",
21032103 );
21042104 assert!(output.contains("og:title"));
21052105 assert!(output.contains("My Page"));
@@ -3636,7 +3636,7 @@ fn breadcrumb_generates_nav_ol() {
36363636
36373637#[test]
36383638fn canonical_directive() {
3639− let output = compile("@page T\n@canonical https://example.com/page\n@text Hello");
3639+ let output = compile("@page [canonical https://example.com/page] T\n@text Hello");
36403640 assert!(
36413641 output.contains("<link rel=\"canonical\" href=\"https://example.com/page\">"),
36423642 "canonical: {}",
@@ -3646,7 +3646,7 @@ fn canonical_directive() {
36463646
36473647#[test]
36483648fn base_directive() {
3649− let output = compile("@page T\n@base https://example.com/\n@text Hello");
3649+ let output = compile("@page [base https://example.com/] T\n@text Hello");
36503650 assert!(
36513651 output.contains("<base href=\"https://example.com/\">"),
36523652 "base: {}",
@@ -4029,16 +4029,19 @@ fn conditional_attr_boolean_false() {
40294029// -----------------------------------------------------------------------
40304030
40314031#[test]
4032−fn component_wraps_in_scoped_div() {
4033− let html = compile("@component card $title\n @text $title\n\n@card [title Hello]\n");
4034− assert!(html.contains("hl-card"));
4032+fn function_with_style_is_scoped() {
4033+ let html = compile(
4034+ "@let card $title\n @style\n .t { color: red; }\n @text [class t] $title\n@card [title Hello]\n",
4035+ );
4036+ assert!(html.contains("hl-card\"><span class=\"t\">Hello"), "{}", html);
4037+ assert!(html.contains(".hl-card .t { color: red; }"), "{}", html);
40354038}
40364039
40374040#[test]
4038−fn component_with_children() {
4041+fn function_without_style_has_no_wrapper() {
40394042 let html =
4040− compile("@component box\n @el [padding 10]\n @children\n\n@box\n @text Inside\n");
4041− assert!(html.contains("hl-box"));
4043+ compile("@let box\n @el [padding 10]\n @children\n\n@box\n @text Inside\n");
4044+ assert!(!html.contains("hl-box"), "{}", html);
40424045 assert!(html.contains("Inside"));
40434046}
40444047
@@ -4407,16 +4410,16 @@ fn test_spread_define() {
44074410#[test]
44084411fn test_log_directive() {
44094412 // @log should not produce errors and should be consumed without output nodes
4410− let result = htmlang::parser::parse("@let x hello\n@log $x\n@text $x");
4413+ let result = htmlang::parser::parse("@let x hello\n@warn x = $x\n@text $x");
44114414 assert!(
44124415 result
44134416 .diagnostics
44144417 .iter()
44154418 .all(|d| d.severity != htmlang::parser::Severity::Error),
4416− "@log should not produce errors"
4419+ "@warn should = $should, not = $not, produce = $produce, errors = $errors"
44174420 );
44184421 let html = htmlang::codegen::generate(&result.document);
4419− assert!(!html.contains("@log"), "@log should not appear in output");
4422+ assert!(!html.contains("@log"), "@warn should = $should, not = $not, appear = $appear, in = $in, output = $output");
44204423}
44214424
44224425#[test]
tests/snapshots/lang_directive.hl+1−2
@@ -1,4 +1,3 @@
1−@page Lang Test
2−@lang en
1+@page [lang en] Lang Test
32
43@text Hello World
tests/snapshots/meta_head.html+1−1
@@ -1 +1 @@
1−<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>My Site</title><meta name="description" content="A portfolio site"><meta name="og:image" content="https://example.com/preview.png"><link rel="icon" href="favicon.ico"><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;padding:20px;}}</style></head><body><div class="a"><span>Hello</span></div></body></html>
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>My Site</title><meta name="description" content="A portfolio site"><meta property="og:image" content="https://example.com/preview.png"><link rel="icon" href="favicon.ico"><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;padding:20px;}}</style></head><body><div class="a"><span>Hello</span></div></body></html>
tests/snapshots/new_directives.hl+1−3
@@ -1,6 +1,4 @@
1−@page My Site
2−@canonical https://example.com/page
3−@base https://example.com/
1+@page [canonical https://example.com/page, base https://example.com/] My Site
42@font-face Inter fonts/inter.woff2
53@font-face Mono fonts/mono.ttf
64@json-ld
tests/snapshots/og_directive.hl+3−3
@@ -1,6 +1,6 @@
11@page OG Test
2−@og title "My Page Title"
3−@og description "A description of the page"
4−@og image "https://example.com/image.png"
2+@meta og:title My Page Title
3+@meta og:description A description of the page
4+@meta og:image https://example.com/image.png
55
66@text Hello