on master help sign in
Add 23 improvements: pseudo-elements, @each @else, 10 CSS properties, LSP features, CLI commands, and new elements
4911ba7iko
DESIGN.md+81−3
@@ -50,6 +50,9 @@ A minimalist layout language inspired by elm-ui that compiles to static HTML.
50| `@figcaption`| figcaption | Caption for `@figure` |
50| `@figcaption`| figcaption | Caption for `@figure` |
51| `@progress` | progress | Progress bar |
51| `@progress` | progress | Progress bar |
52| `@meter` | meter | Meter/gauge element |
52| `@meter` | meter | Meter/gauge element |
53+| `@iframe` | iframe | Embedded external page |
54+| `@output` | output | Form calculation result |
55+| `@canvas` | canvas | Drawing surface for scripts |
53
56
54Bare lines (not starting with `@` or `[`) are text nodes.
57Bare lines (not starting with `@` or `[`) are text nodes.
55
58
@@ -422,6 +425,57 @@ When `@each` has more than one variable and items contain spaces, values are des
422
425
423`var()` and `calc()` expressions are also passed through as-is.
426`var()` and `calc()` expressions are also passed through as-is.
424
427
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+
425## Conditional attribute values
479## Conditional attribute values
426
480
427Use `if(condition, true_value, false_value)` inside attribute values for conditional styling:
481Use `if(condition, true_value, false_value)` inside attribute values for conditional styling:
@@ -549,6 +603,17 @@ Supports equality (`==`), inequality (`!=`), and truthy checks.
549| `background-repeat VALUE`| Background repeat |
603| `background-repeat VALUE`| Background repeat |
550| `word-break VALUE` | Word break (break-all/keep-all) |
604| `word-break VALUE` | Word break (break-all/keep-all) |
551| `overflow-wrap VALUE` | Overflow wrap (break-word) |
605| `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:)|
552
617
553### Pseudo-states
618### Pseudo-states
554
619
@@ -628,6 +693,9 @@ htmlang check src/ # check all .hl files in a directory
628htmlang convert page.html # convert HTML to .hl format (stdout)
693htmlang convert page.html # convert HTML to .hl format (stdout)
629htmlang build src/ -o dist/ # parallel compile + copy static assets
694htmlang build src/ -o dist/ # parallel compile + copy static assets
630htmlang --format json page.hl # output diagnostics as JSON
695htmlang --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)
631```
699```
632
700
633Watch mode recompiles automatically when the source file or any `@include`d/`@import`ed files change.
701Watch 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:
647A VS Code extension is available in `editors/vscode/` with:
715A VS Code extension is available in `editors/vscode/` with:
648
716
649- Syntax highlighting via TextMate grammar
717- Syntax highlighting via TextMate grammar
650−- LSP integration via `htmlang-lsp` for diagnostics, completions, and hover documentation
718+- LSP integration via `htmlang-lsp` for:
651−
719+ - Real-time diagnostics and error checking
652−The language server reports parse errors and warnings for unknown attributes as you type.
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 {
107 Kbd,
107 Kbd,
108 Abbr,
108 Abbr,
109 Datalist,
109 Datalist,
110+ Iframe,
111+ Output,
112+ Canvas,
110}
113}
111
114
112#[derive(Debug, Clone)]
115#[derive(Debug, Clone)]
src/bin/htmlang-lsp.rs+384−1
@@ -93,6 +93,13 @@ impl LanguageServer for Backend {
93 linked_editing_range_provider: Some(
93 linked_editing_range_provider: Some(
94 LinkedEditingRangeServerCapabilities::Simple(true),
94 LinkedEditingRangeServerCapabilities::Simple(true),
95 ),
95 ),
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+ }),
96 ..Default::default()
103 ..Default::default()
97 },
104 },
98 ..Default::default()
105 ..Default::default()
@@ -385,6 +392,60 @@ impl LanguageServer for Backend {
385 drop(docs);
392 drop(docs);
386 Ok(linked_editing_ranges(&text, pos))
393 Ok(linked_editing_ranges(&text, pos))
387 }
394 }
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+ }
388}
449}
389
450
390// ---------------------------------------------------------------------------
451// ---------------------------------------------------------------------------
@@ -419,7 +480,7 @@ fn completions(text: &str, position: Position) -> Vec<CompletionItem> {
419 // State prefix (hover:, active:, focus:) or media prefix (dark:, print:)
480 // State prefix (hover:, active:, focus:) or media prefix (dark:, print:)
420 if let Some(colon) = current_word.find(':') {
481 if let Some(colon) = current_word.find(':') {
421 let prefix = &current_word[..colon];
482 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") {
423 return state_attr_completions(prefix, edit_range);
484 return state_attr_completions(prefix, edit_range);
424 }
485 }
425 }
486 }
@@ -576,6 +637,10 @@ fn element_completions(range: Range) -> Vec<CompletionItem> {
576 ("@abbr", "Abbreviation (abbr)"),
637 ("@abbr", "Abbreviation (abbr)"),
577 // Datalist
638 // Datalist
578 ("@datalist", "Predefined options for @input (datalist)"),
639 ("@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)"),
579 ]
644 ]
580 .iter()
645 .iter()
581 .map(|(name, detail)| item(name, CompletionItemKind::KEYWORD, detail, name, range))
646 .map(|(name, detail)| item(name, CompletionItemKind::KEYWORD, detail, name, range))
@@ -639,6 +704,16 @@ fn snippet_completions(range: Range) -> Vec<CompletionItem> {
639 "Hero section with title and subtitle",
704 "Hero section with title and subtitle",
640 "@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}",
705 "@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}",
641 ),
706 ),
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+ ),
642 ];
717 ];
643
718
644 snippets
719 snippets
@@ -942,6 +1017,26 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
942 ("place-content", "Shorthand for align-content + justify-content", true),
1017 ("place-content", "Shorthand for align-content + justify-content", true),
943 // Background image
1018 // Background image
944 ("background-image", "Background image (url or gradient)", true),
1019 ("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),
945 ]
1040 ]
946 .iter()
1041 .iter()
947 .map(|(name, detail, takes_value)| {
1042 .map(|(name, detail, takes_value)| {
@@ -1208,6 +1303,10 @@ fn hover_builtin(word: &str) -> Option<String> {
1208 (Some("odd"), rest)
1303 (Some("odd"), rest)
1209 } else if let Some(rest) = word.strip_prefix("even:") {
1304 } else if let Some(rest) = word.strip_prefix("even:") {
1210 (Some("even"), rest)
1305 (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)
1211 } else if let Some(rest) = word.strip_prefix("sm:") {
1310 } else if let Some(rest) = word.strip_prefix("sm:") {
1212 (Some("sm"), rest)
1311 (Some("sm"), rest)
1213 } else if let Some(rest) = word.strip_prefix("md:") {
1312 } else if let Some(rest) = word.strip_prefix("md:") {
@@ -1482,6 +1581,22 @@ fn hover_builtin(word: &str) -> Option<String> {
1482 "isolation" => "**isolation** `<value>` \u{2014} Creates a new stacking context (`isolate`, `auto`).",
1581 "isolation" => "**isolation** `<value>` \u{2014} Creates a new stacking context (`isolate`, `auto`).",
1483 "place-content" => "**place-content** `<value>` \u{2014} Shorthand for `align-content` and `justify-content`.",
1582 "place-content" => "**place-content** `<value>` \u{2014} Shorthand for `align-content` and `justify-content`.",
1484 "background-image" => "**background-image** `<value>` \u{2014} Background image (`url()` or gradient function).",
1583 "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]`",
1485 _ => return None,
1600 _ => return None,
1486 };
1601 };
1487
1602
@@ -1964,6 +2079,120 @@ fn code_actions(
1964 }
2079 }
1965 }
2080 }
1966 }
2081 }
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+ }
1967 }
2196 }
1968
2197
1969 actions
2198 actions
@@ -2358,6 +2587,160 @@ fn linked_editing_ranges(text: &str, position: Position) -> Option<LinkedEditing
2358 })
2587 })
2359}
2588}
2360
2589
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+
2361// ---------------------------------------------------------------------------
2744// ---------------------------------------------------------------------------
2362// Entry point
2745// Entry point
2363// ---------------------------------------------------------------------------
2746// ---------------------------------------------------------------------------
src/codegen.rs+76−1
@@ -692,6 +692,9 @@ fn generate_element(
692 ElementKind::Kbd => "kbd",
692 ElementKind::Kbd => "kbd",
693 ElementKind::Abbr => "abbr",
693 ElementKind::Abbr => "abbr",
694 ElementKind::Datalist => "datalist",
694 ElementKind::Datalist => "datalist",
695+ ElementKind::Iframe => "iframe",
696+ ElementKind::Output => "output",
697+ ElementKind::Canvas => "canvas",
695 ElementKind::Image | ElementKind::Input | ElementKind::HorizontalRule
698 ElementKind::Image | ElementKind::Input | ElementKind::HorizontalRule
696 | ElementKind::Children | ElementKind::Slot(_) | ElementKind::Fragment
699 | ElementKind::Children | ElementKind::Slot(_) | ElementKind::Fragment
697 | ElementKind::Source => unreachable!(),
700 | ElementKind::Source => unreachable!(),
@@ -748,6 +751,9 @@ fn generate_element(
748 ElementKind::Kbd => "kbd",
751 ElementKind::Kbd => "kbd",
749 ElementKind::Abbr => "abbr",
752 ElementKind::Abbr => "abbr",
750 ElementKind::Datalist => "datalist",
753 ElementKind::Datalist => "datalist",
754+ ElementKind::Iframe => "iframe",
755+ ElementKind::Output => "output",
756+ ElementKind::Canvas => "canvas",
751 _ => "",
757 _ => "",
752 };
758 };
753
759
@@ -789,6 +795,15 @@ fn generate_element(
789 }
795 }
790 }
796 }
791
797
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+
792 // Time datetime
807 // Time datetime
793 if elem.kind == ElementKind::Time {
808 if elem.kind == ElementKind::Time {
794 if let Some(dt) = elem.attrs.iter().find(|a| a.key == "datetime") {
809 if let Some(dt) = elem.attrs.iter().find(|a| a.key == "datetime") {
@@ -810,6 +825,11 @@ fn generate_element(
810
825
811 emit_html_passthrough_attrs(out, &elem.attrs);
826 emit_html_passthrough_attrs(out, &elem.attrs);
812
827
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+
813 out.push('>');
833 out.push('>');
814 out.push_str(ctx.nl());
834 out.push_str(ctx.nl());
815
835
@@ -903,6 +923,11 @@ fn generate_self_closing(
903
923
904 emit_html_passthrough_attrs(out, &elem.attrs);
924 emit_html_passthrough_attrs(out, &elem.attrs);
905
925
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+
906 // Image optimization: auto-add loading="lazy" and decoding="async"
931 // Image optimization: auto-add loading="lazy" and decoding="async"
907 if elem.kind == ElementKind::Image {
932 if elem.kind == ElementKind::Image {
908 // SVG inlining: @image [inline] logo.svg
933 // SVG inlining: @image [inline] logo.svg
@@ -1032,6 +1057,8 @@ const PSEUDO_PREFIXES: &[(&str, &str)] = &[
1032 ("last:", ":last-child"),
1057 ("last:", ":last-child"),
1033 ("odd:", ":nth-child(odd)"),
1058 ("odd:", ":nth-child(odd)"),
1034 ("even:", ":nth-child(even)"),
1059 ("even:", ":nth-child(even)"),
1060+ ("before:", "::before"),
1061+ ("after:", "::after"),
1035];
1062];
1036const RESPONSIVE_PREFIXES: &[&str] = &["sm:", "md:", "lg:", "xl:", "2xl:"];
1063const RESPONSIVE_PREFIXES: &[&str] = &["sm:", "md:", "lg:", "xl:", "2xl:"];
1037const MEDIA_PREFIXES: &[&str] = &["dark:", "print:", "motion-safe:", "motion-reduce:", "landscape:", "portrait:"];
1064const MEDIA_PREFIXES: &[&str] = &["dark:", "print:", "motion-safe:", "motion-reduce:", "landscape:", "portrait:"];
@@ -1776,6 +1803,52 @@ fn attrs_to_css(
1776 "background-image" => {
1803 "background-image" => {
1777 if let Some(v) = val { push_css(&mut css, "background-image", v); }
1804 if let Some(v) = val { push_css(&mut css, "background-image", v); }
1778 }
1805 }
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+ }
1779
1852
1780 // Identity and HTML passthrough — not CSS
1853 // Identity and HTML passthrough — not CSS
1781 "id" | "class" => {}
1854 "id" | "class" => {}
@@ -1787,7 +1860,9 @@ fn attrs_to_css(
1787 | "loading" | "decoding" | "ordered" | "src"
1860 | "loading" | "decoding" | "ordered" | "src"
1788 | "open" | "novalidate" | "low" | "high" | "optimum"
1861 | "open" | "novalidate" | "low" | "high" | "optimum"
1789 | "colspan" | "rowspan" | "scope" | "inline"
1862 | "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" => {}
1791
1866
1792 _ => {}
1867 _ => {}
1793 }
1868 }
src/convert.rs+15−3
@@ -501,13 +501,13 @@ fn css_to_hl_attrs(styles: &[(String, String)]) -> (Vec<(String, Option<String>)
501 attrs.push(("bold".into(), None));
501 attrs.push(("bold".into(), None));
502 }
502 }
503 "font-weight" => {
503 "font-weight" => {
504− leftover.push((key.clone(), val.clone()));
504+ attrs.push(("font-weight".into(), Some(val.clone())));
505 }
505 }
506 "font-style" if val == "italic" => {
506 "font-style" if val == "italic" => {
507 attrs.push(("italic".into(), None));
507 attrs.push(("italic".into(), None));
508 }
508 }
509 "font-style" => {
509 "font-style" => {
510− leftover.push((key.clone(), val.clone()));
510+ attrs.push(("font-style".into(), Some(val.clone())));
511 }
511 }
512 "text-decoration" if val.contains("underline") => {
512 "text-decoration" if val.contains("underline") => {
513 attrs.push(("underline".into(), None));
513 attrs.push(("underline".into(), None));
@@ -584,6 +584,15 @@ fn css_to_hl_attrs(styles: &[(String, String)]) -> (Vec<(String, Option<String>)
584 "flex" if val == "1" => attrs.push(("width".into(), Some("fill".into()))),
584 "flex" if val == "1" => attrs.push(("width".into(), Some("fill".into()))),
585 "flex-shrink" if val == "0" => attrs.push(("width".into(), Some("shrink".into()))),
585 "flex-shrink" if val == "0" => attrs.push(("width".into(), Some("shrink".into()))),
586
586
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+
587 _ => {
596 _ => {
588 leftover.push((key.clone(), val.clone()));
597 leftover.push((key.clone(), val.clone()));
589 }
598 }
@@ -878,8 +887,11 @@ fn emit_element(
878 out.push('\n');
887 out.push('\n');
879 return;
888 return;
880 }
889 }
890+ "iframe" => "@iframe",
891+ "output" => "@output",
892+ "canvas" => "@canvas",
881 // Unknown / raw elements
893 // Unknown / raw elements
882− "script" | "style" | "svg" | "canvas" | "iframe" | "object" | "embed" => {
894+ "script" | "style" | "svg" | "object" | "embed" => {
883 emit_raw_element(tag, attrs, children, depth, out);
895 emit_raw_element(tag, attrs, children, depth, out);
884 return;
896 return;
885 }
897 }
src/main.rs+211−1
@@ -207,6 +207,8 @@ Commands:
207 convert <file.html> Convert an HTML file to .hl format (stdout)
207 convert <file.html> Convert an HTML file to .hl format (stdout)
208 fmt <file.hl> Format a .hl file (normalizes indentation)
208 fmt <file.hl> Format a .hl file (normalizes indentation)
209 sitemap <dir> Generate sitemap.xml from .hl files
209 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)
210
212
211Options:
213Options:
212 -w, --watch Watch for changes and recompile
214 -w, --watch Watch for changes and recompile
@@ -237,7 +239,9 @@ Examples:
237 htmlang --format json page.hl Get diagnostics as JSON
239 htmlang --format json page.hl Get diagnostics as JSON
238 htmlang fmt page.hl Format a file
240 htmlang fmt page.hl Format a file
239 htmlang build src/ -o dist/ Compile all .hl files to dist/
241 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",
241 env!("CARGO_PKG_VERSION")
245 env!("CARGO_PKG_VERSION")
242 );
246 );
243}
247}
@@ -330,6 +334,164 @@ fn generate_sitemap(dir: &str, base_url: &str) {
330 }
334 }
331}
335}
332
336
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+
333fn open_in_browser(port: u16) {
495fn open_in_browser(port: u16) {
334 let url = format!("http://127.0.0.1:{}", port);
496 let url = format!("http://127.0.0.1:{}", port);
335 let cmd = if cfg!(target_os = "macos") {
497 let cmd = if cfg!(target_os = "macos") {
@@ -461,6 +623,54 @@ fn main() {
461 return;
623 return;
462 }
624 }
463
625
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+
464 // Handle "check" subcommand
674 // Handle "check" subcommand
465 if args.len() >= 2 && args[1] == "check" {
675 if args.len() >= 2 && args[1] == "check" {
466 let mut check_target = None;
676 let mut check_target = None;
src/parser.rs+55−1
@@ -725,10 +725,46 @@ impl Parser {
725 self.pos += 1;
725 self.pos += 1;
726 }
726 }
727
727
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+
728− if body_lines.is_empty() || items.is_empty() {
742+ if body_lines.is_empty() {
729 return Ok(None);
743 return Ok(None);
730 }
744 }
731
745
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+
732 let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
768 let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
733 let adjusted: Vec<Line> = body_lines
769 let adjusted: Vec<Line> = body_lines
734 .iter()
770 .iter()
@@ -1220,6 +1256,7 @@ const KNOWN_ELEMENTS: &[&str] = &[
1220 "fragment",
1256 "fragment",
1221 "btn", "ul", "dialog", "dl", "dt", "dd", "fieldset", "legend",
1257 "btn", "ul", "dialog", "dl", "dt", "dd", "fieldset", "legend",
1222 "picture", "source", "time", "mark", "kbd", "abbr", "datalist",
1258 "picture", "source", "time", "mark", "kbd", "abbr", "datalist",
1259+ "iframe", "output", "canvas",
1223];
1260];
1224
1261
1225const KNOWN_DIRECTIVES: &[&str] = &[
1262const KNOWN_DIRECTIVES: &[&str] = &[
@@ -1289,6 +1326,9 @@ fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseErro
1289 "kbd" => Ok(ElementKind::Kbd),
1326 "kbd" => Ok(ElementKind::Kbd),
1290 "abbr" => Ok(ElementKind::Abbr),
1327 "abbr" => Ok(ElementKind::Abbr),
1291 "datalist" => Ok(ElementKind::Datalist),
1328 "datalist" => Ok(ElementKind::Datalist),
1329+ "iframe" => Ok(ElementKind::Iframe),
1330+ "output" => Ok(ElementKind::Output),
1331+ "canvas" => Ok(ElementKind::Canvas),
1292 "ul" => Ok(ElementKind::List),
1332 "ul" => Ok(ElementKind::List),
1293 _ => {
1333 _ => {
1294 let all_known: Vec<&str> = KNOWN_ELEMENTS
1334 let all_known: Vec<&str> = KNOWN_ELEMENTS
@@ -1434,6 +1474,16 @@ const KNOWN_ATTRS: &[&str] = &[
1434 "column-count", "column-gap", "text-indent", "hyphens",
1474 "column-count", "column-gap", "text-indent", "hyphens",
1435 "flex-grow", "flex-shrink", "flex-basis", "isolation",
1475 "flex-grow", "flex-shrink", "flex-basis", "isolation",
1436 "place-content", "background-image", "datetime",
1476 "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",
1437];
1487];
1438
1488
1439/// Attributes that expect purely numeric values (px-based) or values with CSS units.
1489/// 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 {
2010 ElementKind::Kbd => "@kbd",
2060 ElementKind::Kbd => "@kbd",
2011 ElementKind::Abbr => "@abbr",
2061 ElementKind::Abbr => "@abbr",
2012 ElementKind::Datalist => "@datalist",
2062 ElementKind::Datalist => "@datalist",
2063+ ElementKind::Iframe => "@iframe",
2064+ ElementKind::Output => "@output",
2065+ ElementKind::Canvas => "@canvas",
2013 }
2066 }
2014}
2067}
2015
2068
@@ -2023,6 +2076,7 @@ fn is_container(kind: &ElementKind) -> bool {
2023 | ElementKind::Blockquote
2076 | ElementKind::Blockquote
2024 | ElementKind::Dialog | ElementKind::DefinitionList | ElementKind::DefinitionTerm
2077 | ElementKind::Dialog | ElementKind::DefinitionList | ElementKind::DefinitionTerm
2025 | ElementKind::DefinitionDescription | ElementKind::Fieldset | ElementKind::Datalist
2078 | ElementKind::DefinitionDescription | ElementKind::Fieldset | ElementKind::Datalist
2079+ | ElementKind::Iframe | ElementKind::Canvas
2026 )
2080 )
2027}
2081}
2028
2082
tests/snapshots.rs+104
@@ -2031,3 +2031,107 @@ fn convert_script_to_raw() {
2031 let hl = htmlang::convert::convert("<script>alert('hi')</script>");
2031 let hl = htmlang::convert::convert("<script>alert('hi')</script>");
2032 assert!(hl.contains("@raw"), "script becomes @raw: {}", hl);
2032 assert!(hl.contains("@raw"), "script becomes @raw: {}", hl);
2033}
2033}
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