on master help sign in
Add 46 improvements: @unless/@og/@breakpoint directives, 13 semantic elements, 14 CSS properties, 5 media prefixes, build/sitemap CLI, and LSP enhancements
db89732iko
src/ast.rs+15
@@ -13,6 +13,8 @@ pub struct Document {
13 pub keyframes: Vec<(String, String)>,
13 pub keyframes: Vec<(String, String)>,
14 pub css_vars: Vec<(String, String)>,
14 pub css_vars: Vec<(String, String)>,
15 pub custom_css: Vec<String>,
15 pub custom_css: Vec<String>,
16+ pub og_tags: Vec<(String, String)>,
17+ pub custom_breakpoints: Vec<(String, String)>,
16 pub nodes: Vec<Node>,
18 pub nodes: Vec<Node>,
17}
19}
18
20
@@ -92,6 +94,19 @@ pub enum ElementKind {
92 Progress,
94 Progress,
93 Meter,
95 Meter,
94 Fragment,
96 Fragment,
97+ Dialog,
98+ DefinitionList,
99+ DefinitionTerm,
100+ DefinitionDescription,
101+ Fieldset,
102+ Legend,
103+ Picture,
104+ Source,
105+ Time,
106+ Mark,
107+ Kbd,
108+ Abbr,
109+ Datalist,
95}
110}
96
111
97#[derive(Debug, Clone)]
112#[derive(Debug, Clone)]
src/bin/htmlang-lsp.rs+291−2
@@ -88,6 +88,11 @@ impl LanguageServer for Backend {
88 ..Default::default()
88 ..Default::default()
89 }),
89 }),
90 ),
90 ),
91+ inlay_hint_provider: Some(OneOf::Left(true)),
92+ workspace_symbol_provider: Some(OneOf::Left(true)),
93+ linked_editing_range_provider: Some(
94+ LinkedEditingRangeServerCapabilities::Simple(true),
95+ ),
91 ..Default::default()
96 ..Default::default()
92 },
97 },
93 ..Default::default()
98 ..Default::default()
@@ -311,6 +316,63 @@ impl LanguageServer for Backend {
311 data: tokens,
316 data: tokens,
312 })))
317 })))
313 }
318 }
319+
320+ async fn inlay_hint(
321+ &self,
322+ params: InlayHintParams,
323+ ) -> Result<Option<Vec<InlayHint>>> {
324+ let uri = &params.text_document.uri;
325+ let docs = self.documents.read().await;
326+ let text = match docs.get(uri) {
327+ Some(t) => t.clone(),
328+ None => return Ok(None),
329+ };
330+ drop(docs);
331+ let hints = inlay_hints(&text);
332+ Ok(if hints.is_empty() { None } else { Some(hints) })
333+ }
334+
335+ #[allow(deprecated)]
336+ async fn symbol(
337+ &self,
338+ params: WorkspaceSymbolParams,
339+ ) -> Result<Option<Vec<SymbolInformation>>> {
340+ let query = params.query.to_lowercase();
341+ let docs = self.documents.read().await;
342+ let mut all_symbols = Vec::new();
343+ for (uri, text) in docs.iter() {
344+ let symbols = document_symbols(text);
345+ for mut sym in symbols {
346+ sym.location.uri = uri.clone();
347+ if query.is_empty()
348+ || sym.name.to_lowercase().contains(&query)
349+ {
350+ all_symbols.push(sym);
351+ }
352+ }
353+ }
354+ drop(docs);
355+ Ok(if all_symbols.is_empty() {
356+ None
357+ } else {
358+ Some(all_symbols)
359+ })
360+ }
361+
362+ async fn linked_editing_range(
363+ &self,
364+ params: LinkedEditingRangeParams,
365+ ) -> Result<Option<LinkedEditingRanges>> {
366+ let uri = &params.text_document_position_params.text_document.uri;
367+ let pos = params.text_document_position_params.position;
368+ let docs = self.documents.read().await;
369+ let text = match docs.get(uri) {
370+ Some(t) => t.clone(),
371+ None => return Ok(None),
372+ };
373+ drop(docs);
374+ Ok(linked_editing_ranges(&text, pos))
375+ }
314}
376}
315
377
316// ---------------------------------------------------------------------------
378// ---------------------------------------------------------------------------
@@ -345,7 +407,7 @@ fn completions(text: &str, position: Position) -> Vec<CompletionItem> {
345 // State prefix (hover:, active:, focus:) or media prefix (dark:, print:)
407 // State prefix (hover:, active:, focus:) or media prefix (dark:, print:)
346 if let Some(colon) = current_word.find(':') {
408 if let Some(colon) = current_word.find(':') {
347 let prefix = &current_word[..colon];
409 let prefix = &current_word[..colon];
348− if matches!(prefix, "hover" | "active" | "focus" | "focus-visible" | "focus-within" | "disabled" | "checked" | "placeholder" | "first" | "last" | "odd" | "even" | "dark" | "print") {
410+ 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") {
349 return state_attr_completions(prefix, edit_range);
411 return state_attr_completions(prefix, edit_range);
350 }
412 }
351 }
413 }
@@ -482,6 +544,25 @@ fn element_completions(range: Range) -> Vec<CompletionItem> {
482 ("@progress", "Progress bar (value, max attributes)"),
544 ("@progress", "Progress bar (value, max attributes)"),
483 ("@meter", "Meter/gauge element (value, min, max)"),
545 ("@meter", "Meter/gauge element (value, min, max)"),
484 ("@fragment", "Group children without a wrapper element"),
546 ("@fragment", "Group children without a wrapper element"),
547+ // Dialog & interactive
548+ ("@dialog", "Dialog/modal element (dialog)"),
549+ // Definition lists
550+ ("@dl", "Description list (dl)"),
551+ ("@dt", "Description term (dt)"),
552+ ("@dd", "Description details (dd)"),
553+ // Form grouping
554+ ("@fieldset", "Fieldset group (fieldset)"),
555+ ("@legend", "Legend for @fieldset (legend)"),
556+ // Picture/responsive images
557+ ("@picture", "Responsive image container (picture)"),
558+ ("@source", "Media source for @picture/@video/@audio (source)"),
559+ // Inline semantics
560+ ("@time", "Date/time element (time)"),
561+ ("@mark", "Highlighted/marked text (mark)"),
562+ ("@kbd", "Keyboard input (kbd)"),
563+ ("@abbr", "Abbreviation (abbr)"),
564+ // Datalist
565+ ("@datalist", "Predefined options for @input (datalist)"),
485 ]
566 ]
486 .iter()
567 .iter()
487 .map(|(name, detail)| item(name, CompletionItemKind::KEYWORD, detail, name, range))
568 .map(|(name, detail)| item(name, CompletionItemKind::KEYWORD, detail, name, range))
@@ -512,6 +593,9 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
512 ("@debug", "Print debug message during compilation", "@debug "),
593 ("@debug", "Print debug message during compilation", "@debug "),
513 ("@lang", "Set document language (html lang attribute)", "@lang "),
594 ("@lang", "Set document language (html lang attribute)", "@lang "),
514 ("@favicon", "Set favicon (inlined as base64 data URI)", "@favicon "),
595 ("@favicon", "Set favicon (inlined as base64 data URI)", "@favicon "),
596+ ("@unless", "Inverse conditional (renders when false)", "@unless "),
597+ ("@og", "Add Open Graph meta tag", "@og "),
598+ ("@breakpoint", "Define custom responsive breakpoint", "@breakpoint "),
515 ]
599 ]
516 .iter()
600 .iter()
517 .map(|(name, detail, insert)| {
601 .map(|(name, detail, insert)| {
@@ -728,9 +812,39 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
728 ("md:", "Style at 768px+ (medium)", false),
812 ("md:", "Style at 768px+ (medium)", false),
729 ("lg:", "Style at 1024px+ (large)", false),
813 ("lg:", "Style at 1024px+ (large)", false),
730 ("xl:", "Style at 1280px+ (extra large)", false),
814 ("xl:", "Style at 1280px+ (extra large)", false),
815+ // Additional responsive prefixes
816+ ("2xl:", "Style at 1536px+ (2x extra large)", false),
817+ // Motion prefixes
818+ ("motion-safe:", "Style when motion is allowed", false),
819+ ("motion-reduce:", "Style when reduced motion preferred", false),
820+ // Orientation prefixes
821+ ("landscape:", "Style in landscape orientation", false),
822+ ("portrait:", "Style in portrait orientation", false),
731 // Media prefixes
823 // Media prefixes
732 ("dark:", "Style in dark color scheme", false),
824 ("dark:", "Style in dark color scheme", false),
733 ("print:", "Style for print media", false),
825 ("print:", "Style for print media", false),
826+ // Clipping & blending
827+ ("clip-path", "Clip path (circle, polygon, etc.)", true),
828+ ("mix-blend-mode", "Blend mode (multiply, screen, overlay, etc.)", true),
829+ ("background-blend-mode", "Background blend mode", true),
830+ // Writing mode
831+ ("writing-mode", "Writing mode (horizontal-tb, vertical-rl, etc.)", true),
832+ // Multi-column layout
833+ ("column-count", "Number of columns in multi-column layout", true),
834+ ("column-gap", "Gap between columns", true),
835+ // Text
836+ ("text-indent", "First-line text indentation", true),
837+ ("hyphens", "Hyphenation behavior (none/manual/auto)", true),
838+ // Flex item sizing
839+ ("flex-grow", "Flex grow factor", true),
840+ ("flex-shrink", "Flex shrink factor", true),
841+ ("flex-basis", "Flex basis (initial main size)", true),
842+ // Stacking context
843+ ("isolation", "Create stacking context (isolate/auto)", true),
844+ // Grid/flex placement
845+ ("place-content", "Shorthand for align-content + justify-content", true),
846+ // Background image
847+ ("background-image", "Background image (url or gradient)", true),
734 ]
848 ]
735 .iter()
849 .iter()
736 .map(|(name, detail, takes_value)| {
850 .map(|(name, detail, takes_value)| {
@@ -997,6 +1111,24 @@ fn hover_builtin(word: &str) -> Option<String> {
997 (Some("odd"), rest)
1111 (Some("odd"), rest)
998 } else if let Some(rest) = word.strip_prefix("even:") {
1112 } else if let Some(rest) = word.strip_prefix("even:") {
999 (Some("even"), rest)
1113 (Some("even"), rest)
1114+ } else if let Some(rest) = word.strip_prefix("sm:") {
1115+ (Some("sm"), rest)
1116+ } else if let Some(rest) = word.strip_prefix("md:") {
1117+ (Some("md"), rest)
1118+ } else if let Some(rest) = word.strip_prefix("lg:") {
1119+ (Some("lg"), rest)
1120+ } else if let Some(rest) = word.strip_prefix("xl:") {
1121+ (Some("xl"), rest)
1122+ } else if let Some(rest) = word.strip_prefix("2xl:") {
1123+ (Some("2xl"), rest)
1124+ } else if let Some(rest) = word.strip_prefix("motion-safe:") {
1125+ (Some("motion-safe"), rest)
1126+ } else if let Some(rest) = word.strip_prefix("motion-reduce:") {
1127+ (Some("motion-reduce"), rest)
1128+ } else if let Some(rest) = word.strip_prefix("landscape:") {
1129+ (Some("landscape"), rest)
1130+ } else if let Some(rest) = word.strip_prefix("portrait:") {
1131+ (Some("portrait"), rest)
1000 } else {
1132 } else {
1001 (None, word)
1133 (None, word)
1002 };
1134 };
@@ -1066,6 +1198,20 @@ fn hover_builtin(word: &str) -> Option<String> {
1066 "@progress" => "**@progress** \u{2014} Progress bar\n\nRenders as `<progress>`.\n\nUsage: `@progress [value 70, max 100]`",
1198 "@progress" => "**@progress** \u{2014} Progress bar\n\nRenders as `<progress>`.\n\nUsage: `@progress [value 70, max 100]`",
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]`",
1199 "@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.",
1200 "@fragment" => "**@fragment** \u{2014} Fragment\n\nGroups children without emitting a wrapper element. Renders children directly in the parent.",
1201+ // New elements
1202+ "@dialog" => "**@dialog** \u{2014} Dialog\n\nRenders as `<dialog>`. Modal or non-modal dialog box.\n\nUsage: `@dialog [open] Dialog content`",
1203+ "@dl" => "**@dl** \u{2014} Description list\n\nRenders as `<dl>`. Contains `@dt` and `@dd` pairs.",
1204+ "@dt" => "**@dt** \u{2014} Description term\n\nRenders as `<dt>`. Term in a `@dl` description list.",
1205+ "@dd" => "**@dd** \u{2014} Description details\n\nRenders as `<dd>`. Details for a `@dt` term.",
1206+ "@fieldset" => "**@fieldset** \u{2014} Fieldset\n\nRenders as `<fieldset>`. Groups related form elements.\n\nUse `@legend` for a caption.",
1207+ "@legend" => "**@legend** \u{2014} Legend\n\nRenders as `<legend>`. Caption for a `@fieldset`.",
1208+ "@picture" => "**@picture** \u{2014} Picture\n\nRenders as `<picture>`. Container for responsive image sources.\n\nUse `@source` children for different media queries.",
1209+ "@source" => "**@source** \u{2014} Source\n\nRenders as `<source>`. Media source for `@picture`, `@video`, or `@audio`.\n\nUsage: `@source [src image.webp, type image/webp]`",
1210+ "@time" => "**@time** \u{2014} Time\n\nRenders as `<time>`. Machine-readable date/time.\n\nUsage: `@time [datetime 2024-01-15] January 15`",
1211+ "@mark" => "**@mark** \u{2014} Mark\n\nRenders as `<mark>`. Highlighted or marked text.",
1212+ "@kbd" => "**@kbd** \u{2014} Keyboard input\n\nRenders as `<kbd>`. Represents keyboard input.\n\nUsage: `@kbd Ctrl+C`",
1213+ "@abbr" => "**@abbr** \u{2014} Abbreviation\n\nRenders as `<abbr>`. Abbreviation with optional title.\n\nUsage: `@abbr [title Hypertext Markup Language] HTML`",
1214+ "@datalist" => "**@datalist** \u{2014} Datalist\n\nRenders as `<datalist>`. Provides predefined options for `@input`.\n\nUsage: `@datalist [id colors]`",
1069 // Directives
1215 // Directives
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```",
1216 "@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```",
1071 "@case" => "**@case** \u{2014} Match case\n\nA case inside `@match`. Matches when the value equals the case value.",
1217 "@case" => "**@case** \u{2014} Match case\n\nA case inside `@match`. Matches when the value equals the case value.",
@@ -1074,6 +1220,9 @@ fn hover_builtin(word: &str) -> Option<String> {
1074 "@debug" => "**@debug** \u{2014} Debug message\n\nPrint a debug message to stderr during compilation.\n\nUsage: `@debug Theme is $theme`",
1220 "@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`",
1221 "@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`",
1222 "@favicon" => "**@favicon** \u{2014} Favicon\n\nInlines a favicon as a base64 data URI in the `<head>`.\n\nUsage: `@favicon favicon.png`",
1223+ "@unless" => "**@unless** \u{2014} Inverse conditional\n\nRenders children when the condition is false (opposite of `@if`).\n\nUsage: `@unless $debug`",
1224+ "@og" => "**@og** \u{2014} Open Graph meta tag\n\nAdds an Open Graph `<meta>` tag to `<head>`.\n\nUsage: `@og title My Page Title`",
1225+ "@breakpoint" => "**@breakpoint** \u{2014} Custom breakpoint\n\nDefines a custom responsive breakpoint.\n\nUsage: `@breakpoint tablet 600`",
1077 // Attributes
1226 // Attributes
1078 "spacing" | "gap" => "**spacing** `<value>`\n\nGap between children. Supports CSS units (px, rem, em, %).\nMaps to CSS `gap`.",
1227 "spacing" | "gap" => "**spacing** `<value>`\n\nGap between children. Supports CSS units (px, rem, em, %).\nMaps to CSS `gap`.",
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.",
1228 "padding" => "**padding** `<value>` | `<y> <x>` | `<t> <h> <b>` | `<t> <r> <b> <l>`\n\nInner padding. Supports CSS units. Accepts 1\u{2013}4 values.",
@@ -1221,6 +1370,21 @@ fn hover_builtin(word: &str) -> Option<String> {
1221 "place-self" => "**place-self** `<value>` \u{2014} Shorthand for `align-self` and `justify-self`.",
1370 "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`).",
1371 "scroll-behavior" => "**scroll-behavior** `<value>` \u{2014} Scroll behavior (`smooth`, `auto`).",
1223 "resize" => "**resize** `<value>` \u{2014} Resize behavior (`none`, `both`, `horizontal`, `vertical`).",
1372 "resize" => "**resize** `<value>` \u{2014} Resize behavior (`none`, `both`, `horizontal`, `vertical`).",
1373+ // New CSS attributes
1374+ "clip-path" => "**clip-path** `<value>` \u{2014} Clip path (`circle()`, `polygon()`, `inset()`, `url()`).",
1375+ "mix-blend-mode" => "**mix-blend-mode** `<value>` \u{2014} Blend mode (`multiply`, `screen`, `overlay`, `darken`, `lighten`).",
1376+ "background-blend-mode" => "**background-blend-mode** `<value>` \u{2014} Background blend mode for layered backgrounds.",
1377+ "writing-mode" => "**writing-mode** `<value>` \u{2014} Writing direction (`horizontal-tb`, `vertical-rl`, `vertical-lr`).",
1378+ "column-count" => "**column-count** `<value>` \u{2014} Number of columns in multi-column layout.",
1379+ "column-gap" => "**column-gap** `<value>` \u{2014} Gap between columns. Supports CSS units.",
1380+ "text-indent" => "**text-indent** `<value>` \u{2014} Indentation of the first line of text.",
1381+ "hyphens" => "**hyphens** `<value>` \u{2014} Hyphenation behavior (`none`, `manual`, `auto`).",
1382+ "flex-grow" => "**flex-grow** `<value>` \u{2014} Flex grow factor (number). Controls how much an item grows.",
1383+ "flex-shrink" => "**flex-shrink** `<value>` \u{2014} Flex shrink factor (number). Controls how much an item shrinks.",
1384+ "flex-basis" => "**flex-basis** `<value>` \u{2014} Initial main size of a flex item (e.g., `200px`, `auto`, `0`).",
1385+ "isolation" => "**isolation** `<value>` \u{2014} Creates a new stacking context (`isolate`, `auto`).",
1386+ "place-content" => "**place-content** `<value>` \u{2014} Shorthand for `align-content` and `justify-content`.",
1387+ "background-image" => "**background-image** `<value>` \u{2014} Background image (`url()` or gradient function).",
1224 _ => return None,
1388 _ => return None,
1225 };
1389 };
1226
1390
@@ -1887,7 +2051,8 @@ fn semantic_tokens(text: &str) -> Vec<SemanticToken> {
1887 "@page" | "@let" | "@define" | "@fn" | "@if" | "@else" | "@each"
2051 "@page" | "@let" | "@define" | "@fn" | "@if" | "@else" | "@each"
1888 | "@include" | "@import" | "@meta" | "@head" | "@style" | "@keyframes"
2052 | "@include" | "@import" | "@meta" | "@head" | "@style" | "@keyframes"
1889 | "@match" | "@case" | "@default" | "@slot" | "@children"
2053 | "@match" | "@case" | "@default" | "@slot" | "@children"
1890− | "@warn" | "@debug" | "@lang" | "@favicon" | "@fragment" => 0, // keyword
2054+ | "@warn" | "@debug" | "@lang" | "@favicon" | "@fragment"
2055+ | "@unless" | "@og" | "@breakpoint" => 0, // keyword
1891 _ => {
2056 _ => {
1892 // Check if it's a user function call (starts with @ but not a builtin element)
2057 // 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
2058 if is_builtin_element(word) { 0 } else { 2 } // function
@@ -1921,6 +2086,8 @@ fn is_builtin_element(word: &str) -> bool {
1921 | "@video" | "@audio" | "@form" | "@details" | "@summary"
2086 | "@video" | "@audio" | "@form" | "@details" | "@summary"
1922 | "@blockquote" | "@cite" | "@code" | "@pre" | "@hr" | "@divider"
2087 | "@blockquote" | "@cite" | "@code" | "@pre" | "@hr" | "@divider"
1923 | "@figure" | "@figcaption" | "@progress" | "@meter" | "@fragment"
2088 | "@figure" | "@figcaption" | "@progress" | "@meter" | "@fragment"
2089+ | "@dialog" | "@dl" | "@dt" | "@dd" | "@fieldset" | "@legend"
2090+ | "@picture" | "@source" | "@time" | "@mark" | "@kbd" | "@abbr" | "@datalist"
1924 )
2091 )
1925}
2092}
1926
2093
@@ -1950,6 +2117,128 @@ fn push_token(
1950 *prev_start = start;
2117 *prev_start = start;
1951}
2118}
1952
2119
2120+// ---------------------------------------------------------------------------
2121+// Inlay hints
2122+// ---------------------------------------------------------------------------
2123+
2124+fn inlay_hints(text: &str) -> Vec<InlayHint> {
2125+ // Build variable map: name -> value
2126+ let mut vars: HashMap<&str, &str> = HashMap::new();
2127+ for line in text.lines() {
2128+ let trimmed = line.trim();
2129+ if let Some(rest) = trimmed.strip_prefix("@let ") {
2130+ if let Some((name, value)) = rest.trim().split_once(' ') {
2131+ vars.insert(name, value.trim());
2132+ }
2133+ }
2134+ }
2135+
2136+ let mut hints = Vec::new();
2137+ for (line_idx, line) in text.lines().enumerate() {
2138+ let trimmed = line.trim();
2139+ // Skip @let definition lines — the value is already visible there
2140+ if trimmed.starts_with("@let ") {
2141+ continue;
2142+ }
2143+ let bytes = line.as_bytes();
2144+ let mut i = 0;
2145+ while i < bytes.len() {
2146+ if bytes[i] == b'$' {
2147+ let start = i;
2148+ i += 1;
2149+ while i < bytes.len()
2150+ && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_')
2151+ {
2152+ i += 1;
2153+ }
2154+ if i > start + 1 {
2155+ let var_name = &line[start + 1..i];
2156+ if let Some(value) = vars.get(var_name) {
2157+ hints.push(InlayHint {
2158+ position: Position::new(line_idx as u32, i as u32),
2159+ label: InlayHintLabel::String(format!(" \u{2192} {}", value)),
2160+ kind: None,
2161+ text_edits: None,
2162+ tooltip: None,
2163+ padding_left: Some(false),
2164+ padding_right: Some(true),
2165+ data: None,
2166+ });
2167+ }
2168+ }
2169+ } else {
2170+ i += 1;
2171+ }
2172+ }
2173+ }
2174+ hints
2175+}
2176+
2177+// ---------------------------------------------------------------------------
2178+// Linked editing ranges
2179+// ---------------------------------------------------------------------------
2180+
2181+fn linked_editing_ranges(text: &str, position: Position) -> Option<LinkedEditingRanges> {
2182+ let lines: Vec<&str> = text.lines().collect();
2183+ let line = lines.get(position.line as usize)?;
2184+ let col = (position.character as usize).min(line.len());
2185+
2186+ // Find the $variable at the cursor
2187+ let bytes = line.as_bytes();
2188+ let mut start = col;
2189+ while start > 0 && (bytes[start - 1].is_ascii_alphanumeric() || bytes[start - 1] == b'$' || bytes[start - 1] == b'-' || bytes[start - 1] == b'_') {
2190+ start -= 1;
2191+ }
2192+ let mut end = col;
2193+ while end < bytes.len() && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'$' || bytes[end] == b'-' || bytes[end] == b'_') {
2194+ end += 1;
2195+ }
2196+ if start == end {
2197+ return None;
2198+ }
2199+
2200+ let word = &line[start..end];
2201+ if !word.starts_with('$') {
2202+ return None;
2203+ }
2204+
2205+ // Find all occurrences of this $variable in the document
2206+ let mut ranges = Vec::new();
2207+ for (line_idx, line) in text.lines().enumerate() {
2208+ let line_bytes = line.as_bytes();
2209+ let mut offset = 0;
2210+ while let Some(pos) = line[offset..].find(word) {
2211+ let abs_pos = offset + pos;
2212+ // Check it's a whole word match
2213+ let before_ok = abs_pos == 0 || {
2214+ let c = line_bytes[abs_pos - 1];
2215+ !c.is_ascii_alphanumeric() && c != b'-' && c != b'_'
2216+ };
2217+ let after_end = abs_pos + word.len();
2218+ let after_ok = after_end >= line.len() || {
2219+ let c = line_bytes[after_end];
2220+ !c.is_ascii_alphanumeric() && c != b'-' && c != b'_'
2221+ };
2222+ if before_ok && after_ok {
2223+ ranges.push(Range::new(
2224+ Position::new(line_idx as u32, abs_pos as u32),
2225+ Position::new(line_idx as u32, after_end as u32),
2226+ ));
2227+ }
2228+ offset = abs_pos + word.len();
2229+ }
2230+ }
2231+
2232+ if ranges.len() < 2 {
2233+ return None;
2234+ }
2235+
2236+ Some(LinkedEditingRanges {
2237+ ranges,
2238+ word_pattern: None,
2239+ })
2240+}
2241+
1953// ---------------------------------------------------------------------------
2242// ---------------------------------------------------------------------------
1954// Entry point
2243// Entry point
1955// ---------------------------------------------------------------------------
2244// ---------------------------------------------------------------------------
src/codegen.rs+231−10
@@ -12,6 +12,7 @@ const BREAKPOINTS: &[(&str, &str)] = &[
12 ("md", "768px"),
12 ("md", "768px"),
13 ("lg", "1024px"),
13 ("lg", "1024px"),
14 ("xl", "1280px"),
14 ("xl", "1280px"),
15+ ("2xl", "1536px"),
15];
16];
16
17
17struct StyleEntry {
18struct StyleEntry {
@@ -25,6 +26,10 @@ struct StyleEntry {
25 dark: String,
26 dark: String,
26 /// Print overrides
27 /// Print overrides
27 print: String,
28 print: String,
29+ motion_safe: String,
30+ motion_reduce: String,
31+ landscape: String,
32+ portrait: String,
28}
33}
29
34
30struct StyleCollector {
35struct StyleCollector {
@@ -48,12 +53,20 @@ impl StyleCollector {
48 responsive: Vec<(String, String)>,
53 responsive: Vec<(String, String)>,
49 dark: String,
54 dark: String,
50 print: String,
55 print: String,
56+ motion_safe: String,
57+ motion_reduce: String,
58+ landscape: String,
59+ portrait: String,
51 ) -> Option<String> {
60 ) -> Option<String> {
52 if base.is_empty()
61 if base.is_empty()
53 && pseudo.is_empty()
62 && pseudo.is_empty()
54 && responsive.is_empty()
63 && responsive.is_empty()
55 && dark.is_empty()
64 && dark.is_empty()
56 && print.is_empty()
65 && print.is_empty()
66+ && motion_safe.is_empty()
67+ && motion_reduce.is_empty()
68+ && landscape.is_empty()
69+ && portrait.is_empty()
57 {
70 {
58 return None;
71 return None;
59 }
72 }
@@ -67,7 +80,7 @@ impl StyleCollector {
67 .map(|(bp, css)| format!("{}={}", bp, css))
80 .map(|(bp, css)| format!("{}={}", bp, css))
68 .collect::<Vec<_>>()
81 .collect::<Vec<_>>()
69 .join("|");
82 .join("|");
70− let key = format!("{}|{}|{}|{}|{}", base, pseudo_key, resp_key, dark, print);
83+ let key = format!("{}|{}|{}|{}|{}|{}|{}|{}|{}", base, pseudo_key, resp_key, dark, print, motion_safe, motion_reduce, landscape, portrait);
71 if let Some(&idx) = self.index.get(&key) {
84 if let Some(&idx) = self.index.get(&key) {
72 return Some(self.entries[idx].class_name.clone());
85 return Some(self.entries[idx].class_name.clone());
73 }
86 }
@@ -80,6 +93,10 @@ impl StyleCollector {
80 responsive,
93 responsive,
81 dark,
94 dark,
82 print,
95 print,
96+ motion_safe,
97+ motion_reduce,
98+ landscape,
99+ portrait,
83 });
100 });
84 self.index.insert(key, idx);
101 self.index.insert(key, idx);
85 Some(name)
102 Some(name)
@@ -168,6 +185,82 @@ impl StyleCollector {
168 }
185 }
169 }
186 }
170
187
188+ // Motion safe rules
189+ let mut motion_safe_css = String::new();
190+ for e in &self.entries {
191+ if !e.motion_safe.is_empty() {
192+ if dev {
193+ motion_safe_css.push_str(&format!(" .{} {{{}}}\n", e.class_name, e.motion_safe));
194+ } else {
195+ motion_safe_css.push_str(&format!(".{}{{{}}}", e.class_name, e.motion_safe));
196+ }
197+ }
198+ }
199+ if !motion_safe_css.is_empty() {
200+ if dev {
201+ css.push_str(&format!("@media (prefers-reduced-motion: no-preference) {{\n{}}}\n", motion_safe_css));
202+ } else {
203+ css.push_str(&format!("@media(prefers-reduced-motion:no-preference){{{}}}", motion_safe_css));
204+ }
205+ }
206+
207+ // Motion reduce rules
208+ let mut motion_reduce_css = String::new();
209+ for e in &self.entries {
210+ if !e.motion_reduce.is_empty() {
211+ if dev {
212+ motion_reduce_css.push_str(&format!(" .{} {{{}}}\n", e.class_name, e.motion_reduce));
213+ } else {
214+ motion_reduce_css.push_str(&format!(".{}{{{}}}", e.class_name, e.motion_reduce));
215+ }
216+ }
217+ }
218+ if !motion_reduce_css.is_empty() {
219+ if dev {
220+ css.push_str(&format!("@media (prefers-reduced-motion: reduce) {{\n{}}}\n", motion_reduce_css));
221+ } else {
222+ css.push_str(&format!("@media(prefers-reduced-motion:reduce){{{}}}", motion_reduce_css));
223+ }
224+ }
225+
226+ // Landscape rules
227+ let mut landscape_css = String::new();
228+ for e in &self.entries {
229+ if !e.landscape.is_empty() {
230+ if dev {
231+ landscape_css.push_str(&format!(" .{} {{{}}}\n", e.class_name, e.landscape));
232+ } else {
233+ landscape_css.push_str(&format!(".{}{{{}}}", e.class_name, e.landscape));
234+ }
235+ }
236+ }
237+ if !landscape_css.is_empty() {
238+ if dev {
239+ css.push_str(&format!("@media (orientation: landscape) {{\n{}}}\n", landscape_css));
240+ } else {
241+ css.push_str(&format!("@media(orientation:landscape){{{}}}", landscape_css));
242+ }
243+ }
244+
245+ // Portrait rules
246+ let mut portrait_css = String::new();
247+ for e in &self.entries {
248+ if !e.portrait.is_empty() {
249+ if dev {
250+ portrait_css.push_str(&format!(" .{} {{{}}}\n", e.class_name, e.portrait));
251+ } else {
252+ portrait_css.push_str(&format!(".{}{{{}}}", e.class_name, e.portrait));
253+ }
254+ }
255+ }
256+ if !portrait_css.is_empty() {
257+ if dev {
258+ css.push_str(&format!("@media (orientation: portrait) {{\n{}}}\n", portrait_css));
259+ } else {
260+ css.push_str(&format!("@media(orientation:portrait){{{}}}", portrait_css));
261+ }
262+ }
263+
171 css
264 css
172 }
265 }
173}
266}
@@ -284,6 +377,29 @@ fn generate_with_options(doc: &Document, dev: bool) -> String {
284 m
377 m
285 };
378 };
286
379
380+ // Build OG meta tags
381+ let og_html = if doc.og_tags.is_empty() {
382+ String::new()
383+ } else {
384+ let mut o = String::new();
385+ for (property, content) in &doc.og_tags {
386+ if dev {
387+ o.push_str(&format!(
388+ "<meta property=\"og:{}\" content=\"{}\">\n",
389+ html_escape(property),
390+ html_escape(content)
391+ ));
392+ } else {
393+ o.push_str(&format!(
394+ "<meta property=\"og:{}\" content=\"{}\">",
395+ html_escape(property),
396+ html_escape(content)
397+ ));
398+ }
399+ }
400+ o
401+ };
402+
287 // Build head blocks string
403 // Build head blocks string
288 let head_html = if doc.head_blocks.is_empty() {
404 let head_html = if doc.head_blocks.is_empty() {
289 String::new()
405 String::new()
@@ -345,7 +461,7 @@ fn generate_with_options(doc: &Document, dev: bool) -> String {
345<meta charset=\"utf-8\">
461<meta charset=\"utf-8\">
346<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
462<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
347<title>{title}</title>
463<title>{title}</title>
348−{meta_html}{favicon_html}{head_html}\
464+{meta_html}{og_html}{favicon_html}{head_html}\
349<style>
465<style>
350*, *::before, *::after {{ box-sizing: border-box; }}
466*, *::before, *::after {{ box-sizing: border-box; }}
351body {{ margin: 0; font-family: system-ui, -apple-system, sans-serif; }}
467body {{ margin: 0; font-family: system-ui, -apple-system, sans-serif; }}
@@ -363,15 +479,17 @@ img {{ display: block; }}
363 meta_html = meta_html,
479 meta_html = meta_html,
364 favicon_html = favicon_html,
480 favicon_html = favicon_html,
365 head_html = head_html,
481 head_html = head_html,
482+ og_html = og_html,
366 element_css = element_css,
483 element_css = element_css,
367 body = body,
484 body = body,
368 )
485 )
369 } else {
486 } else {
370 format!(
487 format!(
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>",
488+ "<!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}{og_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>",
372 title = html_escape(title),
489 title = html_escape(title),
373 lang_attr = lang_attr,
490 lang_attr = lang_attr,
374 meta_html = meta_html,
491 meta_html = meta_html,
492+ og_html = og_html,
375 favicon_html = favicon_html,
493 favicon_html = favicon_html,
376 head_html = head_html,
494 head_html = head_html,
377 element_css = element_css,
495 element_css = element_css,
@@ -415,6 +533,9 @@ fn generate_node(
415 | Some(ElementKind::Aside) | Some(ElementKind::ListItem)
533 | Some(ElementKind::Aside) | Some(ElementKind::ListItem)
416 | Some(ElementKind::Form) | Some(ElementKind::Details) | Some(ElementKind::Figure)
534 | Some(ElementKind::Form) | Some(ElementKind::Details) | Some(ElementKind::Figure)
417 | Some(ElementKind::Blockquote)
535 | Some(ElementKind::Blockquote)
536+ | Some(ElementKind::Dialog) | Some(ElementKind::DefinitionList)
537+ | Some(ElementKind::DefinitionDescription) | Some(ElementKind::Fieldset)
538+ | Some(ElementKind::Datalist)
418 );
539 );
419 if needs_wrap {
540 if needs_wrap {
420 out.push_str(&ctx.indent());
541 out.push_str(&ctx.indent());
@@ -446,6 +567,8 @@ const HTML_PASSTHROUGH_ATTRS: &[&str] = &[
446 "loading", "decoding",
567 "loading", "decoding",
447 // Media src (explicit attribute form)
568 // Media src (explicit attribute form)
448 "src",
569 "src",
570+ // New element attributes
571+ "datetime", "media", "sizes", "srcset", "list",
449 // Details
572 // Details
450 "open",
573 "open",
451 // Form
574 // Form
@@ -493,7 +616,7 @@ fn generate_element(
493 ctx: &mut GenContext,
616 ctx: &mut GenContext,
494) {
617) {
495 // Self-closing elements
618 // Self-closing elements
496− if matches!(elem.kind, ElementKind::Image | ElementKind::Input | ElementKind::HorizontalRule) {
619+ if matches!(elem.kind, ElementKind::Image | ElementKind::Input | ElementKind::HorizontalRule | ElementKind::Source) {
497 generate_self_closing(elem, parent_kind, out, styles, ctx);
620 generate_self_closing(elem, parent_kind, out, styles, ctx);
498 return;
621 return;
499 }
622 }
@@ -556,8 +679,22 @@ fn generate_element(
556 ElementKind::FigCaption => "figcaption",
679 ElementKind::FigCaption => "figcaption",
557 ElementKind::Progress => "progress",
680 ElementKind::Progress => "progress",
558 ElementKind::Meter => "meter",
681 ElementKind::Meter => "meter",
682+ // New elements
683+ ElementKind::Dialog => "dialog",
684+ ElementKind::DefinitionList => "dl",
685+ ElementKind::DefinitionTerm => "dt",
686+ ElementKind::DefinitionDescription => "dd",
687+ ElementKind::Fieldset => "fieldset",
688+ ElementKind::Legend => "legend",
689+ ElementKind::Picture => "picture",
690+ ElementKind::Time => "time",
691+ ElementKind::Mark => "mark",
692+ ElementKind::Kbd => "kbd",
693+ ElementKind::Abbr => "abbr",
694+ ElementKind::Datalist => "datalist",
559 ElementKind::Image | ElementKind::Input | ElementKind::HorizontalRule
695 ElementKind::Image | ElementKind::Input | ElementKind::HorizontalRule
560− | ElementKind::Children | ElementKind::Slot(_) | ElementKind::Fragment => unreachable!(),
696+ | ElementKind::Children | ElementKind::Slot(_) | ElementKind::Fragment
697+ | ElementKind::Source => unreachable!(),
561 };
698 };
562
699
563 let kind_label = match elem.kind {
700 let kind_label = match elem.kind {
@@ -599,6 +736,18 @@ fn generate_element(
599 ElementKind::Figure => "figure",
736 ElementKind::Figure => "figure",
600 ElementKind::FigCaption => "figcaption",
737 ElementKind::FigCaption => "figcaption",
601 ElementKind::Fragment => "fragment",
738 ElementKind::Fragment => "fragment",
739+ ElementKind::Dialog => "dialog",
740+ ElementKind::DefinitionList => "dl",
741+ ElementKind::DefinitionTerm => "dt",
742+ ElementKind::DefinitionDescription => "dd",
743+ ElementKind::Fieldset => "fieldset",
744+ ElementKind::Legend => "legend",
745+ ElementKind::Picture => "picture",
746+ ElementKind::Time => "time",
747+ ElementKind::Mark => "mark",
748+ ElementKind::Kbd => "kbd",
749+ ElementKind::Abbr => "abbr",
750+ ElementKind::Datalist => "datalist",
602 _ => "",
751 _ => "",
603 };
752 };
604
753
@@ -640,7 +789,16 @@ fn generate_element(
640 }
789 }
641 }
790 }
642
791
643− // Details open attribute (handled via HTML_PASSTHROUGH_ATTRS, no extra handling needed)
792+ // Time datetime
793+ if elem.kind == ElementKind::Time {
794+ if let Some(dt) = elem.attrs.iter().find(|a| a.key == "datetime") {
795+ if let Some(val) = &dt.value {
796+ out.push_str(" datetime=\"");
797+ out.push_str(&html_escape(val));
798+ out.push('"');
799+ }
800+ }
801+ }
644
802
645 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
803 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
646
804
@@ -670,6 +828,13 @@ fn generate_element(
670 | ElementKind::Cite
828 | ElementKind::Cite
671 | ElementKind::Code
829 | ElementKind::Code
672 | ElementKind::FigCaption
830 | ElementKind::FigCaption
831+ | ElementKind::Legend
832+ | ElementKind::DefinitionTerm
833+ | ElementKind::Mark
834+ | ElementKind::Kbd
835+ | ElementKind::Abbr
836+ | ElementKind::Time
837+ | ElementKind::DefinitionDescription
673 ) {
838 ) {
674 if let Some(text) = &elem.argument {
839 if let Some(text) = &elem.argument {
675 out.push_str(&html_escape(text));
840 out.push_str(&html_escape(text));
@@ -708,6 +873,7 @@ fn generate_self_closing(
708 ElementKind::Image => ("img", "image"),
873 ElementKind::Image => ("img", "image"),
709 ElementKind::Input => ("input", "input"),
874 ElementKind::Input => ("input", "input"),
710 ElementKind::HorizontalRule => ("hr", "hr"),
875 ElementKind::HorizontalRule => ("hr", "hr"),
876+ ElementKind::Source => ("source", "source"),
711 _ => unreachable!(),
877 _ => unreachable!(),
712 };
878 };
713
879
@@ -817,8 +983,12 @@ fn compute_class(
817
983
818 let dark = attrs_to_css(attrs, "dark:", kind, parent_kind);
984 let dark = attrs_to_css(attrs, "dark:", kind, parent_kind);
819 let print = attrs_to_css(attrs, "print:", kind, parent_kind);
985 let print = attrs_to_css(attrs, "print:", kind, parent_kind);
986+ let motion_safe = attrs_to_css(attrs, "motion-safe:", kind, parent_kind);
987+ let motion_reduce = attrs_to_css(attrs, "motion-reduce:", kind, parent_kind);
988+ let landscape = attrs_to_css(attrs, "landscape:", kind, parent_kind);
989+ let portrait = attrs_to_css(attrs, "portrait:", kind, parent_kind);
820
990
821− styles.get_class(base, pseudo, responsive, dark, print)
991+ styles.get_class(base, pseudo, responsive, dark, print, motion_safe, motion_reduce, landscape, portrait)
822}
992}
823
993
824fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option<&str>) {
994fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option<&str>) {
@@ -863,8 +1033,8 @@ const PSEUDO_PREFIXES: &[(&str, &str)] = &[
863 ("odd:", ":nth-child(odd)"),
1033 ("odd:", ":nth-child(odd)"),
864 ("even:", ":nth-child(even)"),
1034 ("even:", ":nth-child(even)"),
865];
1035];
866−const RESPONSIVE_PREFIXES: &[&str] = &["sm:", "md:", "lg:", "xl:"];
1036+const RESPONSIVE_PREFIXES: &[&str] = &["sm:", "md:", "lg:", "xl:", "2xl:"];
867−const MEDIA_PREFIXES: &[&str] = &["dark:", "print:"];
1037+const MEDIA_PREFIXES: &[&str] = &["dark:", "print:", "motion-safe:", "motion-reduce:", "landscape:", "portrait:"];
868
1038
869fn is_prefixed_attr(key: &str) -> bool {
1039fn is_prefixed_attr(key: &str) -> bool {
870 PSEUDO_PREFIXES.iter().any(|&(p, _)| key.starts_with(p))
1040 PSEUDO_PREFIXES.iter().any(|&(p, _)| key.starts_with(p))
@@ -906,6 +1076,12 @@ fn attrs_to_css(
906 ElementKind::Pre => css.push_str("margin:0;white-space:pre;font-family:ui-monospace,monospace;"),
1076 ElementKind::Pre => css.push_str("margin:0;white-space:pre;font-family:ui-monospace,monospace;"),
907 // Code: monospace font
1077 // Code: monospace font
908 ElementKind::Code => css.push_str("font-family:ui-monospace,monospace;"),
1078 ElementKind::Code => css.push_str("font-family:ui-monospace,monospace;"),
1079+ // New elements
1080+ ElementKind::Dialog => css.push_str("display:flex;flex-direction:column;"),
1081+ ElementKind::DefinitionList => css.push_str("margin:0;"),
1082+ ElementKind::DefinitionDescription => css.push_str("margin:0;display:flex;flex-direction:column;"),
1083+ ElementKind::Fieldset => css.push_str("display:flex;flex-direction:column;border:1px solid currentColor;padding:0.5em;margin:0;"),
1084+ ElementKind::Kbd => css.push_str("font-family:ui-monospace,monospace;"),
909 _ => {}
1085 _ => {}
910 }
1086 }
911 }
1087 }
@@ -1557,6 +1733,50 @@ fn attrs_to_css(
1557 }
1733 }
1558 }
1734 }
1559
1735
1736+ // New CSS properties
1737+ "clip-path" => {
1738+ if let Some(v) = val { push_css(&mut css, "clip-path", v); }
1739+ }
1740+ "mix-blend-mode" => {
1741+ if let Some(v) = val { push_css(&mut css, "mix-blend-mode", v); }
1742+ }
1743+ "background-blend-mode" => {
1744+ if let Some(v) = val { push_css(&mut css, "background-blend-mode", v); }
1745+ }
1746+ "writing-mode" => {
1747+ if let Some(v) = val { push_css(&mut css, "writing-mode", v); }
1748+ }
1749+ "column-count" => {
1750+ if let Some(v) = val { push_css(&mut css, "column-count", v); }
1751+ }
1752+ "column-gap" => {
1753+ if let Some(v) = val { push_css(&mut css, "column-gap", &css_px(v)); }
1754+ }
1755+ "text-indent" => {
1756+ if let Some(v) = val { push_css(&mut css, "text-indent", &css_px(v)); }
1757+ }
1758+ "hyphens" => {
1759+ if let Some(v) = val { push_css(&mut css, "hyphens", v); }
1760+ }
1761+ "flex-grow" => {
1762+ if let Some(v) = val { push_css(&mut css, "flex-grow", v); }
1763+ }
1764+ "flex-shrink" => {
1765+ if let Some(v) = val { push_css(&mut css, "flex-shrink", v); }
1766+ }
1767+ "flex-basis" => {
1768+ if let Some(v) = val { push_css(&mut css, "flex-basis", &css_px(v)); }
1769+ }
1770+ "isolation" => {
1771+ if let Some(v) = val { push_css(&mut css, "isolation", v); }
1772+ }
1773+ "place-content" => {
1774+ if let Some(v) = val { push_css(&mut css, "place-content", v); }
1775+ }
1776+ "background-image" => {
1777+ if let Some(v) = val { push_css(&mut css, "background-image", v); }
1778+ }
1779+
1560 // Identity and HTML passthrough — not CSS
1780 // Identity and HTML passthrough — not CSS
1561 "id" | "class" => {}
1781 "id" | "class" => {}
1562 "type" | "placeholder" | "name" | "value" | "disabled" | "required"
1782 "type" | "placeholder" | "name" | "value" | "disabled" | "required"
@@ -1566,7 +1786,8 @@ fn attrs_to_css(
1566 | "controls" | "autoplay" | "loop" | "muted" | "poster" | "preload"
1786 | "controls" | "autoplay" | "loop" | "muted" | "poster" | "preload"
1567 | "loading" | "decoding" | "ordered" | "src"
1787 | "loading" | "decoding" | "ordered" | "src"
1568 | "open" | "novalidate" | "low" | "high" | "optimum"
1788 | "open" | "novalidate" | "low" | "high" | "optimum"
1569− | "colspan" | "rowspan" | "scope" | "inline" => {}
1789+ | "colspan" | "rowspan" | "scope" | "inline"
1790+ | "datetime" | "media" | "sizes" | "srcset" | "cite" | "list" => {}
1570
1791
1571 _ => {}
1792 _ => {}
1572 }
1793 }
src/fmt.rs+92−6
@@ -1,3 +1,5 @@
1+const MAX_LINE_WIDTH: usize = 100;
2+
1/// Attribute category for sorting.
3/// Attribute category for sorting.
2fn attr_category(key: &str) -> u8 {
4fn attr_category(key: &str) -> u8 {
3 // Strip pseudo-state/responsive/media prefixes for categorization
5 // Strip pseudo-state/responsive/media prefixes for categorization
@@ -20,28 +22,36 @@ fn attr_category(key: &str) -> u8 {
20 .or_else(|| key.strip_prefix("xl:"))
22 .or_else(|| key.strip_prefix("xl:"))
21 .or_else(|| key.strip_prefix("dark:"))
23 .or_else(|| key.strip_prefix("dark:"))
22 .or_else(|| key.strip_prefix("print:"))
24 .or_else(|| key.strip_prefix("print:"))
25+ .or_else(|| key.strip_prefix("2xl:"))
26+ .or_else(|| key.strip_prefix("motion-safe:"))
27+ .or_else(|| key.strip_prefix("motion-reduce:"))
28+ .or_else(|| key.strip_prefix("landscape:"))
29+ .or_else(|| key.strip_prefix("portrait:"))
23 .unwrap_or(key);
30 .unwrap_or(key);
24
31
25 match base {
32 match base {
26 // Layout (parent)
33 // Layout (parent)
27 "spacing" | "gap" | "gap-x" | "gap-y" | "wrap"
34 "spacing" | "gap" | "gap-x" | "gap-y" | "wrap"
28− | "grid" | "grid-cols" | "grid-rows" => 0,
35+ | "grid" | "grid-cols" | "grid-rows"
36+ | "column-count" | "column-gap" => 0,
29 // Sizing
37 // Sizing
30− "width" | "height" | "min-width" | "max-width" | "min-height" | "max-height" => 1,
38+ "width" | "height" | "min-width" | "max-width" | "min-height" | "max-height"
39+ | "flex-grow" | "flex-shrink" | "flex-basis" => 1,
31 // Padding & margin
40 // Padding & margin
32 "padding" | "padding-x" | "padding-y" | "padding-inline" | "padding-block"
41 "padding" | "padding-x" | "padding-y" | "padding-inline" | "padding-block"
33 | "margin" | "margin-x" | "margin-y" | "margin-inline" | "margin-block" => 2,
42 | "margin" | "margin-x" | "margin-y" | "margin-inline" | "margin-block" => 2,
34 // Alignment
43 // Alignment
35 "center-x" | "center-y" | "align-left" | "align-right"
44 "center-x" | "center-y" | "align-left" | "align-right"
36 | "align-top" | "align-bottom" | "justify-content" | "align-items"
45 | "align-top" | "align-bottom" | "justify-content" | "align-items"
37− | "place-items" | "place-self" => 3,
46+ | "place-items" | "place-self" | "place-content" => 3,
38 // Positioning
47 // Positioning
39 "position" | "top" | "right" | "bottom" | "left" | "z-index" | "order" | "inset"
48 "position" | "top" | "right" | "bottom" | "left" | "z-index" | "order" | "inset"
40 | "col-span" | "row-span" | "display" | "visibility"
49 | "col-span" | "row-span" | "display" | "visibility"
41 | "overflow" | "overflow-x" | "overflow-y" | "hidden" => 4,
50 | "overflow" | "overflow-x" | "overflow-y" | "hidden" => 4,
42 // Visual style
51 // Visual style
43 "background" | "background-size" | "background-position" | "background-repeat"
52 "background" | "background-size" | "background-position" | "background-repeat"
44− | "color" | "opacity" | "accent-color" | "caret-color" => 5,
53+ | "color" | "opacity" | "accent-color" | "caret-color"
54+ | "background-image" | "background-blend-mode" | "mix-blend-mode" | "isolation" => 5,
45 // Border & shape
55 // Border & shape
46 "border" | "border-top" | "border-bottom" | "border-left" | "border-right"
56 "border" | "border-top" | "border-bottom" | "border-left" | "border-right"
47 | "rounded" | "outline" | "shadow" | "text-shadow"
57 | "rounded" | "outline" | "shadow" | "text-shadow"
@@ -51,11 +61,13 @@ fn attr_category(key: &str) -> u8 {
51 | "text-align" | "line-height" | "letter-spacing" | "text-transform"
61 | "text-align" | "line-height" | "letter-spacing" | "text-transform"
52 | "white-space" | "text-overflow" | "word-break" | "overflow-wrap"
62 | "white-space" | "text-overflow" | "word-break" | "overflow-wrap"
53 | "text-decoration" | "text-decoration-color" | "text-decoration-thickness"
63 | "text-decoration" | "text-decoration-color" | "text-decoration-thickness"
54− | "text-decoration-style" | "list-style" => 7,
64+ | "text-decoration-style" | "list-style"
65+ | "text-indent" | "hyphens" | "writing-mode" => 7,
55 // Effects & interaction
66 // Effects & interaction
56 "transform" | "transition" | "animation" | "cursor" | "backdrop-filter"
67 "transform" | "transition" | "animation" | "cursor" | "backdrop-filter"
57 | "filter" | "pointer-events" | "user-select" | "aspect-ratio"
68 | "filter" | "pointer-events" | "user-select" | "aspect-ratio"
58− | "object-fit" | "object-position" | "resize" => 8,
69+ | "object-fit" | "object-position" | "resize"
70+ | "clip-path" => 8,
59 // Container queries & scroll
71 // Container queries & scroll
60 "container" | "container-name" | "container-type"
72 "container" | "container-name" | "container-type"
61 | "scroll-snap-type" | "scroll-snap-align" | "scroll-behavior" => 9,
73 | "scroll-snap-type" | "scroll-snap-align" | "scroll-behavior" => 9,
@@ -99,6 +111,20 @@ pub fn format(input: &str) -> String {
99 continue;
111 continue;
100 }
112 }
101
113
114+ // Preserve comments at current indent level
115+ if trimmed.starts_with("--") {
116+ let raw_indent = (line.len() - line.trim_start().len()) as i32;
117+ while indent_stack.len() > 1 && *indent_stack.last().unwrap() >= raw_indent {
118+ indent_stack.pop();
119+ }
120+ let level = indent_stack.len() - 1;
121+ output.push_str(&" ".repeat(level));
122+ output.push_str(trimmed);
123+ output.push('\n');
124+ indent_stack.push(raw_indent);
125+ continue;
126+ }
127+
102 let open = trimmed.chars().filter(|&c| c == '[').count() as i32;
128 let open = trimmed.chars().filter(|&c| c == '[').count() as i32;
103 let close = trimmed.chars().filter(|&c| c == ']').count() as i32;
129 let close = trimmed.chars().filter(|&c| c == ']').count() as i32;
104
130
@@ -142,6 +168,17 @@ pub fn format(input: &str) -> String {
142
168
143 // Single-line: format brackets inline
169 // Single-line: format brackets inline
144 let formatted = format_line_with_brackets(trimmed);
170 let formatted = format_line_with_brackets(trimmed);
171+
172+ let indented_len = level * 2 + formatted.len();
173+ if indented_len > MAX_LINE_WIDTH && formatted.contains('[') {
174+ // Wrap long attribute lists
175+ if let Some(wrapped) = wrap_attrs(&formatted, level) {
176+ output.push_str(&wrapped);
177+ indent_stack.push(raw_indent);
178+ continue;
179+ }
180+ }
181+
145 output.push_str(&" ".repeat(level));
182 output.push_str(&" ".repeat(level));
146 output.push_str(&formatted);
183 output.push_str(&formatted);
147 output.push('\n');
184 output.push('\n');
@@ -185,3 +222,52 @@ fn format_line_with_brackets(line: &str) -> String {
185
222
186 format!("{}[{}]{}", before, sorted, after)
223 format!("{}[{}]{}", before, sorted, after)
187}
224}
225+
226+fn wrap_attrs(line: &str, indent_level: usize) -> Option<String> {
227+ let bracket_start = line.find('[')?;
228+ let mut depth = 0;
229+ let mut bracket_end = None;
230+ for (i, c) in line.char_indices() {
231+ match c {
232+ '[' => depth += 1,
233+ ']' => {
234+ depth -= 1;
235+ if depth == 0 {
236+ bracket_end = Some(i);
237+ break;
238+ }
239+ }
240+ _ => {}
241+ }
242+ }
243+ let bracket_end = bracket_end?;
244+
245+ let before = &line[..bracket_start];
246+ let attrs_inner = &line[bracket_start + 1..bracket_end];
247+ let after = &line[bracket_end + 1..];
248+
249+ let parts: Vec<&str> = attrs_inner.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
250+ if parts.len() <= 1 {
251+ return None;
252+ }
253+
254+ let base_indent = " ".repeat(indent_level);
255+ let attr_indent = " ".repeat(indent_level + 1);
256+ let mut result = String::new();
257+ result.push_str(&base_indent);
258+ result.push_str(before);
259+ result.push_str("[\n");
260+ for (i, part) in parts.iter().enumerate() {
261+ result.push_str(&attr_indent);
262+ result.push_str(part);
263+ if i < parts.len() - 1 {
264+ result.push(',');
265+ }
266+ result.push('\n');
267+ }
268+ result.push_str(&base_indent);
269+ result.push(']');
270+ result.push_str(after);
271+ result.push('\n');
272+ Some(result)
273+}
src/main.rs+160−12
@@ -5,7 +5,7 @@ use std::hash::{Hash, Hasher};
5use std::path::{Path, PathBuf};
5use std::path::{Path, PathBuf};
6use std::process;
6use std::process;
7
7
8−fn compile(input_path: &str, dev: bool, error_overlay: bool, check_only: bool) -> (bool, Vec<PathBuf>) {
8+fn compile(input_path: &str, dev: bool, error_overlay: bool, check_only: bool, output_path: Option<&str>) -> (bool, Vec<PathBuf>) {
9 let input = match fs::read_to_string(input_path) {
9 let input = match fs::read_to_string(input_path) {
10 Ok(s) => s,
10 Ok(s) => s,
11 Err(e) => {
11 Err(e) => {
@@ -33,7 +33,10 @@ fn compile(input_path: &str, dev: bool, error_overlay: bool, check_only: bool) -
33 .iter()
33 .iter()
34 .any(|d| d.severity == htmlang::parser::Severity::Error);
34 .any(|d| d.severity == htmlang::parser::Severity::Error);
35
35
36+ let out_path = match output_path {
37+ Some(p) => PathBuf::from(p),
36− let out_path = Path::new(input_path).with_extension("html");
38+ None => Path::new(input_path).with_extension("html"),
39+ };
37
40
38 if !check_only {
41 if !check_only {
39 if has_errors {
42 if has_errors {
@@ -100,19 +103,22 @@ htmlang {} - a minimalist layout language that compiles to static HTML
100
103
101Usage: htmlang [options] <file.hl | directory>
104Usage: htmlang [options] <file.hl | directory>
102
105
106+Commands:
107+ init [dir] Create a new project (defaults to current directory)
108+ build <dir> [-o <out>] Compile all .hl files recursively
109+ fmt <file.hl> Format a .hl file (normalizes indentation)
110+ sitemap <dir> Generate sitemap.xml from .hl files
111+
103Options:
112Options:
104 -w, --watch Watch for changes and recompile
113 -w, --watch Watch for changes and recompile
105 -s, --serve Start dev server with hot reload (implies --watch)
114 -s, --serve Start dev server with hot reload (implies --watch)
106 -p, --port <N> Port for dev server (default: 3000)
115 -p, --port <N> Port for dev server (default: 3000)
116+ -o, --output <path> Output file/directory path
107 -d, --dev Development mode
117 -d, --dev Development mode
108 -c, --check Check for errors without writing output
118 -c, --check Check for errors without writing output
109 -h, --help Show this help
119 -h, --help Show this help
110 -V, --version Show version
120 -V, --version Show version
111
121
112−Commands:
113− init [dir] Create a new project (defaults to current directory)
114− fmt <file.hl> Format a .hl file (normalizes indentation)
115−
116Examples:
122Examples:
117 htmlang init Scaffold a new project
123 htmlang init Scaffold a new project
118 htmlang init my-site Scaffold in a new directory
124 htmlang init my-site Scaffold in a new directory
@@ -123,7 +129,9 @@ Examples:
123 htmlang -s site/ Serve a multi-page site
129 htmlang -s site/ Serve a multi-page site
124 htmlang -s -p 8080 page.hl
130 htmlang -s -p 8080 page.hl
125 htmlang -c page.hl Lint without writing output
131 htmlang -c page.hl Lint without writing output
126− htmlang fmt page.hl Format a file",
132+ htmlang fmt page.hl Format a file
133+ htmlang build src/ -o dist/ Compile all .hl files to dist/
134+ htmlang sitemap src/ Generate sitemap.xml",
127 env!("CARGO_PKG_VERSION")
135 env!("CARGO_PKG_VERSION")
128 );
136 );
129}
137}
@@ -167,6 +175,55 @@ fn init_project(dir: &str) {
167 }
175 }
168}
176}
169
177
178+fn collect_hl_files_recursive(dir: &Path) -> Vec<PathBuf> {
179+ let mut files = Vec::new();
180+ collect_hl_recursive_inner(dir, &mut files);
181+ files.sort();
182+ files
183+}
184+
185+fn collect_hl_recursive_inner(dir: &Path, files: &mut Vec<PathBuf>) {
186+ if let Ok(entries) = fs::read_dir(dir) {
187+ for entry in entries.flatten() {
188+ let path = entry.path();
189+ if path.is_dir() {
190+ collect_hl_recursive_inner(&path, files);
191+ } else if path.is_file() && path.extension().map_or(false, |e| e == "hl") {
192+ files.push(path);
193+ }
194+ }
195+ }
196+}
197+
198+fn generate_sitemap(dir: &str, base_url: &str) {
199+ let dir = Path::new(dir);
200+ let hl_files = collect_hl_files_recursive(dir);
201+ if hl_files.is_empty() {
202+ eprintln!("no .hl files found in {}", dir.display());
203+ process::exit(1);
204+ }
205+ let mut xml = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
206+ for file in &hl_files {
207+ let rel = file.strip_prefix(dir).unwrap_or(file);
208+ let url_path = rel.with_extension("html").to_string_lossy().replace('\\', "/");
209+ let url = if url_path == "index.html" {
210+ format!("{}/", base_url.trim_end_matches('/'))
211+ } else {
212+ format!("{}/{}", base_url.trim_end_matches('/'), url_path)
213+ };
214+ xml.push_str(&format!(" <url><loc>{}</loc></url>\n", url));
215+ }
216+ xml.push_str("</urlset>\n");
217+ let out_path = dir.join("sitemap.xml");
218+ match fs::write(&out_path, &xml) {
219+ Ok(()) => eprintln!("wrote {}", out_path.display()),
220+ Err(e) => {
221+ eprintln!("error: {}: {}", out_path.display(), e);
222+ process::exit(1);
223+ }
224+ }
225+}
226+
170fn main() {
227fn main() {
171 let args: Vec<String> = env::args().collect();
228 let args: Vec<String> = env::args().collect();
172 let mut watch = false;
229 let mut watch = false;
@@ -174,6 +231,7 @@ fn main() {
174 let mut dev = false;
231 let mut dev = false;
175 let mut check = false;
232 let mut check = false;
176 let mut port: u16 = 3000;
233 let mut port: u16 = 3000;
234+ let mut output_path: Option<String> = None;
177 let mut input_path = None;
235 let mut input_path = None;
178
236
179 // Handle "init" subcommand
237 // Handle "init" subcommand
@@ -205,6 +263,70 @@ fn main() {
205 return;
263 return;
206 }
264 }
207
265
266+ // Handle "build" subcommand
267+ if args.len() >= 2 && args[1] == "build" {
268+ let mut src_dir = None;
269+ let mut out_dir = None;
270+ let mut i = 2;
271+ while i < args.len() {
272+ match args[i].as_str() {
273+ "-o" | "--output" => {
274+ i += 1;
275+ out_dir = args.get(i).map(|s| s.as_str());
276+ }
277+ _ if src_dir.is_none() => src_dir = Some(args[i].as_str()),
278+ _ => {
279+ eprintln!("unknown argument: {}", args[i]);
280+ process::exit(1);
281+ }
282+ }
283+ i += 1;
284+ }
285+ let src = src_dir.unwrap_or(".");
286+ let dir = Path::new(src);
287+ if !dir.is_dir() {
288+ eprintln!("error: '{}' is not a directory", src);
289+ process::exit(1);
290+ }
291+ let hl_files = collect_hl_files_recursive(dir);
292+ if hl_files.is_empty() {
293+ eprintln!("no .hl files found in {}", src);
294+ process::exit(1);
295+ }
296+ // Create output dir if needed
297+ if let Some(out) = out_dir {
298+ let _ = fs::create_dir_all(out);
299+ }
300+ let mut any_errors = false;
301+ for file in &hl_files {
302+ let path_str = file.to_string_lossy().to_string();
303+ let effective_out = out_dir.map(|o| {
304+ // Mirror directory structure
305+ let rel = file.strip_prefix(dir).unwrap_or(file);
306+ let out_path = Path::new(o).join(rel).with_extension("html");
307+ if let Some(parent) = out_path.parent() {
308+ let _ = fs::create_dir_all(parent);
309+ }
310+ out_path.to_string_lossy().to_string()
311+ });
312+ let (has_errors, _) = compile(&path_str, false, false, false, effective_out.as_deref());
313+ if has_errors { any_errors = true; }
314+ }
315+ if any_errors { process::exit(1); }
316+ return;
317+ }
318+
319+ // Handle "sitemap" subcommand
320+ if args.len() >= 2 && args[1] == "sitemap" {
321+ let dir = if args.len() >= 3 { &args[2] } else { "." };
322+ let base_url = args.iter().position(|a| a == "--base-url" || a == "-b")
323+ .and_then(|i| args.get(i + 1))
324+ .map(|s| s.as_str())
325+ .unwrap_or("https://example.com");
326+ generate_sitemap(dir, base_url);
327+ return;
328+ }
329+
208 let mut i = 1;
330 let mut i = 1;
209 while i < args.len() {
331 while i < args.len() {
210 match args[i].as_str() {
332 match args[i].as_str() {
@@ -238,6 +360,16 @@ fn main() {
238 }
360 }
239 }
361 }
240 }
362 }
363+ "--output" | "-o" => {
364+ i += 1;
365+ match args.get(i) {
366+ Some(p) => output_path = Some(p.clone()),
367+ None => {
368+ eprintln!("--output requires a value");
369+ process::exit(1);
370+ }
371+ }
372+ }
241 _ if input_path.is_none() => input_path = Some(args[i].clone()),
373 _ if input_path.is_none() => input_path = Some(args[i].clone()),
242 _ => {
374 _ => {
243 eprintln!("unknown argument: {}", args[i]);
375 eprintln!("unknown argument: {}", args[i]);
@@ -266,11 +398,24 @@ fn main() {
266 process::exit(1);
398 process::exit(1);
267 }
399 }
268
400
401+ // Create output dir if needed
402+ if let Some(ref out) = output_path {
403+ let _ = fs::create_dir_all(out);
404+ }
405+
269 let mut any_errors = false;
406 let mut any_errors = false;
270 let mut all_included: Vec<PathBuf> = Vec::new();
407 let mut all_included: Vec<PathBuf> = Vec::new();
271 for file in &hl_files {
408 for file in &hl_files {
272 let path_str = file.to_string_lossy().to_string();
409 let path_str = file.to_string_lossy().to_string();
410+ let effective_out = output_path.as_ref().map(|o| {
411+ let rel = file.strip_prefix(dir).unwrap_or(file);
412+ let out_p = Path::new(o).join(rel).with_extension("html");
413+ if let Some(parent) = out_p.parent() {
414+ let _ = fs::create_dir_all(parent);
415+ }
416+ out_p.to_string_lossy().to_string()
417+ });
273− let (has_errors, included) = compile(&path_str, dev, serve, check);
418+ let (has_errors, included) = compile(&path_str, dev, serve, check, effective_out.as_deref());
274 if has_errors {
419 if has_errors {
275 any_errors = true;
420 any_errors = true;
276 }
421 }
@@ -303,7 +448,7 @@ fn main() {
303 }
448 }
304
449
305 // --- Single file mode ---
450 // --- Single file mode ---
306− let (has_errors, included_files) = compile(&input_path, dev, serve, check);
451+ let (has_errors, included_files) = compile(&input_path, dev, serve, check, output_path.as_deref());
307
452
308 if !watch {
453 if !watch {
309 if has_errors {
454 if has_errors {
@@ -315,7 +460,10 @@ fn main() {
315 // Start dev server if requested
460 // Start dev server if requested
316 let reload_tx = if serve {
461 let reload_tx = if serve {
317 let (tx, _) = tokio::sync::broadcast::channel::<()>(16);
462 let (tx, _) = tokio::sync::broadcast::channel::<()>(16);
463+ let out_path = match output_path {
464+ Some(ref p) => PathBuf::from(p),
318− let out_path = Path::new(&input_path).with_extension("html");
465+ None => Path::new(&input_path).with_extension("html"),
466+ };
319 let server_tx = tx.clone();
467 let server_tx = tx.clone();
320 std::thread::spawn(move || {
468 std::thread::spawn(move || {
321 let rt = tokio::runtime::Runtime::new().expect("failed to create runtime");
469 let rt = tokio::runtime::Runtime::new().expect("failed to create runtime");
@@ -464,7 +612,7 @@ fn watch_loop(
464 // Recompile all source files
612 // Recompile all source files
465 for file in source_files {
613 for file in source_files {
466 let path_str = file.to_string_lossy().to_string();
614 let path_str = file.to_string_lossy().to_string();
467− let (_, new_includes) = compile(&path_str, dev, serve, false);
615+ let (_, new_includes) = compile(&path_str, dev, serve, false, None);
468 for inc in &new_includes {
616 for inc in &new_includes {
469 let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
617 let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
470 if let Some(h) = hash_file(inc) {
618 if let Some(h) = hash_file(inc) {
@@ -478,7 +626,7 @@ fn watch_loop(
478 for file in &collect_hl_files(watch_dir) {
626 for file in &collect_hl_files(watch_dir) {
479 if !source_files.contains(file) {
627 if !source_files.contains(file) {
480 let path_str = file.to_string_lossy().to_string();
628 let path_str = file.to_string_lossy().to_string();
481− let (_, new_includes) = compile(&path_str, dev, serve, false);
629+ let (_, new_includes) = compile(&path_str, dev, serve, false, None);
482 for inc in &new_includes {
630 for inc in &new_includes {
483 let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
631 let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
484 }
632 }
src/parser.rs+148−2
@@ -76,6 +76,8 @@ struct ParseContext {
76 keyframes: Vec<(String, String)>,
76 keyframes: Vec<(String, String)>,
77 css_vars: Vec<(String, String)>,
77 css_vars: Vec<(String, String)>,
78 custom_css: Vec<String>,
78 custom_css: Vec<String>,
79+ og_tags: Vec<(String, String)>,
80+ custom_breakpoints: Vec<(String, String)>,
79 diagnostics: Vec<Diagnostic>,
81 diagnostics: Vec<Diagnostic>,
80 base_path: Option<PathBuf>,
82 base_path: Option<PathBuf>,
81 included_files: Vec<PathBuf>,
83 included_files: Vec<PathBuf>,
@@ -120,6 +122,8 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
120 keyframes: Vec::new(),
122 keyframes: Vec::new(),
121 css_vars: Vec::new(),
123 css_vars: Vec::new(),
122 custom_css: Vec::new(),
124 custom_css: Vec::new(),
125+ og_tags: Vec::new(),
126+ custom_breakpoints: Vec::new(),
123 diagnostics: Vec::new(),
127 diagnostics: Vec::new(),
124 base_path: base_path.map(|p| p.to_path_buf()),
128 base_path: base_path.map(|p| p.to_path_buf()),
125 included_files: Vec::new(),
129 included_files: Vec::new(),
@@ -148,6 +152,8 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
148 keyframes: ctx.keyframes,
152 keyframes: ctx.keyframes,
149 css_vars: ctx.css_vars,
153 css_vars: ctx.css_vars,
150 custom_css: ctx.custom_css,
154 custom_css: ctx.custom_css,
155+ og_tags: ctx.og_tags,
156+ custom_breakpoints: ctx.custom_breakpoints,
151 nodes,
157 nodes,
152 },
158 },
153 diagnostics: ctx.diagnostics,
159 diagnostics: ctx.diagnostics,
@@ -340,6 +346,7 @@ impl Parser {
340 };
346 };
341 track_var_refs(value, &mut ctx.used_variables);
347 track_var_refs(value, &mut ctx.used_variables);
342 let value = substitute_vars(value, &ctx.variables);
348 let value = substitute_vars(value, &ctx.variables);
349+ let value = evaluate_arithmetic(&value);
343 if name.starts_with("--") {
350 if name.starts_with("--") {
344 // CSS custom property
351 // CSS custom property
345 ctx.css_vars.push((name.to_string(), value.clone()));
352 ctx.css_vars.push((name.to_string(), value.clone()));
@@ -359,6 +366,29 @@ impl Parser {
359 return Ok(None);
366 return Ok(None);
360 }
367 }
361
368
369+ if let Some(rest) = content.strip_prefix("@og ") {
370+ let rest = rest.trim();
371+ let parts: Vec<&str> = rest.splitn(2, ' ').collect();
372+ if parts.len() == 2 {
373+ let value = substitute_vars(parts[1].trim(), &ctx.variables);
374+ let value = if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
375+ value[1..value.len()-1].to_string()
376+ } else {
377+ value
378+ };
379+ ctx.og_tags.push((parts[0].to_string(), value));
380+ }
381+ return Ok(None);
382+ }
383+
384+ if let Some(rest) = content.strip_prefix("@breakpoint ") {
385+ let rest = rest.trim();
386+ if let Some((name, value)) = rest.split_once(' ') {
387+ ctx.custom_breakpoints.push((name.trim().to_string(), substitute_vars(value.trim(), &ctx.variables)));
388+ }
389+ return Ok(None);
390+ }
391+
362 if content == "@head" || content.starts_with("@head ") {
392 if content == "@head" || content.starts_with("@head ") {
363 // Collect indented body lines as raw head content
393 // Collect indented body lines as raw head content
364 let mut head_content = String::new();
394 let mut head_content = String::new();
@@ -618,6 +648,31 @@ impl Parser {
618 return Ok(Some(nodes));
648 return Ok(Some(nodes));
619 }
649 }
620
650
651+ if let Some(rest) = content.strip_prefix("@unless ") {
652+ let rest = rest.trim();
653+ let condition = substitute_vars(rest, &ctx.variables);
654+ let result = !evaluate_condition(&condition);
655+ let mut body_lines = Vec::new();
656+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
657+ body_lines.push(self.lines[self.pos].clone());
658+ self.pos += 1;
659+ }
660+ if !result || body_lines.is_empty() {
661+ return Ok(None);
662+ }
663+ let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
664+ let adjusted: Vec<Line> = body_lines.iter().map(|l| Line {
665+ indent: l.indent - min_indent,
666+ content: l.content.clone(),
667+ line_num: l.line_num,
668+ }).collect();
669+ let saved_vars = ctx.variables.clone();
670+ let mut body_parser = Parser { lines: adjusted, pos: 0 };
671+ let nodes = body_parser.parse_children(0, ctx);
672+ ctx.variables = saved_vars;
673+ return Ok(Some(nodes));
674+ }
675+
621 if content.trim() == "@else" || content.trim().starts_with("@else if ") {
676 if content.trim() == "@else" || content.trim().starts_with("@else if ") {
622 return Err(ParseError {
677 return Err(ParseError {
623 line: line_num,
678 line: line_num,
@@ -1163,12 +1218,15 @@ const KNOWN_ELEMENTS: &[&str] = &[
1163 "form", "details", "summary", "blockquote", "cite", "code", "pre", "hr", "divider",
1218 "form", "details", "summary", "blockquote", "cite", "code", "pre", "hr", "divider",
1164 "figure", "figcaption", "progress", "meter",
1219 "figure", "figcaption", "progress", "meter",
1165 "fragment",
1220 "fragment",
1221+ "btn", "ul", "dialog", "dl", "dt", "dd", "fieldset", "legend",
1222+ "picture", "source", "time", "mark", "kbd", "abbr", "datalist",
1166];
1223];
1167
1224
1168const KNOWN_DIRECTIVES: &[&str] = &[
1225const KNOWN_DIRECTIVES: &[&str] = &[
1169 "page", "let", "define", "fn", "include", "import", "raw", "keyframes",
1226 "page", "let", "define", "fn", "include", "import", "raw", "keyframes",
1170 "if", "else", "each", "meta", "head", "style",
1227 "if", "else", "each", "meta", "head", "style",
1171 "match", "case", "default", "warn", "debug",
1228 "match", "case", "default", "warn", "debug",
1229+ "unless", "og", "breakpoint", "lang", "favicon",
1172];
1230];
1173
1231
1174fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
1232fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
@@ -1218,6 +1276,20 @@ fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseErro
1218 "progress" => Ok(ElementKind::Progress),
1276 "progress" => Ok(ElementKind::Progress),
1219 "meter" => Ok(ElementKind::Meter),
1277 "meter" => Ok(ElementKind::Meter),
1220 "fragment" => Ok(ElementKind::Fragment),
1278 "fragment" => Ok(ElementKind::Fragment),
1279+ "dialog" => Ok(ElementKind::Dialog),
1280+ "dl" => Ok(ElementKind::DefinitionList),
1281+ "dt" => Ok(ElementKind::DefinitionTerm),
1282+ "dd" => Ok(ElementKind::DefinitionDescription),
1283+ "fieldset" => Ok(ElementKind::Fieldset),
1284+ "legend" => Ok(ElementKind::Legend),
1285+ "picture" => Ok(ElementKind::Picture),
1286+ "source" => Ok(ElementKind::Source),
1287+ "time" => Ok(ElementKind::Time),
1288+ "mark" => Ok(ElementKind::Mark),
1289+ "kbd" => Ok(ElementKind::Kbd),
1290+ "abbr" => Ok(ElementKind::Abbr),
1291+ "datalist" => Ok(ElementKind::Datalist),
1292+ "ul" => Ok(ElementKind::List),
1221 _ => {
1293 _ => {
1222 let all_known: Vec<&str> = KNOWN_ELEMENTS
1294 let all_known: Vec<&str> = KNOWN_ELEMENTS
1223 .iter()
1295 .iter()
@@ -1357,6 +1429,11 @@ const KNOWN_ATTRS: &[&str] = &[
1357 "scroll-behavior",
1429 "scroll-behavior",
1358 // Resize
1430 // Resize
1359 "resize",
1431 "resize",
1432+ // New CSS properties
1433+ "clip-path", "mix-blend-mode", "background-blend-mode", "writing-mode",
1434+ "column-count", "column-gap", "text-indent", "hyphens",
1435+ "flex-grow", "flex-shrink", "flex-basis", "isolation",
1436+ "place-content", "background-image", "datetime",
1360];
1437];
1361
1438
1362/// Attributes that expect purely numeric values (px-based) or values with CSS units.
1439/// Attributes that expect purely numeric values (px-based) or values with CSS units.
@@ -1760,6 +1837,31 @@ fn evaluate_condition(condition: &str) -> bool {
1760 }
1837 }
1761}
1838}
1762
1839
1840+fn evaluate_arithmetic(input: &str) -> String {
1841+ let input = input.trim();
1842+ for op in &[" * ", " / ", " + ", " - "] {
1843+ if let Some((left, right)) = input.split_once(op) {
1844+ let left = left.trim();
1845+ let right = right.trim();
1846+ let op_char = op.trim().chars().next().unwrap();
1847+ if let (Ok(l), Ok(r)) = (left.parse::<f64>(), right.parse::<f64>()) {
1848+ let result = match op_char {
1849+ '+' => l + r,
1850+ '-' => l - r,
1851+ '*' => l * r,
1852+ '/' => if r != 0.0 { l / r } else { return input.to_string() },
1853+ _ => return input.to_string(),
1854+ };
1855+ if result == result.floor() && result.abs() < i64::MAX as f64 {
1856+ return format!("{}", result as i64);
1857+ }
1858+ return format!("{}", result);
1859+ }
1860+ }
1861+ }
1862+ input.to_string()
1863+}
1864+
1763// ---------------------------------------------------------------------------
1865// ---------------------------------------------------------------------------
1764// Post-parse validation (context-dependent warnings)
1866// Post-parse validation (context-dependent warnings)
1765// ---------------------------------------------------------------------------
1867// ---------------------------------------------------------------------------
@@ -1785,11 +1887,18 @@ fn strip_all_prefixes(key: &str) -> &str {
1785 .or_else(|| key.strip_prefix("md:"))
1887 .or_else(|| key.strip_prefix("md:"))
1786 .or_else(|| key.strip_prefix("lg:"))
1888 .or_else(|| key.strip_prefix("lg:"))
1787 .or_else(|| key.strip_prefix("xl:"))
1889 .or_else(|| key.strip_prefix("xl:"))
1890+ .or_else(|| key.strip_prefix("2xl:"))
1788 .unwrap_or(key);
1891 .unwrap_or(key);
1789 // Strip media prefixes
1892 // Strip media prefixes
1790− key.strip_prefix("dark:")
1893+ let key = key.strip_prefix("dark:")
1791 .or_else(|| key.strip_prefix("print:"))
1894 .or_else(|| key.strip_prefix("print:"))
1792− .unwrap_or(key)
1895+ .unwrap_or(key);
1896+ let key = key.strip_prefix("motion-safe:")
1897+ .or_else(|| key.strip_prefix("motion-reduce:"))
1898+ .or_else(|| key.strip_prefix("landscape:"))
1899+ .or_else(|| key.strip_prefix("portrait:"))
1900+ .unwrap_or(key);
1901+ key
1793}
1902}
1794
1903
1795/// Attributes that only make sense on container elements (@row, @column, @el).
1904/// Attributes that only make sense on container elements (@row, @column, @el).
@@ -1846,6 +1955,19 @@ fn element_kind_name(kind: &ElementKind) -> &'static str {
1846 ElementKind::Progress => "@progress",
1955 ElementKind::Progress => "@progress",
1847 ElementKind::Meter => "@meter",
1956 ElementKind::Meter => "@meter",
1848 ElementKind::Fragment => "@fragment",
1957 ElementKind::Fragment => "@fragment",
1958+ ElementKind::Dialog => "@dialog",
1959+ ElementKind::DefinitionList => "@dl",
1960+ ElementKind::DefinitionTerm => "@dt",
1961+ ElementKind::DefinitionDescription => "@dd",
1962+ ElementKind::Fieldset => "@fieldset",
1963+ ElementKind::Legend => "@legend",
1964+ ElementKind::Picture => "@picture",
1965+ ElementKind::Source => "@source",
1966+ ElementKind::Time => "@time",
1967+ ElementKind::Mark => "@mark",
1968+ ElementKind::Kbd => "@kbd",
1969+ ElementKind::Abbr => "@abbr",
1970+ ElementKind::Datalist => "@datalist",
1849 }
1971 }
1850}
1972}
1851
1973
@@ -1857,6 +1979,8 @@ fn is_container(kind: &ElementKind) -> bool {
1857 | ElementKind::Aside | ElementKind::List | ElementKind::ListItem
1979 | ElementKind::Aside | ElementKind::List | ElementKind::ListItem
1858 | ElementKind::Form | ElementKind::Details | ElementKind::Figure
1980 | ElementKind::Form | ElementKind::Details | ElementKind::Figure
1859 | ElementKind::Blockquote
1981 | ElementKind::Blockquote
1982+ | ElementKind::Dialog | ElementKind::DefinitionList | ElementKind::DefinitionTerm
1983+ | ElementKind::DefinitionDescription | ElementKind::Fieldset | ElementKind::Datalist
1860 )
1984 )
1861}
1985}
1862
1986
@@ -1988,6 +2112,28 @@ fn validate_tree(
1988 });
2112 });
1989 }
2113 }
1990 }
2114 }
2115+ if matches!(elem.kind, ElementKind::Input) {
2116+ if !elem.attrs.iter().any(|a| a.key == "type") {
2117+ diagnostics.push(Diagnostic {
2118+ line: elem.line_num,
2119+ message: "@input missing 'type' attribute (defaults to 'text')".to_string(),
2120+ severity: Severity::Warning,
2121+ source_line: None,
2122+ });
2123+ }
2124+ }
2125+ if matches!(elem.kind, ElementKind::Link) {
2126+ // For @link, argument is the URL, not text content
2127+ let has_text = !elem.children.is_empty();
2128+ if !has_text && !elem.attrs.iter().any(|a| a.key == "aria-label" || a.key == "title") {
2129+ diagnostics.push(Diagnostic {
2130+ line: elem.line_num,
2131+ message: "@link has no visible text or aria-label (accessibility)".to_string(),
2132+ severity: Severity::Warning,
2133+ source_line: None,
2134+ });
2135+ }
2136+ }
1991
2137
1992 validate_tree(&elem.children, Some(&elem.kind), diagnostics);
2138 validate_tree(&elem.children, Some(&elem.kind), diagnostics);
1993 }
2139 }
tests/snapshots.rs+383
@@ -1510,3 +1510,386 @@ fn no_warning_new_css_attrs() {
1510 diags
1510 diags
1511 );
1511 );
1512}
1512}
1513+
1514+// =========================================================================
1515+// New elements snapshot
1516+// =========================================================================
1517+
1518+#[test]
1519+fn snapshot_new_elements_2() {
1520+ snapshot_test("new_elements_2");
1521+}
1522+
1523+// =========================================================================
1524+// New CSS properties snapshot
1525+// =========================================================================
1526+
1527+#[test]
1528+fn snapshot_new_css_properties_2() {
1529+ snapshot_test("new_css_properties_2");
1530+}
1531+
1532+// =========================================================================
1533+// Extended media queries snapshot
1534+// =========================================================================
1535+
1536+#[test]
1537+fn snapshot_media_extended() {
1538+ snapshot_test("media_extended");
1539+}
1540+
1541+// =========================================================================
1542+// @unless directive snapshot
1543+// =========================================================================
1544+
1545+#[test]
1546+fn snapshot_unless_directive() {
1547+ snapshot_test("unless_directive");
1548+}
1549+
1550+// =========================================================================
1551+// @og directive snapshot
1552+// =========================================================================
1553+
1554+#[test]
1555+fn snapshot_og_directive() {
1556+ snapshot_test("og_directive");
1557+}
1558+
1559+// =========================================================================
1560+// Arithmetic in @let snapshot
1561+// =========================================================================
1562+
1563+#[test]
1564+fn snapshot_arithmetic() {
1565+ snapshot_test("arithmetic");
1566+}
1567+
1568+// =========================================================================
1569+// New element assertion tests
1570+// =========================================================================
1571+
1572+#[test]
1573+fn element_dialog() {
1574+ let output = compile("@page T\n@dialog [id modal, open]\n @text Hello");
1575+ assert!(output.contains("<dialog"));
1576+ assert!(output.contains("id=\"modal\""));
1577+ assert!(output.contains("open"));
1578+ assert!(output.contains("</dialog>"));
1579+}
1580+
1581+#[test]
1582+fn element_definition_list() {
1583+ let output = compile("@page T\n@dl\n @dt Term\n @dd Definition");
1584+ assert!(output.contains("<dl"));
1585+ assert!(output.contains("<dt>Term</dt>"));
1586+ assert!(output.contains("<dd"));
1587+ assert!(output.contains(">Definition</dd>"));
1588+}
1589+
1590+#[test]
1591+fn element_fieldset_legend() {
1592+ let output = compile("@page T\n@fieldset\n @legend Info\n @input [type text, name n]");
1593+ assert!(output.contains("<fieldset"));
1594+ assert!(output.contains("<legend>Info</legend>"));
1595+ assert!(output.contains("</fieldset>"));
1596+}
1597+
1598+#[test]
1599+fn element_picture_source() {
1600+ let output = compile("@page T\n@picture\n @source [srcset wide.jpg, media (min-width: 800px)]\n @image [alt Photo] photo.jpg");
1601+ assert!(output.contains("<picture"));
1602+ assert!(output.contains("<source"));
1603+ assert!(output.contains("srcset=\"wide.jpg\""));
1604+ assert!(output.contains("</picture>"));
1605+}
1606+
1607+#[test]
1608+fn element_time() {
1609+ let output = compile("@page T\n@time [datetime 2026-04-15] April 15");
1610+ assert!(output.contains("<time"));
1611+ assert!(output.contains("datetime=\"2026-04-15\""));
1612+ assert!(output.contains("April 15"));
1613+ assert!(output.contains("</time>"));
1614+}
1615+
1616+#[test]
1617+fn element_mark() {
1618+ let output = compile("@page T\n@mark highlighted");
1619+ assert!(output.contains("<mark>highlighted</mark>"));
1620+}
1621+
1622+#[test]
1623+fn element_kbd() {
1624+ let output = compile("@page T\n@kbd Ctrl+C");
1625+ assert!(output.contains("<kbd"));
1626+ assert!(output.contains("Ctrl+C</kbd>"));
1627+}
1628+
1629+#[test]
1630+fn element_abbr() {
1631+ let output = compile("@page T\n@abbr [title HyperText Markup Language] HTML");
1632+ assert!(output.contains("<abbr"));
1633+ assert!(output.contains("title=\"HyperText Markup Language\""));
1634+ assert!(output.contains("HTML"));
1635+}
1636+
1637+#[test]
1638+fn element_datalist() {
1639+ let output = compile("@page T\n@datalist [id browsers]\n @option Chrome\n @option Firefox");
1640+ assert!(output.contains("<datalist"));
1641+ assert!(output.contains("id=\"browsers\""));
1642+ assert!(output.contains("</datalist>"));
1643+}
1644+
1645+// =========================================================================
1646+// New CSS property assertion tests
1647+// =========================================================================
1648+
1649+#[test]
1650+fn css_clip_path() {
1651+ let output = compile("@page T\n@el [clip-path circle(50%)]");
1652+ assert!(output.contains("clip-path:circle(50%)"));
1653+}
1654+
1655+#[test]
1656+fn css_mix_blend_mode() {
1657+ let output = compile("@page T\n@el [mix-blend-mode multiply]");
1658+ assert!(output.contains("mix-blend-mode:multiply"));
1659+}
1660+
1661+#[test]
1662+fn css_background_blend_mode() {
1663+ let output = compile("@page T\n@el [background-blend-mode overlay]");
1664+ assert!(output.contains("background-blend-mode:overlay"));
1665+}
1666+
1667+#[test]
1668+fn css_writing_mode() {
1669+ let output = compile("@page T\n@el [writing-mode vertical-rl]");
1670+ assert!(output.contains("writing-mode:vertical-rl"));
1671+}
1672+
1673+#[test]
1674+fn css_column_count() {
1675+ let output = compile("@page T\n@column [column-count 3]");
1676+ assert!(output.contains("column-count:3"));
1677+}
1678+
1679+#[test]
1680+fn css_column_gap() {
1681+ let output = compile("@page T\n@column [column-gap 20]");
1682+ assert!(output.contains("column-gap:20px"));
1683+}
1684+
1685+#[test]
1686+fn css_text_indent() {
1687+ let output = compile("@page T\n@paragraph [text-indent 2em]");
1688+ assert!(output.contains("text-indent:2em"));
1689+}
1690+
1691+#[test]
1692+fn css_hyphens() {
1693+ let output = compile("@page T\n@paragraph [hyphens auto]");
1694+ assert!(output.contains("hyphens:auto"));
1695+}
1696+
1697+#[test]
1698+fn css_flex_grow() {
1699+ let output = compile("@page T\n@el [flex-grow 2]");
1700+ assert!(output.contains("flex-grow:2"));
1701+}
1702+
1703+#[test]
1704+fn css_flex_shrink() {
1705+ let output = compile("@page T\n@el [flex-shrink 0]");
1706+ assert!(output.contains("flex-shrink:0"));
1707+}
1708+
1709+#[test]
1710+fn css_flex_basis() {
1711+ let output = compile("@page T\n@el [flex-basis 200]");
1712+ assert!(output.contains("flex-basis:200px"));
1713+}
1714+
1715+#[test]
1716+fn css_isolation() {
1717+ let output = compile("@page T\n@el [isolation isolate]");
1718+ assert!(output.contains("isolation:isolate"));
1719+}
1720+
1721+#[test]
1722+fn css_place_content() {
1723+ let output = compile("@page T\n@el [grid, place-content center]");
1724+ assert!(output.contains("place-content:center"));
1725+}
1726+
1727+#[test]
1728+fn css_background_image() {
1729+ let output = compile("@page T\n@el [background-image linear-gradient(red, blue)]");
1730+ assert!(output.contains("background-image:linear-gradient(red, blue)"));
1731+}
1732+
1733+// =========================================================================
1734+// Media prefix assertion tests
1735+// =========================================================================
1736+
1737+#[test]
1738+fn media_2xl_breakpoint() {
1739+ let output = compile("@page T\n@el [2xl:padding 40]");
1740+ assert!(output.contains("@media(min-width:1536px)"));
1741+ assert!(output.contains("padding:40px"));
1742+}
1743+
1744+#[test]
1745+fn media_motion_reduce() {
1746+ let output = compile("@page T\n@el [motion-reduce:transition none]");
1747+ assert!(output.contains("@media(prefers-reduced-motion:reduce)"));
1748+ assert!(output.contains("transition:none"));
1749+}
1750+
1751+#[test]
1752+fn media_motion_safe() {
1753+ let output = compile("@page T\n@el [motion-safe:animation spin 1s]");
1754+ assert!(output.contains("@media(prefers-reduced-motion:no-preference)"));
1755+ assert!(output.contains("animation:spin 1s"));
1756+}
1757+
1758+#[test]
1759+fn media_landscape() {
1760+ let output = compile("@page T\n@el [landscape:padding 10]");
1761+ assert!(output.contains("@media(orientation:landscape)"));
1762+ assert!(output.contains("padding:10px"));
1763+}
1764+
1765+#[test]
1766+fn media_portrait() {
1767+ let output = compile("@page T\n@el [portrait:padding 40]");
1768+ assert!(output.contains("@media(orientation:portrait)"));
1769+ assert!(output.contains("padding:40px"));
1770+}
1771+
1772+// =========================================================================
1773+// @unless assertion tests
1774+// =========================================================================
1775+
1776+#[test]
1777+fn unless_false_shows_content() {
1778+ let output = compile("@page T\n@let show false\n@unless $show\n @text Visible");
1779+ assert!(output.contains("Visible"));
1780+}
1781+
1782+#[test]
1783+fn unless_true_hides_content() {
1784+ let output = compile("@page T\n@let show true\n@unless $show\n @text Hidden");
1785+ assert!(!output.contains("Hidden"));
1786+}
1787+
1788+// =========================================================================
1789+// @og assertion tests
1790+// =========================================================================
1791+
1792+#[test]
1793+fn og_tags_in_output() {
1794+ let output = compile("@page T\n@og title \"My Page\"\n@og image \"https://example.com/img.png\"\n@text Hello");
1795+ assert!(output.contains("og:title"));
1796+ assert!(output.contains("My Page"));
1797+ assert!(output.contains("og:image"));
1798+ assert!(output.contains("https://example.com/img.png"));
1799+}
1800+
1801+// =========================================================================
1802+// Arithmetic assertion tests
1803+// =========================================================================
1804+
1805+#[test]
1806+fn let_arithmetic_multiply() {
1807+ let output = compile("@page T\n@let x 10\n@let y $x * 2\n@el [width $y]\n @text test");
1808+ assert!(output.contains("width:20px"));
1809+}
1810+
1811+#[test]
1812+fn let_arithmetic_add() {
1813+ let output = compile("@page T\n@let a 10\n@let b $a + 5\n@el [padding $b]\n @text test");
1814+ assert!(output.contains("padding:15px"));
1815+}
1816+
1817+#[test]
1818+fn let_arithmetic_divide() {
1819+ let output = compile("@page T\n@let x 200 / 4\n@el [height $x]\n @text test");
1820+ assert!(output.contains("height:50px"));
1821+}
1822+
1823+// =========================================================================
1824+// New diagnostics assertion tests
1825+// =========================================================================
1826+
1827+#[test]
1828+fn warning_missing_input_type() {
1829+ let diags = parse_diagnostics("@input [name email]");
1830+ assert!(
1831+ diags.iter().any(|d| d.message.contains("missing 'type'")),
1832+ "should warn about missing type on @input, got: {:?}",
1833+ diags
1834+ );
1835+}
1836+
1837+#[test]
1838+fn warning_link_without_text() {
1839+ let diags = parse_diagnostics("@link https://example.com");
1840+ assert!(
1841+ diags.iter().any(|d| d.message.contains("no visible text")),
1842+ "should warn about @link without visible text, got: {:?}",
1843+ diags
1844+ );
1845+}
1846+
1847+#[test]
1848+fn no_warning_link_with_text() {
1849+ let diags = parse_diagnostics("@link https://example.com Click here");
1850+ assert!(
1851+ !diags.iter().any(|d| d.message.contains("no visible text")),
1852+ "should not warn when @link has text, got: {:?}",
1853+ diags
1854+ );
1855+}
1856+
1857+// =========================================================================
1858+// No warnings for new CSS attributes
1859+// =========================================================================
1860+
1861+#[test]
1862+fn no_warning_new_css_properties_2() {
1863+ let diags = parse_diagnostics(
1864+ "@el [clip-path circle(50%), mix-blend-mode multiply, writing-mode vertical-rl, isolation isolate]"
1865+ );
1866+ assert!(
1867+ !diags.iter().any(|d| d.message.contains("unknown attribute")),
1868+ "new CSS properties should be recognized, got: {:?}",
1869+ diags
1870+ );
1871+}
1872+
1873+#[test]
1874+fn no_warning_new_css_properties_3() {
1875+ let diags = parse_diagnostics(
1876+ "@el [column-count 3, column-gap 20, text-indent 2em, hyphens auto, flex-grow 1, flex-shrink 0, flex-basis 200, place-content center, background-image url(x)]"
1877+ );
1878+ assert!(
1879+ !diags.iter().any(|d| d.message.contains("unknown attribute")),
1880+ "new CSS properties should be recognized, got: {:?}",
1881+ diags
1882+ );
1883+}
1884+
1885+#[test]
1886+fn no_warning_new_media_prefixes() {
1887+ let diags = parse_diagnostics(
1888+ "@el [2xl:padding 40, motion-safe:animation none, motion-reduce:transition none, landscape:width 100%, portrait:padding 20]"
1889+ );
1890+ assert!(
1891+ !diags.iter().any(|d| d.message.contains("unknown attribute")),
1892+ "new media prefixes should be recognized, got: {:?}",
1893+ diags
1894+ );
1895+}
tests/snapshots/arithmetic.hlnew file+19
@@ -0,0 +1,19 @@
1+@page Arithmetic
2+
3+@let base 10
4+@let double $base * 2
5+@let sum $base + 5
6+@let diff 100 - 30
7+@let half 200 / 4
8+
9+@el [width $double]
10+ @text double is $double
11+
12+@el [padding $sum]
13+ @text sum is $sum
14+
15+@el [margin $diff]
16+ @text diff is $diff
17+
18+@el [height $half]
19+ @text half is $half
tests/snapshots/media_extended.hlnew file+13
@@ -0,0 +1,13 @@
1+@page Media Extended
2+
3+@el [width 100%, 2xl:width 1536, 2xl:padding 40]
4+ @text Wide screen layout
5+
6+@el [transition all 0.3s ease, motion-reduce:transition none]
7+ @text Respects motion preferences
8+
9+@el [animation spin 1s linear infinite, motion-safe:animation spin 1s linear infinite, motion-reduce:animation none]
10+ @text Animation with motion safety
11+
12+@el [padding 20, landscape:padding 10, portrait:padding 40]
13+ @text Orientation-aware padding
tests/snapshots/new_css_properties_2.hlnew file+36
@@ -0,0 +1,36 @@
1+@page New CSS Properties 2
2+
3+@el [clip-path polygon(50% 0%, 0% 100%, 100% 100%)]
4+ @text Triangle
5+
6+@el [mix-blend-mode multiply, background red]
7+ @text Blended
8+
9+@el [background-blend-mode overlay, background-image linear-gradient(red, blue)]
10+ @text Background blend
11+
12+@el [writing-mode vertical-rl]
13+ @text Vertical text
14+
15+@column [column-count 3, column-gap 20]
16+ @text Multi-column layout content here
17+
18+@paragraph [text-indent 2em]
19+ Indented first line of the paragraph.
20+
21+@paragraph [hyphens auto]
22+ A very long word that might need hyphenation in narrow containers.
23+
24+@row
25+ @el [flex-grow 1] Grow
26+ @el [flex-shrink 0] No shrink
27+ @el [flex-basis 200] Fixed basis
28+
29+@el [isolation isolate]
30+ @text Isolated stacking
31+
32+@el [grid, place-content center]
33+ @text Centered grid content
34+
35+@el [background-image url(bg.png), background-size cover]
36+ @text With background image
tests/snapshots/new_elements_2.hlnew file+35
@@ -0,0 +1,35 @@
1+@page New Elements 2
2+
3+@dialog [id modal, open]
4+ @text [bold] Dialog Title
5+ @text Dialog content here
6+ @button Close
7+
8+@dl
9+ @dt Term One
10+ @dd Definition of term one
11+ @dt Term Two
12+ @dd Definition of term two
13+
14+@fieldset
15+ @legend Personal Info
16+ @label [for name] Name
17+ @input [type text, name name]
18+
19+@picture
20+ @source [srcset photo-wide.jpg, media (min-width: 800px)]
21+ @image [alt A photo] photo.jpg
22+
23+@time [datetime 2026-04-15] April 15, 2026
24+
25+@text This is {@mark highlighted} text
26+
27+@text Press {@kbd Ctrl+C} to copy
28+
29+@abbr [title HyperText Markup Language] HTML
30+
31+@input [type text, name browser, list browsers]
32+@datalist [id browsers]
33+ @option Chrome
34+ @option Firefox
35+ @option Safari
tests/snapshots/og_directive.hlnew file+6
@@ -0,0 +1,6 @@
1+@page OG Test
2+@og title "My Page Title"
3+@og description "A description of the page"
4+@og image "https://example.com/image.png"
5+
6+@text Hello
tests/snapshots/unless_directive.hlnew file+11
@@ -0,0 +1,11 @@
1+@page Unless
2+
3+@let show false
4+
5+@unless $show
6+ @text This shows because show is false
7+
8+@let visible true
9+
10+@unless $visible
11+ @text This should not appear