on master help sign in
Add 23 improvements: pseudo-elements, @each @else, 10 CSS properties, LSP features, CLI commands, and new elements
commit4911ba73e0844481fe64f28612c88ea794a4d8f1 authoriko <mail@iko.soy> dateWed 15 Apr 2026, 14:38 (6 months ago) parentb63259a
12 files changed+982−10
DESIGN.md+81−3 src/ast.rs+3 src/bin/htmlang-lsp.rs+384−1 src/codegen.rs+76−1 src/convert.rs+15−3 src/main.rs+211−1 src/parser.rs+55−1 tests/snapshots.rs+104 tests/snapshots/each_else.hl+17 tests/snapshots/new_css_properties_3.hl+19 tests/snapshots/new_elements_3.hl+8 tests/snapshots/pseudo_elements.hl+9
DESIGN.md+81−3
@@ -50,6 +50,9 @@ A minimalist layout language inspired by elm-ui that compiles to static HTML.
5050| `@figcaption`| figcaption | Caption for `@figure` |
5151| `@progress` | progress | Progress bar |
5252| `@meter` | meter | Meter/gauge element |
53+| `@iframe` | iframe | Embedded external page |
54+| `@output` | output | Form calculation result |
55+| `@canvas` | canvas | Drawing surface for scripts |
5356
5457Bare lines (not starting with `@` or `[`) are text nodes.
5558
@@ -422,6 +425,57 @@ When `@each` has more than one variable and items contain spaces, values are des
422425
423426`var()` and `calc()` expressions are also passed through as-is.
424427
428+## `@each` with `@else`
429+
430+When the list is empty, the `@else` block is rendered as a fallback:
431+
432+```
433+@each $item in $items
434+ @text $item
435+@else
436+ @text [color #888] No items found.
437+```
438+
439+## `@iframe`
440+
441+Embedded external page. Argument is the `src` URL.
442+
443+```
444+@iframe [width fill, height 400, sandbox] https://example.com
445+```
446+
447+Attributes: `sandbox`, `allow`, `allowfullscreen`, `referrerpolicy`.
448+
449+## `@canvas`
450+
451+Drawing surface for JavaScript. Use with `@raw` for scripts.
452+
453+```
454+@canvas [width 400, height 300, id chart]
455+```
456+
457+## `@output`
458+
459+Form output element for displaying calculation results.
460+
461+```
462+@output [for a b] Result
463+```
464+
465+## Pseudo-elements
466+
467+Prefix any style attribute with `before:` or `after:` to apply it to the `::before` or `::after` pseudo-element. Use with `content` to set the generated content.
468+
469+```
470+@el [before:content "→ ", before:color red, before:font-weight bold]
471+ Item with arrow prefix
472+
473+@el [after:content " ✓", after:color green]
474+ Completed item
475+```
476+
477+Content values are automatically quoted unless they are CSS keywords (`none`, `normal`) or CSS functions (`attr()`, `counter()`).
478+
425479## Conditional attribute values
426480
427481Use `if(condition, true_value, false_value)` inside attribute values for conditional styling:
@@ -549,6 +603,17 @@ Supports equality (`==`), inequality (`!=`), and truthy checks.
549603| `background-repeat VALUE`| Background repeat |
550604| `word-break VALUE` | Word break (break-all/keep-all) |
551605| `overflow-wrap VALUE` | Overflow wrap (break-word) |
606+| `font-weight VALUE` | Font weight (100-900/bold/lighter)|
607+| `font-style VALUE` | Font style (normal/italic/oblique)|
608+| `text-wrap VALUE` | Text wrapping (balance/pretty) |
609+| `will-change VALUE` | Performance hint (transform) |
610+| `touch-action VALUE` | Touch behavior (none/pan-x) |
611+| `vertical-align VALUE` | Vertical alignment (middle/top) |
612+| `contain VALUE` | CSS containment (layout/paint) |
613+| `content-visibility VALUE` | Lazy rendering (auto) |
614+| `scroll-margin N` | Scroll margin (for anchors) |
615+| `scroll-padding N` | Scroll padding (scroll-snap) |
616+| `content VALUE` | Pseudo-element content (with before:/after:)|
552617
553618### Pseudo-states
554619
@@ -628,6 +693,9 @@ htmlang check src/ # check all .hl files in a directory
628693htmlang convert page.html # convert HTML to .hl format (stdout)
629694htmlang build src/ -o dist/ # parallel compile + copy static assets
630695htmlang --format json page.hl # output diagnostics as JSON
696+htmlang lint page.hl # stricter lint checks (accessibility, nesting)
697+htmlang lint src/ # lint all .hl files in a directory
698+htmlang stats page.hl # show file statistics (elements, CSS, colors)
631699```
632700
633701Watch mode recompiles automatically when the source file or any `@include`d/`@import`ed files change.
@@ -647,6 +715,16 @@ Each `.hl` file compiles to a single self-contained `.html` file:
647715A VS Code extension is available in `editors/vscode/` with:
648716
649717- Syntax highlighting via TextMate grammar
650−- LSP integration via `htmlang-lsp` for diagnostics, completions, and hover documentation
651−
652−The language server reports parse errors and warnings for unknown attributes as you type.
718+- LSP integration via `htmlang-lsp` for:
719+ - Real-time diagnostics and error checking
720+ - Completions for elements, attributes, variables, and functions
721+ - Hover documentation for all elements and attributes
722+ - Go to definition for variables, defines, and functions
723+ - Find all references for variables and functions
724+ - Rename refactoring for variables and functions
725+ - Signature help for `@fn` parameters
726+ - Document formatting (format on save)
727+ - Code actions (quick-fixes for typos, unused variables, missing attributes)
728+ - Color picker for hex colors
729+ - Code folding and document symbols
730+ - Semantic tokens for syntax highlighting
src/ast.rs+3
@@ -107,6 +107,9 @@ pub enum ElementKind {
107107 Kbd,
108108 Abbr,
109109 Datalist,
110+ Iframe,
111+ Output,
112+ Canvas,
110113}
111114
112115#[derive(Debug, Clone)]
src/bin/htmlang-lsp.rs+384−1
@@ -93,6 +93,13 @@ impl LanguageServer for Backend {
9393 linked_editing_range_provider: Some(
9494 LinkedEditingRangeServerCapabilities::Simple(true),
9595 ),
96+ document_formatting_provider: Some(OneOf::Left(true)),
97+ references_provider: Some(OneOf::Left(true)),
98+ signature_help_provider: Some(SignatureHelpOptions {
99+ trigger_characters: Some(vec!["[".into(), ",".into()]),
100+ retrigger_characters: Some(vec![",".into()]),
101+ work_done_progress_options: Default::default(),
102+ }),
96103 ..Default::default()
97104 },
98105 ..Default::default()
@@ -385,6 +392,60 @@ impl LanguageServer for Backend {
385392 drop(docs);
386393 Ok(linked_editing_ranges(&text, pos))
387394 }
395+
396+ async fn formatting(
397+ &self,
398+ params: DocumentFormattingParams,
399+ ) -> Result<Option<Vec<TextEdit>>> {
400+ let uri = &params.text_document.uri;
401+ let docs = self.documents.read().await;
402+ let text = match docs.get(uri) {
403+ Some(t) => t.clone(),
404+ None => return Ok(None),
405+ };
406+ drop(docs);
407+ let formatted = htmlang::fmt::format(&text);
408+ if formatted == text {
409+ return Ok(None);
410+ }
411+ let last_line = text.lines().count().saturating_sub(1) as u32;
412+ let last_col = text.lines().last().map_or(0, |l| l.len()) as u32;
413+ Ok(Some(vec![TextEdit {
414+ range: Range::new(Position::new(0, 0), Position::new(last_line, last_col)),
415+ new_text: formatted,
416+ }]))
417+ }
418+
419+ async fn references(
420+ &self,
421+ params: ReferenceParams,
422+ ) -> Result<Option<Vec<Location>>> {
423+ let uri = params.text_document_position.text_document.uri.clone();
424+ let pos = params.text_document_position.position;
425+ let docs = self.documents.read().await;
426+ let text = match docs.get(&uri) {
427+ Some(t) => t.clone(),
428+ None => return Ok(None),
429+ };
430+ drop(docs);
431+ let refs = find_references(&text, pos, &uri);
432+ Ok(if refs.is_empty() { None } else { Some(refs) })
433+ }
434+
435+ async fn signature_help(
436+ &self,
437+ params: SignatureHelpParams,
438+ ) -> Result<Option<SignatureHelp>> {
439+ let uri = &params.text_document_position_params.text_document.uri;
440+ let pos = params.text_document_position_params.position;
441+ let docs = self.documents.read().await;
442+ let text = match docs.get(uri) {
443+ Some(t) => t.clone(),
444+ None => return Ok(None),
445+ };
446+ drop(docs);
447+ Ok(get_signature_help(&text, pos))
448+ }
388449}
389450
390451// ---------------------------------------------------------------------------
@@ -419,7 +480,7 @@ fn completions(text: &str, position: Position) -> Vec<CompletionItem> {
419480 // State prefix (hover:, active:, focus:) or media prefix (dark:, print:)
420481 if let Some(colon) = current_word.find(':') {
421482 let prefix = &current_word[..colon];
422− if matches!(prefix, "hover" | "active" | "focus" | "focus-visible" | "focus-within" | "disabled" | "checked" | "placeholder" | "first" | "last" | "odd" | "even" | "dark" | "print" | "sm" | "md" | "lg" | "xl" | "2xl" | "motion-safe" | "motion-reduce" | "landscape" | "portrait") {
483+ if matches!(prefix, "hover" | "active" | "focus" | "focus-visible" | "focus-within" | "disabled" | "checked" | "placeholder" | "first" | "last" | "odd" | "even" | "before" | "after" | "dark" | "print" | "sm" | "md" | "lg" | "xl" | "2xl" | "motion-safe" | "motion-reduce" | "landscape" | "portrait") {
423484 return state_attr_completions(prefix, edit_range);
424485 }
425486 }
@@ -576,6 +637,10 @@ fn element_completions(range: Range) -> Vec<CompletionItem> {
576637 ("@abbr", "Abbreviation (abbr)"),
577638 // Datalist
578639 ("@datalist", "Predefined options for @input (datalist)"),
640+ // New elements
641+ ("@iframe", "Embedded external page (iframe)"),
642+ ("@output", "Form calculation result (output)"),
643+ ("@canvas", "Drawing surface for scripts (canvas)"),
579644 ]
580645 .iter()
581646 .map(|(name, detail)| item(name, CompletionItemKind::KEYWORD, detail, name, range))
@@ -639,6 +704,16 @@ fn snippet_completions(range: Range) -> Vec<CompletionItem> {
639704 "Hero section with title and subtitle",
640705 "@column [padding 80, center-x, center-y, min-height 60vh]\n @text [bold, size 48] ${1:Title}\n @paragraph [size 18, color #666] ${2:Subtitle}",
641706 ),
707+ (
708+ "each with else",
709+ "Loop with empty-state fallback",
710+ "@each \\$${1:item} in ${2:list}\n @text \\$${1:item}\n@else\n @text [color #888] No items found.",
711+ ),
712+ (
713+ "button with hover",
714+ "Interactive button with hover effect",
715+ "@el [padding 12 24, background ${1:#3b82f6}, hover:background ${2:#2563eb}, rounded 8, cursor pointer, transition all 0.15s ease] > @link ${3:url}\n @text [color white, bold] ${4:Click me}",
716+ ),
642717 ];
643718
644719 snippets
@@ -942,6 +1017,26 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
9421017 ("place-content", "Shorthand for align-content + justify-content", true),
9431018 // Background image
9441019 ("background-image", "Background image (url or gradient)", true),
1020+ // New CSS properties
1021+ ("font-weight", "Font weight (100-900, bold, lighter, bolder)", true),
1022+ ("font-style", "Font style (normal/italic/oblique)", true),
1023+ ("text-wrap", "Text wrapping (balance/pretty/nowrap)", true),
1024+ ("will-change", "Performance hint for animations (transform, opacity)", true),
1025+ ("touch-action", "Touch behavior (none/pan-x/pan-y/manipulation)", true),
1026+ ("vertical-align", "Vertical alignment (middle/top/bottom/baseline)", true),
1027+ ("contain", "CSS containment (layout/paint/content/strict)", true),
1028+ ("content-visibility", "Content visibility (auto/visible/hidden)", true),
1029+ ("scroll-margin", "Scroll margin (for scroll-snap and anchor offsets)", true),
1030+ ("scroll-margin-top", "Scroll margin top", true),
1031+ ("scroll-padding", "Scroll padding (for scroll-snap containers)", true),
1032+ ("scroll-padding-top", "Scroll padding top", true),
1033+ // Pseudo-element content
1034+ ("content", "Content for ::before/::after (use with before:/after: prefix)", true),
1035+ // Iframe/form/link attributes
1036+ ("sandbox", "Iframe sandbox restrictions", true),
1037+ ("allow", "Iframe permissions policy", true),
1038+ ("allowfullscreen", "Allow iframe fullscreen", false),
1039+ ("target", "Link/form target (_blank/_self/_parent/_top)", true),
9451040 ]
9461041 .iter()
9471042 .map(|(name, detail, takes_value)| {
@@ -1208,6 +1303,10 @@ fn hover_builtin(word: &str) -> Option<String> {
12081303 (Some("odd"), rest)
12091304 } else if let Some(rest) = word.strip_prefix("even:") {
12101305 (Some("even"), rest)
1306+ } else if let Some(rest) = word.strip_prefix("before:") {
1307+ (Some("before"), rest)
1308+ } else if let Some(rest) = word.strip_prefix("after:") {
1309+ (Some("after"), rest)
12111310 } else if let Some(rest) = word.strip_prefix("sm:") {
12121311 (Some("sm"), rest)
12131312 } else if let Some(rest) = word.strip_prefix("md:") {
@@ -1482,6 +1581,22 @@ fn hover_builtin(word: &str) -> Option<String> {
14821581 "isolation" => "**isolation** `<value>` \u{2014} Creates a new stacking context (`isolate`, `auto`).",
14831582 "place-content" => "**place-content** `<value>` \u{2014} Shorthand for `align-content` and `justify-content`.",
14841583 "background-image" => "**background-image** `<value>` \u{2014} Background image (`url()` or gradient function).",
1584+ // New CSS properties (batch 2)
1585+ "font-weight" => "**font-weight** `<value>` \u{2014} Font weight (`100`\u{2013}`900`, `bold`, `lighter`, `bolder`). More precise than `bold`.",
1586+ "font-style" => "**font-style** `<value>` \u{2014} Font style (`normal`, `italic`, `oblique`). More precise than `italic`.",
1587+ "text-wrap" => "**text-wrap** `<value>` \u{2014} Text wrapping behavior (`balance`, `pretty`, `nowrap`, `wrap`). `balance` is great for headings.",
1588+ "will-change" => "**will-change** `<value>` \u{2014} Performance hint for upcoming changes (`transform`, `opacity`, `scroll-position`).",
1589+ "touch-action" => "**touch-action** `<value>` \u{2014} Touch behavior for mobile (`none`, `pan-x`, `pan-y`, `manipulation`, `auto`).",
1590+ "vertical-align" => "**vertical-align** `<value>` \u{2014} Vertical alignment for inline elements (`middle`, `top`, `bottom`, `baseline`, `text-top`).",
1591+ "contain" => "**contain** `<value>` \u{2014} CSS containment for performance (`layout`, `paint`, `content`, `strict`, `size`).",
1592+ "content-visibility" => "**content-visibility** `<value>` \u{2014} Content rendering optimization (`auto`, `visible`, `hidden`). `auto` enables lazy rendering.",
1593+ "scroll-margin" => "**scroll-margin** `<value>` \u{2014} Scroll margin (for scroll-snap and anchor link offsets). Supports CSS units.",
1594+ "scroll-padding" => "**scroll-padding** `<value>` \u{2014} Scroll padding for scroll-snap containers. Supports CSS units.",
1595+ "content" => "**content** `<value>` \u{2014} CSS content property. Use with `before:` or `after:` prefix.\n\nExample: `before:content \u{2192}, before:color red`",
1596+ // New elements
1597+ "@iframe" => "**@iframe** \u{2014} Embedded page\n\nRenders as `<iframe>`.\n\nUsage: `@iframe [width fill, height 400] https://example.com`\n\nAttributes: `sandbox`, `allow`, `allowfullscreen`",
1598+ "@output" => "**@output** \u{2014} Form output\n\nRenders as `<output>`. Displays calculation results in forms.\n\nUsage: `@output [for a b] Result`",
1599+ "@canvas" => "**@canvas** \u{2014} Drawing surface\n\nRenders as `<canvas>`. Use with `@raw` JavaScript for drawing.\n\nUsage: `@canvas [width 400, height 300, id myCanvas]`",
14851600 _ => return None,
14861601 };
14871602
@@ -1964,6 +2079,120 @@ fn code_actions(
19642079 }
19652080 }
19662081 }
2082+
2083+ // Quick-fix: remove unused @let variable
2084+ if msg.contains("unused variable") {
2085+ let line = diag.range.start.line as usize;
2086+ let lines: Vec<&str> = text.lines().collect();
2087+ if let Some(source_line) = lines.get(line) {
2088+ let trimmed = source_line.trim_start();
2089+ if trimmed.starts_with("@let ") {
2090+ let edit = TextEdit {
2091+ range: Range::new(
2092+ Position::new(diag.range.start.line, 0),
2093+ Position::new(diag.range.start.line + 1, 0),
2094+ ),
2095+ new_text: String::new(),
2096+ };
2097+ let mut changes = HashMap::new();
2098+ changes.insert(uri.clone(), vec![edit]);
2099+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
2100+ title: "Remove unused variable".into(),
2101+ kind: Some(CodeActionKind::QUICKFIX),
2102+ diagnostics: Some(vec![diag.clone()]),
2103+ edit: Some(WorkspaceEdit {
2104+ changes: Some(changes),
2105+ ..Default::default()
2106+ }),
2107+ ..Default::default()
2108+ }));
2109+ }
2110+ }
2111+ }
2112+
2113+ // Quick-fix: remove unused @define
2114+ if msg.contains("unused define") {
2115+ let line = diag.range.start.line as usize;
2116+ let lines: Vec<&str> = text.lines().collect();
2117+ if let Some(source_line) = lines.get(line) {
2118+ let trimmed = source_line.trim_start();
2119+ if trimmed.starts_with("@define ") {
2120+ let edit = TextEdit {
2121+ range: Range::new(
2122+ Position::new(diag.range.start.line, 0),
2123+ Position::new(diag.range.start.line + 1, 0),
2124+ ),
2125+ new_text: String::new(),
2126+ };
2127+ let mut changes = HashMap::new();
2128+ changes.insert(uri.clone(), vec![edit]);
2129+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
2130+ title: "Remove unused define".into(),
2131+ kind: Some(CodeActionKind::QUICKFIX),
2132+ diagnostics: Some(vec![diag.clone()]),
2133+ edit: Some(WorkspaceEdit {
2134+ changes: Some(changes),
2135+ ..Default::default()
2136+ }),
2137+ ..Default::default()
2138+ }));
2139+ }
2140+ }
2141+ }
2142+
2143+ // Quick-fix: add alt attribute to @image
2144+ if msg.contains("@image should have") && msg.contains("alt") {
2145+ let line = diag.range.start.line as usize;
2146+ let lines: Vec<&str> = text.lines().collect();
2147+ if let Some(source_line) = lines.get(line) {
2148+ if let Some(bracket_pos) = source_line.find('[') {
2149+ let insert_pos = bracket_pos + 1;
2150+ let edit = TextEdit {
2151+ range: Range::new(
2152+ Position::new(diag.range.start.line, insert_pos as u32),
2153+ Position::new(diag.range.start.line, insert_pos as u32),
2154+ ),
2155+ new_text: "alt , ".into(),
2156+ };
2157+ let mut changes = HashMap::new();
2158+ changes.insert(uri.clone(), vec![edit]);
2159+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
2160+ title: "Add alt attribute".into(),
2161+ kind: Some(CodeActionKind::QUICKFIX),
2162+ diagnostics: Some(vec![diag.clone()]),
2163+ edit: Some(WorkspaceEdit {
2164+ changes: Some(changes),
2165+ ..Default::default()
2166+ }),
2167+ ..Default::default()
2168+ }));
2169+ } else if source_line.contains("@image") {
2170+ // No brackets yet, add them
2171+ if let Some(img_pos) = source_line.find("@image") {
2172+ let after_image = img_pos + "@image".len();
2173+ let edit = TextEdit {
2174+ range: Range::new(
2175+ Position::new(diag.range.start.line, after_image as u32),
2176+ Position::new(diag.range.start.line, after_image as u32),
2177+ ),
2178+ new_text: " [alt ]".into(),
2179+ };
2180+ let mut changes = HashMap::new();
2181+ changes.insert(uri.clone(), vec![edit]);
2182+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
2183+ title: "Add alt attribute".into(),
2184+ kind: Some(CodeActionKind::QUICKFIX),
2185+ diagnostics: Some(vec![diag.clone()]),
2186+ edit: Some(WorkspaceEdit {
2187+ changes: Some(changes),
2188+ ..Default::default()
2189+ }),
2190+ ..Default::default()
2191+ }));
2192+ }
2193+ }
2194+ }
2195+ }
19672196 }
19682197
19692198 actions
@@ -2358,6 +2587,160 @@ fn linked_editing_ranges(text: &str, position: Position) -> Option<LinkedEditing
23582587 })
23592588}
23602589
2590+// ---------------------------------------------------------------------------
2591+// Find references
2592+// ---------------------------------------------------------------------------
2593+
2594+fn find_references(text: &str, position: Position, uri: &Url) -> Vec<Location> {
2595+ let lines: Vec<&str> = text.lines().collect();
2596+ let line = match lines.get(position.line as usize) {
2597+ Some(l) => *l,
2598+ None => return vec![],
2599+ };
2600+ let col = (position.character as usize).min(line.len());
2601+ let bytes = line.as_bytes();
2602+
2603+ // Find the word at cursor
2604+ let mut start = col;
2605+ while start > 0 && is_word_byte(bytes[start - 1]) {
2606+ start -= 1;
2607+ }
2608+ let mut end = col;
2609+ while end < bytes.len() && is_word_byte(bytes[end]) {
2610+ end += 1;
2611+ }
2612+ if start == end {
2613+ return vec![];
2614+ }
2615+ let word = &line[start..end];
2616+
2617+ // Determine the search pattern
2618+ let search = if word.starts_with('$') || word.starts_with('@') {
2619+ word.to_string()
2620+ } else if start > 0 && bytes[start - 1] == b'$' {
2621+ format!("${}", word)
2622+ } else if start > 0 && bytes[start - 1] == b'@' {
2623+ format!("@{}", word)
2624+ } else {
2625+ return vec![];
2626+ };
2627+
2628+ let mut locations = Vec::new();
2629+ for (line_idx, line_text) in text.lines().enumerate() {
2630+ let mut offset = 0;
2631+ while let Some(pos) = line_text[offset..].find(&search) {
2632+ let abs_pos = offset + pos;
2633+ let after = abs_pos + search.len();
2634+ // Ensure word boundary
2635+ let before_ok = abs_pos == 0 || {
2636+ let c = line_text.as_bytes()[abs_pos - 1];
2637+ !c.is_ascii_alphanumeric() && c != b'_' && c != b'-'
2638+ };
2639+ let after_ok = after >= line_text.len() || {
2640+ let c = line_text.as_bytes()[after];
2641+ !c.is_ascii_alphanumeric() && c != b'_' && c != b'-'
2642+ };
2643+ if before_ok && after_ok {
2644+ locations.push(Location {
2645+ uri: uri.clone(),
2646+ range: Range::new(
2647+ Position::new(line_idx as u32, abs_pos as u32),
2648+ Position::new(line_idx as u32, after as u32),
2649+ ),
2650+ });
2651+ }
2652+ offset = after;
2653+ }
2654+ }
2655+
2656+ locations
2657+}
2658+
2659+// ---------------------------------------------------------------------------
2660+// Signature help
2661+// ---------------------------------------------------------------------------
2662+
2663+fn get_signature_help(text: &str, position: Position) -> Option<SignatureHelp> {
2664+ let lines: Vec<&str> = text.lines().collect();
2665+ let line = lines.get(position.line as usize)?;
2666+ let col = (position.character as usize).min(line.len());
2667+ let before = &line[..col];
2668+
2669+ // Check if we're inside a function call: @funcname [...
2670+ let trimmed = before.trim_start();
2671+ if !trimmed.starts_with('@') {
2672+ return None;
2673+ }
2674+
2675+ // Extract the function name
2676+ let after_at = &trimmed[1..];
2677+ let name_end = after_at.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')
2678+ .unwrap_or(after_at.len());
2679+ let fn_name = &after_at[..name_end];
2680+
2681+ // Must be inside brackets
2682+ if !in_brackets(before) {
2683+ return None;
2684+ }
2685+
2686+ // Find the @fn definition
2687+ for (line_idx, line_text) in text.lines().enumerate() {
2688+ let t = line_text.trim_start();
2689+ if let Some(rest) = t.strip_prefix("@fn ") {
2690+ let rest = rest.trim();
2691+ let def_name_end = rest.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')
2692+ .unwrap_or(rest.len());
2693+ let def_name = &rest[..def_name_end];
2694+ if def_name != fn_name {
2695+ continue;
2696+ }
2697+ let params_str = &rest[def_name_end..].trim();
2698+ let params: Vec<&str> = params_str
2699+ .split_whitespace()
2700+ .filter(|p| p.starts_with('$'))
2701+ .collect();
2702+
2703+ if params.is_empty() {
2704+ return None;
2705+ }
2706+
2707+ let param_labels: Vec<ParameterInformation> = params.iter().map(|p| {
2708+ let name = p.trim_start_matches('$');
2709+ let (label, doc) = if let Some((n, default)) = name.split_once('=') {
2710+ (n.to_string(), Some(format!("Default: {}", default)))
2711+ } else {
2712+ (name.to_string(), None)
2713+ };
2714+ ParameterInformation {
2715+ label: ParameterLabel::Simple(label),
2716+ documentation: doc.map(|d| Documentation::String(d)),
2717+ }
2718+ }).collect();
2719+
2720+ let sig_label = format!("@{} {}", fn_name, params.join(" "));
2721+
2722+ // Determine active parameter by counting commas before cursor inside brackets
2723+ let bracket_start = before.rfind('[').unwrap_or(0);
2724+ let inside = &before[bracket_start..];
2725+ let active_param = inside.matches(',').count() as u32;
2726+
2727+ return Some(SignatureHelp {
2728+ signatures: vec![SignatureInformation {
2729+ label: sig_label,
2730+ documentation: Some(Documentation::String(
2731+ format!("Defined at line {}", line_idx + 1),
2732+ )),
2733+ parameters: Some(param_labels),
2734+ active_parameter: Some(active_param),
2735+ }],
2736+ active_signature: Some(0),
2737+ active_parameter: Some(active_param),
2738+ });
2739+ }
2740+ }
2741+ None
2742+}
2743+
23612744// ---------------------------------------------------------------------------
23622745// Entry point
23632746// ---------------------------------------------------------------------------
src/codegen.rs+76−1
@@ -692,6 +692,9 @@ fn generate_element(
692692 ElementKind::Kbd => "kbd",
693693 ElementKind::Abbr => "abbr",
694694 ElementKind::Datalist => "datalist",
695+ ElementKind::Iframe => "iframe",
696+ ElementKind::Output => "output",
697+ ElementKind::Canvas => "canvas",
695698 ElementKind::Image | ElementKind::Input | ElementKind::HorizontalRule
696699 | ElementKind::Children | ElementKind::Slot(_) | ElementKind::Fragment
697700 | ElementKind::Source => unreachable!(),
@@ -748,6 +751,9 @@ fn generate_element(
748751 ElementKind::Kbd => "kbd",
749752 ElementKind::Abbr => "abbr",
750753 ElementKind::Datalist => "datalist",
754+ ElementKind::Iframe => "iframe",
755+ ElementKind::Output => "output",
756+ ElementKind::Canvas => "canvas",
751757 _ => "",
752758 };
753759
@@ -789,6 +795,15 @@ fn generate_element(
789795 }
790796 }
791797
798+ // Iframe src
799+ if elem.kind == ElementKind::Iframe {
800+ if let Some(src) = &elem.argument {
801+ out.push_str(" src=\"");
802+ out.push_str(&html_escape(src));
803+ out.push('"');
804+ }
805+ }
806+
792807 // Time datetime
793808 if elem.kind == ElementKind::Time {
794809 if let Some(dt) = elem.attrs.iter().find(|a| a.key == "datetime") {
@@ -810,6 +825,11 @@ fn generate_element(
810825
811826 emit_html_passthrough_attrs(out, &elem.attrs);
812827
828+ // Source map attributes in dev mode
829+ if ctx.dev && elem.line_num > 0 {
830+ out.push_str(&format!(" data-hl-line=\"{}\" data-hl-el=\"{}\"", elem.line_num, kind_label));
831+ }
832+
813833 out.push('>');
814834 out.push_str(ctx.nl());
815835
@@ -903,6 +923,11 @@ fn generate_self_closing(
903923
904924 emit_html_passthrough_attrs(out, &elem.attrs);
905925
926+ // Source map attributes in dev mode (self-closing)
927+ if ctx.dev && elem.line_num > 0 {
928+ out.push_str(&format!(" data-hl-line=\"{}\" data-hl-el=\"{}\"", elem.line_num, kind_label));
929+ }
930+
906931 // Image optimization: auto-add loading="lazy" and decoding="async"
907932 if elem.kind == ElementKind::Image {
908933 // SVG inlining: @image [inline] logo.svg
@@ -1032,6 +1057,8 @@ const PSEUDO_PREFIXES: &[(&str, &str)] = &[
10321057 ("last:", ":last-child"),
10331058 ("odd:", ":nth-child(odd)"),
10341059 ("even:", ":nth-child(even)"),
1060+ ("before:", "::before"),
1061+ ("after:", "::after"),
10351062];
10361063const RESPONSIVE_PREFIXES: &[&str] = &["sm:", "md:", "lg:", "xl:", "2xl:"];
10371064const MEDIA_PREFIXES: &[&str] = &["dark:", "print:", "motion-safe:", "motion-reduce:", "landscape:", "portrait:"];
@@ -1776,6 +1803,52 @@ fn attrs_to_css(
17761803 "background-image" => {
17771804 if let Some(v) = val { push_css(&mut css, "background-image", v); }
17781805 }
1806+ "font-weight" => {
1807+ if let Some(v) = val { push_css(&mut css, "font-weight", v); }
1808+ }
1809+ "font-style" => {
1810+ if let Some(v) = val { push_css(&mut css, "font-style", v); }
1811+ }
1812+ "text-wrap" => {
1813+ if let Some(v) = val { push_css(&mut css, "text-wrap", v); }
1814+ }
1815+ "will-change" => {
1816+ if let Some(v) = val { push_css(&mut css, "will-change", v); }
1817+ }
1818+ "touch-action" => {
1819+ if let Some(v) = val { push_css(&mut css, "touch-action", v); }
1820+ }
1821+ "vertical-align" => {
1822+ if let Some(v) = val { push_css(&mut css, "vertical-align", v); }
1823+ }
1824+ "contain" => {
1825+ if let Some(v) = val { push_css(&mut css, "contain", v); }
1826+ }
1827+ "scroll-margin" => {
1828+ if let Some(v) = val { push_css(&mut css, "scroll-margin", &css_px(v)); }
1829+ }
1830+ "scroll-margin-top" | "scroll-margin-bottom" | "scroll-margin-left" | "scroll-margin-right" => {
1831+ if let Some(v) = val { push_css(&mut css, effective_key, &css_px(v)); }
1832+ }
1833+ "scroll-padding" => {
1834+ if let Some(v) = val { push_css(&mut css, "scroll-padding", &css_px(v)); }
1835+ }
1836+ "scroll-padding-top" | "scroll-padding-bottom" | "scroll-padding-left" | "scroll-padding-right" => {
1837+ if let Some(v) = val { push_css(&mut css, effective_key, &css_px(v)); }
1838+ }
1839+ "content-visibility" => {
1840+ if let Some(v) = val { push_css(&mut css, "content-visibility", v); }
1841+ }
1842+ "content" => {
1843+ if let Some(v) = val {
1844+ // Wrap in quotes if not already quoted and not a CSS keyword
1845+ if v.starts_with('"') || v.starts_with('\'') || v == "none" || v == "normal" || v.starts_with("attr(") || v.starts_with("counter(") {
1846+ push_css(&mut css, "content", v);
1847+ } else {
1848+ push_css(&mut css, "content", &format!("\"{}\"", v));
1849+ }
1850+ }
1851+ }
17791852
17801853 // Identity and HTML passthrough — not CSS
17811854 "id" | "class" => {}
@@ -1787,7 +1860,9 @@ fn attrs_to_css(
17871860 | "loading" | "decoding" | "ordered" | "src"
17881861 | "open" | "novalidate" | "low" | "high" | "optimum"
17891862 | "colspan" | "rowspan" | "scope" | "inline"
1790− | "datetime" | "media" | "sizes" | "srcset" | "cite" | "list" => {}
1863+ | "datetime" | "media" | "sizes" | "srcset" | "cite" | "list"
1864+ | "sandbox" | "allow" | "allowfullscreen" | "referrerpolicy"
1865+ | "formaction" | "formmethod" | "formtarget" | "target" => {}
17911866
17921867 _ => {}
17931868 }
src/convert.rs+15−3
@@ -501,13 +501,13 @@ fn css_to_hl_attrs(styles: &[(String, String)]) -> (Vec<(String, Option<String>)
501501 attrs.push(("bold".into(), None));
502502 }
503503 "font-weight" => {
504− leftover.push((key.clone(), val.clone()));
504+ attrs.push(("font-weight".into(), Some(val.clone())));
505505 }
506506 "font-style" if val == "italic" => {
507507 attrs.push(("italic".into(), None));
508508 }
509509 "font-style" => {
510− leftover.push((key.clone(), val.clone()));
510+ attrs.push(("font-style".into(), Some(val.clone())));
511511 }
512512 "text-decoration" if val.contains("underline") => {
513513 attrs.push(("underline".into(), None));
@@ -584,6 +584,15 @@ fn css_to_hl_attrs(styles: &[(String, String)]) -> (Vec<(String, Option<String>)
584584 "flex" if val == "1" => attrs.push(("width".into(), Some("fill".into()))),
585585 "flex-shrink" if val == "0" => attrs.push(("width".into(), Some("shrink".into()))),
586586
587+ "text-wrap" => attrs.push(("text-wrap".into(), Some(val.clone()))),
588+ "will-change" => attrs.push(("will-change".into(), Some(val.clone()))),
589+ "touch-action" => attrs.push(("touch-action".into(), Some(val.clone()))),
590+ "vertical-align" => attrs.push(("vertical-align".into(), Some(val.clone()))),
591+ "contain" => attrs.push(("contain".into(), Some(val.clone()))),
592+ "content-visibility" => attrs.push(("content-visibility".into(), Some(val.clone()))),
593+ "scroll-margin" => attrs.push(("scroll-margin".into(), Some(strip_px(val).into()))),
594+ "scroll-padding" => attrs.push(("scroll-padding".into(), Some(strip_px(val).into()))),
595+
587596 _ => {
588597 leftover.push((key.clone(), val.clone()));
589598 }
@@ -878,8 +887,11 @@ fn emit_element(
878887 out.push('\n');
879888 return;
880889 }
890+ "iframe" => "@iframe",
891+ "output" => "@output",
892+ "canvas" => "@canvas",
881893 // Unknown / raw elements
882− "script" | "style" | "svg" | "canvas" | "iframe" | "object" | "embed" => {
894+ "script" | "style" | "svg" | "object" | "embed" => {
883895 emit_raw_element(tag, attrs, children, depth, out);
884896 return;
885897 }
src/main.rs+211−1
@@ -207,6 +207,8 @@ Commands:
207207 convert <file.html> Convert an HTML file to .hl format (stdout)
208208 fmt <file.hl> Format a .hl file (normalizes indentation)
209209 sitemap <dir> Generate sitemap.xml from .hl files
210+ lint <file.hl | dir> Stricter lint checks (accessibility, nesting, etc.)
211+ stats <file.hl | dir> Show file statistics (elements, CSS rules, colors)
210212
211213Options:
212214 -w, --watch Watch for changes and recompile
@@ -237,7 +239,9 @@ Examples:
237239 htmlang --format json page.hl Get diagnostics as JSON
238240 htmlang fmt page.hl Format a file
239241 htmlang build src/ -o dist/ Compile all .hl files to dist/
240− htmlang sitemap src/ Generate sitemap.xml",
242+ htmlang sitemap src/ Generate sitemap.xml
243+ htmlang lint src/ Lint all files in directory
244+ htmlang stats page.hl Show file statistics",
241245 env!("CARGO_PKG_VERSION")
242246 );
243247}
@@ -330,6 +334,164 @@ fn generate_sitemap(dir: &str, base_url: &str) {
330334 }
331335}
332336
337+fn lint_file(path: &str) -> Vec<String> {
338+ let input = match fs::read_to_string(path) {
339+ Ok(s) => s,
340+ Err(e) => return vec![format!("error: {}: {}", path, e)],
341+ };
342+ let base = Path::new(path).parent();
343+ let result = htmlang::parser::parse_with_base(&input, base);
344+ let mut warnings = Vec::new();
345+
346+ // Report parser diagnostics
347+ for d in &result.diagnostics {
348+ let prefix = match d.severity {
349+ htmlang::parser::Severity::Error => "error",
350+ htmlang::parser::Severity::Warning => "warning",
351+ };
352+ warnings.push(format!("{}:{}:{}: {}", path, d.line, prefix, d.message));
353+ }
354+
355+ // Additional lint checks on the AST
356+ lint_nodes(&result.document.nodes, path, 0, &mut warnings);
357+ warnings
358+}
359+
360+fn lint_nodes(nodes: &[htmlang::ast::Node], path: &str, depth: usize, warnings: &mut Vec<String>) {
361+ for node in nodes {
362+ if let htmlang::ast::Node::Element(elem) = node {
363+ // Deeply nested elements (>10 levels)
364+ if depth > 10 {
365+ warnings.push(format!("{}:{}:lint: deeply nested element ({} levels) — consider simplifying", path, elem.line_num, depth));
366+ }
367+
368+ // @image without alt
369+ if elem.kind == htmlang::ast::ElementKind::Image {
370+ if !elem.attrs.iter().any(|a| a.key == "alt") {
371+ warnings.push(format!("{}:{}:lint: @image missing 'alt' attribute (accessibility)", path, elem.line_num));
372+ }
373+ }
374+
375+ // @link without content or aria-label
376+ if elem.kind == htmlang::ast::ElementKind::Link {
377+ let has_aria = elem.attrs.iter().any(|a| a.key == "aria-label");
378+ let has_children = !elem.children.is_empty();
379+ let has_arg_text = elem.argument.as_ref().map_or(false, |_| false);
380+ if !has_aria && !has_children && !has_arg_text {
381+ warnings.push(format!("{}:{}:lint: @link has no visible text or aria-label (accessibility)", path, elem.line_num));
382+ }
383+ }
384+
385+ // @input without type
386+ if elem.kind == htmlang::ast::ElementKind::Input {
387+ if !elem.attrs.iter().any(|a| a.key == "type") {
388+ warnings.push(format!("{}:{}:lint: @input missing 'type' attribute", path, elem.line_num));
389+ }
390+ }
391+
392+ // Empty containers (no children, no text)
393+ if matches!(elem.kind,
394+ htmlang::ast::ElementKind::Row | htmlang::ast::ElementKind::Column | htmlang::ast::ElementKind::El
395+ ) && elem.children.is_empty() {
396+ warnings.push(format!("{}:{}:lint: empty container (@{}) has no children", path, elem.line_num,
397+ match elem.kind {
398+ htmlang::ast::ElementKind::Row => "row",
399+ htmlang::ast::ElementKind::Column => "column",
400+ _ => "el",
401+ }
402+ ));
403+ }
404+
405+ // @button without type
406+ if elem.kind == htmlang::ast::ElementKind::Button {
407+ if !elem.attrs.iter().any(|a| a.key == "type") {
408+ warnings.push(format!("{}:{}:lint: @button missing 'type' attribute (defaults to submit)", path, elem.line_num));
409+ }
410+ }
411+
412+ lint_nodes(&elem.children, path, depth + 1, warnings);
413+ }
414+ }
415+}
416+
417+fn stats_file(path: &str) {
418+ let input = match fs::read_to_string(path) {
419+ Ok(s) => s,
420+ Err(e) => {
421+ eprintln!("error: {}: {}", path, e);
422+ process::exit(1);
423+ }
424+ };
425+ let base = Path::new(path).parent();
426+ let result = htmlang::parser::parse_with_base(&input, base);
427+ let html = htmlang::codegen::generate(&result.document);
428+
429+ let mut element_count = 0;
430+ let mut colors = std::collections::HashSet::new();
431+ let mut fonts = std::collections::HashSet::new();
432+ count_elements(&result.document.nodes, &mut element_count, &mut colors, &mut fonts);
433+
434+ // Count CSS rules (approximate from generated style block)
435+ let css_rules = html.matches('{').count().saturating_sub(1); // subtract the html/head/body structure
436+
437+ let source_bytes = input.len();
438+ let output_bytes = html.len();
439+
440+ eprintln!("--- {} ---", path);
441+ eprintln!(" source size: {} bytes ({} lines)", source_bytes, input.lines().count());
442+ eprintln!(" output size: {} bytes", output_bytes);
443+ eprintln!(" elements: {}", element_count);
444+ eprintln!(" CSS rules: ~{}", css_rules);
445+ eprintln!(" unique colors: {}", colors.len());
446+ if !colors.is_empty() {
447+ let mut sorted: Vec<_> = colors.iter().collect();
448+ sorted.sort();
449+ eprintln!(" {}", sorted.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", "));
450+ }
451+ eprintln!(" unique fonts: {}", fonts.len());
452+ if !fonts.is_empty() {
453+ let mut sorted: Vec<_> = fonts.iter().collect();
454+ sorted.sort();
455+ eprintln!(" {}", sorted.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", "));
456+ }
457+ if result.diagnostics.iter().any(|d| d.severity == htmlang::parser::Severity::Error) {
458+ eprintln!(" errors: {}", result.diagnostics.iter().filter(|d| d.severity == htmlang::parser::Severity::Error).count());
459+ }
460+ let warn_count = result.diagnostics.iter().filter(|d| d.severity == htmlang::parser::Severity::Warning).count();
461+ if warn_count > 0 {
462+ eprintln!(" warnings: {}", warn_count);
463+ }
464+}
465+
466+fn count_elements(
467+ nodes: &[htmlang::ast::Node],
468+ count: &mut usize,
469+ colors: &mut std::collections::HashSet<String>,
470+ fonts: &mut std::collections::HashSet<String>,
471+) {
472+ for node in nodes {
473+ if let htmlang::ast::Node::Element(elem) = node {
474+ *count += 1;
475+ for attr in &elem.attrs {
476+ let key = attr.key.as_str();
477+ // Strip pseudo/media prefixes for color/font detection
478+ let base_key = key.split(':').last().unwrap_or(key);
479+ if matches!(base_key, "color" | "background") {
480+ if let Some(ref v) = attr.value {
481+ colors.insert(v.clone());
482+ }
483+ }
484+ if base_key == "font" {
485+ if let Some(ref v) = attr.value {
486+ fonts.insert(v.clone());
487+ }
488+ }
489+ }
490+ count_elements(&elem.children, count, colors, fonts);
491+ }
492+ }
493+}
494+
333495fn open_in_browser(port: u16) {
334496 let url = format!("http://127.0.0.1:{}", port);
335497 let cmd = if cfg!(target_os = "macos") {
@@ -461,6 +623,54 @@ fn main() {
461623 return;
462624 }
463625
626+ // Handle "lint" subcommand
627+ if args.len() >= 2 && args[1] == "lint" {
628+ let target = if args.len() >= 3 { &args[2] } else { "." };
629+ let path = Path::new(target);
630+ let mut all_warnings = Vec::new();
631+ if path.is_dir() {
632+ let hl_files = collect_hl_files_recursive(path);
633+ if hl_files.is_empty() {
634+ eprintln!("no .hl files found in {}", target);
635+ process::exit(1);
636+ }
637+ for file in &hl_files {
638+ let path_str = file.to_string_lossy().to_string();
639+ all_warnings.extend(lint_file(&path_str));
640+ }
641+ } else {
642+ all_warnings.extend(lint_file(target));
643+ }
644+ if all_warnings.is_empty() {
645+ eprintln!("no issues found");
646+ } else {
647+ for w in &all_warnings {
648+ eprintln!("{}", w);
649+ }
650+ process::exit(1);
651+ }
652+ return;
653+ }
654+
655+ // Handle "stats" subcommand
656+ if args.len() >= 2 && args[1] == "stats" {
657+ let target = if args.len() >= 3 { &args[2] } else { "." };
658+ let path = Path::new(target);
659+ if path.is_dir() {
660+ let hl_files = collect_hl_files_recursive(path);
661+ if hl_files.is_empty() {
662+ eprintln!("no .hl files found in {}", target);
663+ process::exit(1);
664+ }
665+ for file in &hl_files {
666+ stats_file(&file.to_string_lossy());
667+ }
668+ } else {
669+ stats_file(target);
670+ }
671+ return;
672+ }
673+
464674 // Handle "check" subcommand
465675 if args.len() >= 2 && args[1] == "check" {
466676 let mut check_target = None;
src/parser.rs+55−1
@@ -725,10 +725,46 @@ impl Parser {
725725 self.pos += 1;
726726 }
727727
728− if body_lines.is_empty() || items.is_empty() {
728+ // Check for @else block (empty-state fallback)
729+ let mut else_lines = Vec::new();
730+ if self.pos < self.lines.len() && self.lines[self.pos].indent == current_indent {
731+ if let LineContent::Normal(ref s) = self.lines[self.pos].content {
732+ if s.trim() == "@else" {
733+ self.pos += 1; // consume @else
734+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
735+ else_lines.push(self.lines[self.pos].clone());
736+ self.pos += 1;
737+ }
738+ }
739+ }
740+ }
741+
742+ if body_lines.is_empty() {
729743 return Ok(None);
730744 }
731745
746+ // If list is empty, render @else body
747+ if items.is_empty() {
748+ if else_lines.is_empty() {
749+ return Ok(None);
750+ }
751+ let min_indent = else_lines.iter().map(|l| l.indent).min().unwrap_or(0);
752+ let adjusted: Vec<Line> = else_lines
753+ .iter()
754+ .map(|l| Line {
755+ indent: l.indent - min_indent,
756+ content: l.content.clone(),
757+ line_num: l.line_num,
758+ })
759+ .collect();
760+ let mut body_parser = Parser {
761+ lines: adjusted,
762+ pos: 0,
763+ };
764+ let nodes = body_parser.parse_children(0, ctx);
765+ return Ok(Some(nodes));
766+ }
767+
732768 let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
733769 let adjusted: Vec<Line> = body_lines
734770 .iter()
@@ -1220,6 +1256,7 @@ const KNOWN_ELEMENTS: &[&str] = &[
12201256 "fragment",
12211257 "btn", "ul", "dialog", "dl", "dt", "dd", "fieldset", "legend",
12221258 "picture", "source", "time", "mark", "kbd", "abbr", "datalist",
1259+ "iframe", "output", "canvas",
12231260];
12241261
12251262const KNOWN_DIRECTIVES: &[&str] = &[
@@ -1289,6 +1326,9 @@ fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseErro
12891326 "kbd" => Ok(ElementKind::Kbd),
12901327 "abbr" => Ok(ElementKind::Abbr),
12911328 "datalist" => Ok(ElementKind::Datalist),
1329+ "iframe" => Ok(ElementKind::Iframe),
1330+ "output" => Ok(ElementKind::Output),
1331+ "canvas" => Ok(ElementKind::Canvas),
12921332 "ul" => Ok(ElementKind::List),
12931333 _ => {
12941334 let all_known: Vec<&str> = KNOWN_ELEMENTS
@@ -1434,6 +1474,16 @@ const KNOWN_ATTRS: &[&str] = &[
14341474 "column-count", "column-gap", "text-indent", "hyphens",
14351475 "flex-grow", "flex-shrink", "flex-basis", "isolation",
14361476 "place-content", "background-image", "datetime",
1477+ // New CSS properties (batch 2)
1478+ "font-weight", "font-style", "text-wrap", "will-change", "touch-action",
1479+ "vertical-align", "contain", "content-visibility",
1480+ "scroll-margin", "scroll-margin-top", "scroll-margin-bottom", "scroll-margin-left", "scroll-margin-right",
1481+ "scroll-padding", "scroll-padding-top", "scroll-padding-bottom", "scroll-padding-left", "scroll-padding-right",
1482+ // Iframe/output attrs
1483+ "sandbox", "allow", "allowfullscreen", "referrerpolicy",
1484+ "formaction", "formmethod", "formtarget", "target",
1485+ // Pseudo-element content
1486+ "content",
14371487];
14381488
14391489/// Attributes that expect purely numeric values (px-based) or values with CSS units.
@@ -2010,6 +2060,9 @@ fn element_kind_name(kind: &ElementKind) -> &'static str {
20102060 ElementKind::Kbd => "@kbd",
20112061 ElementKind::Abbr => "@abbr",
20122062 ElementKind::Datalist => "@datalist",
2063+ ElementKind::Iframe => "@iframe",
2064+ ElementKind::Output => "@output",
2065+ ElementKind::Canvas => "@canvas",
20132066 }
20142067}
20152068
@@ -2023,6 +2076,7 @@ fn is_container(kind: &ElementKind) -> bool {
20232076 | ElementKind::Blockquote
20242077 | ElementKind::Dialog | ElementKind::DefinitionList | ElementKind::DefinitionTerm
20252078 | ElementKind::DefinitionDescription | ElementKind::Fieldset | ElementKind::Datalist
2079+ | ElementKind::Iframe | ElementKind::Canvas
20262080 )
20272081}
20282082
tests/snapshots.rs+104
@@ -2031,3 +2031,107 @@ fn convert_script_to_raw() {
20312031 let hl = htmlang::convert::convert("<script>alert('hi')</script>");
20322032 assert!(hl.contains("@raw"), "script becomes @raw: {}", hl);
20332033}
2034+
2035+// ---------------------------------------------------------------------------
2036+// New improvement tests (batch 3)
2037+// ---------------------------------------------------------------------------
2038+
2039+#[test]
2040+fn snapshot_new_elements_3() {
2041+ snapshot_test("new_elements_3");
2042+}
2043+
2044+#[test]
2045+fn snapshot_new_css_properties_3() {
2046+ snapshot_test("new_css_properties_3");
2047+}
2048+
2049+#[test]
2050+fn snapshot_pseudo_elements() {
2051+ snapshot_test("pseudo_elements");
2052+}
2053+
2054+#[test]
2055+fn snapshot_each_else() {
2056+ snapshot_test("each_else");
2057+}
2058+
2059+#[test]
2060+fn each_else_empty_list() {
2061+ // Variable that resolves to empty string: @each produces no items → @else fires
2062+ let output = compile("@let empty \"\"\n@each $item in $empty\n @text $item\n@else\n @text fallback");
2063+ assert!(output.contains("fallback"), "should render @else block when list is empty: {}", output);
2064+}
2065+
2066+#[test]
2067+fn each_else_non_empty_list() {
2068+ let output = compile("@each $x in a,b\n @text $x\n@else\n @text empty");
2069+ assert!(output.contains("a"), "should render loop items: {}", output);
2070+ assert!(output.contains("b"), "should render loop items: {}", output);
2071+ assert!(!output.contains("empty"), "should not render @else block when list is non-empty: {}", output);
2072+}
2073+
2074+#[test]
2075+fn pseudo_element_before_content() {
2076+ let output = compile("@el [before:content arrow, before:color red]\n Hello");
2077+ assert!(output.contains("::before"), "should generate ::before CSS: {}", output);
2078+ assert!(output.contains("content:\"arrow\""), "should generate content property: {}", output);
2079+ assert!(output.contains("color:red"), "should generate color in ::before: {}", output);
2080+}
2081+
2082+#[test]
2083+fn pseudo_element_after_content() {
2084+ let output = compile("@el [after:content ✓]\n Done");
2085+ assert!(output.contains("::after"), "should generate ::after CSS: {}", output);
2086+ assert!(output.contains("content:\"✓\""), "should generate content: {}", output);
2087+}
2088+
2089+#[test]
2090+fn css_font_weight_numeric() {
2091+ let output = compile("@text [font-weight 300] Light text");
2092+ assert!(output.contains("font-weight:300"), "should generate font-weight CSS: {}", output);
2093+}
2094+
2095+#[test]
2096+fn css_text_wrap_balance() {
2097+ let output = compile("@text [text-wrap balance] Balanced");
2098+ assert!(output.contains("text-wrap:balance"), "should generate text-wrap CSS: {}", output);
2099+}
2100+
2101+#[test]
2102+fn css_touch_action() {
2103+ let output = compile("@el [touch-action none]\n No touch");
2104+ assert!(output.contains("touch-action:none"), "should generate touch-action CSS: {}", output);
2105+}
2106+
2107+#[test]
2108+fn css_content_visibility() {
2109+ let output = compile("@el [content-visibility auto]\n Lazy");
2110+ assert!(output.contains("content-visibility:auto"), "should generate content-visibility CSS: {}", output);
2111+}
2112+
2113+#[test]
2114+fn css_scroll_margin() {
2115+ let output = compile("@el [scroll-margin-top 80]\n Offset");
2116+ assert!(output.contains("scroll-margin-top:80px"), "should generate scroll-margin-top CSS: {}", output);
2117+}
2118+
2119+#[test]
2120+fn element_iframe() {
2121+ let output = compile("@iframe [width fill, height 400] https://example.com");
2122+ assert!(output.contains("<iframe"), "should generate iframe tag: {}", output);
2123+ assert!(output.contains("src=\"https://example.com\""), "should have src: {}", output);
2124+}
2125+
2126+#[test]
2127+fn element_canvas() {
2128+ let output = compile("@canvas [width 400, height 300, id myCanvas]");
2129+ assert!(output.contains("<canvas"), "should generate canvas tag: {}", output);
2130+ assert!(output.contains("id=\"myCanvas\""), "should have id: {}", output);
2131+}
2132+
2133+#[test]
2134+fn element_output() {
2135+ let output = compile("@output [for a b]\n 42");
2136+ assert!(output.contains("<output"), "should generate output tag: {}", output);
2137+}
tests/snapshots/each_else.hlnew file+17
@@ -0,0 +1,17 @@
1+@page Each Else
2+
3+-- Empty list triggers @else (variable resolves to empty string)
4+@let empty ""
5+@column [spacing 10]
6+ @each $item in $empty
7+ @text $item
8+ @else
9+ @text [color #888] No items found.
10+
11+-- Non-empty list renders loop, not @else
12+@let fruits apple, banana
13+@column [spacing 10]
14+ @each $fruit in $fruits
15+ @text $fruit
16+ @else
17+ @text No fruits.
tests/snapshots/new_css_properties_3.hlnew file+19
@@ -0,0 +1,19 @@
1+@page New CSS Properties 3
2+
3+@column [spacing 20]
4+ @text [font-weight 600] Semi-bold text
5+ @text [font-style oblique] Oblique text
6+ @text [text-wrap balance, size 32] A heading that wraps nicely
7+ @el [will-change transform, transition transform 0.3s ease]
8+ Optimized for animation
9+ @el [touch-action none]
10+ No touch scrolling
11+ @text [vertical-align middle] Middle aligned
12+ @el [contain content]
13+ Contained element
14+ @el [scroll-margin-top 80]
15+ Offset for fixed header
16+ @el [content-visibility auto]
17+ Lazy rendered content
18+ @el [scroll-padding 20]
19+ Scroll padding container
tests/snapshots/new_elements_3.hlnew file+8
@@ -0,0 +1,8 @@
1+@page New Elements 3
2+
3+@iframe [width fill, height 400] https://example.com
4+
5+@output [id result, for a b]
6+ 42
7+
8+@canvas [width 400, height 300, id myCanvas]
tests/snapshots/pseudo_elements.hlnew file+9
@@ -0,0 +1,9 @@
1+@page Pseudo Elements
2+
3+@column [spacing 20]
4+ @el [before:content "→ ", before:color red]
5+ Item with arrow prefix
6+ @el [after:content " ✓", after:color green]
7+ Completed item
8+ @el [before:content none]
9+ No pseudo element