@@ -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() {