on master help sign in
Merge small directive families
00341fdiko
DESIGN.md+6−7
@@ -375,16 +375,15 @@ blocks) and `@children` (everything in the page outside `@slot` blocks):
375## Page and head
375## Page and head
376
376
377`@page TITLE` produces a full HTML document; without it the output is a fragment.
377`@page TITLE` produces a full HTML document; without it the output is a fragment.
378−Head content comes from `@lang`, `@favicon`, `@meta NAME VALUE`,
378+Its attributes set `lang`, `favicon`, `canonical` and `base`. `@meta NAME VALUE`
379−`@og KEY VALUE`, `@canonical URL`, `@base URL`, `@manifest NAME` (with
379+adds a meta tag (`og:` names become Open Graph `property` tags), and
380−indented settings), `@font-face NAME URL`, `@json-ld` (indented JSON) and `@head`
380+`@font-face NAME URL`, `@manifest NAME` (with indented settings), `@json-ld`
381−(indented raw HTML).
381+(indented JSON) and `@head` (indented raw HTML) add the rest.
382
382
383```
383```
384−@page My Site
384+@page [lang en, favicon /favicon.png] My Site
385−@lang en
386@meta description A small site
385@meta description A small site
387−@og title My Site
386+@meta og:title My Site
388```
387```
389
388
390## CSS
389## CSS
crates/htmlang-core/src/parser.rs+92−211
@@ -71,6 +71,8 @@ struct ParseContext {
71 /// Source line currently being parsed, for diagnostics raised deep
71 /// Source line currently being parsed, for diagnostics raised deep
72 /// inside helpers that don't take a line number.
72 /// inside helpers that don't take a line number.
73 current_line: usize,
73 current_line: usize,
74+ /// Functions whose body has a scoped @style block.
75+ scoped_functions: std::collections::HashSet<String>,
74 page_title: Option<String>,
76 page_title: Option<String>,
75 lang: Option<String>,
77 lang: Option<String>,
76 favicon: Option<String>,
78 favicon: Option<String>,
@@ -154,6 +156,7 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
154 let mut parser = Parser { lines, pos: 0 };
156 let mut parser = Parser { lines, pos: 0 };
155 let mut ctx = ParseContext {
157 let mut ctx = ParseContext {
156 current_line: 0,
158 current_line: 0,
159+ scoped_functions: std::collections::HashSet::new(),
157 page_title: None,
160 page_title: None,
158 lang: None,
161 lang: None,
159 favicon: None,
162 favicon: None,
@@ -516,20 +519,38 @@ impl Parser {
516 });
519 });
517 }
520 }
518
521
522+ // @page [lang en, favicon /f.png, canonical URL, base URL] Title
519 if let Some(rest) = content.strip_prefix("@page ") {
523 if let Some(rest) = content.strip_prefix("@page ") {
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+ };
520− ctx.page_title = Some(substitute_vars(rest, &ctx.variables));
550+ ctx.page_title = Some(substitute_vars(title.trim(), &ctx.variables));
521 return Ok(None);
551 return Ok(None);
522 }
552 }
523
553
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− }
533
554
534 if let Some(rest) = content.strip_prefix("@let ") {
555 if let Some(rest) = content.strip_prefix("@let ") {
535 let rest = rest.trim();
556 let rest = rest.trim();
@@ -574,6 +595,26 @@ impl Parser {
574 self.pos += 1;
595 self.pos += 1;
575 }
596 }
576
597
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+
577 ctx.fn_lines.entry(name.clone()).or_insert(line_num);
618 ctx.fn_lines.entry(name.clone()).or_insert(line_num);
578 ctx.functions.insert(
619 ctx.functions.insert(
579 name,
620 name,
@@ -666,29 +707,19 @@ impl Parser {
666 return Ok(None);
707 return Ok(None);
667 }
708 }
668
709
710+ // @meta NAME VALUE; `og:` names become Open Graph property tags
669 if let Some(rest) = content.strip_prefix("@meta ") {
711 if let Some(rest) = content.strip_prefix("@meta ") {
670 let rest = rest.trim();
712 let rest = rest.trim();
671 if let Some((name, value)) = rest.split_once(' ') {
713 if let Some((name, value)) = rest.split_once(' ') {
672 let value = substitute_vars(value.trim(), &ctx.variables);
714 let value = substitute_vars(value.trim(), &ctx.variables);
715+ match name.trim().strip_prefix("og:") {
716+ Some(property) => ctx.og_tags.push((property.to_string(), value)),
673− ctx.meta_tags.push((name.trim().to_string(), value));
717+ None => ctx.meta_tags.push((name.trim().to_string(), value)),
718+ }
674 }
719 }
675 return Ok(None);
720 return Ok(None);
676 }
721 }
677
722
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− }
692
723
693 if let Some(rest) = content.strip_prefix("@breakpoint ") {
724 if let Some(rest) = content.strip_prefix("@breakpoint ") {
694 let rest = rest.trim();
725 let rest = rest.trim();
@@ -2013,64 +2044,6 @@ impl Parser {
2013 return Ok(None);
2044 return Ok(None);
2014 }
2045 }
2015
2046
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− }
2074
2047
2075 // --- @keyframes directive ---
2048 // --- @keyframes directive ---
2076
2049
@@ -2124,19 +2097,6 @@ impl Parser {
2124 return Ok(None);
2097 return Ok(None);
2125 }
2098 }
2126
2099
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− }
2140
2100
2141 // --- @font-face directive ---
2101 // --- @font-face directive ---
2142
2102
@@ -2379,87 +2339,6 @@ impl Parser {
2379 }
2339 }
2380
2340
2381
2341
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−
2463 // --- Function call ---
2342 // --- Function call ---
2464
2343
2465 if content.starts_with('@') {
2344 if content.starts_with('@') {
@@ -2660,14 +2539,13 @@ impl Parser {
2660 }
2539 }
2661 }
2540 }
2662
2541
2663− // If this is a @component, wrap output in a scoped container
2542+ // A function with a scoped @style wraps its output in the scope class
2664− let scope_key = format!("__component_scope_{}", name);
2543+ if ctx.scoped_functions.contains(name) {
2665− if let Some(scope_class) = ctx.variables.get(&scope_key).cloned() {
2666 let wrapper = Element {
2544 let wrapper = Element {
2667 kind: ElementKind::El,
2545 kind: ElementKind::El,
2668 attrs: vec![Attribute {
2546 attrs: vec![Attribute {
2669 key: "class".to_string(),
2547 key: "class".to_string(),
2670− value: Some(scope_class),
2548+ value: Some(format!("hl-{}", name)),
2671 }],
2549 }],
2672 argument: None,
2550 argument: None,
2673 children: result_nodes,
2551 children: result_nodes,
@@ -2896,21 +2774,13 @@ const KNOWN_DIRECTIVES: &[&str] = &[
2896 "case",
2774 "case",
2897 "default",
2775 "default",
2898 "warn",
2776 "warn",
2899− "debug",
2900− "og",
2901 "breakpoint",
2777 "breakpoint",
2902− "lang",
2903− "favicon",
2904 "theme",
2778 "theme",
2905 "deprecated",
2779 "deprecated",
2906 "extends",
2780 "extends",
2907− "canonical",
2908− "base",
2909 "font-face",
2781 "font-face",
2910 "json-ld",
2782 "json-ld",
2911 "assert",
2783 "assert",
2912− "component",
2913− "log",
2914 "markdown",
2784 "markdown",
2915 "collection",
2785 "collection",
2916 "manifest",
2786 "manifest",
@@ -2965,6 +2835,17 @@ const REMOVED_SYNTAX: &[(&str, &str)] = &[
2965 "write the `@starting-style` rule in an `@style` block",
2835 "write the `@starting-style` rule in an `@style` block",
2966 ),
2836 ),
2967 ("@css-property", "write an `@property` rule in an `@style` block"),
2837 ("@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+ ),
2968 ("@col", "use `@column`"),
2849 ("@col", "use `@column`"),
2969 ("@p", "use `@paragraph`"),
2850 ("@p", "use `@paragraph`"),
2970 ("@img", "use `@image`"),
2851 ("@img", "use `@image`"),
@@ -3663,7 +3544,6 @@ fn parse_attr_list(
3663
3544
3664 let attr = if let Some((key, value)) = part.split_once(' ') {
3545 let attr = if let Some((key, value)) = part.split_once(' ') {
3665 let value = evaluate_if_expr(value.trim());
3546 let value = evaluate_if_expr(value.trim());
3666− let value = substitute_ternary(&value);
3667 Attribute {
3547 Attribute {
3668 key: key.trim().to_string(),
3548 key: key.trim().to_string(),
3669 value: Some(value),
3549 value: Some(value),
@@ -3978,27 +3858,6 @@ fn evaluate_condition(condition: &str) -> bool {
3978 }
3858 }
3979}
3859}
3980
3860
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−}
4002
3861
4003/// Evaluate `if(condition, true_val, false_val)` expressions in attribute values.
3862/// Evaluate `if(condition, true_val, false_val)` expressions in attribute values.
4004fn evaluate_if_expr(input: &str) -> String {
3863fn evaluate_if_expr(input: &str) -> String {
@@ -4851,6 +4710,28 @@ fn parse_json_with_error(input: &str) -> Result<JsonValue, String> {
4851 }
4710 }
4852}
4711}
4853
4712
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+
4854fn parse_json_value(chars: &[char], mut pos: usize) -> Option<(JsonValue, usize)> {
4735fn parse_json_value(chars: &[char], mut pos: usize) -> Option<(JsonValue, usize)> {
4855 pos = skip_ws(chars, pos);
4736 pos = skip_ws(chars, pos);
4856 if pos >= chars.len() {
4737 if pos >= chars.len() {
examples/blog.hl+1−2
@@ -1,6 +1,5 @@
1-- A minimal blog template: header + list of posts + post template.
1-- A minimal blog template: header + list of posts + post template.
2−@page My Blog
2+@page [lang en] My Blog
3−@lang en
4@let primary #3b82f6
3@let primary #3b82f6
5@let ink #1f2937
4@let ink #1f2937
6@let muted #6b7280
5@let muted #6b7280
examples/demo.hl+3−4
@@ -1,9 +1,8 @@
1-- A comprehensive tour of htmlang: layout, style, components, and control flow.
1-- A comprehensive tour of htmlang: layout, style, components, and control flow.
2−@page htmlang Demo
2+@page [lang en] htmlang Demo
3−@lang en
4@meta description A one-page tour of htmlang.
3@meta description A one-page tour of htmlang.
5−@og title htmlang
4+@meta og:title htmlang
6−@og type website
5+@meta og:type website
7
6
8-- Design tokens become both $vars and CSS custom properties
7-- Design tokens become both $vars and CSS custom properties
9@theme
8@theme
examples/docs.hl+1−2
@@ -1,6 +1,5 @@
1-- A documentation page: sidebar nav + prose content + code samples.
1-- A documentation page: sidebar nav + prose content + code samples.
2−@page Docs · Getting Started
2+@page [lang en] Docs · Getting Started
3−@lang en
4@let ink #0f172a
3@let ink #0f172a
5@let muted #64748b
4@let muted #64748b
6@let primary #3b82f6
5@let primary #3b82f6
examples/landing.hl+1−2
@@ -1,6 +1,5 @@
1-- A marketing landing page: hero, feature grid, CTA.
1-- A marketing landing page: hero, feature grid, CTA.
2−@page Launchpad
3−@lang en
2+@page [lang en] Launchpad
4@let primary #2563eb
3@let primary #2563eb
5@let ink #0f172a
4@let ink #0f172a
6@let muted #64748b
5@let muted #64748b
src/upgrade.rs+93−4
@@ -45,10 +45,10 @@ const FILTER_ALIASES: &[(&str, &str)] = &[
45const VERBATIM_BODIES: &[&str] = &["@style", "@script", "@markdown", "@head", "@json-ld"];
45const VERBATIM_BODIES: &[&str] = &["@style", "@script", "@markdown", "@head", "@json-ld"];
46
46
47pub fn upgrade(input: &str) -> Upgrade {
47pub 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;
51 let mut manual = Vec::new();
48 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());
52 let mut attr_depth = 0i32;
52 let mut attr_depth = 0i32;
53 let mut i = 0;
53 let mut i = 0;
54 // A file may define its own function named like an old alias (e.g.
54 // A file may define its own function named like an old alias (e.g.
@@ -327,6 +327,51 @@ fn rewrite_block(
327 None
327 None
328}
328}
329
329
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+
330/// Single-line directive rewrites.
375/// Single-line directive rewrites.
331fn rewrite_directive_line(
376fn rewrite_directive_line(
332 line: &str,
377 line: &str,
@@ -336,11 +381,31 @@ fn rewrite_directive_line(
336) -> String {
381) -> String {
337 let trimmed = line.trim_start();
382 let trimmed = line.trim_start();
338 let pad = " ".repeat(indent);
383 let pad = " ".repeat(indent);
339− for old in ["@fn ", "@define ", "@mixin "] {
384+ for old in ["@fn ", "@define ", "@mixin ", "@component "] {
340 if let Some(rest) = trimmed.strip_prefix(old) {
385 if let Some(rest) = trimmed.strip_prefix(old) {
341 return format!("{pad}@let {}", rest);
386 return format!("{pad}@let {}", rest);
342 }
387 }
343 }
388 }
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+ }
344 if let Some(rest) = trimmed.strip_prefix("@unless ") {
409 if let Some(rest) = trimmed.strip_prefix("@unless ") {
345 return format!("{pad}@if not {}", rest);
410 return format!("{pad}@if not {}", rest);
346 }
411 }
@@ -472,6 +537,16 @@ fn rewrite_attr_list(list: &str) -> String {
472 }
537 }
473 None => body,
538 None => body,
474 };
539 };
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+ };
475 // Renamed keys (keeping any `hover:` / `md:` style prefix)
550 // Renamed keys (keeping any `hover:` / `md:` style prefix)
476 let key_end = body.find(char::is_whitespace).unwrap_or(body.len());
551 let key_end = body.find(char::is_whitespace).unwrap_or(body.len());
477 let (key, value) = body.split_at(key_end);
552 let (key, value) = body.split_at(key_end);
@@ -728,6 +803,20 @@ mod tests {
728 );
803 );
729 }
804 }
730
805
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+
731 #[test]
820 #[test]
732 fn verbatim_regions_are_untouched() {
821 fn verbatim_regions_are_untouched() {
733 let src = "@script\n for (x of y) {}\n@raw \"\"\"\n@unless\n\"\"\"\n@style\n .p { }";
822 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() {
976
976
977#[test]
977#[test]
978fn ternary_expression_in_attrs() {
978fn 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");
980 assert!(output.contains("color:green"));
980 assert!(output.contains("color:green"));
981}
981}
982
982
983#[test]
983#[test]
984fn ternary_expression_false() {
984fn 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");
986 assert!(output.contains("color:gray"));
986 assert!(output.contains("color:gray"));
987}
987}
988
988
@@ -1752,7 +1752,7 @@ fn css_resize() {
1752
1752
1753#[test]
1753#[test]
1754fn lang_sets_html_attr() {
1754fn 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");
1756 assert!(output.contains("<html lang=\"en\">"));
1756 assert!(output.contains("<html lang=\"en\">"));
1757}
1757}
1758
1758
@@ -1768,7 +1768,7 @@ fn lang_not_present_without_directive() {
1768#[test]
1768#[test]
1769fn favicon_fallback_href() {
1769fn favicon_fallback_href() {
1770 // Nonexistent file should fall back to href
1770 // 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");
1772 assert!(output.contains("<link rel=\"icon\" href=\"nonexistent.png\">"));
1772 assert!(output.contains("<link rel=\"icon\" href=\"nonexistent.png\">"));
1773}
1773}
1774
1774
@@ -2099,7 +2099,7 @@ fn unless_true_hides_content() {
2099#[test]
2099#[test]
2100fn og_tags_in_output() {
2100fn og_tags_in_output() {
2101 let output = compile(
2101 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",
2103 );
2103 );
2104 assert!(output.contains("og:title"));
2104 assert!(output.contains("og:title"));
2105 assert!(output.contains("My Page"));
2105 assert!(output.contains("My Page"));
@@ -3636,7 +3636,7 @@ fn breadcrumb_generates_nav_ol() {
3636
3636
3637#[test]
3637#[test]
3638fn canonical_directive() {
3638fn 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");
3640 assert!(
3640 assert!(
3641 output.contains("<link rel=\"canonical\" href=\"https://example.com/page\">"),
3641 output.contains("<link rel=\"canonical\" href=\"https://example.com/page\">"),
3642 "canonical: {}",
3642 "canonical: {}",
@@ -3646,7 +3646,7 @@ fn canonical_directive() {
3646
3646
3647#[test]
3647#[test]
3648fn base_directive() {
3648fn 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");
3650 assert!(
3650 assert!(
3651 output.contains("<base href=\"https://example.com/\">"),
3651 output.contains("<base href=\"https://example.com/\">"),
3652 "base: {}",
3652 "base: {}",
@@ -4029,16 +4029,19 @@ fn conditional_attr_boolean_false() {
4029// -----------------------------------------------------------------------
4029// -----------------------------------------------------------------------
4030
4030
4031#[test]
4031#[test]
4032−fn component_wraps_in_scoped_div() {
4032+fn function_with_style_is_scoped() {
4033+ let html = compile(
4033− let html = compile("@component card $title\n @text $title\n\n@card [title Hello]\n");
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);
4034− assert!(html.contains("hl-card"));
4037+ assert!(html.contains(".hl-card .t { color: red; }"), "{}", html);
4035}
4038}
4036
4039
4037#[test]
4040#[test]
4038−fn component_with_children() {
4041+fn function_without_style_has_no_wrapper() {
4039 let html =
4042 let html =
4040− compile("@component box\n @el [padding 10]\n @children\n\n@box\n @text Inside\n");
4043+ compile("@let box\n @el [padding 10]\n @children\n\n@box\n @text Inside\n");
4041− assert!(html.contains("hl-box"));
4044+ assert!(!html.contains("hl-box"), "{}", html);
4042 assert!(html.contains("Inside"));
4045 assert!(html.contains("Inside"));
4043}
4046}
4044
4047
@@ -4407,16 +4410,16 @@ fn test_spread_define() {
4407#[test]
4410#[test]
4408fn test_log_directive() {
4411fn test_log_directive() {
4409 // @log should not produce errors and should be consumed without output nodes
4412 // @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");
4411 assert!(
4414 assert!(
4412 result
4415 result
4413 .diagnostics
4416 .diagnostics
4414 .iter()
4417 .iter()
4415 .all(|d| d.severity != htmlang::parser::Severity::Error),
4418 .all(|d| d.severity != htmlang::parser::Severity::Error),
4416− "@log should not produce errors"
4419+ "@warn should = $should, not = $not, produce = $produce, errors = $errors"
4417 );
4420 );
4418 let html = htmlang::codegen::generate(&result.document);
4421 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");
4420}
4423}
4421
4424
4422#[test]
4425#[test]
tests/snapshots/lang_directive.hl+1−2
@@ -1,4 +1,3 @@
1−@page Lang Test
1+@page [lang en] Lang Test
2−@lang en
3
2
4@text Hello World
3@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
1+@page [canonical https://example.com/page, base https://example.com/] My Site
2−@canonical https://example.com/page
3−@base https://example.com/
4@font-face Inter fonts/inter.woff2
2@font-face Inter fonts/inter.woff2
5@font-face Mono fonts/mono.ttf
3@font-face Mono fonts/mono.ttf
6@json-ld
4@json-ld
tests/snapshots/og_directive.hl+3−3
@@ -1,6 +1,6 @@
1@page OG Test
1@page OG Test
2−@og title "My Page Title"
2+@meta og:title My Page Title
3−@og description "A description of the page"
3+@meta og:description A description of the page
4−@og image "https://example.com/image.png"
4+@meta og:image https://example.com/image.png
5
5
6@text Hello
6@text Hello