@@ -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 {
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)?;
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 {
538+ "unknown @page attribute '{}' (expected lang, favicon, canonical or base)",
541+ severity: Severity::Warning,
542+ source_line: Some(content.clone()),
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);
524− if let Some(rest) = content.strip_prefix("@lang ") {
525− ctx.lang = Some(substitute_vars(rest.trim(), &ctx.variables));
529− if let Some(rest) = content.strip_prefix("@favicon ") {
530− ctx.favicon = Some(substitute_vars(rest.trim(), &ctx.variables));
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;
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
605+ .filter_map(|line| match &line.content {
606+ LineContent::Normal(s) if !s.trim().is_empty() => {
607+ Some(format!(".{} {}\n", scope_class, s.trim()))
612+ if !scoped_css.is_empty() {
613+ ctx.custom_css.push(scoped_css);
615+ ctx.scoped_functions.insert(name.clone());
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(
@@ -666,29 +707,19 @@ impl Parser {
666 return Ok(None);
707 return Ok(None);
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)),
675 return Ok(None);
720 return Ok(None);
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()
688− ctx.og_tags.push((parts[0].to_string(), value));
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);
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);
2022− // --- @log (compile-time variable inspection) ---
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() {
2034− } else if val.starts_with('#') && is_valid_hex_color(val) {
2036− } else if val.contains('/') || val.ends_with(".hl") || val.ends_with(".html") {
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();
2047− "log: line {}: @{} ({}) — {} line(s)",
2051− def.body_lines.len()
2053− } else if ctx.defines.contains_key(name) {
2054− let attrs = &ctx.defines[name];
2055− let attr_strs: Vec<String> = attrs
2057− .map(|a| match &a.value {
2058− Some(v) => format!("{} {}", a.key, v),
2059− None => a.key.clone(),
2063− "log: line {}: ${} = [{}]",
2066− attr_strs.join(", ")
2069− eprintln!("log: line {}: ${} is undefined", line_num, name);
2075 // --- @keyframes directive ---
2048 // --- @keyframes directive ---
@@ -2124,19 +2097,6 @@ impl Parser {
2124 return Ok(None);
2097 return Ok(None);
2127− // --- @canonical directive ---
2129− if let Some(rest) = content.strip_prefix("@canonical ") {
2130− ctx.canonical = Some(substitute_vars(rest.trim(), &ctx.variables));
2134− // --- @base directive ---
2136− if let Some(rest) = content.strip_prefix("@base ") {
2137− ctx.base_url = Some(substitute_vars(rest.trim(), &ctx.variables));
2141 // --- @font-face directive ---
2101 // --- @font-face directive ---
@@ -2379,87 +2339,6 @@ impl Parser {
2382− // --- @component definition (scoped @fn) ---
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 {
2389− message: "@component requires a name".to_string(),
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());
2401− params.push(part.to_string());
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"
2415− style_indent = self.lines[self.pos].indent;
2419− if in_style && self.lines[self.pos].indent > style_indent {
2420− style_lines.push(self.lines[self.pos].clone());
2423− body_lines.push(self.lines[self.pos].clone());
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));
2441− if !scoped_css.is_empty() {
2442− ctx.custom_css.push(scoped_css);
2446− ctx.fn_lines.entry(name.clone()).or_insert(line_num);
2447− ctx.functions.insert(
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),
2463 // --- Function call ---
2342 // --- Function call ---
2465 if content.starts_with('@') {
2344 if content.starts_with('@') {
@@ -2660,14 +2539,13 @@ impl Parser {
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)),
2672 argument: None,
2550 argument: None,
2673 children: result_nodes,
2551 children: result_nodes,
@@ -2896,21 +2774,13 @@ const KNOWN_DIRECTIVES: &[&str] = &[
2897 "default",
2775 "default",
2901 "breakpoint",
2777 "breakpoint",
2904 "theme",
2778 "theme",
2905 "deprecated",
2779 "deprecated",
2906 "extends",
2780 "extends",
2909 "font-face",
2781 "font-face",
2910 "json-ld",
2782 "json-ld",
2911 "assert",
2783 "assert",
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",
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`"),
2847+ "use `@let`: an `@style` block in a function body is scoped to it",
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(
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 {
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();
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();
3996− return false_val.to_string();
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> {
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;
4721+ let is_style_header =
4722+ line.indent == top && matches!(&line.content, LineContent::Normal(s) if s.trim() == "@style");
4723+ if is_style_header {
4725+ } else if in_style && line.indent > top {
4729+ content.push(line);
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() {