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
commitdb89732e1d3441fef19658e5e276a1a148d7aea8 authoriko <mail@iko.soy> dateWed 15 Apr 2026, 13:17 (6 months ago) parent955bf47
13 files changed+1440−32
src/ast.rs+15 src/bin/htmlang-lsp.rs+291−2 src/codegen.rs+231−10 src/fmt.rs+92−6 src/main.rs+160−12 src/parser.rs+148−2 tests/snapshots.rs+383 tests/snapshots/arithmetic.hl+19 tests/snapshots/media_extended.hl+13 tests/snapshots/new_css_properties_2.hl+36 tests/snapshots/new_elements_2.hl+35 tests/snapshots/og_directive.hl+6 tests/snapshots/unless_directive.hl+11
src/ast.rs+15
@@ -13,6 +13,8 @@ pub struct Document {
1313 pub keyframes: Vec<(String, String)>,
1414 pub css_vars: Vec<(String, String)>,
1515 pub custom_css: Vec<String>,
16+ pub og_tags: Vec<(String, String)>,
17+ pub custom_breakpoints: Vec<(String, String)>,
1618 pub nodes: Vec<Node>,
1719}
1820
@@ -92,6 +94,19 @@ pub enum ElementKind {
9294 Progress,
9395 Meter,
9496 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,
95110}
96111
97112#[derive(Debug, Clone)]
src/bin/htmlang-lsp.rs+291−2
@@ -88,6 +88,11 @@ impl LanguageServer for Backend {
8888 ..Default::default()
8989 }),
9090 ),
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+ ),
9196 ..Default::default()
9297 },
9398 ..Default::default()
@@ -311,6 +316,63 @@ impl LanguageServer for Backend {
311316 data: tokens,
312317 })))
313318 }
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+ }
314376}
315377
316378// ---------------------------------------------------------------------------
@@ -345,7 +407,7 @@ fn completions(text: &str, position: Position) -> Vec<CompletionItem> {
345407 // State prefix (hover:, active:, focus:) or media prefix (dark:, print:)
346408 if let Some(colon) = current_word.find(':') {
347409 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") {
349411 return state_attr_completions(prefix, edit_range);
350412 }
351413 }
@@ -482,6 +544,25 @@ fn element_completions(range: Range) -> Vec<CompletionItem> {
482544 ("@progress", "Progress bar (value, max attributes)"),
483545 ("@meter", "Meter/gauge element (value, min, max)"),
484546 ("@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)"),
485566 ]
486567 .iter()
487568 .map(|(name, detail)| item(name, CompletionItemKind::KEYWORD, detail, name, range))
@@ -512,6 +593,9 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
512593 ("@debug", "Print debug message during compilation", "@debug "),
513594 ("@lang", "Set document language (html lang attribute)", "@lang "),
514595 ("@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 "),
515599 ]
516600 .iter()
517601 .map(|(name, detail, insert)| {
@@ -728,9 +812,39 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
728812 ("md:", "Style at 768px+ (medium)", false),
729813 ("lg:", "Style at 1024px+ (large)", false),
730814 ("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),
731823 // Media prefixes
732824 ("dark:", "Style in dark color scheme", false),
733825 ("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),
734848 ]
735849 .iter()
736850 .map(|(name, detail, takes_value)| {
@@ -997,6 +1111,24 @@ fn hover_builtin(word: &str) -> Option<String> {
9971111 (Some("odd"), rest)
9981112 } else if let Some(rest) = word.strip_prefix("even:") {
9991113 (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)
10001132 } else {
10011133 (None, word)
10021134 };
@@ -1066,6 +1198,20 @@ fn hover_builtin(word: &str) -> Option<String> {
10661198 "@progress" => "**@progress** \u{2014} Progress bar\n\nRenders as `<progress>`.\n\nUsage: `@progress [value 70, max 100]`",
10671199 "@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]`",
10681200 "@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]`",
10691215 // Directives
10701216 "@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```",
10711217 "@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> {
10741220 "@debug" => "**@debug** \u{2014} Debug message\n\nPrint a debug message to stderr during compilation.\n\nUsage: `@debug Theme is $theme`",
10751221 "@lang" => "**@lang** \u{2014} Document language\n\nSets the `lang` attribute on the `<html>` element.\n\nUsage: `@lang en`",
10761222 "@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`",
10771226 // Attributes
10781227 "spacing" | "gap" => "**spacing** `<value>`\n\nGap between children. Supports CSS units (px, rem, em, %).\nMaps to CSS `gap`.",
10791228 "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> {
12211370 "place-self" => "**place-self** `<value>` \u{2014} Shorthand for `align-self` and `justify-self`.",
12221371 "scroll-behavior" => "**scroll-behavior** `<value>` \u{2014} Scroll behavior (`smooth`, `auto`).",
12231372 "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).",
12241388 _ => return None,
12251389 };
12261390
@@ -1887,7 +2051,8 @@ fn semantic_tokens(text: &str) -> Vec<SemanticToken> {
18872051 "@page" | "@let" | "@define" | "@fn" | "@if" | "@else" | "@each"
18882052 | "@include" | "@import" | "@meta" | "@head" | "@style" | "@keyframes"
18892053 | "@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
18912056 _ => {
18922057 // Check if it's a user function call (starts with @ but not a builtin element)
18932058 if is_builtin_element(word) { 0 } else { 2 } // function
@@ -1921,6 +2086,8 @@ fn is_builtin_element(word: &str) -> bool {
19212086 | "@video" | "@audio" | "@form" | "@details" | "@summary"
19222087 | "@blockquote" | "@cite" | "@code" | "@pre" | "@hr" | "@divider"
19232088 | "@figure" | "@figcaption" | "@progress" | "@meter" | "@fragment"
2089+ | "@dialog" | "@dl" | "@dt" | "@dd" | "@fieldset" | "@legend"
2090+ | "@picture" | "@source" | "@time" | "@mark" | "@kbd" | "@abbr" | "@datalist"
19242091 )
19252092}
19262093
@@ -1950,6 +2117,128 @@ fn push_token(
19502117 *prev_start = start;
19512118}
19522119
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+
19532242// ---------------------------------------------------------------------------
19542243// Entry point
19552244// ---------------------------------------------------------------------------
src/codegen.rs+231−10
@@ -12,6 +12,7 @@ const BREAKPOINTS: &[(&str, &str)] = &[
1212 ("md", "768px"),
1313 ("lg", "1024px"),
1414 ("xl", "1280px"),
15+ ("2xl", "1536px"),
1516];
1617
1718struct StyleEntry {
@@ -25,6 +26,10 @@ struct StyleEntry {
2526 dark: String,
2627 /// Print overrides
2728 print: String,
29+ motion_safe: String,
30+ motion_reduce: String,
31+ landscape: String,
32+ portrait: String,
2833}
2934
3035struct StyleCollector {
@@ -48,12 +53,20 @@ impl StyleCollector {
4853 responsive: Vec<(String, String)>,
4954 dark: String,
5055 print: String,
56+ motion_safe: String,
57+ motion_reduce: String,
58+ landscape: String,
59+ portrait: String,
5160 ) -> Option<String> {
5261 if base.is_empty()
5362 && pseudo.is_empty()
5463 && responsive.is_empty()
5564 && dark.is_empty()
5665 && print.is_empty()
66+ && motion_safe.is_empty()
67+ && motion_reduce.is_empty()
68+ && landscape.is_empty()
69+ && portrait.is_empty()
5770 {
5871 return None;
5972 }
@@ -67,7 +80,7 @@ impl StyleCollector {
6780 .map(|(bp, css)| format!("{}={}", bp, css))
6881 .collect::<Vec<_>>()
6982 .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);
7184 if let Some(&idx) = self.index.get(&key) {
7285 return Some(self.entries[idx].class_name.clone());
7386 }
@@ -80,6 +93,10 @@ impl StyleCollector {
8093 responsive,
8194 dark,
8295 print,
96+ motion_safe,
97+ motion_reduce,
98+ landscape,
99+ portrait,
83100 });
84101 self.index.insert(key, idx);
85102 Some(name)
@@ -168,6 +185,82 @@ impl StyleCollector {
168185 }
169186 }
170187
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+
171264 css
172265 }
173266}
@@ -284,6 +377,29 @@ fn generate_with_options(doc: &Document, dev: bool) -> String {
284377 m
285378 };
286379
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+
287403 // Build head blocks string
288404 let head_html = if doc.head_blocks.is_empty() {
289405 String::new()
@@ -345,7 +461,7 @@ fn generate_with_options(doc: &Document, dev: bool) -> String {
345461<meta charset=\"utf-8\">
346462<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
347463<title>{title}</title>
348−{meta_html}{favicon_html}{head_html}\
464+{meta_html}{og_html}{favicon_html}{head_html}\
349465<style>
350466*, *::before, *::after {{ box-sizing: border-box; }}
351467body {{ margin: 0; font-family: system-ui, -apple-system, sans-serif; }}
@@ -363,15 +479,17 @@ img {{ display: block; }}
363479 meta_html = meta_html,
364480 favicon_html = favicon_html,
365481 head_html = head_html,
482+ og_html = og_html,
366483 element_css = element_css,
367484 body = body,
368485 )
369486 } else {
370487 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>",
372489 title = html_escape(title),
373490 lang_attr = lang_attr,
374491 meta_html = meta_html,
492+ og_html = og_html,
375493 favicon_html = favicon_html,
376494 head_html = head_html,
377495 element_css = element_css,
@@ -415,6 +533,9 @@ fn generate_node(
415533 | Some(ElementKind::Aside) | Some(ElementKind::ListItem)
416534 | Some(ElementKind::Form) | Some(ElementKind::Details) | Some(ElementKind::Figure)
417535 | Some(ElementKind::Blockquote)
536+ | Some(ElementKind::Dialog) | Some(ElementKind::DefinitionList)
537+ | Some(ElementKind::DefinitionDescription) | Some(ElementKind::Fieldset)
538+ | Some(ElementKind::Datalist)
418539 );
419540 if needs_wrap {
420541 out.push_str(&ctx.indent());
@@ -446,6 +567,8 @@ const HTML_PASSTHROUGH_ATTRS: &[&str] = &[
446567 "loading", "decoding",
447568 // Media src (explicit attribute form)
448569 "src",
570+ // New element attributes
571+ "datetime", "media", "sizes", "srcset", "list",
449572 // Details
450573 "open",
451574 // Form
@@ -493,7 +616,7 @@ fn generate_element(
493616 ctx: &mut GenContext,
494617) {
495618 // 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) {
497620 generate_self_closing(elem, parent_kind, out, styles, ctx);
498621 return;
499622 }
@@ -556,8 +679,22 @@ fn generate_element(
556679 ElementKind::FigCaption => "figcaption",
557680 ElementKind::Progress => "progress",
558681 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",
559695 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!(),
561698 };
562699
563700 let kind_label = match elem.kind {
@@ -599,6 +736,18 @@ fn generate_element(
599736 ElementKind::Figure => "figure",
600737 ElementKind::FigCaption => "figcaption",
601738 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",
602751 _ => "",
603752 };
604753
@@ -640,7 +789,16 @@ fn generate_element(
640789 }
641790 }
642791
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+ }
644802
645803 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
646804
@@ -670,6 +828,13 @@ fn generate_element(
670828 | ElementKind::Cite
671829 | ElementKind::Code
672830 | ElementKind::FigCaption
831+ | ElementKind::Legend
832+ | ElementKind::DefinitionTerm
833+ | ElementKind::Mark
834+ | ElementKind::Kbd
835+ | ElementKind::Abbr
836+ | ElementKind::Time
837+ | ElementKind::DefinitionDescription
673838 ) {
674839 if let Some(text) = &elem.argument {
675840 out.push_str(&html_escape(text));
@@ -708,6 +873,7 @@ fn generate_self_closing(
708873 ElementKind::Image => ("img", "image"),
709874 ElementKind::Input => ("input", "input"),
710875 ElementKind::HorizontalRule => ("hr", "hr"),
876+ ElementKind::Source => ("source", "source"),
711877 _ => unreachable!(),
712878 };
713879
@@ -817,8 +983,12 @@ fn compute_class(
817983
818984 let dark = attrs_to_css(attrs, "dark:", kind, parent_kind);
819985 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);
820990
821− styles.get_class(base, pseudo, responsive, dark, print)
991+ styles.get_class(base, pseudo, responsive, dark, print, motion_safe, motion_reduce, landscape, portrait)
822992}
823993
824994fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option<&str>) {
@@ -863,8 +1033,8 @@ const PSEUDO_PREFIXES: &[(&str, &str)] = &[
8631033 ("odd:", ":nth-child(odd)"),
8641034 ("even:", ":nth-child(even)"),
8651035];
866−const RESPONSIVE_PREFIXES: &[&str] = &["sm:", "md:", "lg:", "xl:"];
867−const MEDIA_PREFIXES: &[&str] = &["dark:", "print:"];
1036+const RESPONSIVE_PREFIXES: &[&str] = &["sm:", "md:", "lg:", "xl:", "2xl:"];
1037+const MEDIA_PREFIXES: &[&str] = &["dark:", "print:", "motion-safe:", "motion-reduce:", "landscape:", "portrait:"];
8681038
8691039fn is_prefixed_attr(key: &str) -> bool {
8701040 PSEUDO_PREFIXES.iter().any(|&(p, _)| key.starts_with(p))
@@ -906,6 +1076,12 @@ fn attrs_to_css(
9061076 ElementKind::Pre => css.push_str("margin:0;white-space:pre;font-family:ui-monospace,monospace;"),
9071077 // Code: monospace font
9081078 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;"),
9091085 _ => {}
9101086 }
9111087 }
@@ -1557,6 +1733,50 @@ fn attrs_to_css(
15571733 }
15581734 }
15591735
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+
15601780 // Identity and HTML passthrough — not CSS
15611781 "id" | "class" => {}
15621782 "type" | "placeholder" | "name" | "value" | "disabled" | "required"
@@ -1566,7 +1786,8 @@ fn attrs_to_css(
15661786 | "controls" | "autoplay" | "loop" | "muted" | "poster" | "preload"
15671787 | "loading" | "decoding" | "ordered" | "src"
15681788 | "open" | "novalidate" | "low" | "high" | "optimum"
1569− | "colspan" | "rowspan" | "scope" | "inline" => {}
1789+ | "colspan" | "rowspan" | "scope" | "inline"
1790+ | "datetime" | "media" | "sizes" | "srcset" | "cite" | "list" => {}
15701791
15711792 _ => {}
15721793 }
src/fmt.rs+92−6
@@ -1,3 +1,5 @@
1+const MAX_LINE_WIDTH: usize = 100;
2+
13/// Attribute category for sorting.
24fn attr_category(key: &str) -> u8 {
35 // Strip pseudo-state/responsive/media prefixes for categorization
@@ -20,28 +22,36 @@ fn attr_category(key: &str) -> u8 {
2022 .or_else(|| key.strip_prefix("xl:"))
2123 .or_else(|| key.strip_prefix("dark:"))
2224 .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:"))
2330 .unwrap_or(key);
2431
2532 match base {
2633 // Layout (parent)
2734 "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,
2937 // 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,
3140 // Padding & margin
3241 "padding" | "padding-x" | "padding-y" | "padding-inline" | "padding-block"
3342 | "margin" | "margin-x" | "margin-y" | "margin-inline" | "margin-block" => 2,
3443 // Alignment
3544 "center-x" | "center-y" | "align-left" | "align-right"
3645 | "align-top" | "align-bottom" | "justify-content" | "align-items"
37− | "place-items" | "place-self" => 3,
46+ | "place-items" | "place-self" | "place-content" => 3,
3847 // Positioning
3948 "position" | "top" | "right" | "bottom" | "left" | "z-index" | "order" | "inset"
4049 | "col-span" | "row-span" | "display" | "visibility"
4150 | "overflow" | "overflow-x" | "overflow-y" | "hidden" => 4,
4251 // Visual style
4352 "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,
4555 // Border & shape
4656 "border" | "border-top" | "border-bottom" | "border-left" | "border-right"
4757 | "rounded" | "outline" | "shadow" | "text-shadow"
@@ -51,11 +61,13 @@ fn attr_category(key: &str) -> u8 {
5161 | "text-align" | "line-height" | "letter-spacing" | "text-transform"
5262 | "white-space" | "text-overflow" | "word-break" | "overflow-wrap"
5363 | "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,
5566 // Effects & interaction
5667 "transform" | "transition" | "animation" | "cursor" | "backdrop-filter"
5768 | "filter" | "pointer-events" | "user-select" | "aspect-ratio"
58− | "object-fit" | "object-position" | "resize" => 8,
69+ | "object-fit" | "object-position" | "resize"
70+ | "clip-path" => 8,
5971 // Container queries & scroll
6072 "container" | "container-name" | "container-type"
6173 | "scroll-snap-type" | "scroll-snap-align" | "scroll-behavior" => 9,
@@ -99,6 +111,20 @@ pub fn format(input: &str) -> String {
99111 continue;
100112 }
101113
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+
102128 let open = trimmed.chars().filter(|&c| c == '[').count() as i32;
103129 let close = trimmed.chars().filter(|&c| c == ']').count() as i32;
104130
@@ -142,6 +168,17 @@ pub fn format(input: &str) -> String {
142168
143169 // Single-line: format brackets inline
144170 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+
145182 output.push_str(&" ".repeat(level));
146183 output.push_str(&formatted);
147184 output.push('\n');
@@ -185,3 +222,52 @@ fn format_line_with_brackets(line: &str) -> String {
185222
186223 format!("{}[{}]{}", before, sorted, after)
187224}
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};
55use std::path::{Path, PathBuf};
66use std::process;
77
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>) {
99 let input = match fs::read_to_string(input_path) {
1010 Ok(s) => s,
1111 Err(e) => {
@@ -33,7 +33,10 @@ fn compile(input_path: &str, dev: bool, error_overlay: bool, check_only: bool) -
3333 .iter()
3434 .any(|d| d.severity == htmlang::parser::Severity::Error);
3535
36− let out_path = Path::new(input_path).with_extension("html");
36+ let out_path = match output_path {
37+ Some(p) => PathBuf::from(p),
38+ None => Path::new(input_path).with_extension("html"),
39+ };
3740
3841 if !check_only {
3942 if has_errors {
@@ -100,19 +103,22 @@ htmlang {} - a minimalist layout language that compiles to static HTML
100103
101104Usage: htmlang [options] <file.hl | directory>
102105
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+
103112Options:
104113 -w, --watch Watch for changes and recompile
105114 -s, --serve Start dev server with hot reload (implies --watch)
106115 -p, --port <N> Port for dev server (default: 3000)
116+ -o, --output <path> Output file/directory path
107117 -d, --dev Development mode
108118 -c, --check Check for errors without writing output
109119 -h, --help Show this help
110120 -V, --version Show version
111121
112−Commands:
113− init [dir] Create a new project (defaults to current directory)
114− fmt <file.hl> Format a .hl file (normalizes indentation)
115−
116122Examples:
117123 htmlang init Scaffold a new project
118124 htmlang init my-site Scaffold in a new directory
@@ -123,7 +129,9 @@ Examples:
123129 htmlang -s site/ Serve a multi-page site
124130 htmlang -s -p 8080 page.hl
125131 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",
127135 env!("CARGO_PKG_VERSION")
128136 );
129137}
@@ -167,6 +175,55 @@ fn init_project(dir: &str) {
167175 }
168176}
169177
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+
170227fn main() {
171228 let args: Vec<String> = env::args().collect();
172229 let mut watch = false;
@@ -174,6 +231,7 @@ fn main() {
174231 let mut dev = false;
175232 let mut check = false;
176233 let mut port: u16 = 3000;
234+ let mut output_path: Option<String> = None;
177235 let mut input_path = None;
178236
179237 // Handle "init" subcommand
@@ -205,6 +263,70 @@ fn main() {
205263 return;
206264 }
207265
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+
208330 let mut i = 1;
209331 while i < args.len() {
210332 match args[i].as_str() {
@@ -238,6 +360,16 @@ fn main() {
238360 }
239361 }
240362 }
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+ }
241373 _ if input_path.is_none() => input_path = Some(args[i].clone()),
242374 _ => {
243375 eprintln!("unknown argument: {}", args[i]);
@@ -266,11 +398,24 @@ fn main() {
266398 process::exit(1);
267399 }
268400
401+ // Create output dir if needed
402+ if let Some(ref out) = output_path {
403+ let _ = fs::create_dir_all(out);
404+ }
405+
269406 let mut any_errors = false;
270407 let mut all_included: Vec<PathBuf> = Vec::new();
271408 for file in &hl_files {
272409 let path_str = file.to_string_lossy().to_string();
273− let (has_errors, included) = compile(&path_str, dev, serve, check);
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+ });
418+ let (has_errors, included) = compile(&path_str, dev, serve, check, effective_out.as_deref());
274419 if has_errors {
275420 any_errors = true;
276421 }
@@ -303,7 +448,7 @@ fn main() {
303448 }
304449
305450 // --- 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());
307452
308453 if !watch {
309454 if has_errors {
@@ -315,7 +460,10 @@ fn main() {
315460 // Start dev server if requested
316461 let reload_tx = if serve {
317462 let (tx, _) = tokio::sync::broadcast::channel::<()>(16);
318− let out_path = Path::new(&input_path).with_extension("html");
463+ let out_path = match output_path {
464+ Some(ref p) => PathBuf::from(p),
465+ None => Path::new(&input_path).with_extension("html"),
466+ };
319467 let server_tx = tx.clone();
320468 std::thread::spawn(move || {
321469 let rt = tokio::runtime::Runtime::new().expect("failed to create runtime");
@@ -464,7 +612,7 @@ fn watch_loop(
464612 // Recompile all source files
465613 for file in source_files {
466614 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);
468616 for inc in &new_includes {
469617 let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
470618 if let Some(h) = hash_file(inc) {
@@ -478,7 +626,7 @@ fn watch_loop(
478626 for file in &collect_hl_files(watch_dir) {
479627 if !source_files.contains(file) {
480628 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);
482630 for inc in &new_includes {
483631 let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
484632 }
src/parser.rs+148−2
@@ -76,6 +76,8 @@ struct ParseContext {
7676 keyframes: Vec<(String, String)>,
7777 css_vars: Vec<(String, String)>,
7878 custom_css: Vec<String>,
79+ og_tags: Vec<(String, String)>,
80+ custom_breakpoints: Vec<(String, String)>,
7981 diagnostics: Vec<Diagnostic>,
8082 base_path: Option<PathBuf>,
8183 included_files: Vec<PathBuf>,
@@ -120,6 +122,8 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
120122 keyframes: Vec::new(),
121123 css_vars: Vec::new(),
122124 custom_css: Vec::new(),
125+ og_tags: Vec::new(),
126+ custom_breakpoints: Vec::new(),
123127 diagnostics: Vec::new(),
124128 base_path: base_path.map(|p| p.to_path_buf()),
125129 included_files: Vec::new(),
@@ -148,6 +152,8 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
148152 keyframes: ctx.keyframes,
149153 css_vars: ctx.css_vars,
150154 custom_css: ctx.custom_css,
155+ og_tags: ctx.og_tags,
156+ custom_breakpoints: ctx.custom_breakpoints,
151157 nodes,
152158 },
153159 diagnostics: ctx.diagnostics,
@@ -340,6 +346,7 @@ impl Parser {
340346 };
341347 track_var_refs(value, &mut ctx.used_variables);
342348 let value = substitute_vars(value, &ctx.variables);
349+ let value = evaluate_arithmetic(&value);
343350 if name.starts_with("--") {
344351 // CSS custom property
345352 ctx.css_vars.push((name.to_string(), value.clone()));
@@ -359,6 +366,29 @@ impl Parser {
359366 return Ok(None);
360367 }
361368
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+
362392 if content == "@head" || content.starts_with("@head ") {
363393 // Collect indented body lines as raw head content
364394 let mut head_content = String::new();
@@ -618,6 +648,31 @@ impl Parser {
618648 return Ok(Some(nodes));
619649 }
620650
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+
621676 if content.trim() == "@else" || content.trim().starts_with("@else if ") {
622677 return Err(ParseError {
623678 line: line_num,
@@ -1163,12 +1218,15 @@ const KNOWN_ELEMENTS: &[&str] = &[
11631218 "form", "details", "summary", "blockquote", "cite", "code", "pre", "hr", "divider",
11641219 "figure", "figcaption", "progress", "meter",
11651220 "fragment",
1221+ "btn", "ul", "dialog", "dl", "dt", "dd", "fieldset", "legend",
1222+ "picture", "source", "time", "mark", "kbd", "abbr", "datalist",
11661223];
11671224
11681225const KNOWN_DIRECTIVES: &[&str] = &[
11691226 "page", "let", "define", "fn", "include", "import", "raw", "keyframes",
11701227 "if", "else", "each", "meta", "head", "style",
11711228 "match", "case", "default", "warn", "debug",
1229+ "unless", "og", "breakpoint", "lang", "favicon",
11721230];
11731231
11741232fn 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
12181276 "progress" => Ok(ElementKind::Progress),
12191277 "meter" => Ok(ElementKind::Meter),
12201278 "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),
12211293 _ => {
12221294 let all_known: Vec<&str> = KNOWN_ELEMENTS
12231295 .iter()
@@ -1357,6 +1429,11 @@ const KNOWN_ATTRS: &[&str] = &[
13571429 "scroll-behavior",
13581430 // Resize
13591431 "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",
13601437];
13611438
13621439/// Attributes that expect purely numeric values (px-based) or values with CSS units.
@@ -1760,6 +1837,31 @@ fn evaluate_condition(condition: &str) -> bool {
17601837 }
17611838}
17621839
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+
17631865// ---------------------------------------------------------------------------
17641866// Post-parse validation (context-dependent warnings)
17651867// ---------------------------------------------------------------------------
@@ -1785,11 +1887,18 @@ fn strip_all_prefixes(key: &str) -> &str {
17851887 .or_else(|| key.strip_prefix("md:"))
17861888 .or_else(|| key.strip_prefix("lg:"))
17871889 .or_else(|| key.strip_prefix("xl:"))
1890+ .or_else(|| key.strip_prefix("2xl:"))
17881891 .unwrap_or(key);
17891892 // Strip media prefixes
1790− key.strip_prefix("dark:")
1893+ let key = key.strip_prefix("dark:")
17911894 .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
17931902}
17941903
17951904/// Attributes that only make sense on container elements (@row, @column, @el).
@@ -1846,6 +1955,19 @@ fn element_kind_name(kind: &ElementKind) -> &'static str {
18461955 ElementKind::Progress => "@progress",
18471956 ElementKind::Meter => "@meter",
18481957 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",
18491971 }
18501972}
18511973
@@ -1857,6 +1979,8 @@ fn is_container(kind: &ElementKind) -> bool {
18571979 | ElementKind::Aside | ElementKind::List | ElementKind::ListItem
18581980 | ElementKind::Form | ElementKind::Details | ElementKind::Figure
18591981 | ElementKind::Blockquote
1982+ | ElementKind::Dialog | ElementKind::DefinitionList | ElementKind::DefinitionTerm
1983+ | ElementKind::DefinitionDescription | ElementKind::Fieldset | ElementKind::Datalist
18601984 )
18611985}
18621986
@@ -1988,6 +2112,28 @@ fn validate_tree(
19882112 });
19892113 }
19902114 }
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+ }
19912137
19922138 validate_tree(&elem.children, Some(&elem.kind), diagnostics);
19932139 }
tests/snapshots.rs+383
@@ -1510,3 +1510,386 @@ fn no_warning_new_css_attrs() {
15101510 diags
15111511 );
15121512}
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