on master help sign in
Add 30 improvements: pseudo-state prefixes, child selectors, @fragment, @lang, @favicon, hidden, 17 CSS properties, and LSP color picker/folding/semantic tokens
955bf47iko
src/ast.rs+3
@@ -4,6 +4,8 @@ use std::collections::HashMap;
4#[allow(dead_code)]
4#[allow(dead_code)]
5pub struct Document {
5pub struct Document {
6 pub page_title: Option<String>,
6 pub page_title: Option<String>,
7+ pub lang: Option<String>,
8+ pub favicon: Option<String>,
7 pub meta_tags: Vec<(String, String)>,
9 pub meta_tags: Vec<(String, String)>,
8 pub head_blocks: Vec<String>,
10 pub head_blocks: Vec<String>,
9 pub variables: HashMap<String, String>,
11 pub variables: HashMap<String, String>,
@@ -89,6 +91,7 @@ pub enum ElementKind {
89 FigCaption,
91 FigCaption,
90 Progress,
92 Progress,
91 Meter,
93 Meter,
94+ Fragment,
92}
95}
93
96
94#[derive(Debug, Clone)]
97#[derive(Debug, Clone)]
src/bin/htmlang-lsp.rs+421−1
@@ -68,6 +68,26 @@ impl LanguageServer for Backend {
68 })),
68 })),
69 document_symbol_provider: Some(OneOf::Left(true)),
69 document_symbol_provider: Some(OneOf::Left(true)),
70 code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
70 code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
71+ color_provider: Some(ColorProviderCapability::Simple(true)),
72+ folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)),
73+ semantic_tokens_provider: Some(
74+ SemanticTokensServerCapabilities::SemanticTokensOptions(SemanticTokensOptions {
75+ legend: SemanticTokensLegend {
76+ token_types: vec![
77+ SemanticTokenType::KEYWORD,
78+ SemanticTokenType::VARIABLE,
79+ SemanticTokenType::FUNCTION,
80+ SemanticTokenType::STRING,
81+ SemanticTokenType::COMMENT,
82+ SemanticTokenType::PROPERTY,
83+ ],
84+ token_modifiers: vec![],
85+ },
86+ full: Some(SemanticTokensFullOptions::Bool(true)),
87+ range: None,
88+ ..Default::default()
89+ }),
90+ ),
71 ..Default::default()
91 ..Default::default()
72 },
92 },
73 ..Default::default()
93 ..Default::default()
@@ -220,6 +240,77 @@ impl LanguageServer for Backend {
220 Some(actions)
240 Some(actions)
221 })
241 })
222 }
242 }
243+
244+ async fn document_color(
245+ &self,
246+ params: DocumentColorParams,
247+ ) -> Result<Vec<ColorInformation>> {
248+ let uri = &params.text_document.uri;
249+ let docs = self.documents.read().await;
250+ let text = match docs.get(uri) {
251+ Some(t) => t.clone(),
252+ None => return Ok(vec![]),
253+ };
254+ drop(docs);
255+ Ok(find_colors(&text))
256+ }
257+
258+ async fn color_presentation(
259+ &self,
260+ params: ColorPresentationParams,
261+ ) -> Result<Vec<ColorPresentation>> {
262+ let c = params.color;
263+ let r = (c.red * 255.0) as u8;
264+ let g = (c.green * 255.0) as u8;
265+ let b = (c.blue * 255.0) as u8;
266+ let hex = if c.alpha < 1.0 {
267+ let a = (c.alpha * 255.0) as u8;
268+ format!("#{:02x}{:02x}{:02x}{:02x}", r, g, b, a)
269+ } else {
270+ format!("#{:02x}{:02x}{:02x}", r, g, b)
271+ };
272+ Ok(vec![ColorPresentation {
273+ label: hex.clone(),
274+ text_edit: Some(TextEdit {
275+ range: params.range,
276+ new_text: hex,
277+ }),
278+ additional_text_edits: None,
279+ }])
280+ }
281+
282+ async fn folding_range(
283+ &self,
284+ params: FoldingRangeParams,
285+ ) -> Result<Option<Vec<FoldingRange>>> {
286+ let uri = &params.text_document.uri;
287+ let docs = self.documents.read().await;
288+ let text = match docs.get(uri) {
289+ Some(t) => t.clone(),
290+ None => return Ok(None),
291+ };
292+ drop(docs);
293+ let ranges = folding_ranges(&text);
294+ Ok(if ranges.is_empty() { None } else { Some(ranges) })
295+ }
296+
297+ async fn semantic_tokens_full(
298+ &self,
299+ params: SemanticTokensParams,
300+ ) -> Result<Option<SemanticTokensResult>> {
301+ let uri = &params.text_document.uri;
302+ let docs = self.documents.read().await;
303+ let text = match docs.get(uri) {
304+ Some(t) => t.clone(),
305+ None => return Ok(None),
306+ };
307+ drop(docs);
308+ let tokens = semantic_tokens(&text);
309+ Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
310+ result_id: None,
311+ data: tokens,
312+ })))
313+ }
223}
314}
224
315
225// ---------------------------------------------------------------------------
316// ---------------------------------------------------------------------------
@@ -254,7 +345,7 @@ fn completions(text: &str, position: Position) -> Vec<CompletionItem> {
254 // State prefix (hover:, active:, focus:) or media prefix (dark:, print:)
345 // State prefix (hover:, active:, focus:) or media prefix (dark:, print:)
255 if let Some(colon) = current_word.find(':') {
346 if let Some(colon) = current_word.find(':') {
256 let prefix = &current_word[..colon];
347 let prefix = &current_word[..colon];
257− if matches!(prefix, "hover" | "active" | "focus" | "dark" | "print") {
348+ if matches!(prefix, "hover" | "active" | "focus" | "focus-visible" | "focus-within" | "disabled" | "checked" | "placeholder" | "first" | "last" | "odd" | "even" | "dark" | "print") {
258 return state_attr_completions(prefix, edit_range);
349 return state_attr_completions(prefix, edit_range);
259 }
350 }
260 }
351 }
@@ -390,6 +481,7 @@ fn element_completions(range: Range) -> Vec<CompletionItem> {
390 ("@figcaption", "Caption for @figure"),
481 ("@figcaption", "Caption for @figure"),
391 ("@progress", "Progress bar (value, max attributes)"),
482 ("@progress", "Progress bar (value, max attributes)"),
392 ("@meter", "Meter/gauge element (value, min, max)"),
483 ("@meter", "Meter/gauge element (value, min, max)"),
484+ ("@fragment", "Group children without a wrapper element"),
393 ]
485 ]
394 .iter()
486 .iter()
395 .map(|(name, detail)| item(name, CompletionItemKind::KEYWORD, detail, name, range))
487 .map(|(name, detail)| item(name, CompletionItemKind::KEYWORD, detail, name, range))
@@ -418,6 +510,8 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
418 ("@default", "Default case (inside @match)", "@default"),
510 ("@default", "Default case (inside @match)", "@default"),
419 ("@warn", "Emit a compile-time warning", "@warn "),
511 ("@warn", "Emit a compile-time warning", "@warn "),
420 ("@debug", "Print debug message during compilation", "@debug "),
512 ("@debug", "Print debug message during compilation", "@debug "),
513+ ("@lang", "Set document language (html lang attribute)", "@lang "),
514+ ("@favicon", "Set favicon (inlined as base64 data URI)", "@favicon "),
421 ]
515 ]
422 .iter()
516 .iter()
423 .map(|(name, detail, insert)| {
517 .map(|(name, detail, insert)| {
@@ -587,10 +681,48 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
587 ("rowspan", "Table cell row span", true),
681 ("rowspan", "Table cell row span", true),
588 ("scope", "Table header scope (col/row/colgroup/rowgroup)", true),
682 ("scope", "Table header scope (col/row/colgroup/rowgroup)", true),
589 ("inline", "Inline SVG images into output", false),
683 ("inline", "Inline SVG images into output", false),
684+ // Hidden
685+ ("hidden", "Hide element (display:none)", false),
686+ // Overflow directional
687+ ("overflow-x", "Horizontal overflow (hidden/scroll/auto)", true),
688+ ("overflow-y", "Vertical overflow (hidden/scroll/auto)", true),
689+ // Inset
690+ ("inset", "Shorthand for top/right/bottom/left", true),
691+ // Modern form theming
692+ ("accent-color", "Accent color for form controls", true),
693+ ("caret-color", "Text cursor color", true),
694+ // List styling
695+ ("list-style", "List style type (disc/circle/square/none)", true),
696+ // Table styling
697+ ("border-collapse", "Border collapse mode (collapse/separate)", true),
698+ ("border-spacing", "Spacing between table cell borders", true),
699+ // Text decoration
700+ ("text-decoration", "Text decoration (underline/overline/line-through)", true),
701+ ("text-decoration-color", "Text decoration color", true),
702+ ("text-decoration-thickness", "Text decoration thickness", true),
703+ ("text-decoration-style", "Text decoration style (solid/dashed/dotted/wavy)", true),
704+ // Grid/flex placement
705+ ("place-items", "Shorthand for align-items + justify-items", true),
706+ ("place-self", "Shorthand for align-self + justify-self", true),
707+ // Scroll behavior
708+ ("scroll-behavior", "Scroll behavior (smooth/auto)", true),
709+ // Resize
710+ ("resize", "Resize behavior (none/both/horizontal/vertical)", true),
590 // State prefixes
711 // State prefixes
591 ("hover:", "Style on hover", false),
712 ("hover:", "Style on hover", false),
592 ("active:", "Style on active/click", false),
713 ("active:", "Style on active/click", false),
593 ("focus:", "Style on focus", false),
714 ("focus:", "Style on focus", false),
715+ // New pseudo-state prefixes
716+ ("focus-visible:", "Style on keyboard focus", false),
717+ ("focus-within:", "Style when child has focus", false),
718+ ("disabled:", "Style when disabled", false),
719+ ("checked:", "Style when checked", false),
720+ ("placeholder:", "Style placeholder text", false),
721+ // Child selectors
722+ ("first:", "Style first child", false),
723+ ("last:", "Style last child", false),
724+ ("odd:", "Style odd children (1st, 3rd, ...)", false),
725+ ("even:", "Style even children (2nd, 4th, ...)", false),
594 // Responsive prefixes
726 // Responsive prefixes
595 ("sm:", "Style at 640px+ (small)", false),
727 ("sm:", "Style at 640px+ (small)", false),
596 ("md:", "Style at 768px+ (medium)", false),
728 ("md:", "Style at 768px+ (medium)", false),
@@ -619,6 +751,8 @@ fn state_attr_completions(prefix: &str, range: Range) -> Vec<CompletionItem> {
619 ("border", "Border (width [color])", true),
751 ("border", "Border (width [color])", true),
620 ("border-top", "Top border (width [color])", true),
752 ("border-top", "Top border (width [color])", true),
621 ("border-bottom", "Bottom border (width [color])", true),
753 ("border-bottom", "Bottom border (width [color])", true),
754+ ("border-left", "Left border (width [color])", true),
755+ ("border-right", "Right border (width [color])", true),
622 ("rounded", "Border radius", true),
756 ("rounded", "Border radius", true),
623 ("bold", "Bold text", false),
757 ("bold", "Bold text", false),
624 ("italic", "Italic text", false),
758 ("italic", "Italic text", false),
@@ -634,6 +768,18 @@ fn state_attr_completions(prefix: &str, range: Range) -> Vec<CompletionItem> {
634 ("visibility", "Visibility", true),
768 ("visibility", "Visibility", true),
635 ("pointer-events", "Pointer events", true),
769 ("pointer-events", "Pointer events", true),
636 ("user-select", "User selection", true),
770 ("user-select", "User selection", true),
771+ // Layout attrs for pseudo-states
772+ ("width", "Width", true),
773+ ("height", "Height", true),
774+ ("padding", "Inner padding", true),
775+ ("padding-x", "Horizontal padding", true),
776+ ("padding-y", "Vertical padding", true),
777+ ("margin", "Outer margin", true),
778+ ("margin-x", "Horizontal margin", true),
779+ ("margin-y", "Vertical margin", true),
780+ ("outline", "Outline", true),
781+ ("text-decoration", "Text decoration", true),
782+ ("text-decoration-color", "Text decoration color", true),
637 ]
783 ]
638 .iter()
784 .iter()
639 .map(|(name, detail, takes_value)| {
785 .map(|(name, detail, takes_value)| {
@@ -833,6 +979,24 @@ fn hover_builtin(word: &str) -> Option<String> {
833 (Some("active"), rest)
979 (Some("active"), rest)
834 } else if let Some(rest) = word.strip_prefix("focus:") {
980 } else if let Some(rest) = word.strip_prefix("focus:") {
835 (Some("focus"), rest)
981 (Some("focus"), rest)
982+ } else if let Some(rest) = word.strip_prefix("focus-visible:") {
983+ (Some("focus-visible"), rest)
984+ } else if let Some(rest) = word.strip_prefix("focus-within:") {
985+ (Some("focus-within"), rest)
986+ } else if let Some(rest) = word.strip_prefix("disabled:") {
987+ (Some("disabled"), rest)
988+ } else if let Some(rest) = word.strip_prefix("checked:") {
989+ (Some("checked"), rest)
990+ } else if let Some(rest) = word.strip_prefix("placeholder:") {
991+ (Some("placeholder"), rest)
992+ } else if let Some(rest) = word.strip_prefix("first:") {
993+ (Some("first"), rest)
994+ } else if let Some(rest) = word.strip_prefix("last:") {
995+ (Some("last"), rest)
996+ } else if let Some(rest) = word.strip_prefix("odd:") {
997+ (Some("odd"), rest)
998+ } else if let Some(rest) = word.strip_prefix("even:") {
999+ (Some("even"), rest)
836 } else {
1000 } else {
837 (None, word)
1001 (None, word)
838 };
1002 };
@@ -901,12 +1065,15 @@ fn hover_builtin(word: &str) -> Option<String> {
901 "@figcaption" => "**@figcaption** \u{2014} Figure caption\n\nRenders as `<figcaption>`. Caption text inside `@figure`.",
1065 "@figcaption" => "**@figcaption** \u{2014} Figure caption\n\nRenders as `<figcaption>`. Caption text inside `@figure`.",
902 "@progress" => "**@progress** \u{2014} Progress bar\n\nRenders as `<progress>`.\n\nUsage: `@progress [value 70, max 100]`",
1066 "@progress" => "**@progress** \u{2014} Progress bar\n\nRenders as `<progress>`.\n\nUsage: `@progress [value 70, max 100]`",
903 "@meter" => "**@meter** \u{2014} Meter\n\nRenders as `<meter>`. Gauge for scalar measurement.\n\nUsage: `@meter [value 0.7, min 0, max 1, low 0.3, high 0.8]`",
1067 "@meter" => "**@meter** \u{2014} Meter\n\nRenders as `<meter>`. Gauge for scalar measurement.\n\nUsage: `@meter [value 0.7, min 0, max 1, low 0.3, high 0.8]`",
1068+ "@fragment" => "**@fragment** \u{2014} Fragment\n\nGroups children without emitting a wrapper element. Renders children directly in the parent.",
904 // Directives
1069 // Directives
905 "@match" => "**@match** \u{2014} Pattern matching\n\nMatch a value against cases.\n\n```\n@match $theme\n @case dark\n @el [background #333]\n @case light\n @el [background white]\n @default\n @el [background gray]\n```",
1070 "@match" => "**@match** \u{2014} Pattern matching\n\nMatch a value against cases.\n\n```\n@match $theme\n @case dark\n @el [background #333]\n @case light\n @el [background white]\n @default\n @el [background gray]\n```",
906 "@case" => "**@case** \u{2014} Match case\n\nA case inside `@match`. Matches when the value equals the case value.",
1071 "@case" => "**@case** \u{2014} Match case\n\nA case inside `@match`. Matches when the value equals the case value.",
907 "@default" => "**@default** \u{2014} Default case\n\nFallback case inside `@match` when no other case matches.",
1072 "@default" => "**@default** \u{2014} Default case\n\nFallback case inside `@match` when no other case matches.",
908 "@warn" => "**@warn** \u{2014} Compile warning\n\nEmit a custom warning during compilation.\n\nUsage: `@warn This value is deprecated`",
1073 "@warn" => "**@warn** \u{2014} Compile warning\n\nEmit a custom warning during compilation.\n\nUsage: `@warn This value is deprecated`",
909 "@debug" => "**@debug** \u{2014} Debug message\n\nPrint a debug message to stderr during compilation.\n\nUsage: `@debug Theme is $theme`",
1074 "@debug" => "**@debug** \u{2014} Debug message\n\nPrint a debug message to stderr during compilation.\n\nUsage: `@debug Theme is $theme`",
1075+ "@lang" => "**@lang** \u{2014} Document language\n\nSets the `lang` attribute on the `<html>` element.\n\nUsage: `@lang en`",
1076+ "@favicon" => "**@favicon** \u{2014} Favicon\n\nInlines a favicon as a base64 data URI in the `<head>`.\n\nUsage: `@favicon favicon.png`",
910 // Attributes
1077 // Attributes
911 "spacing" | "gap" => "**spacing** `<value>`\n\nGap between children. Supports CSS units (px, rem, em, %).\nMaps to CSS `gap`.",
1078 "spacing" | "gap" => "**spacing** `<value>`\n\nGap between children. Supports CSS units (px, rem, em, %).\nMaps to CSS `gap`.",
912 "padding" => "**padding** `<value>` | `<y> <x>` | `<t> <h> <b>` | `<t> <r> <b> <l>`\n\nInner padding. Supports CSS units. Accepts 1\u{2013}4 values.",
1079 "padding" => "**padding** `<value>` | `<y> <x>` | `<t> <h> <b>` | `<t> <r> <b> <l>`\n\nInner padding. Supports CSS units. Accepts 1\u{2013}4 values.",
@@ -1037,6 +1204,23 @@ fn hover_builtin(word: &str) -> Option<String> {
1037 "rowspan" => "**rowspan** `<value>` \u{2014} Number of rows a cell spans.",
1204 "rowspan" => "**rowspan** `<value>` \u{2014} Number of rows a cell spans.",
1038 "scope" => "**scope** `<value>` \u{2014} Header scope (`col`, `row`, `colgroup`, `rowgroup`).",
1205 "scope" => "**scope** `<value>` \u{2014} Header scope (`col`, `row`, `colgroup`, `rowgroup`).",
1039 "inline" => "**inline** \u{2014} Inline SVG image content into the HTML output.",
1206 "inline" => "**inline** \u{2014} Inline SVG image content into the HTML output.",
1207+ "hidden" => "**hidden** \u{2014} Hide element (`display: none`).",
1208+ "overflow-x" => "**overflow-x** `<value>` \u{2014} Horizontal overflow (`hidden`, `scroll`, `auto`, `visible`).",
1209+ "overflow-y" => "**overflow-y** `<value>` \u{2014} Vertical overflow (`hidden`, `scroll`, `auto`, `visible`).",
1210+ "inset" => "**inset** `<value>` \u{2014} Shorthand for `top`, `right`, `bottom`, `left`. Maps to CSS `inset`.",
1211+ "accent-color" => "**accent-color** `<color>` \u{2014} Accent color for form controls (checkboxes, radios, range).",
1212+ "caret-color" => "**caret-color** `<color>` \u{2014} Color of the text input cursor.",
1213+ "list-style" => "**list-style** `<value>` \u{2014} List style type (`disc`, `circle`, `square`, `decimal`, `none`).",
1214+ "border-collapse" => "**border-collapse** `<value>` \u{2014} Table border model (`collapse`, `separate`).",
1215+ "border-spacing" => "**border-spacing** `<value>` \u{2014} Spacing between table cell borders (when `border-collapse: separate`).",
1216+ "text-decoration" => "**text-decoration** `<value>` \u{2014} Text decoration (`underline`, `overline`, `line-through`, `none`).",
1217+ "text-decoration-color" => "**text-decoration-color** `<color>` \u{2014} Color of text decoration.",
1218+ "text-decoration-thickness" => "**text-decoration-thickness** `<value>` \u{2014} Thickness of text decoration.",
1219+ "text-decoration-style" => "**text-decoration-style** `<value>` \u{2014} Style of text decoration (`solid`, `dashed`, `dotted`, `wavy`, `double`).",
1220+ "place-items" => "**place-items** `<value>` \u{2014} Shorthand for `align-items` and `justify-items`.",
1221+ "place-self" => "**place-self** `<value>` \u{2014} Shorthand for `align-self` and `justify-self`.",
1222+ "scroll-behavior" => "**scroll-behavior** `<value>` \u{2014} Scroll behavior (`smooth`, `auto`).",
1223+ "resize" => "**resize** `<value>` \u{2014} Resize behavior (`none`, `both`, `horizontal`, `vertical`).",
1040 _ => return None,
1224 _ => return None,
1041 };
1225 };
1042
1226
@@ -1530,6 +1714,242 @@ fn extract_between<'a>(msg: &'a str, prefix: &str, suffix: &str) -> Option<&'a s
1530 }
1714 }
1531}
1715}
1532
1716
1717+// ---------------------------------------------------------------------------
1718+// Color provider
1719+// ---------------------------------------------------------------------------
1720+
1721+fn find_colors(text: &str) -> Vec<ColorInformation> {
1722+ let mut colors = Vec::new();
1723+ for (line_idx, line) in text.lines().enumerate() {
1724+ let mut start = 0;
1725+ while let Some(pos) = line[start..].find('#') {
1726+ let abs_pos = start + pos;
1727+ let hex_start = abs_pos + 1;
1728+ let hex_end = line[hex_start..]
1729+ .find(|c: char| !c.is_ascii_hexdigit())
1730+ .map(|p| hex_start + p)
1731+ .unwrap_or(line.len());
1732+ let hex = &line[hex_start..hex_end];
1733+ let len = hex.len();
1734+ if len == 3 || len == 6 || len == 8 {
1735+ if let Some((r, g, b, a)) = parse_hex_color(hex) {
1736+ colors.push(ColorInformation {
1737+ range: Range::new(
1738+ Position::new(line_idx as u32, abs_pos as u32),
1739+ Position::new(line_idx as u32, hex_end as u32),
1740+ ),
1741+ color: Color {
1742+ red: r as f32 / 255.0,
1743+ green: g as f32 / 255.0,
1744+ blue: b as f32 / 255.0,
1745+ alpha: a as f32 / 255.0,
1746+ },
1747+ });
1748+ }
1749+ }
1750+ start = hex_end;
1751+ }
1752+ }
1753+ colors
1754+}
1755+
1756+fn parse_hex_color(hex: &str) -> Option<(u8, u8, u8, u8)> {
1757+ match hex.len() {
1758+ 3 => {
1759+ let r = u8::from_str_radix(&hex[0..1], 16).ok()? * 17;
1760+ let g = u8::from_str_radix(&hex[1..2], 16).ok()? * 17;
1761+ let b = u8::from_str_radix(&hex[2..3], 16).ok()? * 17;
1762+ Some((r, g, b, 255))
1763+ }
1764+ 6 => {
1765+ let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
1766+ let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
1767+ let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
1768+ Some((r, g, b, 255))
1769+ }
1770+ 8 => {
1771+ let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
1772+ let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
1773+ let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
1774+ let a = u8::from_str_radix(&hex[6..8], 16).ok()?;
1775+ Some((r, g, b, a))
1776+ }
1777+ _ => None,
1778+ }
1779+}
1780+
1781+// ---------------------------------------------------------------------------
1782+// Folding ranges
1783+// ---------------------------------------------------------------------------
1784+
1785+fn folding_ranges(text: &str) -> Vec<FoldingRange> {
1786+ let mut ranges = Vec::new();
1787+ let lines: Vec<&str> = text.lines().collect();
1788+ let mut i = 0;
1789+ while i < lines.len() {
1790+ let trimmed = lines[i].trim();
1791+ // Fold blocks that start with @fn, @if, @else, @each, @match, @style, @head, @keyframes, @define
1792+ if trimmed.starts_with("@fn ")
1793+ || trimmed.starts_with("@if ")
1794+ || trimmed == "@else"
1795+ || trimmed.starts_with("@else if ")
1796+ || trimmed.starts_with("@each ")
1797+ || trimmed.starts_with("@match ")
1798+ || trimmed == "@style"
1799+ || trimmed == "@head"
1800+ || trimmed.starts_with("@keyframes ")
1801+ {
1802+ let start_indent = lines[i].len() - lines[i].trim_start().len();
1803+ let start_line = i;
1804+ let mut end_line = i;
1805+ let mut j = i + 1;
1806+ while j < lines.len() {
1807+ let l = lines[j];
1808+ if l.trim().is_empty() {
1809+ j += 1;
1810+ continue;
1811+ }
1812+ let indent = l.len() - l.trim_start().len();
1813+ if indent <= start_indent {
1814+ break;
1815+ }
1816+ end_line = j;
1817+ j += 1;
1818+ }
1819+ if end_line > start_line {
1820+ ranges.push(FoldingRange {
1821+ start_line: start_line as u32,
1822+ start_character: None,
1823+ end_line: end_line as u32,
1824+ end_character: None,
1825+ kind: Some(FoldingRangeKind::Region),
1826+ collapsed_text: None,
1827+ });
1828+ }
1829+ }
1830+ // Fold comment blocks (lines starting with --)
1831+ if trimmed.starts_with("--") {
1832+ let start_line = i;
1833+ let mut end_line = i;
1834+ let mut j = i + 1;
1835+ while j < lines.len() && lines[j].trim().starts_with("--") {
1836+ end_line = j;
1837+ j += 1;
1838+ }
1839+ if end_line > start_line {
1840+ ranges.push(FoldingRange {
1841+ start_line: start_line as u32,
1842+ start_character: None,
1843+ end_line: end_line as u32,
1844+ end_character: None,
1845+ kind: Some(FoldingRangeKind::Comment),
1846+ collapsed_text: None,
1847+ });
1848+ }
1849+ }
1850+ i += 1;
1851+ }
1852+ ranges
1853+}
1854+
1855+// ---------------------------------------------------------------------------
1856+// Semantic tokens
1857+// ---------------------------------------------------------------------------
1858+
1859+fn semantic_tokens(text: &str) -> Vec<SemanticToken> {
1860+ let mut tokens = Vec::new();
1861+ let mut prev_line: u32 = 0;
1862+ let mut prev_start: u32 = 0;
1863+
1864+ for (line_idx, line) in text.lines().enumerate() {
1865+ let trimmed = line.trim();
1866+ let line_num = line_idx as u32;
1867+
1868+ // Detect comments
1869+ if trimmed.starts_with("--") {
1870+ let col = (line.len() - trimmed.len()) as u32;
1871+ push_token(&mut tokens, &mut prev_line, &mut prev_start, line_num, col, trimmed.len() as u32, 4);
1872+ continue;
1873+ }
1874+
1875+ // Scan for @keywords
1876+ let bytes = line.as_bytes();
1877+ let mut i = 0;
1878+ while i < bytes.len() {
1879+ if bytes[i] == b'@' {
1880+ let start = i;
1881+ i += 1;
1882+ while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_') {
1883+ i += 1;
1884+ }
1885+ let word = &line[start..i];
1886+ let token_type = match word {
1887+ "@page" | "@let" | "@define" | "@fn" | "@if" | "@else" | "@each"
1888+ | "@include" | "@import" | "@meta" | "@head" | "@style" | "@keyframes"
1889+ | "@match" | "@case" | "@default" | "@slot" | "@children"
1890+ | "@warn" | "@debug" | "@lang" | "@favicon" | "@fragment" => 0, // keyword
1891+ _ => {
1892+ // Check if it's a user function call (starts with @ but not a builtin element)
1893+ if is_builtin_element(word) { 0 } else { 2 } // function
1894+ }
1895+ };
1896+ push_token(&mut tokens, &mut prev_line, &mut prev_start, line_num, start as u32, (i - start) as u32, token_type);
1897+ } else if bytes[i] == b'$' {
1898+ let start = i;
1899+ i += 1;
1900+ while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_') {
1901+ i += 1;
1902+ }
1903+ if i > start + 1 {
1904+ push_token(&mut tokens, &mut prev_line, &mut prev_start, line_num, start as u32, (i - start) as u32, 1); // variable
1905+ }
1906+ } else {
1907+ i += 1;
1908+ }
1909+ }
1910+ }
1911+ tokens
1912+}
1913+
1914+fn is_builtin_element(word: &str) -> bool {
1915+ matches!(word,
1916+ "@row" | "@column" | "@col" | "@el" | "@text" | "@paragraph" | "@p"
1917+ | "@image" | "@img" | "@link" | "@input" | "@button" | "@btn"
1918+ | "@select" | "@textarea" | "@option" | "@opt" | "@label" | "@raw"
1919+ | "@nav" | "@header" | "@footer" | "@main" | "@section" | "@article" | "@aside"
1920+ | "@list" | "@item" | "@li" | "@table" | "@thead" | "@tbody" | "@tr" | "@td" | "@th"
1921+ | "@video" | "@audio" | "@form" | "@details" | "@summary"
1922+ | "@blockquote" | "@cite" | "@code" | "@pre" | "@hr" | "@divider"
1923+ | "@figure" | "@figcaption" | "@progress" | "@meter" | "@fragment"
1924+ )
1925+}
1926+
1927+fn push_token(
1928+ tokens: &mut Vec<SemanticToken>,
1929+ prev_line: &mut u32,
1930+ prev_start: &mut u32,
1931+ line: u32,
1932+ start: u32,
1933+ length: u32,
1934+ token_type: u32,
1935+) {
1936+ let delta_line = line - *prev_line;
1937+ let delta_start = if delta_line == 0 {
1938+ start - *prev_start
1939+ } else {
1940+ start
1941+ };
1942+ tokens.push(SemanticToken {
1943+ delta_line,
1944+ delta_start,
1945+ length,
1946+ token_type,
1947+ token_modifiers_bitset: 0,
1948+ });
1949+ *prev_line = line;
1950+ *prev_start = start;
1951+}
1952+
1533// ---------------------------------------------------------------------------
1953// ---------------------------------------------------------------------------
1534// Entry point
1954// Entry point
1535// ---------------------------------------------------------------------------
1955// ---------------------------------------------------------------------------
src/codegen.rs+216−34
@@ -17,9 +17,8 @@ const BREAKPOINTS: &[(&str, &str)] = &[
17struct StyleEntry {
17struct StyleEntry {
18 class_name: String,
18 class_name: String,
19 base: String,
19 base: String,
20− hover: String,
20+ /// (CSS selector suffix, css_rules) — e.g. (":hover", "color:red;")
21− active: String,
21+ pseudo: Vec<(String, String)>,
22− focus: String,
23 /// Responsive overrides: (breakpoint_prefix, css)
22 /// Responsive overrides: (breakpoint_prefix, css)
24 responsive: Vec<(String, String)>,
23 responsive: Vec<(String, String)>,
25 /// Dark mode overrides
24 /// Dark mode overrides
@@ -45,29 +44,30 @@ impl StyleCollector {
45 fn get_class(
44 fn get_class(
46 &mut self,
45 &mut self,
47 base: String,
46 base: String,
48− hover: String,
47+ pseudo: Vec<(String, String)>,
49− active: String,
50− focus: String,
51 responsive: Vec<(String, String)>,
48 responsive: Vec<(String, String)>,
52 dark: String,
49 dark: String,
53 print: String,
50 print: String,
54 ) -> Option<String> {
51 ) -> Option<String> {
55 if base.is_empty()
52 if base.is_empty()
56− && hover.is_empty()
57− && active.is_empty()
58− && focus.is_empty()
53+ && pseudo.is_empty()
59 && responsive.is_empty()
54 && responsive.is_empty()
60 && dark.is_empty()
55 && dark.is_empty()
61 && print.is_empty()
56 && print.is_empty()
62 {
57 {
63 return None;
58 return None;
64 }
59 }
60+ let pseudo_key: String = pseudo
61+ .iter()
62+ .map(|(sel, css)| format!("{}={}", sel, css))
63+ .collect::<Vec<_>>()
64+ .join("|");
65 let resp_key: String = responsive
65 let resp_key: String = responsive
66 .iter()
66 .iter()
67 .map(|(bp, css)| format!("{}={}", bp, css))
67 .map(|(bp, css)| format!("{}={}", bp, css))
68 .collect::<Vec<_>>()
68 .collect::<Vec<_>>()
69 .join("|");
69 .join("|");
70− let key = format!("{}|{}|{}|{}|{}|{}|{}", base, hover, active, focus, resp_key, dark, print);
70+ let key = format!("{}|{}|{}|{}|{}", base, pseudo_key, resp_key, dark, print);
71 if let Some(&idx) = self.index.get(&key) {
71 if let Some(&idx) = self.index.get(&key) {
72 return Some(self.entries[idx].class_name.clone());
72 return Some(self.entries[idx].class_name.clone());
73 }
73 }
@@ -76,9 +76,7 @@ impl StyleCollector {
76 self.entries.push(StyleEntry {
76 self.entries.push(StyleEntry {
77 class_name: name.clone(),
77 class_name: name.clone(),
78 base,
78 base,
79− hover,
79+ pseudo,
80− active,
81− focus,
82 responsive,
80 responsive,
83 dark,
81 dark,
84 print,
82 print,
@@ -96,14 +94,10 @@ impl StyleCollector {
96 if !e.base.is_empty() {
94 if !e.base.is_empty() {
97 css.push_str(&format!(".{}{sp}{{{}}}{nl}", e.class_name, e.base));
95 css.push_str(&format!(".{}{sp}{{{}}}{nl}", e.class_name, e.base));
98 }
96 }
99− if !e.hover.is_empty() {
97+ for (selector, pseudo_css) in &e.pseudo {
100− css.push_str(&format!(".{}:hover{sp}{{{}}}{nl}", e.class_name, e.hover));
101− }
102− if !e.active.is_empty() {
103− css.push_str(&format!(".{}:active{sp}{{{}}}{nl}", e.class_name, e.active));
104− }
105− if !e.focus.is_empty() {
98+ if !pseudo_css.is_empty() {
106− css.push_str(&format!(".{}:focus{sp}{{{}}}{nl}", e.class_name, e.focus));
99+ css.push_str(&format!(".{}{}{sp}{{{}}}{nl}", e.class_name, selector, pseudo_css));
100+ }
107 }
101 }
108 }
102 }
109
103
@@ -304,18 +298,54 @@ fn generate_with_options(doc: &Document, dev: bool) -> String {
304 h
298 h
305 };
299 };
306
300
301+ let lang_attr = match &doc.lang {
302+ Some(lang) => format!(" lang=\"{}\"", html_escape(lang)),
303+ None => String::new(),
304+ };
305+
306+ let favicon_html = match &doc.favicon {
307+ Some(path) => {
308+ // Try to read and inline the favicon
309+ if let Ok(data) = std::fs::read(path) {
310+ let mime = if path.ends_with(".ico") {
311+ "image/x-icon"
312+ } else if path.ends_with(".png") {
313+ "image/png"
314+ } else if path.ends_with(".svg") {
315+ "image/svg+xml"
316+ } else {
317+ "image/x-icon"
318+ };
319+ let b64 = base64_encode(&data);
320+ if dev {
321+ format!("<link rel=\"icon\" href=\"data:{};base64,{}\">\n", mime, b64)
322+ } else {
323+ format!("<link rel=\"icon\" href=\"data:{};base64,{}\">", mime, b64)
324+ }
325+ } else {
326+ // Fall back to href
327+ if dev {
328+ format!("<link rel=\"icon\" href=\"{}\">\n", html_escape(path))
329+ } else {
330+ format!("<link rel=\"icon\" href=\"{}\">", html_escape(path))
331+ }
332+ }
333+ }
334+ None => String::new(),
335+ };
336+
307 match &doc.page_title {
337 match &doc.page_title {
308 Some(title) => {
338 Some(title) => {
309 if dev {
339 if dev {
310 format!(
340 format!(
311 "\
341 "\
312<!DOCTYPE html>
342<!DOCTYPE html>
313−<html>
343+<html{lang_attr}>
314<head>
344<head>
315<meta charset=\"utf-8\">
345<meta charset=\"utf-8\">
316<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
346<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
317<title>{title}</title>
347<title>{title}</title>
318−{meta_html}{head_html}\
348+{meta_html}{favicon_html}{head_html}\
319<style>
349<style>
320*, *::before, *::after {{ box-sizing: border-box; }}
350*, *::before, *::after {{ box-sizing: border-box; }}
321body {{ margin: 0; font-family: system-ui, -apple-system, sans-serif; }}
351body {{ margin: 0; font-family: system-ui, -apple-system, sans-serif; }}
@@ -329,16 +359,20 @@ img {{ display: block; }}
329</html>
359</html>
330",
360",
331 title = html_escape(title),
361 title = html_escape(title),
362+ lang_attr = lang_attr,
332 meta_html = meta_html,
363 meta_html = meta_html,
364+ favicon_html = favicon_html,
333 head_html = head_html,
365 head_html = head_html,
334 element_css = element_css,
366 element_css = element_css,
335 body = body,
367 body = body,
336 )
368 )
337 } else {
369 } else {
338 format!(
370 format!(
339− "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>{title}</title>{meta_html}{head_html}<style>*,*::before,*::after{{box-sizing:border-box}}body{{margin:0;font-family:system-ui,-apple-system,sans-serif}}img{{display:block}}{element_css}</style></head><body>{body}</body></html>",
371+ "<!DOCTYPE html><html{lang_attr}><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>{title}</title>{meta_html}{favicon_html}{head_html}<style>*,*::before,*::after{{box-sizing:border-box}}body{{margin:0;font-family:system-ui,-apple-system,sans-serif}}img{{display:block}}{element_css}</style></head><body>{body}</body></html>",
340 title = html_escape(title),
372 title = html_escape(title),
373+ lang_attr = lang_attr,
341 meta_html = meta_html,
374 meta_html = meta_html,
375+ favicon_html = favicon_html,
342 head_html = head_html,
376 head_html = head_html,
343 element_css = element_css,
377 element_css = element_css,
344 body = body,
378 body = body,
@@ -469,6 +503,13 @@ fn generate_element(
469 if matches!(elem.kind, ElementKind::Slot(_)) {
503 if matches!(elem.kind, ElementKind::Slot(_)) {
470 return;
504 return;
471 }
505 }
506+ if elem.kind == ElementKind::Fragment {
507+ // Render children without a wrapper element
508+ for child in &elem.children {
509+ generate_node(child, parent_kind, out, styles, ctx);
510+ }
511+ return;
512+ }
472
513
473 let tag = match &elem.kind {
514 let tag = match &elem.kind {
474 ElementKind::Row | ElementKind::Column | ElementKind::El => "div",
515 ElementKind::Row | ElementKind::Column | ElementKind::El => "div",
@@ -516,7 +557,7 @@ fn generate_element(
516 ElementKind::Progress => "progress",
557 ElementKind::Progress => "progress",
517 ElementKind::Meter => "meter",
558 ElementKind::Meter => "meter",
518 ElementKind::Image | ElementKind::Input | ElementKind::HorizontalRule
559 ElementKind::Image | ElementKind::Input | ElementKind::HorizontalRule
519− | ElementKind::Children | ElementKind::Slot(_) => unreachable!(),
560+ | ElementKind::Children | ElementKind::Slot(_) | ElementKind::Fragment => unreachable!(),
520 };
561 };
521
562
522 let kind_label = match elem.kind {
563 let kind_label = match elem.kind {
@@ -557,6 +598,7 @@ fn generate_element(
557 ElementKind::Pre => "pre",
598 ElementKind::Pre => "pre",
558 ElementKind::Figure => "figure",
599 ElementKind::Figure => "figure",
559 ElementKind::FigCaption => "figcaption",
600 ElementKind::FigCaption => "figcaption",
601+ ElementKind::Fragment => "fragment",
560 _ => "",
602 _ => "",
561 };
603 };
562
604
@@ -753,9 +795,15 @@ fn compute_class(
753 styles: &mut StyleCollector,
795 styles: &mut StyleCollector,
754) -> Option<String> {
796) -> Option<String> {
755 let base = attrs_to_css(attrs, "", kind, parent_kind);
797 let base = attrs_to_css(attrs, "", kind, parent_kind);
756− let hover = attrs_to_css(attrs, "hover:", kind, parent_kind);
798+
799+ // Collect pseudo-state overrides
800+ let mut pseudo = Vec::new();
801+ for &(prefix, selector) in PSEUDO_PREFIXES {
757− let active = attrs_to_css(attrs, "active:", kind, parent_kind);
802+ let css = attrs_to_css(attrs, prefix, kind, parent_kind);
758− let focus = attrs_to_css(attrs, "focus:", kind, parent_kind);
803+ if !css.is_empty() {
804+ pseudo.push((selector.to_string(), css));
805+ }
806+ }
759
807
760 // Collect responsive overrides
808 // Collect responsive overrides
761 let mut responsive = Vec::new();
809 let mut responsive = Vec::new();
@@ -767,13 +815,10 @@ fn compute_class(
767 }
815 }
768 }
816 }
769
817
770− // Dark mode
771 let dark = attrs_to_css(attrs, "dark:", kind, parent_kind);
818 let dark = attrs_to_css(attrs, "dark:", kind, parent_kind);
772−
773− // Print
774 let print = attrs_to_css(attrs, "print:", kind, parent_kind);
819 let print = attrs_to_css(attrs, "print:", kind, parent_kind);
775
820
776− styles.get_class(base, hover, active, focus, responsive, dark, print)
821+ styles.get_class(base, pseudo, responsive, dark, print)
777}
822}
778
823
779fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option<&str>) {
824fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option<&str>) {
@@ -803,12 +848,26 @@ fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option
803// Attribute → CSS mapping
848// Attribute → CSS mapping
804// ---------------------------------------------------------------------------
849// ---------------------------------------------------------------------------
805
850
806−const STATE_PREFIXES: &[&str] = &["hover:", "active:", "focus:"];
851+/// (htmlang prefix, CSS selector suffix)
852+const PSEUDO_PREFIXES: &[(&str, &str)] = &[
853+ ("hover:", ":hover"),
854+ ("active:", ":active"),
855+ ("focus:", ":focus"),
856+ ("focus-visible:", ":focus-visible"),
857+ ("focus-within:", ":focus-within"),
858+ ("disabled:", ":disabled"),
859+ ("checked:", ":checked"),
860+ ("placeholder:", "::placeholder"),
861+ ("first:", ":first-child"),
862+ ("last:", ":last-child"),
863+ ("odd:", ":nth-child(odd)"),
864+ ("even:", ":nth-child(even)"),
865+];
807const RESPONSIVE_PREFIXES: &[&str] = &["sm:", "md:", "lg:", "xl:"];
866const RESPONSIVE_PREFIXES: &[&str] = &["sm:", "md:", "lg:", "xl:"];
808const MEDIA_PREFIXES: &[&str] = &["dark:", "print:"];
867const MEDIA_PREFIXES: &[&str] = &["dark:", "print:"];
809
868
810fn is_prefixed_attr(key: &str) -> bool {
869fn is_prefixed_attr(key: &str) -> bool {
811− STATE_PREFIXES.iter().any(|p| key.starts_with(p))
870+ PSEUDO_PREFIXES.iter().any(|&(p, _)| key.starts_with(p))
812 || RESPONSIVE_PREFIXES.iter().any(|p| key.starts_with(p))
871 || RESPONSIVE_PREFIXES.iter().any(|p| key.starts_with(p))
813 || MEDIA_PREFIXES.iter().any(|p| key.starts_with(p))
872 || MEDIA_PREFIXES.iter().any(|p| key.starts_with(p))
814}
873}
@@ -1138,6 +1197,7 @@ fn attrs_to_css(
1138 push_css(&mut css, "visibility", v);
1197 push_css(&mut css, "visibility", v);
1139 }
1198 }
1140 }
1199 }
1200+ "hidden" => push_css(&mut css, "display", "none"),
1141
1201
1142 // Transform & filters
1202 // Transform & filters
1143 "transform" => {
1203 "transform" => {
@@ -1399,6 +1459,104 @@ fn attrs_to_css(
1399 }
1459 }
1400 }
1460 }
1401
1461
1462+ // Overflow axis
1463+ "overflow-x" => {
1464+ if let Some(v) = val {
1465+ push_css(&mut css, "overflow-x", v);
1466+ }
1467+ }
1468+ "overflow-y" => {
1469+ if let Some(v) = val {
1470+ push_css(&mut css, "overflow-y", v);
1471+ }
1472+ }
1473+
1474+ // Inset (shorthand for top/right/bottom/left)
1475+ "inset" => {
1476+ if let Some(v) = val {
1477+ push_css(&mut css, "inset", &css_px(v));
1478+ }
1479+ }
1480+
1481+ // Accent & caret colors
1482+ "accent-color" => {
1483+ if let Some(v) = val {
1484+ push_css(&mut css, "accent-color", v);
1485+ }
1486+ }
1487+ "caret-color" => {
1488+ if let Some(v) = val {
1489+ push_css(&mut css, "caret-color", v);
1490+ }
1491+ }
1492+
1493+ // List style
1494+ "list-style" => {
1495+ if let Some(v) = val {
1496+ push_css(&mut css, "list-style", v);
1497+ }
1498+ }
1499+
1500+ // Table
1501+ "border-collapse" => {
1502+ if let Some(v) = val {
1503+ push_css(&mut css, "border-collapse", v);
1504+ }
1505+ }
1506+ "border-spacing" => {
1507+ if let Some(v) = val {
1508+ push_css(&mut css, "border-spacing", &css_px(v));
1509+ }
1510+ }
1511+
1512+ // Text decoration
1513+ "text-decoration" => {
1514+ if let Some(v) = val {
1515+ push_css(&mut css, "text-decoration", v);
1516+ }
1517+ }
1518+ "text-decoration-color" => {
1519+ if let Some(v) = val {
1520+ push_css(&mut css, "text-decoration-color", v);
1521+ }
1522+ }
1523+ "text-decoration-thickness" => {
1524+ if let Some(v) = val {
1525+ push_css(&mut css, "text-decoration-thickness", &css_px(v));
1526+ }
1527+ }
1528+ "text-decoration-style" => {
1529+ if let Some(v) = val {
1530+ push_css(&mut css, "text-decoration-style", v);
1531+ }
1532+ }
1533+
1534+ // Place items/self
1535+ "place-items" => {
1536+ if let Some(v) = val {
1537+ push_css(&mut css, "place-items", v);
1538+ }
1539+ }
1540+ "place-self" => {
1541+ if let Some(v) = val {
1542+ push_css(&mut css, "place-self", v);
1543+ }
1544+ }
1545+
1546+ // Scroll behavior
1547+ "scroll-behavior" => {
1548+ if let Some(v) = val {
1549+ push_css(&mut css, "scroll-behavior", v);
1550+ }
1551+ }
1552+
1553+ // Resize
1554+ "resize" => {
1555+ if let Some(v) = val {
1556+ push_css(&mut css, "resize", v);
1557+ }
1558+ }
1559+
1402 // Identity and HTML passthrough — not CSS
1560 // Identity and HTML passthrough — not CSS
1403 "id" | "class" => {}
1561 "id" | "class" => {}
1404 "type" | "placeholder" | "name" | "value" | "disabled" | "required"
1562 "type" | "placeholder" | "name" | "value" | "disabled" | "required"
@@ -1474,3 +1632,27 @@ fn html_escape(s: &str) -> String {
1474 .replace('>', "&gt;")
1632 .replace('>', "&gt;")
1475 .replace('"', "&quot;")
1633 .replace('"', "&quot;")
1476}
1634}
1635+
1636+fn base64_encode(data: &[u8]) -> String {
1637+ const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1638+ let mut result = String::new();
1639+ for chunk in data.chunks(3) {
1640+ let b0 = chunk[0] as u32;
1641+ let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
1642+ let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
1643+ let triple = (b0 << 16) | (b1 << 8) | b2;
1644+ result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
1645+ result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
1646+ if chunk.len() > 1 {
1647+ result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
1648+ } else {
1649+ result.push('=');
1650+ }
1651+ if chunk.len() > 2 {
1652+ result.push(CHARS[(triple & 0x3F) as usize] as char);
1653+ } else {
1654+ result.push('=');
1655+ }
1656+ }
1657+ result
1658+}
src/fmt.rs+23−9
@@ -1,10 +1,19 @@
1/// Attribute category for sorting.
1/// Attribute category for sorting.
2fn attr_category(key: &str) -> u8 {
2fn attr_category(key: &str) -> u8 {
3− // Strip state/responsive/media prefixes for categorization
3+ // Strip pseudo-state/responsive/media prefixes for categorization
4 let base = key
4 let base = key
5 .strip_prefix("hover:")
5 .strip_prefix("hover:")
6 .or_else(|| key.strip_prefix("active:"))
6 .or_else(|| key.strip_prefix("active:"))
7 .or_else(|| key.strip_prefix("focus:"))
7 .or_else(|| key.strip_prefix("focus:"))
8+ .or_else(|| key.strip_prefix("focus-visible:"))
9+ .or_else(|| key.strip_prefix("focus-within:"))
10+ .or_else(|| key.strip_prefix("disabled:"))
11+ .or_else(|| key.strip_prefix("checked:"))
12+ .or_else(|| key.strip_prefix("placeholder:"))
13+ .or_else(|| key.strip_prefix("first:"))
14+ .or_else(|| key.strip_prefix("last:"))
15+ .or_else(|| key.strip_prefix("odd:"))
16+ .or_else(|| key.strip_prefix("even:"))
8 .or_else(|| key.strip_prefix("sm:"))
17 .or_else(|| key.strip_prefix("sm:"))
9 .or_else(|| key.strip_prefix("md:"))
18 .or_else(|| key.strip_prefix("md:"))
10 .or_else(|| key.strip_prefix("lg:"))
19 .or_else(|| key.strip_prefix("lg:"))
@@ -24,27 +33,32 @@ fn attr_category(key: &str) -> u8 {
24 | "margin" | "margin-x" | "margin-y" | "margin-inline" | "margin-block" => 2,
33 | "margin" | "margin-x" | "margin-y" | "margin-inline" | "margin-block" => 2,
25 // Alignment
34 // Alignment
26 "center-x" | "center-y" | "align-left" | "align-right"
35 "center-x" | "center-y" | "align-left" | "align-right"
27− | "align-top" | "align-bottom" | "justify-content" | "align-items" => 3,
36+ | "align-top" | "align-bottom" | "justify-content" | "align-items"
37+ | "place-items" | "place-self" => 3,
28 // Positioning
38 // Positioning
29− "position" | "top" | "right" | "bottom" | "left" | "z-index" | "order"
39+ "position" | "top" | "right" | "bottom" | "left" | "z-index" | "order" | "inset"
30− | "col-span" | "row-span" | "display" | "visibility" | "overflow" => 4,
40+ | "col-span" | "row-span" | "display" | "visibility"
41+ | "overflow" | "overflow-x" | "overflow-y" | "hidden" => 4,
31 // Visual style
42 // Visual style
32 "background" | "background-size" | "background-position" | "background-repeat"
43 "background" | "background-size" | "background-position" | "background-repeat"
33− | "color" | "opacity" => 5,
44+ | "color" | "opacity" | "accent-color" | "caret-color" => 5,
34 // Border & shape
45 // Border & shape
35 "border" | "border-top" | "border-bottom" | "border-left" | "border-right"
46 "border" | "border-top" | "border-bottom" | "border-left" | "border-right"
36− | "rounded" | "outline" | "shadow" | "text-shadow" => 6,
47+ | "rounded" | "outline" | "shadow" | "text-shadow"
48+ | "border-collapse" | "border-spacing" => 6,
37 // Typography
49 // Typography
38 "bold" | "italic" | "underline" | "size" | "font"
50 "bold" | "italic" | "underline" | "size" | "font"
39 | "text-align" | "line-height" | "letter-spacing" | "text-transform"
51 | "text-align" | "line-height" | "letter-spacing" | "text-transform"
40− | "white-space" | "text-overflow" | "word-break" | "overflow-wrap" => 7,
52+ | "white-space" | "text-overflow" | "word-break" | "overflow-wrap"
53+ | "text-decoration" | "text-decoration-color" | "text-decoration-thickness"
54+ | "text-decoration-style" | "list-style" => 7,
41 // Effects & interaction
55 // Effects & interaction
42 "transform" | "transition" | "animation" | "cursor" | "backdrop-filter"
56 "transform" | "transition" | "animation" | "cursor" | "backdrop-filter"
43 | "filter" | "pointer-events" | "user-select" | "aspect-ratio"
57 | "filter" | "pointer-events" | "user-select" | "aspect-ratio"
44− | "object-fit" | "object-position" => 8,
58+ | "object-fit" | "object-position" | "resize" => 8,
45 // Container queries & scroll
59 // Container queries & scroll
46 "container" | "container-name" | "container-type"
60 "container" | "container-name" | "container-type"
47− | "scroll-snap-type" | "scroll-snap-align" => 9,
61+ | "scroll-snap-type" | "scroll-snap-align" | "scroll-behavior" => 9,
48 // Identity
62 // Identity
49 "id" | "class" => 10,
63 "id" | "class" => 10,
50 // HTML passthrough / form / accessibility
64 // HTML passthrough / form / accessibility
src/parser.rs+53−35
@@ -66,6 +66,8 @@ struct FnDef {
66
66
67struct ParseContext {
67struct ParseContext {
68 page_title: Option<String>,
68 page_title: Option<String>,
69+ lang: Option<String>,
70+ favicon: Option<String>,
69 meta_tags: Vec<(String, String)>,
71 meta_tags: Vec<(String, String)>,
70 head_blocks: Vec<String>,
72 head_blocks: Vec<String>,
71 variables: HashMap<String, String>,
73 variables: HashMap<String, String>,
@@ -108,6 +110,8 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
108 let mut parser = Parser { lines, pos: 0 };
110 let mut parser = Parser { lines, pos: 0 };
109 let mut ctx = ParseContext {
111 let mut ctx = ParseContext {
110 page_title: None,
112 page_title: None,
113+ lang: None,
114+ favicon: None,
111 meta_tags: Vec::new(),
115 meta_tags: Vec::new(),
112 head_blocks: Vec::new(),
116 head_blocks: Vec::new(),
113 variables: HashMap::new(),
117 variables: HashMap::new(),
@@ -135,6 +139,8 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
135 ParseResult {
139 ParseResult {
136 document: Document {
140 document: Document {
137 page_title: ctx.page_title,
141 page_title: ctx.page_title,
142+ lang: ctx.lang,
143+ favicon: ctx.favicon,
138 meta_tags: ctx.meta_tags,
144 meta_tags: ctx.meta_tags,
139 head_blocks: ctx.head_blocks,
145 head_blocks: ctx.head_blocks,
140 variables: ctx.variables,
146 variables: ctx.variables,
@@ -312,6 +318,16 @@ impl Parser {
312 return Ok(None);
318 return Ok(None);
313 }
319 }
314
320
321+ if let Some(rest) = content.strip_prefix("@lang ") {
322+ ctx.lang = Some(substitute_vars(rest.trim(), &ctx.variables));
323+ return Ok(None);
324+ }
325+
326+ if let Some(rest) = content.strip_prefix("@favicon ") {
327+ ctx.favicon = Some(substitute_vars(rest.trim(), &ctx.variables));
328+ return Ok(None);
329+ }
330+
315 if let Some(rest) = content.strip_prefix("@let ") {
331 if let Some(rest) = content.strip_prefix("@let ") {
316 let rest = rest.trim();
332 let rest = rest.trim();
317 if let Some((name, value)) = rest.split_once(' ') {
333 if let Some((name, value)) = rest.split_once(' ') {
@@ -1146,6 +1162,7 @@ const KNOWN_ELEMENTS: &[&str] = &[
1146 "video", "audio",
1162 "video", "audio",
1147 "form", "details", "summary", "blockquote", "cite", "code", "pre", "hr", "divider",
1163 "form", "details", "summary", "blockquote", "cite", "code", "pre", "hr", "divider",
1148 "figure", "figcaption", "progress", "meter",
1164 "figure", "figcaption", "progress", "meter",
1165+ "fragment",
1149];
1166];
1150
1167
1151const KNOWN_DIRECTIVES: &[&str] = &[
1168const KNOWN_DIRECTIVES: &[&str] = &[
@@ -1200,6 +1217,7 @@ fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseErro
1200 "figcaption" => Ok(ElementKind::FigCaption),
1217 "figcaption" => Ok(ElementKind::FigCaption),
1201 "progress" => Ok(ElementKind::Progress),
1218 "progress" => Ok(ElementKind::Progress),
1202 "meter" => Ok(ElementKind::Meter),
1219 "meter" => Ok(ElementKind::Meter),
1220+ "fragment" => Ok(ElementKind::Fragment),
1203 _ => {
1221 _ => {
1204 let all_known: Vec<&str> = KNOWN_ELEMENTS
1222 let all_known: Vec<&str> = KNOWN_ELEMENTS
1205 .iter()
1223 .iter()
@@ -1319,6 +1337,26 @@ const KNOWN_ATTRS: &[&str] = &[
1319 "word-break", "overflow-wrap",
1337 "word-break", "overflow-wrap",
1320 // Asset inlining
1338 // Asset inlining
1321 "inline",
1339 "inline",
1340+ // Hidden
1341+ "hidden",
1342+ // Overflow directional
1343+ "overflow-x", "overflow-y",
1344+ // Inset shorthand
1345+ "inset",
1346+ // Modern form theming
1347+ "accent-color", "caret-color",
1348+ // List styling
1349+ "list-style",
1350+ // Table styling
1351+ "border-collapse", "border-spacing",
1352+ // Text decoration variants
1353+ "text-decoration", "text-decoration-color", "text-decoration-thickness", "text-decoration-style",
1354+ // Grid/flex placement
1355+ "place-items", "place-self",
1356+ // Scroll behavior
1357+ "scroll-behavior",
1358+ // Resize
1359+ "resize",
1322];
1360];
1323
1361
1324/// Attributes that expect purely numeric values (px-based) or values with CSS units.
1362/// Attributes that expect purely numeric values (px-based) or values with CSS units.
@@ -1345,22 +1383,7 @@ const NUMERIC_OR_KEYWORD_ATTRS: &[&str] = &["width", "height"];
1345const SIZE_KEYWORDS: &[&str] = &["fill", "shrink"];
1383const SIZE_KEYWORDS: &[&str] = &["fill", "shrink"];
1346
1384
1347fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext) {
1385fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext) {
1348− let base_key = attr.key.as_str();
1386+ let base_key = strip_all_prefixes(attr.key.as_str());
1349− let base_key = base_key
1350− .strip_prefix("hover:")
1351− .or_else(|| base_key.strip_prefix("active:"))
1352− .or_else(|| base_key.strip_prefix("focus:"))
1353− .unwrap_or(base_key);
1354− let base_key = base_key
1355− .strip_prefix("sm:")
1356− .or_else(|| base_key.strip_prefix("md:"))
1357− .or_else(|| base_key.strip_prefix("lg:"))
1358− .or_else(|| base_key.strip_prefix("xl:"))
1359− .unwrap_or(base_key);
1360− let base_key = base_key
1361− .strip_prefix("dark:")
1362− .or_else(|| base_key.strip_prefix("print:"))
1363− .unwrap_or(base_key);
1364
1387
1365 if let Some(val) = &attr.value {
1388 if let Some(val) = &attr.value {
1366 if NUMERIC_ATTRS.contains(&base_key) {
1389 if NUMERIC_ATTRS.contains(&base_key) {
@@ -1555,25 +1578,7 @@ fn parse_attr_list(input: &str, line_num: usize, ctx: &mut ParseContext, validat
1555
1578
1556 // Warn on unknown attributes
1579 // Warn on unknown attributes
1557 if validate {
1580 if validate {
1558− let base_key = attr.key.as_str();
1581+ let base_key = strip_all_prefixes(attr.key.as_str());
1559− // Strip state prefix (hover:, active:, focus:)
1560− let base_key = base_key
1561− .strip_prefix("hover:")
1562− .or_else(|| base_key.strip_prefix("active:"))
1563− .or_else(|| base_key.strip_prefix("focus:"))
1564− .unwrap_or(base_key);
1565− // Strip responsive prefix (sm:, md:, lg:, xl:)
1566− let base_key = base_key
1567− .strip_prefix("sm:")
1568− .or_else(|| base_key.strip_prefix("md:"))
1569− .or_else(|| base_key.strip_prefix("lg:"))
1570− .or_else(|| base_key.strip_prefix("xl:"))
1571− .unwrap_or(base_key);
1572− // Strip media prefixes (dark:, print:)
1573− let base_key = base_key
1574− .strip_prefix("dark:")
1575− .or_else(|| base_key.strip_prefix("print:"))
1576− .unwrap_or(base_key);
1577 if !KNOWN_ATTRS.contains(&base_key)
1582 if !KNOWN_ATTRS.contains(&base_key)
1578 && !base_key.starts_with("aria-")
1583 && !base_key.starts_with("aria-")
1579 && !base_key.starts_with("data-")
1584 && !base_key.starts_with("data-")
@@ -1760,16 +1765,28 @@ fn evaluate_condition(condition: &str) -> bool {
1760// ---------------------------------------------------------------------------
1765// ---------------------------------------------------------------------------
1761
1766
1762fn strip_all_prefixes(key: &str) -> &str {
1767fn strip_all_prefixes(key: &str) -> &str {
1768+ // Strip pseudo-state prefixes
1763 let key = key
1769 let key = key
1764 .strip_prefix("hover:")
1770 .strip_prefix("hover:")
1765 .or_else(|| key.strip_prefix("active:"))
1771 .or_else(|| key.strip_prefix("active:"))
1766 .or_else(|| key.strip_prefix("focus:"))
1772 .or_else(|| key.strip_prefix("focus:"))
1773+ .or_else(|| key.strip_prefix("focus-visible:"))
1774+ .or_else(|| key.strip_prefix("focus-within:"))
1775+ .or_else(|| key.strip_prefix("disabled:"))
1776+ .or_else(|| key.strip_prefix("checked:"))
1777+ .or_else(|| key.strip_prefix("placeholder:"))
1778+ .or_else(|| key.strip_prefix("first:"))
1779+ .or_else(|| key.strip_prefix("last:"))
1780+ .or_else(|| key.strip_prefix("odd:"))
1781+ .or_else(|| key.strip_prefix("even:"))
1767 .unwrap_or(key);
1782 .unwrap_or(key);
1783+ // Strip responsive prefixes
1768 let key = key.strip_prefix("sm:")
1784 let key = key.strip_prefix("sm:")
1769 .or_else(|| key.strip_prefix("md:"))
1785 .or_else(|| key.strip_prefix("md:"))
1770 .or_else(|| key.strip_prefix("lg:"))
1786 .or_else(|| key.strip_prefix("lg:"))
1771 .or_else(|| key.strip_prefix("xl:"))
1787 .or_else(|| key.strip_prefix("xl:"))
1772 .unwrap_or(key);
1788 .unwrap_or(key);
1789+ // Strip media prefixes
1773 key.strip_prefix("dark:")
1790 key.strip_prefix("dark:")
1774 .or_else(|| key.strip_prefix("print:"))
1791 .or_else(|| key.strip_prefix("print:"))
1775 .unwrap_or(key)
1792 .unwrap_or(key)
@@ -1828,6 +1845,7 @@ fn element_kind_name(kind: &ElementKind) -> &'static str {
1828 ElementKind::FigCaption => "@figcaption",
1845 ElementKind::FigCaption => "@figcaption",
1829 ElementKind::Progress => "@progress",
1846 ElementKind::Progress => "@progress",
1830 ElementKind::Meter => "@meter",
1847 ElementKind::Meter => "@meter",
1848+ ElementKind::Fragment => "@fragment",
1831 }
1849 }
1832}
1850}
1833
1851
tests/snapshots.rs+242
@@ -1268,3 +1268,245 @@ fn fmt_multiline_brackets() {
1268 assert!(formatted.contains("[width 200, color red]"));
1268 assert!(formatted.contains("[width 200, color red]"));
1269 assert!(formatted.contains(" @text hello"));
1269 assert!(formatted.contains(" @text hello"));
1270}
1270}
1271+
1272+// ---------------------------------------------------------------------------
1273+// New features: pseudo-states, child selectors, fragment, hidden, CSS props
1274+// ---------------------------------------------------------------------------
1275+
1276+#[test]
1277+fn snapshot_pseudo_states_extended() {
1278+ snapshot_test("pseudo_states_extended");
1279+}
1280+
1281+#[test]
1282+fn snapshot_fragment() {
1283+ snapshot_test("fragment");
1284+}
1285+
1286+#[test]
1287+fn snapshot_hidden_attr() {
1288+ snapshot_test("hidden_attr");
1289+}
1290+
1291+#[test]
1292+fn snapshot_new_css_properties() {
1293+ snapshot_test("new_css_properties");
1294+}
1295+
1296+#[test]
1297+fn snapshot_lang_directive() {
1298+ snapshot_test("lang_directive");
1299+}
1300+
1301+// --- Assertion tests for new pseudo-states ---
1302+
1303+#[test]
1304+fn focus_visible_generates_pseudo() {
1305+ let output = compile("@page T\n@el [focus-visible:border 2 blue]");
1306+ assert!(output.contains(":focus-visible"));
1307+ assert!(output.contains("border:2px solid blue"));
1308+}
1309+
1310+#[test]
1311+fn focus_within_generates_pseudo() {
1312+ let output = compile("@page T\n@el [focus-within:background #eee]");
1313+ assert!(output.contains(":focus-within"));
1314+ assert!(output.contains("background:#eee"));
1315+}
1316+
1317+#[test]
1318+fn disabled_generates_pseudo() {
1319+ let output = compile("@page T\n@el [disabled:opacity 0.5]");
1320+ assert!(output.contains(":disabled"));
1321+ assert!(output.contains("opacity:0.5"));
1322+}
1323+
1324+#[test]
1325+fn checked_generates_pseudo() {
1326+ let output = compile("@page T\n@el [checked:background green]");
1327+ assert!(output.contains(":checked"));
1328+ assert!(output.contains("background:green"));
1329+}
1330+
1331+#[test]
1332+fn placeholder_generates_pseudo() {
1333+ let output = compile("@page T\n@input [type text, placeholder:color #999]");
1334+ assert!(output.contains("::placeholder"));
1335+ assert!(output.contains("color:#999"));
1336+}
1337+
1338+#[test]
1339+fn first_child_generates_pseudo() {
1340+ let output = compile("@page T\n@el [first:border-top 0]");
1341+ assert!(output.contains(":first-child"));
1342+}
1343+
1344+#[test]
1345+fn last_child_generates_pseudo() {
1346+ let output = compile("@page T\n@el [last:border-bottom 0]");
1347+ assert!(output.contains(":last-child"));
1348+}
1349+
1350+#[test]
1351+fn odd_generates_pseudo() {
1352+ let output = compile("@page T\n@el [odd:background #f5f5f5]");
1353+ assert!(output.contains(":nth-child(odd)"));
1354+}
1355+
1356+#[test]
1357+fn even_generates_pseudo() {
1358+ let output = compile("@page T\n@el [even:background white]");
1359+ assert!(output.contains(":nth-child(even)"));
1360+}
1361+
1362+// --- Assertion tests for fragment ---
1363+
1364+#[test]
1365+fn fragment_no_wrapper() {
1366+ let output = compile("@page T\n@column\n @fragment\n @text A\n @text B");
1367+ // Fragment should NOT add any div wrapper
1368+ assert!(!output.contains("<div class=\"_1\"><span"));
1369+ // But children should still be present
1370+ assert!(output.contains("A"));
1371+ assert!(output.contains("B"));
1372+}
1373+
1374+// --- Assertion tests for hidden ---
1375+
1376+#[test]
1377+fn hidden_generates_display_none() {
1378+ let output = compile("@page T\n@el [hidden]");
1379+ assert!(output.contains("display:none"));
1380+}
1381+
1382+// --- Assertion tests for new CSS properties ---
1383+
1384+#[test]
1385+fn css_overflow_x() {
1386+ let output = compile("@page T\n@el [overflow-x hidden]");
1387+ assert!(output.contains("overflow-x:hidden"));
1388+}
1389+
1390+#[test]
1391+fn css_overflow_y() {
1392+ let output = compile("@page T\n@el [overflow-y auto]");
1393+ assert!(output.contains("overflow-y:auto"));
1394+}
1395+
1396+#[test]
1397+fn css_inset() {
1398+ let output = compile("@page T\n@el [inset 0]");
1399+ assert!(output.contains("inset:0"));
1400+}
1401+
1402+#[test]
1403+fn css_accent_color() {
1404+ let output = compile("@page T\n@input [type checkbox, accent-color blue]");
1405+ assert!(output.contains("accent-color:blue"));
1406+}
1407+
1408+#[test]
1409+fn css_caret_color() {
1410+ let output = compile("@page T\n@input [type text, caret-color red]");
1411+ assert!(output.contains("caret-color:red"));
1412+}
1413+
1414+#[test]
1415+fn css_list_style() {
1416+ let output = compile("@page T\n@list [list-style disc]");
1417+ assert!(output.contains("list-style:disc"));
1418+}
1419+
1420+#[test]
1421+fn css_border_collapse() {
1422+ let output = compile("@page T\n@table [border-collapse collapse]");
1423+ assert!(output.contains("border-collapse:collapse"));
1424+}
1425+
1426+#[test]
1427+fn css_text_decoration_full() {
1428+ let output = compile("@page T\n@text [text-decoration underline, text-decoration-color red, text-decoration-style wavy] Hello");
1429+ assert!(output.contains("text-decoration:underline"));
1430+ assert!(output.contains("text-decoration-color:red"));
1431+ assert!(output.contains("text-decoration-style:wavy"));
1432+}
1433+
1434+#[test]
1435+fn css_place_items() {
1436+ let output = compile("@page T\n@el [grid, place-items center]");
1437+ assert!(output.contains("place-items:center"));
1438+}
1439+
1440+#[test]
1441+fn css_place_self() {
1442+ let output = compile("@page T\n@el [place-self center]");
1443+ assert!(output.contains("place-self:center"));
1444+}
1445+
1446+#[test]
1447+fn css_scroll_behavior() {
1448+ let output = compile("@page T\n@el [scroll-behavior smooth]");
1449+ assert!(output.contains("scroll-behavior:smooth"));
1450+}
1451+
1452+#[test]
1453+fn css_resize() {
1454+ let output = compile("@page T\n@textarea [resize vertical]");
1455+ assert!(output.contains("resize:vertical"));
1456+}
1457+
1458+// --- Assertion tests for @lang ---
1459+
1460+#[test]
1461+fn lang_sets_html_attr() {
1462+ let output = compile("@page T\n@lang en\n@text Hello");
1463+ assert!(output.contains("<html lang=\"en\">"));
1464+}
1465+
1466+#[test]
1467+fn lang_not_present_without_directive() {
1468+ let output = compile("@page T\n@text Hello");
1469+ assert!(output.contains("<html>"));
1470+ assert!(!output.contains("lang="));
1471+}
1472+
1473+// --- Assertion test for @favicon ---
1474+
1475+#[test]
1476+fn favicon_fallback_href() {
1477+ // Nonexistent file should fall back to href
1478+ let output = compile("@page T\n@favicon nonexistent.png\n@text Hello");
1479+ assert!(output.contains("<link rel=\"icon\" href=\"nonexistent.png\">"));
1480+}
1481+
1482+// --- No warnings for new attrs ---
1483+
1484+#[test]
1485+fn no_warning_new_pseudo_prefixes() {
1486+ let diags = parse_diagnostics("@el [focus-visible:border 2 blue, disabled:opacity 0.5, checked:background green]");
1487+ assert!(
1488+ !diags.iter().any(|d| d.message.contains("unknown attribute")),
1489+ "new pseudo-state prefixes should be recognized, got: {:?}",
1490+ diags
1491+ );
1492+}
1493+
1494+#[test]
1495+fn no_warning_child_selectors() {
1496+ let diags = parse_diagnostics("@el [first:padding 0, last:padding 0, odd:background #eee, even:background white]");
1497+ assert!(
1498+ !diags.iter().any(|d| d.message.contains("unknown attribute")),
1499+ "child selectors should be recognized, got: {:?}",
1500+ diags
1501+ );
1502+}
1503+
1504+#[test]
1505+fn no_warning_new_css_attrs() {
1506+ let diags = parse_diagnostics("@el [overflow-x hidden, overflow-y auto, inset 0, accent-color blue, hidden]");
1507+ assert!(
1508+ !diags.iter().any(|d| d.message.contains("unknown attribute")),
1509+ "new CSS attrs should be recognized, got: {:?}",
1510+ diags
1511+ );
1512+}
tests/snapshots/fragment.hlnew file+7
@@ -0,0 +1,7 @@
1+@page Fragment Test
2+
3+@column [spacing 10]
4+ @fragment
5+ @text First
6+ @text Second
7+ @text Third
tests/snapshots/hidden_attr.hlnew file+7
@@ -0,0 +1,7 @@
1+@page Hidden Test
2+
3+@el [hidden]
4+ @text This is hidden
5+
6+@el [padding 10]
7+ @text This is visible
tests/snapshots/lang_directive.hlnew file+4
@@ -0,0 +1,4 @@
1+@page Lang Test
2+@lang en
3+
4+@text Hello World
tests/snapshots/new_css_properties.hlnew file+32
@@ -0,0 +1,32 @@
1+@page New CSS Properties
2+
3+@el [overflow-x hidden, overflow-y auto]
4+ @text Overflow
5+
6+@el [position absolute, inset 0]
7+ @text Overlay
8+
9+@input [type checkbox, accent-color #3b82f6]
10+
11+@input [type text, caret-color red]
12+
13+@list [list-style disc]
14+ @item One
15+ @item Two
16+
17+@table [border-collapse collapse, border-spacing 0]
18+ @tr
19+ @td Cell
20+
21+@text [text-decoration underline, text-decoration-color red, text-decoration-thickness 2, text-decoration-style wavy] Decorated
22+
23+@el [grid, place-items center]
24+ @text Centered
25+
26+@el [place-self center]
27+ @text Self centered
28+
29+@el [overflow auto, scroll-behavior smooth]
30+ @text Smooth scroll
31+
32+@textarea [resize vertical, rows 4] Resizable
tests/snapshots/pseudo_states_extended.hlnew file+14
@@ -0,0 +1,14 @@
1+@page Pseudo States Extended
2+
3+@el [padding 10, focus-visible:border 2 blue, focus-within:background #eee]
4+ @input [type text, placeholder Name]
5+
6+@el [disabled:opacity 0.5, checked:background green]
7+
8+@input [type text, placeholder:color #999]
9+
10+@el [first:border-top 0, last:border-bottom 0]
11+
12+@row [spacing 10]
13+ @el [odd:background #f5f5f5, even:background white, padding 10]
14+ Item